diff --git a/.env.example b/.env.example index 7329f42d..fb0f43bd 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,8 +15,6 @@ 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). -ZULIP_PREFS_URL_BASE= # Optional dedicated encryption/signing secret for prefs links; falls back to DJANGO_SECRET_KEY when empty. ZULIP_PREFS_TOKEN_SECRET= # Salt namespace used for key derivation; changing it invalidates existing links. @@ -27,7 +31,6 @@ ZULIP_REGISTRATION_OAUTH_STATE_SALT=zulip_bot.registration.oauth_state ZULIP_REGISTRATION_OAUTH_STATE_TTL_SECONDS=600 # Zulip assignment command behavior -ZULIP_ASSIGNMENT_SUCCESS_EMOJI=thumbs_up # Enable live GitHub assign/unassign mutations (true/false, 1/0, yes/no) ZULIP_ASSIGNMENT_MUTATIONS_ENABLED= @@ -99,13 +102,19 @@ 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: both OAuth callbacks are built from QUEUEBOARD_BASE_URL — the registration callback +# is /api/zulip/register/github/callback/ and the console callback is +# /console/oauth/callback/. 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 sign-in fails with redirect_uri_mismatch. GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_SECRET= -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. @@ -269,7 +278,44 @@ 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 attention daily sweep controls. +# 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 +# Acceptance-gate rollout flags (design doc 050), each independently toggleable. +# 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). +# If both are enabled, the apply task skips itself (with an error log) so the gate holds. +# ENABLED: propose task creates proposals / direct-assigns. ASSIGN_ON_ACCEPT_ENABLED: +# console accept performs the GitHub assign. DRY_RUN: compute + record would-do outcomes +# with no side effects. Proposal DMs ride the daily reviewer-attention report, so also +# enable ANALYZER_REVIEWER_ATTENTION_ENABLED + ANALYZER_REVIEWER_ATTENTION_DELIVERY_ENABLED. +ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=0 +ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=0 +ANALYZER_ASSIGNMENT_PROPOSALS_DRY_RUN=0 +# CONSOLE_UNASSIGN_ENABLED: the console assigned-PR roster becomes a form letting a reviewer remove +# themselves from PRs (self-service only; it never unassigns anyone else). Off by default. +ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED=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. This DM also carries the acceptance-gate +# "Proposed to you" section (design doc 050): an un-notified pending proposal triggers a +# send even for reviewers with notifications_enabled off (proposal prompts are transactional). # ENABLED: run the sweep task at all (computes reports/counters). ANALYZER_REVIEWER_ATTENTION_ENABLED=0 # ENFORCEMENT_ENABLED: execute GitHub auto-unassign for threshold hits. diff --git a/AGENTS.md b/AGENTS.md index 4d856f46..c0698570 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. @@ -63,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 new file mode 100644 index 00000000..0d8245a9 --- /dev/null +++ b/docs/design-decisions/050-reviewer-assignment-acceptance-gate.md @@ -0,0 +1,332 @@ +# Reviewer Assignment Acceptance Gate (Propose → Accept → Assign) + +> Status: **Implemented** (including a post-implementation review-hardening pass). All feature +> flags default off; staged production rollout and the staging end-to-end check are pending — see +> Operational Notes. + +## Context + +- Reviewers were auto-assigned to PRs directly, and assignments were frequently ignored or + 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) asked that an assignment be **proposed** to a reviewer, who must **accept** + it within a few days before it is executed — so "an assignment really means the assignee knows + they're in charge." +- The pre-existing pipeline was three decoupled daily Celery stages in `analyzer/`: + compute (`analyzer.refresh_reviewer_assignments` → `ReviewerAssignmentSnapshot` with + `{pr_number: login}`), apply (`analyzer.apply_reviewer_assignments`, design doc 046), and + attention (`analyzer.reviewer_attention_daily`, design doc 028). +- Key realization: with a **daily recompute loop already in place**, the gate needs 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 and the engine picks + the next-best candidate. No hand-written state machine. +- Reused infrastructure: proactive Zulip DMs (`ZulipClient.send_direct_message`), GitHub OAuth + (`core.services.github_oauth.GitHubOAuthClient`), per-PR opt-outs (`analyzer.ReviewerOptOut`), + GitHub App operation tokens (`assign_pr`), and the 046 mutation/audit path + (`ReviewerAssignmentApplication`). + +## Decision + +### Pipeline + +`analyzer.propose_reviewer_assignments` (daily, default 00:45 UTC) replaces the direct-POST batch. +Per `{pr, login}` from the authoritative default-rule-set snapshot, it branches on the reviewer's +`ReviewerPreference.assignment_acceptance`: + +- `auto` → direct-assign via the verbatim 046 mutation path (`assign_reviewer_and_record`). +- `confirm` + Zulip-linked → create `AssignmentProposal(state=proposed, expires_at=now+window)`. +- `confirm` but unreachable (no `core.User.zulip_user_id`) → fall back to direct-assign. + +Downstream: the daily reviewer-attention DM (`analyzer.reviewer_attention_daily`, design doc 028) +carries a distinct **"Proposed to you, awaiting your response"** section listing the reviewer's +pending proposals with expiry and a console link — there is no separate proposal digest task (one +existed initially and was folded into the attention report so reviewers get a single daily DM). +The reviewer **accepts** (re-validate live → GitHub assign → `accepted`) or **declines** +(`declined` + active `ReviewerOptOut`) on the console; `analyzer.expire_assignment_proposals` +(hourly) retires proposals that timed out or became invalid. Next day's recompute excludes retired +reviewers and proposes to the next candidate. + +The legacy `analyzer.apply_reviewer_assignments` is retained for `auto`-only operation but is +**superseded** by propose. Mutual exclusion is enforced in code, not just documented: when +`ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED` is set, the apply task skips itself (reason +`superseded_by_proposals_pipeline`, error log), so the proposal-unaware path can never bypass the +gate; propose logs a warning about the misconfiguration. + +### PR assignment state model + +One intermediate value on the existing assignment axis — for an open, on-queue PR: +**unassigned** / **proposed** (no GitHub assignee, one active proposal) / **assigned**. Invariants: + +1. Exactly one of {unassigned, proposed, assigned} at any moment — enforced by the partial-unique + index (one active proposal per PR). +2. Under the default `invalidate` on-queue-exit policy, "proposed" is a strict substate of + on-queue/awaiting-review. The `retain` policy deliberately relaxes this, so load-accounting and + surfacing never *assume* proposed ⇒ on-queue. +3. Live state is always reconstructable from durable facts: GitHub assignees + the single active + proposal. No derived state that can drift. +4. A **proposal is never an assignee**; every surface renders "proposed to X" distinct from + "assigned to X". +5. Terminal proposals (`accepted`/`declined`/`expired`/`superseded`) are history, not live state; + they feed only the bounded expire-cooldown lookup. + +### Model: `analyzer.AssignmentProposal` + +- `repository` FK, `pr_number`, `reviewer_login` (snapshot/opt-out login keying), `snapshot` FK + (`SET_NULL` provenance), `state`, `expires_at`, `decided_at`, `notified_at`, + `decided_via` (`console` / `auto_expire` / `sync_superseded`); `created_at` is the proposal time. +- Partial unique index `(repository, pr_number) WHERE state='proposed'` enforces one active + proposal per PR race-safely; creation is conflict-aware per the "Concurrent Writers and Unique + Keys" rules. Indexes on `(repository, reviewer_login, state, decided_at)` and + `(repository, pr_number, state)` serve the console, cooldown, and pr-info queries. +- **History is retained indefinitely** — no cleanup task. Volume is low/append-only, the rows have + analytical value (accept rate, time-to-accept), and the cooldown is a bounded query window, not + a deletion policy. Classified as durable history in `scripts/backup_policy.py` (BACKUP + + TRUNCATE-from-sanitized-dump, like sibling reviewer tables). + +### Per-reviewer mode and notification semantics + +`ReviewerPreference.assignment_acceptance` ∈ {`auto`, `confirm`}; migration backfilled existing +rows to `auto` (grandfathered), new rows default `confirm`. The importer never touches the field +on update (regression-pinned). Bulk admin actions flip a selection, and reviewers can flip their +own mode from the Zulip prefs form (a two-option radio in the Auto-Assignment section) — the +control is shown only while `ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED` is on, so reviewers never see +an option that has no effect. + +Confirmation prompts are **transactional** and NOT gated by `notifications_enabled` (which governs +only optional attention nudges — matching doc 028's precedent that essential actions ignore the +notification opt-in). Even though the prompt rides the attention DM, an un-notified pending +proposal triggers a send for a muted reviewer; the resulting DM then contains only the proposal +section, never the muted nudge categories. Fallback to `auto` happens only when a reviewer is +genuinely unreachable (no Zulip link), never merely because nudges are muted. + +| `assignment_acceptance` | nudges (`notifications_enabled`) | behavior | +| --- | --- | --- | +| `auto` | on | Direct-assign + optional "you were assigned" DM (= pre-gate behavior). | +| `auto` | off | Direct-assign, silently. | +| `confirm` | on | Propose → proposal section in the daily DM → console accept → assign, plus optional nudges. | +| `confirm` | off | Propose → proposal-only daily DM → accept → assign; no optional nudges. | + +`auto_assign=false` → never auto-assigned; the gate is irrelevant. + +### Validity: one authority for "is this proposal still live" + +`analyzer.services.assignment_proposal_validity` is the sole owner of pending-proposal liveness: + +- `proposal_validity(...)` — pure predicate over durable facts (`pr_state`, `current_assignees`, + `on_queue`, `opted_out`). Precedence: already-terminal → assignee landed (**any** non-empty + assignee set supersedes — "don't fight the human/self-assignee") → reviewer **opted out** + (superseded; a self-unassign reconciled into an opt-out retires the pending proposal on the next + sweep instead of leaving it dangling) → PR closed/merged (superseded) → past `expires_at` + (expired; seeds the soft cooldown) → off-queue under the `invalidate` policy (superseded) → live. +- The **input assembly is shared too**, not just the verdict: `queue_membership(repo, now)` + (queue/known-PR sets + freshness from the latest default `QueueSnapshot`) and + `live_proposal_validity(...)` (facts assembly + predicate) are consumed by both the expiry sweep + and the console, so the two surfaces cannot drift. `on_queue=None` when no fresh snapshot covers + the PR — a stale/missing snapshot never mass-invalidates. `queue_membership` extracts only the + two needed payload fragments in SQL (`payload #> '{lists,dashboards,Queue}'`, + `jsonb_object_keys(payload->'prs')`; Postgres-only, per repo policy) rather than loading the + multi-MB payload per sweep run / console POST. +- The on-queue-exit behavior is selectable via `ANALYZER_ASSIGNMENT_PROPOSAL_ON_QUEUE_EXIT` + (`invalidate` default / `retain`), read inside the predicate; a future flip is a one-setting + change. +- **Propose-time re-validation matches the predicate**: any non-empty assignee set blocks proposal + creation (not just assignees who are eligible reviewers). Without this alignment, a PR with a + non-reviewer assignee would be proposed, DM'd, superseded by the sweep, and re-proposed daily — + superseded rows feed neither the cooldown nor the active-proposal exclusion. + +### Decline vs. expire + +- **Decline** = explicit "not this PR" → permanent per-PR `ReviewerOptOut` (builder-enforced). + The console writes the opt-out with the **lowercase-normalized** login, matching every other + writer/clearer against the table's case-sensitive unique constraint. +- **Expire** (silent timeout) = soft: the builder skips a reviewer for a PR with an `expired` + proposal within `ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRE_COOLDOWN_DAYS` (default 14), then re-allows. + The PR advances to other candidates immediately; a reviewer who was merely away gets another + shot later. + +### Builder / engine / stats integration + +All routed through the shared `_prepare_assignment_inputs` so the builder and the diagnostic trace +cannot diverge; data-driven and ungated (no proposals ⇒ no-op): + +1. Candidate exclusion — PRs with an active proposal are withheld from re-proposal. +2. Pending-load contribution — active proposals add weighted load + (`ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT`, default 1.0; a proposal occupies a slot) + via the pure engine seam `add_pending_proposal_load`. **`AreaStatsBuilder` applies the same + pending load**, so area-level `at_max_capacity` agrees with what the next assignment run will do. +3. Cooldown exclusion — recent `expired` proposals merge into the per-PR exclusion set alongside + opt-outs. + +### Delivery and attention integration + +- Proposal prompts ride the **daily reviewer-attention DM** (doc 028's + `analyzer.reviewer_attention_daily`), not a separate digest: `build_reviewer_attention_reports` + attaches the reviewer's pending proposals as `proposal_items`, rendered as a "Proposed to you, + awaiting your response" section (expiry + console link) distinct from every assigned-PR section + (invariant 4). One DM per reviewer across all repos. +- Send triggers: a claimed nudge category (notifications on) or an **un-notified** pending + proposal (transactional, bypasses the mute). Already-notified pending proposals never re-trigger + a DM by themselves; they ride along as context whenever another trigger fires. +- Proposal dedupe key = `AssignmentProposal.notified_at`, stamped by a race-safe conditional + `UPDATE ... WHERE state='proposed' AND notified_at IS NULL` after a successful send; no separate + record model, and a failed send leaves the rows un-stamped for the next run's retry. Nudge + categories keep their own `ReviewerAttentionNotificationRecord` dedupe — the two mechanisms are + independent by design (a proposal is notified once per proposal, not once per queue window). +- Message chunking uses the single shared `zulip_bot.services.zulip_client.split_message_chunks` + (+ `MAX_MESSAGE_CHARS`), which hard-splits any oversized line so no chunk can exceed Zulip's + ceiling and fail a send. (Previously three drifting copies; do not re-implement per call site.) +- The attention sweep suppresses its "newly assigned" ping only for the assignment **the + acceptance actually produced**: the console-accept `decided_at` must match the ASSIGNED timeline + event's `occurred_at` within a 1-hour tolerance (the accept performs the GitHub assign moments + before `decided_at` is stamped). A later unrelated re-assignment of the same pair still pings. + +### Reviewer console (`qb_site/console/`) + +- Dedicated Django app at `/console/`; plain server-rendered views; no models of its own. The DM + carries a plain, stable, bookmarkable URL (no token), built from `QUEUEBOARD_BASE_URL` via + `core.services.site_urls.build_site_url`. The same URL + is available on demand via the `console` Zulip command (an in-place reply, not a private DM: the + link is non-secret and identical for every reviewer since the page self-authenticates); like all + commands it is reachable only where `ZULIP_COMMAND_POLICY` permits it. +- **Auth:** GitHub OAuth → Django session holding the resolved `core.User` id. CSRF nonce in the + session echoed through the Fernet-signed OAuth `state` (`core.services.oauth_state`, shared with + the registration flow, which delegates to it). Hardened: + - `core.services.github_identity.resolve_user_from_identity` is **resolve-only by + construction** — it never creates users, so the public sign-in URL cannot mint a `core.User` + for an arbitrary GitHub account; and a login match whose stored `github_node_id` differs is + treated as no match (a **recycled username** must not inherit the previous owner's session and + proposals). + - The session key is **rotated on login promotion** (`cycle_key`, like + `django.contrib.auth.login`) against session fixation. +- Access is keyed on the authenticated `github_login`, matched case-insensitively against + `AssignmentProposal.reviewer_login`; a reviewer can only act on their own proposals, and console + access is independent of Zulip reachability. +- **Accept** (gated by `ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED`): re-validate via + the shared validity path → 046 mutation (`assign_reviewer_and_record`) → mark `accepted` only + when the assignment **landed** on GitHub (`applied`, or `already_recorded` whose record is + APPLIED — a prior FAILED attempt is never reported as success; the proposal stays pending for a + later retry). **Decline:** mark `declined` + upsert the (normalized) opt-out. Every POST + re-validates; a no-longer-actionable proposal renders "no longer available, here's why" (and the + stale row is retired) instead of erroring. +- All proposal state transitions are idempotent conditional updates + (`UPDATE ... WHERE state='proposed'`), safe against concurrent sweeps/clicks. +- **Assign-anyway / self-unassign (post-050 console additions).** Two self-service actions extend the + console beyond accept/decline: + - **Assign-anyway** is offered on the "no longer available" page and **deliberately bypasses the + validity gate** — it exists precisely to let a reviewer take a PR whose proposal lapsed, was + superseded, or was declined. Its single precondition is behavioral, not reason-enumerated: the PR + is open and the reviewer is not already an assignee (`_can_self_assign`). That set is exactly the + "recoverable" states (expired / off-queue / assigned-to-others / opted-out) and excludes + closed-merged and already-held PRs. It reuses the same `ASSIGN_ON_ACCEPT` gate and 046 mutation as + accept, additionally clearing any active per-PR opt-out so the builder won't undo it. + - **Self-unassign** turns the assigned-PR roster into a checkbox form (gated by + `ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED`) that removes the reviewer from selected + PRs via `unassign_pr`. The login removed is always the authenticated reviewer's own — never taken + from the request — so the surface can only unassign its operator. + Both keep the console's write flags off by default, consistent with the staged rollout. +- The console load line now also shows a **per-PR contribution** (`reviewer_load.pr_load_breakdown`), + folded over the same snapshot as the aggregate so the parts sum to the whole. + +### Surfacing (never the GitHub PR page) + +- **Board/API:** the queue snapshot embeds a `proposal` field (`{reviewer, expires_at}` or `null`) + per PR entry (one batched query; payload served verbatim). +- **Single PR:** `PRQueueInfo.proposed_to`/`proposal_expires_at`, read live (indexed point query); + the Zulip `pr-info` command renders a distinct "Proposed to X (awaiting acceptance, expires …)" + line, never merged into Assignees. +- Full history lives in the `AssignmentProposal` `ReadOnlyAdmin`; default views show current state + only. Nothing is ever written to the GitHub PR page (no comments, no labels). + +## Consequences + +- A `confirm` PR carries no GitHub assignee during the pending window (up to the acceptance + window, default 7 days); the board/pr-info "proposed" state is the only visibility, which is why + surfacing shipped in the same phase. Placement latency for unresponsive reviewers is bounded by + window + cooldown recompute rather than instant. +- Proposal notification is coupled to the attention pipeline: with + `ANALYZER_REVIEWER_ATTENTION_ENABLED` or `_DELIVERY_ENABLED` off, proposals are created but + never DM'd (the console still shows them). The upside is one daily DM per reviewer and one + delivery/dedupe/chunking path instead of two. +- Two assignment pipelines coexist (legacy apply, gate propose). The code-level yield rule removes + the bypass risk, at the cost of the apply task being a silent no-op in a both-enabled + misconfiguration (it logs an error explaining why). +- The single validity authority (verdict *and* input assembly, opt-out-aware) is load-bearing: new + invalidation rules belong in `assignment_proposal_validity`, never inline at a call site. +- `queue_membership` uses Postgres jsonb operators — consistent with the repo's Postgres-only + policy, but not portable to other backends. +- Proposal history grows without bound by design; acceptable at current volume, revisit only if it + surprises (a cleanup task can be added without redesign). +- The console is a new externally-reachable authenticated surface; its safety rests on + resolve-only identity, the recycled-login guard, session-key rotation, per-reviewer proposal + scoping, and re-validation on every POST. + +## Operational Notes + +- **Settings** (all in `settings/base.py` + `.env.example`): flags + `ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED` / `_ASSIGN_ON_ACCEPT_ENABLED` / `_DRY_RUN`; tuning + `ANALYZER_ASSIGNMENT_PROPOSAL_WINDOW_DAYS` (default 7 — the **global value is + honored as-is**; only the per-reviewer `notification_settings` override is clamped ≥7), + `_EXPIRE_COOLDOWN_DAYS` (14), `_PENDING_LOAD_WEIGHT` (1.0), `_ON_QUEUE_EXIT` + (`invalidate`/`retain`); schedules `ANALYZER_ASSIGNMENT_PROPOSE_*` (daily 00:45 UTC), + `ANALYZER_ASSIGNMENT_PROPOSAL_EXPIRY_PERIOD_SECONDS` (3600). The expiry sweep is essential + maintenance: not gated by the master switch (flipping the gate off lets proposals drain) and + performs no GitHub writes. Proposal DMs ride the reviewer-attention pipeline, so notifying + reviewers requires doc 028's `ANALYZER_REVIEWER_ATTENTION_ENABLED` + + `ANALYZER_REVIEWER_ATTENTION_DELIVERY_ENABLED` (there is no proposal-specific delivery flag). +- **Deploy prerequisites:** set `QUEUEBOARD_BASE_URL` (console links + OAuth `redirect_uri`); + register the GitHub OAuth App callback at the **site root** so both the registration and + console callback paths are covered (`docs/zulip_github_oauth_setup.md`). +- **Rollout** (staged, per the 028 discipline; pending): flip selected reviewers to `confirm` via + the bulk admin action → `_DRY_RUN=1` and inspect propose output → enable `_ENABLED` → ensure the + attention pipeline delivers (`ANALYZER_REVIEWER_ATTENTION_ENABLED` + `_DELIVERY_ENABLED`) → + `_ASSIGN_ON_ACCEPT_ENABLED`; disable the legacy + `ANALYZER_REVIEWER_ASSIGNMENT_APPLY_ENABLED`. Staging end-to-end (attention DM with the proposal + section → console sign-in → accept → assignee lands + `ReviewerAssignmentApplication` recorded → + second propose run no-op) remains a manual step — tests mock GitHub/Zulip. +- **Management commands:** `propose_reviewer_assignments` + (`--repo o/n`, `--dry-run`, `--enable`); `backfill_reviewer_opt_outs`. +- Keep the 21-day auto-unassign backstop for accepted-but-then-stale PRs; for `confirm` reviewers + the clock starts at acceptance. + +## Deferred Follow-Ups + +- Re-confirm ~10 days after `awaiting-author` is removed (maps onto the attention sweep's + queue-re-entry reset). +- Throughput cap ("reviewed X PRs in 30 days → pause auto-assign") — a separate capacity input. +- Email channel (Zulip-first today); console-only `confirm` (`confirmation_prompts_enabled`) — + YAGNI until a reviewer asks. (The once-deferred "unify the proposal digest with the attention + DM" has since shipped: the digest task was removed and proposals ride the daily attention DM.) +- "(N prior proposals)" hint on default views (kept out of the hot snapshot build); a unified + "assignment activity" view joining proposals with the assignment timeline. +- Hybrid "propose, then assign anyway if all candidates time out" — revisit only if PRs sitting + unproposed becomes a real problem. +- **Stable `/prefs` URL sharing the console session.** Today the Zulip prefs form authenticates via + an expiring Fernet token embedding a `preference_ids` snapshot; the console authenticates via + GitHub OAuth → Django session (`core.User` id). A follow-up could give prefs a token-less, stable + URL that reads the same session and loads the user's live preferences. Feasible and clean (the + console already resolves identity by `github_login`, so the eligible population is essentially the + same), but it is an auth-model change, not a URL tweak — it makes GitHub OAuth the entry point for + a flow that today needs none, changes scoping from a token snapshot to all-current prefs, and + wants its own CSRF/session plumbing, tests, and security-surface review. Deliberately kept off this + branch so the gate's rollout stays focused. + +## Alternatives (discarded) + +- **Post-assignment fast-confirm** (assign now, revoke if not accepted): only shortens the ghost + assignment; rejected for the true pre-assignment gate. +- **Per-PR signed accept links**: don't scale to multiple pending proposals; stale-link rot. + Superseded by the GitHub-login console with a stable URL. +- **Token-bootstrapped console session**: an automated DM carrying a fast-expiring link is + annoying; GitHub login gives a bookmarkable URL. +- **Deriving the acceptance default from account age** instead of a stored column: 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` diff --git a/docs/zulip_github_oauth_setup.md b/docs/zulip_github_oauth_setup.md index 613eff31..79520f99 100644 --- a/docs/zulip_github_oauth_setup.md +++ b/docs/zulip_github_oauth_setup.md @@ -1,14 +1,20 @@ -# 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/`. +- Both flows derive their callback from `QUEUEBOARD_BASE_URL` (there is no separate redirect-URI + setting): registration → `/api/zulip/register/github/callback/`, + console → `/console/oauth/callback/`. - 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 +23,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 +31,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 +46,12 @@ Set these in your Queueboard environment (`.env` or deployment secrets): - Required: - `GITHUB_OAUTH_CLIENT_ID` - `GITHUB_OAUTH_CLIENT_SECRET` -- Recommended: - - `GITHUB_OAUTH_REDIRECT_URI` - Use the same URL configured in GitHub app callback settings, e.g. - `https://queueboard.example/api/zulip/register/github/callback/` + - `QUEUEBOARD_BASE_URL` — canonical site base (`https://`, no trailing path). Every + deep-link is built from this: both OAuth callbacks (registration and console), the console link + in reviewer DMs, and the Zulip prefs/registration links. **Must be set for the + console/notification rollout** and for OAuth to produce an absolute `redirect_uri`. +- 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` @@ -48,7 +60,6 @@ Optional overrides (defaults shown): - `GITHUB_OAUTH_SCOPE=read:user` Related registration token settings: -- `ZULIP_PREFS_URL_BASE` (used for registration links too) - `ZULIP_PREFS_TOKEN_SECRET` (shared secret material) - `ZULIP_REGISTRATION_TOKEN_SALT` (registration token namespace) - `ZULIP_REGISTRATION_TOKEN_TTL_SECONDS` @@ -59,13 +70,12 @@ Related registration token settings: For local Django at `http://localhost:8000`: -- GitHub OAuth app callback URL: - - `http://localhost:8000/api/zulip/register/github/callback/` +- GitHub OAuth app callback URL (register at the site root so both flows are covered): + - `http://localhost:8000/` - Local env: - `GITHUB_OAUTH_CLIENT_ID=` - `GITHUB_OAUTH_CLIENT_SECRET=` - - `GITHUB_OAUTH_REDIRECT_URI=http://localhost:8000/api/zulip/register/github/callback/` - - `ZULIP_PREFS_URL_BASE=http://localhost:8000` + - `QUEUEBOARD_BASE_URL=http://localhost:8000` If your team shares a deployed app, prefer separate local vs production OAuth apps. @@ -103,7 +113,9 @@ Example policy snippet: - "GitHub OAuth is not configured yet" - Missing `GITHUB_OAUTH_CLIENT_ID` or `GITHUB_OAUTH_CLIENT_SECRET`. - OAuth callback invalid/failed - - `GITHUB_OAUTH_REDIRECT_URI` mismatch with GitHub app callback URL. + - `redirect_uri_mismatch`: the derived callback (`/…`) is not a subdirectory + of the GitHub app's registered callback URL. Register that callback at the site root. + - `QUEUEBOARD_BASE_URL` unset, so the `redirect_uri` is a relative path (OAuth needs an absolute URL). - Expired or tampered OAuth `state` token. - Registration token expired before callback completed. - Link expired diff --git a/qb_site/AGENTS.md b/qb_site/AGENTS.md index 5c93cab4..00065056 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. @@ -117,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/analyzer/AGENTS.md b/qb_site/analyzer/AGENTS.md index 59f25470..194cd1c7 100644 --- a/qb_site/analyzer/AGENTS.md +++ b/qb_site/analyzer/AGENTS.md @@ -8,13 +8,18 @@ - 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. - - `reviewer_attention.py` / `reviewer_attention_format.py` — per-reviewer queue attention reports and formatting helpers. + - `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. Reports also carry the reviewer's pending acceptance-gate proposals (`proposal_items`, design doc 050), + rendered as a distinct "Proposed to you" section of the daily DM. - `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. + review-load (weighted, matching the assignment engine's capacity gate, **incl. pending assignment proposals** + per design doc 050) as of the latest cached queue snapshot, plus `format_load_line`. Single authority shared by + the `assigned-prs` command, the daily reviewer-attention digest, and the reviewer console; 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. 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 @@ -59,10 +64,31 @@ 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 (enforced: + the apply task skips itself when the proposals flag is also set). 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, whose reviewer opted out of the PR, or that 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** -- `analyzer.reviewer_attention_daily` — daily sweep that computes reviewer-attention signals. +- `analyzer.reviewer_attention_daily` — daily sweep that computes reviewer-attention signals + and delivers the per-reviewer DM. The DM also carries the acceptance-gate "Proposed to you" + section (design doc 050): pending `AssignmentProposal` rows render distinct from assigned + PRs, and an un-notified proposal triggers a send even for reviewers with + `notifications_enabled` off (proposal prompts are transactional; dedupe is + `AssignmentProposal.notified_at`, stamped after a successful send). There is no separate + proposal digest task. - `analyzer.reviewer_attention_cleanup` — prunes stale reviewer-attention records. Keep tasks idempotent and resumable; prefer explicit summary payloads to aid admin/task-result debugging. 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/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/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/services/assignment_proposal_expiry.py b/qb_site/analyzer/services/assignment_proposal_expiry.py new file mode 100644 index 00000000..a10cacd8 --- /dev/null +++ b/qb_site/analyzer/services/assignment_proposal_expiry.py @@ -0,0 +1,118 @@ +"""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, a human/self-assignee landed, the reviewer opted out of the PR, 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 +from analyzer.services.assignment_proposal_validity import ( + SNAPSHOT_FRESH_SECONDS, + ProposalValidity, + live_proposal_validity, + queue_membership, + resolve_on_queue_exit_policy, +) +from analyzer.services.reviewer_assignment import _opt_outs_for_prs +from core.models import Repository +from syncer.models import PullRequest + +log = logging.getLogger(__name__) + + +def _empty_stats() -> dict[str, Any]: + return { + "active": 0, + "expired": 0, + "superseded": 0, + "still_live": 0, + "errored": 0, + } + + +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") + } + membership = queue_membership(repository, now=now) + opt_outs = _opt_outs_for_prs(repository, sorted(pr_numbers)) + policy = resolve_on_queue_exit_policy() + + for proposal in proposals: + try: + pr_number = int(proposal.pr_number) + validity: ProposalValidity = live_proposal_validity( + proposal, + now=now, + live_pr=live_by_number.get(pr_number), + membership=membership, + opt_outs=opt_outs, + 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..a4ea05d2 --- /dev/null +++ b/qb_site/analyzer/services/assignment_proposal_validity.py @@ -0,0 +1,239 @@ +"""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 django.contrib.postgres.fields import ArrayField +from django.db.models import JSONField, TextField +from django.db.models.expressions import RawSQL + +from analyzer.models import AssignmentProposal, QueueSnapshot +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 + +ON_QUEUE_EXIT_INVALIDATE = "invalidate" +ON_QUEUE_EXIT_RETAIN = "retain" + +# A queue snapshot older than this is not trusted for off-queue determination (mirrors pr_info). +SNAPSHOT_FRESH_SECONDS = 7200 + +# 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" +REASON_OPTED_OUT = "opted_out" + + +@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 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 a caller assert on-queue membership. Everything else must yield ``on_queue=None`` + so a stale/missing snapshot never mass-invalidates — ``live_proposal_validity`` applies that + rule. This is the one shared assembly of queue-membership facts for validity consumers (the + expiry sweep and the console); do not reimplement it per call site. + + Only the two needed payload fragments are extracted in SQL: the full payload carries complete + entries for every open PR (multi-MB on large repos) and this runs per repo on every expiry + sweep run and console accept/decline. Postgres-only (jsonb operators), like the rest of the + Django runtime. + """ + rule_set = default_rule_set_for_repo(repository) + cache_key = str(rule_set.id) if rule_set else "default" + row = ( + QueueSnapshot.objects.filter(repository=repository, cache_key=cache_key) + .order_by("-generated_at") + .annotate( + queue_pr_numbers=RawSQL("payload #> '{lists,dashboards,Queue}'", [], output_field=JSONField()), + known_pr_numbers=RawSQL( + "ARRAY(SELECT jsonb_object_keys(payload -> 'prs'))", [], output_field=ArrayField(TextField()) + ), + ) + .values_list("generated_at", "queue_pr_numbers", "known_pr_numbers") + .first() + ) + if row is None: + return set(), set(), False + generated_at, queue_raw, known_raw = row + fresh = generated_at is not None and (now - generated_at).total_seconds() <= SNAPSHOT_FRESH_SECONDS + queue_prs = {int(n) for n in (queue_raw or [])} + known_prs = {int(n) for n in (known_raw or [])} + return queue_prs, known_prs, fresh + + +def live_proposal_validity( + proposal: AssignmentProposal, + *, + now: datetime, + live_pr: PullRequest | None, + membership: tuple[set[int], set[int], bool], + opt_outs: dict[int, set[str]] | None = None, + on_queue_exit: str | None = None, +) -> ProposalValidity: + """Assemble the durable facts for one proposal and run ``proposal_validity`` on them. + + ``membership`` is the repo-level ``queue_membership(...)`` result, computed once per repo so + batch callers (the expiry sweep) don't refetch the snapshot per proposal. ``live_pr`` is the + proposal's PR row (``.only("number", "state", "assignees")`` suffices), or ``None`` if unknown. + ``opt_outs`` is the ``{pr_number: {normalized_login, ...}}`` map of active opt-outs (as built + by ``reviewer_assignment._opt_outs_for_prs``), likewise computed once per repo. + """ + queue_prs, known_prs, fresh = membership + pr_number = int(proposal.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} + on_queue = (pr_number in queue_prs) if (fresh and pr_number in known_prs) else None + opted_out = _normalize_login(proposal.reviewer_login) in (opt_outs or {}).get(pr_number, set()) + return proposal_validity( + proposal, + now=now, + pr_state=pr_state, + current_assignees=current_assignees, + on_queue=on_queue, + on_queue_exit=on_queue_exit, + opted_out=opted_out, + ) + + +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, + opted_out: bool = False, +) -> 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. + - ``opted_out``: the proposal's reviewer holds an active ``ReviewerOptOut`` for this PR + (explicit decline, or a GitHub self-unassign reconciled into one after the proposal was + created). + + 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. Reviewer opted out of this PR -> superseded (they have said no; don't keep asking). + 4. PR closed/merged -> superseded (a closed PR can't be usefully reviewed, regardless of policy). + 5. 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. + 6. Open but off-queue, under the ``invalidate`` policy -> superseded. + 7. 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 opted_out: + return ProposalValidity( + is_live=False, + reason=REASON_OPTED_OUT, + 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", + "SNAPSHOT_FRESH_SECONDS", + "ProposalValidity", + "live_proposal_validity", + "proposal_validity", + "queue_membership", + "resolve_on_queue_exit_policy", + "REASON_LIVE", + "REASON_ALREADY_TERMINAL", + "REASON_EXPIRED", + "REASON_PR_ASSIGNED", + "REASON_PR_CLOSED", + "REASON_PR_OFF_QUEUE", + "REASON_OPTED_OUT", +] 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/services/reviewer_assignment.py b/qb_site/analyzer/services/reviewer_assignment.py index 6ba622e0..35db0819 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,90 @@ 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 pending_proposal_load_for_repo(repository: Repository, *, weight: float) -> dict[str, float]: + """Weighted pending-proposal load per reviewer login for a repo (public wrapper). + + Combines the active-proposal rows with per-login weighting so callers outside this module + (e.g. ``analyzer.services.reviewer_load``) can fold pending-proposal load into capacity + accounting without reaching into the private helpers. Data-driven: no active proposals -> ``{}``. + Login casing matches ``ReviewerProfile.github_login`` / the snapshot keying. + """ + return _pending_proposal_load(_active_proposal_rows(repository), weight=weight) + + +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 +399,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 +473,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 +616,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), ) @@ -573,11 +704,19 @@ def build( reviewers = build_reviewer_catalog(repository, now=current_time) assignment_stats = collect_assignment_statistics(payload) + # Pending proposals occupy capacity (design doc 050), for area stats exactly as for the + # assignment builder/trace — otherwise at_max_capacity here disagrees with what the next + # assignment run will actually do. + proposal_weight = float(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT", 1.0)) + existing_assignments = add_pending_proposal_load( + assignment_stats.assignments, + _pending_proposal_load(_active_proposal_rows(repository), weight=proposal_weight), + ) dashboards = payload.get("lists", {}).get("dashboards", {}) queue_prs = dashboards.get("Queue", []) area_stats = compute_area_stats( - existing_assignments=assignment_stats.assignments, + existing_assignments=existing_assignments, reviewers=reviewers, queue_pr_numbers=queue_prs, all_prs=payload.get("prs", {}), @@ -647,10 +786,12 @@ def _get_or_build_queue_snapshot( "ReviewerAssignmentBuilder", "ReviewerProfile", "ReviewerSuggestionResult", + "add_pending_proposal_load", "build_reviewer_assignment_trace", "build_reviewer_catalog", "collect_assignment_statistics", "compute_area_stats", + "pending_proposal_load_for_repo", "rank_prs_for_assignment", "suggest_reviewer_for_pr", "suggest_reviewers_many", diff --git a/qb_site/analyzer/services/reviewer_assignment_apply.py b/qb_site/analyzer/services/reviewer_assignment_apply.py index 5240113a..f8e2a665 100644 --- a/qb_site/analyzer/services/reviewer_assignment_apply.py +++ b/qb_site/analyzer/services/reviewer_assignment_apply.py @@ -57,6 +57,113 @@ 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, )`` 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 + 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, record) + + 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 +211,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 +227,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 +347,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_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/services/reviewer_assignment_propose.py b/qb_site/analyzer/services/reviewer_assignment_propose.py new file mode 100644 index 00000000..3b4c4f0c --- /dev/null +++ b/qb_site/analyzer/services/reviewer_assignment_propose.py @@ -0,0 +1,302 @@ +"""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; overrides are 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. The weekly floor + applies only to per-reviewer overrides — the operator-configured global default + (``ANALYZER_ASSIGNMENT_PROPOSAL_WINDOW_DAYS``) is honored as-is (floored at 1 day), matching + how base.py/.env.example document the clamp. + """ + raw = (pref.notification_settings or {}).get("assignment_proposal_window_days") + try: + override = int(raw) if raw is not None else None + except (TypeError, ValueError): + override = None + if override is None: + return max(1, int(default)) + return max(MIN_WINDOW_DAYS, override) + + +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) + # ANY assignee blocks, mirroring proposal_validity's REASON_PR_ASSIGNED rule ("don't fight + # the human/self-assignee") — not just assignees who are eligible reviewers. A proposal + # created for a PR with a non-reviewer assignee would be superseded by the next expiry + # sweep and re-created (and re-DM'd) by the next propose run, since superseded rows feed + # neither the cooldown nor the active-proposal exclusion. + if not_open or 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/services/reviewer_attention.py b/qb_site/analyzer/services/reviewer_attention.py index 45309e19..419989bc 100644 --- a/qb_site/analyzer/services/reviewer_attention.py +++ b/qb_site/analyzer/services/reviewer_attention.py @@ -5,11 +5,17 @@ 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 +# The console accept performs the GitHub assign moments before decided_at is stamped, so the +# acceptance-produced ASSIGNED event's occurred_at lands within seconds of decided_at. A generous +# tolerance absorbs clock skew while still separating the acceptance's own assignment from a later +# unrelated re-assignment of the same (pr, reviewer). +CONSOLE_ACCEPT_MATCH_TOLERANCE_SECONDS = 3600 + def _normalize_login(login: str | None) -> str: return (login or "").strip().lower() @@ -31,6 +37,22 @@ class ReviewerAttentionItem: missing_assignment_timestamp: bool = False +@dataclass(frozen=True) +class ReviewerProposalItem: + """A pending (state=proposed) assignment proposal awaiting this reviewer's decision. + + ``notified`` mirrors ``AssignmentProposal.notified_at``: an un-notified proposal is what + triggers a transactional send; already-notified pending proposals ride along as context in + any report that goes out. + """ + + proposal_id: int + pr_number: int + pr_title: str + expires_at: datetime + notified: bool + + @dataclass(frozen=True) class ReviewerAttentionReport: reviewer_login: str @@ -41,14 +63,25 @@ class ReviewerAttentionReport: auto_unassign_days: int items: tuple[ReviewerAttentionItem, ...] = field(default_factory=tuple) warnings: tuple[str, ...] = field(default_factory=tuple) + proposal_items: tuple[ReviewerProposalItem, ...] = field(default_factory=tuple) @property def has_events_of_interest(self) -> bool: return any(item.needs_new_assignment_ping or item.needs_nudge or item.needs_auto_unassign for item in self.items) + @property + def has_pending_proposals(self) -> bool: + return bool(self.proposal_items) + + @property + def has_unnotified_proposals(self) -> bool: + return any(not proposal.notified for proposal in self.proposal_items) + @property def has_notifications_to_send(self) -> bool: - return self.notifications_enabled and self.has_events_of_interest + # Optional attention nudges honor the notifications opt-in; proposal prompts are + # transactional (design doc 050) and must reach the reviewer even when nudges are muted. + return (self.notifications_enabled and self.has_events_of_interest) or self.has_unnotified_proposals def build_reviewer_attention_reports( @@ -150,6 +183,51 @@ 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). Suppression is tied to + # the acceptance that *produced* the assignment (event time within a small tolerance of + # decided_at), so a later unrelated re-assignment of the same pair still pings, and an acceptance + # just before the window still suppresses its own assignment landing just inside it. + # Data-driven: no accepted-via-console rows -> no effect. + console_accept_decided_at: dict[tuple[int, str], datetime] = {} + 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 - timedelta(seconds=CONSOLE_ACCEPT_MATCH_TOLERANCE_SECONDS), + ).values_list("pr_number", "reviewer_login", "decided_at") + for n, login, decided_at in accepted_rows: + if decided_at is None: + continue + key = (int(n), _normalize_login(str(login))) + prev = console_accept_decided_at.get(key) + if prev is None or decided_at > prev: + console_accept_decided_at[key] = decided_at + + # Pending acceptance-gate proposals (design doc 050) surface in the same report. PR titles come + # from the open-PR set already loaded; a proposal's PR is open by construction (the expiry sweep + # supersedes proposals on closed/merged PRs), so the fallback label is a transient-race guard. + proposals_by_reviewer: dict[str, list[ReviewerProposalItem]] = {} + proposal_rows = AssignmentProposal.objects.filter( + repository=repository, + state=AssignmentProposal.STATE_PROPOSED, + ).order_by("expires_at", "pr_number", "id") + for proposal in proposal_rows: + pr_number = int(proposal.pr_number) + pr = pr_by_number.get(pr_number) + proposals_by_reviewer.setdefault(_normalize_login(proposal.reviewer_login), []).append( + ReviewerProposalItem( + proposal_id=int(proposal.id), + pr_number=pr_number, + pr_title=pr.title if pr is not None else f"PR #{pr_number}", + expires_at=proposal.expires_at, + notified=proposal.notified_at is not None, + ) + ) + reports: list[ReviewerAttentionReport] = [] for pref in prefs: reviewer_login = _normalize_login(getattr(pref.user, "github_login", None)) @@ -199,7 +277,12 @@ def build_reviewer_attention_reports( 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: - needs_new_assignment_ping = True + accepted_at = console_accept_decided_at.get(assignment_key) + produced_by_console_accept = ( + accepted_at is not None + and abs((last_assigned_at - accepted_at).total_seconds()) <= CONSOLE_ACCEPT_MATCH_TOLERANCE_SECONDS + ) + needs_new_assignment_ping = not produced_by_console_accept items.append( ReviewerAttentionItem( @@ -228,6 +311,7 @@ def build_reviewer_attention_reports( auto_unassign_days=policy.auto_unassign_days, items=tuple(items), warnings=tuple(warnings), + proposal_items=tuple(proposals_by_reviewer.get(reviewer_login, ())), ) ) diff --git a/qb_site/analyzer/services/reviewer_attention_format.py b/qb_site/analyzer/services/reviewer_attention_format.py index fc333a75..23c0ff7c 100644 --- a/qb_site/analyzer/services/reviewer_attention_format.py +++ b/qb_site/analyzer/services/reviewer_attention_format.py @@ -41,6 +41,24 @@ def format_since_timestamp(ts: datetime | None, *, now: datetime | None = None) return f"{format_global_time(ts)} ({format_compact_duration(total_seconds)} ago)" +def format_proposal_expiry(expires_at: datetime, *, now: datetime | None = None) -> str: + """Absolute (Zulip-localized) proposal deadline plus a coarse relative hint.""" + now_ts = now or datetime.now(timezone.utc) + if now_ts.tzinfo is None: + now_ts = now_ts.replace(tzinfo=timezone.utc) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + tag = format_global_time(expires_at) + remaining = (expires_at - now_ts).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 sort_by_assignment_recency(items: list[ReviewerAttentionItem]) -> list[ReviewerAttentionItem]: return sorted( items, diff --git a/qb_site/analyzer/services/reviewer_load.py b/qb_site/analyzer/services/reviewer_load.py index 1c588ee5..19b73710 100644 --- a/qb_site/analyzer/services/reviewer_load.py +++ b/qb_site/analyzer/services/reviewer_load.py @@ -7,9 +7,12 @@ reviewer sees is the same one that gates whether they get auto-assigned: - ``current_load`` is the engine's **weighted** load (status-weighted, self-authored PRs excluded), - not a raw PR count. ``maximum_capacity - current_load`` is the reviewer's remaining capacity. -- ``assigned_open`` is the raw count of open PRs they are assigned to, kept alongside for human - context (the gap between it and ``current_load`` is what silently reflects zero-weight PRs). + and also folds in the reviewer's pending assignment proposals (design doc 050), which occupy + capacity exactly as the engine / area stats count them. ``maximum_capacity - current_load`` is the + reviewer's remaining capacity. +- ``assigned_open`` is the raw count of open PRs they are assigned to (proposals are load, not + assignees, so they are *not* counted here), kept alongside for human context (the gap between it + and ``current_load`` reflects zero-weight PRs and pending proposals). This module deliberately does **not** re-derive load math: it reads the cached queue snapshot (the same one ``pr_info`` uses) and folds ``collect_assignment_statistics`` output against reviewer @@ -23,9 +26,17 @@ from datetime import datetime from typing import Iterable, Mapping +from django.conf import settings + from analyzer.models import QueueSnapshot from analyzer.services.queue_rules import default_rule_set_for_repo -from analyzer.services.reviewer_assignment import build_reviewer_catalog, collect_assignment_statistics +from analyzer.services.reviewer_assignment import ( + _compute_weight, + add_pending_proposal_load, + build_reviewer_catalog, + collect_assignment_statistics, + pending_proposal_load_for_repo, +) from analyzer.services.reviewer_assignment_engine import ReviewerProfile from core.models import Repository @@ -101,9 +112,10 @@ def build_reviewer_loads( ) -> dict[str, ReviewerLoad]: """Per-reviewer load for a repo, keyed by normalized login. - Reads the latest cached queue snapshot (same resolution as ``pr_info``). Returns ``{}`` when no - snapshot or no reviewers are available — callers should render no load line in that case rather - than fabricate a count. Read-only: never builds a snapshot. + Reads the latest cached queue snapshot (same resolution as ``pr_info``) and folds in pending + assignment-proposal load (design doc 050) so the figure matches the engine's capacity gate. + Returns ``{}`` when no snapshot or no reviewers are available — callers should render no load line + in that case rather than fabricate a count. Read-only: never builds a snapshot. """ payload = snapshot_payload if snapshot_payload is not None else _latest_snapshot_payload(repository) if not payload: @@ -112,9 +124,17 @@ def build_reviewer_loads( if not reviewers: return {} stats = collect_assignment_statistics(payload) + # Pending proposals occupy capacity exactly as the assignment engine / area stats count them + # (design doc 050): fold their weighted load in so ``current_load`` matches the gate that governs + # auto-assignment. Data-driven — no active proposals -> no change. + proposal_weight = float(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT", 1.0)) + assignments = add_pending_proposal_load( + stats.assignments, + pending_proposal_load_for_repo(repository, weight=proposal_weight), + ) return compute_reviewer_loads( repository_id=int(repository.id), - assignments=stats.assignments, + assignments=assignments, reviewers=reviewers, ) @@ -131,6 +151,52 @@ def reviewer_load_for( return loads.get(normalize_login(reviewer_login)) +def pr_load_breakdown(payload: dict, reviewer_login: str) -> dict[int, float]: + """Per-PR weighted load contribution for one reviewer, from a queue-snapshot payload. + + Mirrors the per-PR fold inside ``collect_assignment_statistics`` (status-weighted via the shared + ``_compute_weight``, self-authored PRs contributing 0) but scoped to a single reviewer, so the + values sum to that reviewer's assigned-PR share of ``current_load``. Pending proposals are load + too, but they are not snapshot assignees, so they are not included here — callers add the + proposal weight (``ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT``) separately. Keyed by PR + number. + """ + norm = normalize_login(reviewer_login) + if not norm: + return {} + breakdown: dict[int, float] = {} + for pr_number_raw, entry in (payload.get("prs", {}) or {}).items(): + assignees = entry.get("assignees") or [] + if norm not in {normalize_login(login) for login in assignees if login}: + continue + try: + pr_number = int(pr_number_raw) + except (TypeError, ValueError): + continue + author_norm = normalize_login(entry.get("author") or "") + breakdown[pr_number] = 0.0 if norm == author_norm else _compute_weight(pr_number, entry) + return breakdown + + +def reviewer_load_with_breakdown( + repository: Repository, + reviewer_login: str, + *, + now: datetime | None = None, +) -> tuple[ReviewerLoad | None, dict[int, float]]: + """One reviewer's load plus its per-PR weight breakdown, from a single snapshot read. + + Both halves derive from the same cached queue snapshot, so the per-PR contributions sum to the + assigned-PR portion of the aggregate ``current_load`` (pending-proposal load is the remainder). + Returns ``(None, {})`` when no snapshot is available. + """ + payload = _latest_snapshot_payload(repository) + if not payload: + return None, {} + load = reviewer_load_for(repository, reviewer_login, snapshot_payload=payload, now=now) + return load, pr_load_breakdown(payload, reviewer_login) + + def _latest_snapshot_payload(repository: Repository) -> dict | None: rule_set = default_rule_set_for_repo(repository) cache_key = str(rule_set.id) if rule_set else "default" @@ -153,6 +219,15 @@ def _fmt_load_number(value: float) -> str: return f"{rounded:.1f}" +def format_load_contribution(weight: float) -> str: + """Render one PR's load contribution as a signed figure (``+1`` / ``+0.1`` / ``+0``). + + Uses the same number formatting as the aggregate load line, so a per-PR ``+1`` reads consistently + with the ``Load: 3 / 5`` it rolls up into. + """ + return f"+{_fmt_load_number(weight)}" + + def format_load_line(load: ReviewerLoad, *, include_assigned_count: bool = False) -> str: """Render the one-line load summary. 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_apply.py b/qb_site/analyzer/tasks/reviewer_assignment_apply.py index 4c7e9e51..de0001c5 100644 --- a/qb_site/analyzer/tasks/reviewer_assignment_apply.py +++ b/qb_site/analyzer/tasks/reviewer_assignment_apply.py @@ -51,6 +51,16 @@ def apply_reviewer_assignments_task( if not enabled and not dry_run: return {"skipped": True, "reason": "feature_disabled", "enabled": enabled, "dry_run": dry_run} + # The acceptance-gate propose task supersedes this one. If both pipelines are enabled, this + # proposal-unaware task would direct-assign confirm-mode reviewers at the same 00:45 slot, + # bypassing the gate — so yield to the gate rather than trusting the docs alone. + if enabled and bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED", False)): + log.error( + "analyzer.apply_reviewer_assignments: skipping — ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED is also set and " + "analyzer.propose_reviewer_assignments supersedes this task. Enable one pipeline or the other, not both." + ) + return {"skipped": True, "reason": "superseded_by_proposals_pipeline", "enabled": enabled, "dry_run": dry_run} + 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)) 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..60445227 --- /dev/null +++ b/qb_site/analyzer/tasks/reviewer_assignment_propose.py @@ -0,0 +1,146 @@ +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} + + if enabled and bool(getattr(settings, "ANALYZER_REVIEWER_ASSIGNMENT_APPLY_ENABLED", False)): + # Misconfiguration guard: both pipelines are switched on. The legacy apply task yields to + # this one (it skips itself), so proceed — but tell the operator to turn one flag off. + log.warning( + "analyzer.propose_reviewer_assignments: ANALYZER_REVIEWER_ASSIGNMENT_APPLY_ENABLED is also set; the legacy " + "apply task is skipping itself in favor of this acceptance-gate pipeline. Disable one of the two flags." + ) + + 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/tasks/reviewer_attention.py b/qb_site/analyzer/tasks/reviewer_attention.py index c7c36ec6..b18642ce 100644 --- a/qb_site/analyzer/tasks/reviewer_attention.py +++ b/qb_site/analyzer/tasks/reviewer_attention.py @@ -7,9 +7,11 @@ from celery import shared_task from django.conf import settings +from django.urls import reverse from django.utils import timezone as dj_timezone from analyzer.models import ( + AssignmentProposal, ReviewerAttentionAutoUnassignRecord, ReviewerAttentionDailyRun, ReviewerAttentionNotificationRecord, @@ -18,6 +20,7 @@ from analyzer.services.reviewer_attention import ReviewerAttentionItem, ReviewerAttentionReport from analyzer.services.reviewer_load import ReviewerLoad, build_reviewer_loads, format_load_line, normalize_login from analyzer.services.reviewer_attention_format import ( + format_proposal_expiry, format_since_timestamp, render_consecutive_queue_time_since_assignment_line, render_total_queue_time_line, @@ -26,13 +29,13 @@ ) from core.services.github_assignment import AssignmentMutationError, GitHubAssignmentClient from core.services.github_operation_tokens import resolve_github_app_operation_token +from core.services.site_urls import build_site_url from core.models import Repository, User from core.utils.zulip_time import format_global_time -from zulip_bot.services.zulip_client import ZulipApiError, ZulipClient +from zulip_bot.services.zulip_client import MAX_MESSAGE_CHARS, ZulipApiError, ZulipClient, split_message_chunks log = logging.getLogger(__name__) -MAX_MESSAGE_CHARS = 9000 def _iter_item_notification_categories(item: ReviewerAttentionItem) -> list[str]: @@ -245,36 +248,6 @@ def _coerce_utc_datetime(raw: Any) -> datetime | None: return None -def _split_message_chunks(*, content: str, max_chars: int) -> list[str]: - if len(content) <= max_chars: - return [content] - - lines = content.splitlines() - chunks: list[str] = [] - current: list[str] = [] - current_len = 0 - for line in lines: - 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 - if not current and line_len > max_chars: - start = 0 - while start < len(line): - end = min(start + max_chars, len(line)) - chunks.append(line[start:end]) - start = end - continue - current.append(line) - current_len += line_len - - if current: - chunks.append("\n".join(current)) - return chunks - - def _format_item_line(item: ReviewerAttentionItem) -> str: return f"- PR #{item.pr_number}: {item.pr_title}" @@ -294,21 +267,24 @@ def _render_reviewer_message( enforcement_enabled: bool, unassign_outcomes: dict[tuple[int, int, int], str], loads_by_repo_id: dict[int, dict[str, ReviewerLoad]] | None = None, + console_url: str | None = None, ) -> str: loads_by_repo_id = loads_by_repo_id or {} reviewer_login_norm = normalize_login(reviewer_login) lines: list[str] = [ - "### Assigned queue PRs that may need your attention", + "### Queue PRs that may need your attention", "", f"Generated at {format_global_time(_now_utc_unix())}.", "", ] + any_proposals = False for repo_label, report in repo_reports: + proposal_items = list(report.proposal_items) new_items = sort_by_assignment_recency([item for item in report.items if item.needs_new_assignment_ping]) nudge_items = sort_by_queue_age([item for item in report.items if item.needs_nudge]) unassign_items = sort_by_queue_age([item for item in report.items if item.needs_auto_unassign]) - if not (new_items or nudge_items or unassign_items): + if not (proposal_items or new_items or nudge_items or unassign_items): continue lines.append(f"## {repo_label}") @@ -322,6 +298,16 @@ def _render_reviewer_message( if load is not None: lines.append(format_load_line(load, include_assigned_count=True)) lines.append("") + if proposal_items: + # Acceptance-gate proposals (design doc 050): proposed, never assigned — a distinct + # section so "proposed to you" is never conflated with an assignment. + any_proposals = True + lines.append(f"#### Proposed to you, awaiting your response ({len(proposal_items)})") + if console_url: + lines.append(f"Accept or decline them here: {console_url}") + for proposal in proposal_items: + lines.append(f"- PR #{proposal.pr_number}: {proposal.pr_title}") + lines.append(f" - {format_proposal_expiry(proposal.expires_at)}") if new_items: lines.append(f"#### Newly assigned ({len(new_items)})") lines.append("Assigned recently and worth an initial pass.") @@ -372,6 +358,12 @@ def _render_reviewer_message( lines.append(f" - assigned {format_since_timestamp(item.last_assigned_at)}") lines.append("") + if any_proposals: + lines.append( + "Declining a proposal opts you out of that PR. Letting it expire simply passes it to " + "another reviewer, and you may be proposed again later." + ) + lines.append("") lines.append("Tips:") lines.append("- Unassign yourself: `unassign #`") lines.append("- See all your assigned PRs: `assigned-prs`") @@ -470,6 +462,8 @@ def reviewer_attention_daily_task( "would_new_assignment_ping": 0, "missing_assignment_timestamps": 0, "warnings": 0, + "pending_proposals": 0, + "unnotified_proposals": 0, } auto_unassign_candidates: list[dict[str, Any]] = [] seen_auto_unassign_candidates: set[tuple[int, int, int]] = set() @@ -491,6 +485,8 @@ def reviewer_attention_daily_task( would_new_assignment_ping = sum(1 for report in reports for item in report.items if item.needs_new_assignment_ping) missing_assignment_timestamps = sum(1 for report in reports for item in report.items if item.missing_assignment_timestamp) warnings = sum(len(report.warnings) for report in reports) + pending_proposals = sum(len(report.proposal_items) for report in reports) + unnotified_proposals = sum(1 for report in reports for proposal in report.proposal_items if not proposal.notified) repo_payload = { "repo": f"{repo.owner}/{repo.name}", @@ -505,6 +501,8 @@ def reviewer_attention_daily_task( "would_new_assignment_ping": would_new_assignment_ping, "missing_assignment_timestamps": missing_assignment_timestamps, "warnings": warnings, + "pending_proposals": pending_proposals, + "unnotified_proposals": unnotified_proposals, } repos_summary.append(repo_payload) @@ -525,7 +523,10 @@ def reviewer_attention_daily_task( "pr_number": int(item.pr_number), } ) - if not report.has_notifications_to_send: + # A report enters the delivery bucket when it can trigger a send (nudge events with + # notifications on, or un-notified proposals) OR when it carries pending proposals + # that should ride along as context in a DM triggered by another repo. + if not (report.has_notifications_to_send or report.has_pending_proposals): continue bucket = reports_by_reviewer.setdefault( int(report.reviewer_user_id), @@ -546,6 +547,8 @@ def reviewer_attention_daily_task( totals["would_new_assignment_ping"] += would_new_assignment_ping totals["missing_assignment_timestamps"] += missing_assignment_timestamps totals["warnings"] += warnings + totals["pending_proposals"] += pending_proposals + totals["unnotified_proposals"] += unnotified_proposals enforcement_stats = { "candidates": len(auto_unassign_candidates), @@ -645,6 +648,7 @@ def reviewer_attention_daily_task( "skipped_delivery_disabled": 0, "skipped_by_reviewer_filter": skipped_by_reviewer_filter, "skipped_already_sent": 0, + "proposals_notified": 0, } client: ZulipClient | None = None @@ -663,6 +667,8 @@ def reviewer_attention_daily_task( for repo in repos: loads_by_repo_id[int(repo.id)] = build_reviewer_loads(repo) + console_url = build_site_url(reverse("console:home")) + for reviewer_user_id, payload in sorted(reports_by_reviewer.items()): reviewer_login = str(payload["reviewer_login"]) user = users_by_id.get(reviewer_user_id) @@ -709,27 +715,34 @@ def reviewer_attention_daily_task( continue reviewer_claimed_notification_keys: set[tuple[int, int, int, str, datetime]] = set() + unnotified_proposal_ids: list[int] = [] filtered_repo_reports: list[tuple[str, ReviewerAttentionReport]] = [] for repo_label, report in list(payload["repo_reports"]): - claimed = _claim_notification_categories( - run=daily_run, - run_date=run_date, - reviewer_id=reviewer_user_id, - report=report, - now_ts=now_ts, - ) - if not claimed: - continue + unnotified_proposal_ids.extend(proposal.proposal_id for proposal in report.proposal_items if not proposal.notified) + claimed: set[tuple[int, int, int, str, datetime]] = set() + # Optional nudge categories honor the notifications opt-in; proposal prompts are + # transactional and bypass it (their dedupe is AssignmentProposal.notified_at). + if report.notifications_enabled: + claimed = _claim_notification_categories( + run=daily_run, + run_date=run_date, + reviewer_id=reviewer_user_id, + report=report, + now_ts=now_ts, + ) reviewer_claimed_notification_keys.update(claimed) filtered_report = _filter_report_for_claimed_categories( report=report, reviewer_id=reviewer_user_id, claimed_keys=reviewer_claimed_notification_keys, ) - if filtered_report.has_notifications_to_send: + if filtered_report.items or filtered_report.proposal_items: filtered_repo_reports.append((repo_label, filtered_report)) - if not filtered_repo_reports or not reviewer_claimed_notification_keys: + # Send only when something new triggers it: a claimed nudge category or an un-notified + # proposal. Already-notified pending proposals alone never re-trigger a DM; they ride + # along as context when another trigger fires. + if not filtered_repo_reports or not (reviewer_claimed_notification_keys or unnotified_proposal_ids): delivery_stats["skipped_already_sent"] += 1 deliveries.append( { @@ -746,24 +759,37 @@ def reviewer_attention_daily_task( enforcement_enabled=enforcement_enabled, unassign_outcomes=unassign_outcomes, loads_by_repo_id=loads_by_repo_id, + console_url=console_url, ) - chunks = _split_message_chunks(content=message, max_chars=MAX_MESSAGE_CHARS) + chunks = split_message_chunks(content=message, max_chars=MAX_MESSAGE_CHARS) delivery_stats["attempted"] += 1 try: for chunk in chunks: client.send_direct_message(to=[int(user.zulip_user_id)], content=chunk) + sent_ts = dj_timezone.now() _mark_notification_records_sent( run=daily_run, claimed_keys=reviewer_claimed_notification_keys, - now_ts=dj_timezone.now(), + now_ts=sent_ts, ) + proposals_notified = 0 + if unnotified_proposal_ids: + # Stamp only rows still proposed + un-notified so a concurrent accept/expire is + # never clobbered (same conditional-update discipline as every proposal writer). + proposals_notified = AssignmentProposal.objects.filter( + id__in=unnotified_proposal_ids, + state=AssignmentProposal.STATE_PROPOSED, + notified_at__isnull=True, + ).update(notified_at=sent_ts, updated_at=sent_ts) delivery_stats["sent"] += 1 + delivery_stats["proposals_notified"] += int(proposals_notified) deliveries.append( { "reviewer_user_id": reviewer_user_id, "reviewer_login": reviewer_login, "status": "sent", "chunks": len(chunks), + "proposals_notified": int(proposals_notified), } ) except ZulipApiError as exc: 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/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..4f1a4e74 --- /dev/null +++ b/qb_site/analyzer/tests/services/test_assignment_proposal_expiry.py @@ -0,0 +1,226 @@ +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, ReviewerOptOut +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_reviewer_opt_out_supersedes_proposal(self) -> None: + # An opt-out landing after the proposal was created (e.g. a GitHub self-unassign reconciled + # into one) retires the pending proposal on the next sweep instead of leaving it dangling + # on the console/board until its multi-day window times out. + proposal = self._proposal(105, login="Bob") # mixed case: opt-outs are stored lowercase + self._make_pr(105) + self._make_queue_snapshot(queue=[105], known=[105]) + ReviewerOptOut.objects.create( + repository=self.repo, pr_number=105, reviewer_login="bob", active=True, opted_out_at=self.now + ) + + 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_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..0804c422 --- /dev/null +++ b/qb_site/analyzer/tests/services/test_assignment_proposal_validity.py @@ -0,0 +1,192 @@ +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_OPTED_OUT, + 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_superseded_when_reviewer_opted_out(self) -> None: + # An active opt-out (decline elsewhere, or a self-unassign reconciled into one after the + # proposal was created) retires the pending proposal instead of leaving it dangling. + v = proposal_validity( + self._proposal(), + now=self.now, + pr_state="open", + current_assignees=set(), + on_queue=True, + on_queue_exit=ON_QUEUE_EXIT_INVALIDATE, + opted_out=True, + ) + self.assertFalse(v.is_live) + self.assertEqual(v.reason, REASON_OPTED_OUT) + self.assertEqual(v.terminal_state, AssignmentProposal.STATE_SUPERSEDED) + self.assertEqual(v.decided_via, AssignmentProposal.DECIDED_VIA_SYNC_SUPERSEDED) + + def test_assignee_takes_precedence_over_opt_out(self) -> None: + v = proposal_validity( + self._proposal(), + now=self.now, + pr_state="open", + current_assignees={"human"}, + on_queue=True, + opted_out=True, + ) + self.assertEqual(v.reason, REASON_PR_ASSIGNED) + + 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_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/services/test_reviewer_assignment.py b/qb_site/analyzer/tests/services/test_reviewer_assignment.py index 420ff361..7df7b10d 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, @@ -1257,3 +1455,30 @@ def test_build_and_store_area_stats_snapshot(self): self.assertIsNotNone(obj.queue_snapshot) self.assertGreaterEqual(obj.area_count, 1) self.assertIn("t-analysis", obj.payload["area_stats"]) + + def test_pending_proposal_load_counts_toward_area_capacity(self): + # A reviewer whose capacity is fully occupied by a pending proposal must read as + # at_max_capacity in area stats, exactly as the assignment builder/trace see it + # (design doc 050: a pending proposal counts toward load). + self._make_pr(60, labels=("t-analysis",)) + ReviewerPreference.objects.create( + repository=self.repo, + user=self.alice, + preferred_labels=["t-analysis"], + maximum_capacity=1, + auto_assign=True, + ) + builder = AreaStatsBuilder(rng=random.Random(0)) + + before = builder.build_and_store(self.repo) + self.assertFalse(before.payload["area_stats"]["t-analysis"]["at_max_capacity"]) + + AssignmentProposal.objects.create( + repository=self.repo, + pr_number=61, + reviewer_login="alice", + state=AssignmentProposal.STATE_PROPOSED, + expires_at=self.now + timedelta(days=7), + ) + after = builder.build_and_store(self.repo) + self.assertTrue(after.payload["area_stats"]["t-analysis"]["at_max_capacity"]) 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..7d4a5f59 --- /dev/null +++ b/qb_site/analyzer/tests/services/test_reviewer_assignment_propose.py @@ -0,0 +1,405 @@ +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_global_window_below_seven_is_honored(self) -> None: + # The >=7 clamp applies only to per-reviewer overrides; the operator-configured global + # ANALYZER_ASSIGNMENT_PROPOSAL_WINDOW_DAYS is honored as-is (base.py/.env.example document + # the clamp as per-reviewer-only). + self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True) + self._make_snapshot({106: "bob"}) + self._make_pr(106) + + self._propose(window_days=3) + + proposal = AssignmentProposal.objects.get(repository=self.repo, pr_number=106) + self.assertEqual(proposal.expires_at, self.now + timedelta(days=3)) + + 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_any_assignee_blocks_proposal_even_a_non_reviewer(self) -> None: + # Mirror proposal_validity: ANY assignee supersedes, not just eligible reviewers. Creating + # a proposal here would have it superseded by the next expiry sweep and re-created (and + # re-DM'd) by the next propose run — the daily-churn loop from the design doc 050 review. + self._make_reviewer("bob", acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, reachable=True) + self._make_snapshot({101: "bob"}) + self._make_pr(101, assignees=["some-bot"]) # assignee is not an eligible reviewer + + result, client, _sync = self._propose() + + self.assertEqual(result["stats"]["skipped_already_assigned"], 1) + self.assertEqual(result["stats"]["proposed"], 0) + self.assertFalse(AssignmentProposal.objects.exists()) + 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/services/test_reviewer_attention.py b/qb_site/analyzer/tests/services/test_reviewer_attention.py index 7b3c064b..5e0a9e94 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,170 @@ 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_later_manual_reassignment_still_pings_after_console_accept(self) -> None: + # The suppression is tied to the assignment the acceptance produced. A reviewer who accepts, + # self-unassigns, and is later manually re-assigned within the same ping window must still be + # pinged for the unrelated re-assignment. + pr = self._mk_pr(114, assignees=["alice"]) + self._make_accepted_proposal( + pr_number=114, + reviewer_login="alice", + decided_at=self.now - timedelta(hours=10), + decided_via=AssignmentProposal.DECIDED_VIA_CONSOLE, + ) + # The latest ASSIGNED event is hours after the acceptance -> a different assignment. + self._add_assignment_event(pr=pr, assignee_login="alice", occurred_at=self.now - timedelta(hours=2)) + + 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.assertTrue(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) + + def _make_pending_proposal( + self, + *, + pr_number: int, + reviewer_login: str, + notified_at: datetime | None = None, + ) -> AssignmentProposal: + return AssignmentProposal.objects.create( + repository=self.repo, + pr_number=pr_number, + reviewer_login=reviewer_login, + state=AssignmentProposal.STATE_PROPOSED, + expires_at=self.now + timedelta(days=5), + notified_at=notified_at, + ) + + def test_pending_proposal_surfaces_as_proposal_item_not_assigned_item(self) -> None: + self._mk_pr(120, assignees=[]) + proposal = self._make_pending_proposal(pr_number=120, reviewer_login="Alice") # case-insensitive match + + reports = build_reviewer_attention_reports(repository=self.repo, as_of=self.now) + report = reports[0] + + self.assertEqual(len(report.items), 0) # a proposal is never an assignee (invariant 4) + self.assertEqual(len(report.proposal_items), 1) + item = report.proposal_items[0] + self.assertEqual(item.proposal_id, proposal.id) + self.assertEqual(item.pr_number, 120) + self.assertEqual(item.pr_title, "PR 120") + self.assertEqual(item.expires_at, proposal.expires_at) + self.assertFalse(item.notified) + self.assertTrue(report.has_pending_proposals) + self.assertTrue(report.has_unnotified_proposals) + self.assertFalse(report.has_events_of_interest) + self.assertTrue(report.has_notifications_to_send) + + def test_unnotified_proposal_is_transactional_despite_muted_notifications(self) -> None: + self.pref.notifications_enabled = False + self.pref.save(update_fields=["notifications_enabled"]) + self._make_pending_proposal(pr_number=121, reviewer_login="alice") + + reports = build_reviewer_attention_reports(repository=self.repo, as_of=self.now) + report = reports[0] + + self.assertTrue(report.has_unnotified_proposals) + self.assertTrue(report.has_notifications_to_send) + + def test_already_notified_proposal_alone_does_not_trigger_a_send(self) -> None: + self._mk_pr(122, assignees=[]) + self._make_pending_proposal(pr_number=122, reviewer_login="alice", notified_at=self.now - timedelta(days=1)) + + reports = build_reviewer_attention_reports(repository=self.repo, as_of=self.now) + report = reports[0] + + self.assertTrue(report.has_pending_proposals) + self.assertFalse(report.has_unnotified_proposals) + self.assertFalse(report.has_notifications_to_send) + + def test_proposal_title_falls_back_when_pr_row_is_missing(self) -> None: + self._make_pending_proposal(pr_number=123, reviewer_login="alice") + + reports = build_reviewer_attention_reports(repository=self.repo, as_of=self.now) + item = reports[0].proposal_items[0] + + self.assertEqual(item.pr_title, "PR #123") + + def test_terminal_proposals_do_not_surface(self) -> None: + self._mk_pr(124, assignees=[]) + AssignmentProposal.objects.create( + repository=self.repo, + pr_number=124, + reviewer_login="alice", + state=AssignmentProposal.STATE_EXPIRED, + expires_at=self.now - timedelta(days=1), + decided_at=self.now - timedelta(days=1), + decided_via=AssignmentProposal.DECIDED_VIA_AUTO_EXPIRE, + ) + + reports = build_reviewer_attention_reports(repository=self.repo, as_of=self.now) + + self.assertEqual(len(reports[0].proposal_items), 0) diff --git a/qb_site/analyzer/tests/services/test_reviewer_load.py b/qb_site/analyzer/tests/services/test_reviewer_load.py index d626eeaa..2eca6434 100644 --- a/qb_site/analyzer/tests/services/test_reviewer_load.py +++ b/qb_site/analyzer/tests/services/test_reviewer_load.py @@ -4,14 +4,17 @@ from django.test import TestCase -from analyzer.models import QueueRuleSet, QueueSnapshot +from analyzer.models import AssignmentProposal, QueueRuleSet, QueueSnapshot from analyzer.services.reviewer_assignment_engine import ReviewerProfile from analyzer.services.reviewer_load import ( ReviewerLoad, build_reviewer_loads, compute_reviewer_loads, + format_load_contribution, format_load_line, + pr_load_breakdown, reviewer_load_for, + reviewer_load_with_breakdown, ) from core.models import Repository, ReviewerPreference, User @@ -201,3 +204,56 @@ def test_reviewer_load_for_convenience(self) -> None: self.assertIsNotNone(load) self.assertEqual(load.current_load, 1.0) self.assertIsNone(reviewer_load_for(self.repo, "nobody")) + + def test_pending_proposals_count_toward_load_not_assigned_open(self) -> None: + # 1 assigned PR (weight 1.0) + 1 pending proposal (weight 1.0) = 2.0 load; a proposal is + # load, not an assignee, so assigned_open stays 1 (design doc 050). + self._seed_snapshot({"1": {"assignees": ["alice"], "author": "bob", "pr_status": "AwaitingReview"}}) + AssignmentProposal.objects.create( + repository=self.repo, + pr_number=42, + reviewer_login="alice", + state=AssignmentProposal.STATE_PROPOSED, + expires_at=datetime(2026, 8, 1, tzinfo=dt_timezone.utc), + ) + loads = build_reviewer_loads(self.repo) + self.assertEqual(loads["alice"].assigned_open, 1) + self.assertEqual(loads["alice"].current_load, 2.0) + + def test_pr_load_breakdown_from_payload(self) -> None: + # Per-PR contribution mirrors the aggregate fold: status-weighted, self-authored = 0, and + # scoped to the one reviewer (erin's PR is excluded). Login matching is case-insensitive. + self._seed_snapshot( + { + "1": {"assignees": ["alice"], "author": "bob", "pr_status": "AwaitingReview"}, + "2": {"assignees": ["alice"], "author": "alice", "pr_status": "AwaitingReview"}, + "3": {"assignees": ["erin"], "author": "frank", "pr_status": "AwaitingReview"}, + } + ) + payload = QueueSnapshot.objects.get(repository=self.repo).payload + self.assertEqual(pr_load_breakdown(payload, "AliCe"), {1: 1.0, 2: 0.0}) + + def test_reviewer_load_with_breakdown_parts_sum_to_assigned_load(self) -> None: + # With no pending proposals, the per-PR contributions sum exactly to current_load. + self._seed_snapshot( + { + "1": {"assignees": ["alice"], "author": "bob", "pr_status": "AwaitingReview"}, + "2": {"assignees": ["alice"], "author": "alice", "pr_status": "AwaitingReview"}, + } + ) + load, breakdown = reviewer_load_with_breakdown(self.repo, "alice") + self.assertIsNotNone(load) + self.assertEqual(breakdown, {1: 1.0, 2: 0.0}) + self.assertEqual(sum(breakdown.values()), load.current_load) + + def test_reviewer_load_with_breakdown_none_without_snapshot(self) -> None: + load, breakdown = reviewer_load_with_breakdown(self.repo, "alice") + self.assertIsNone(load) + self.assertEqual(breakdown, {}) + + +class TestFormatLoadContribution(TestCase): + def test_signed_and_consistent_with_load_line(self) -> None: + self.assertEqual(format_load_contribution(1.0), "+1") + self.assertEqual(format_load_contribution(0.1), "+0.1") + self.assertEqual(format_load_contribution(0.0), "+0") diff --git a/qb_site/analyzer/tests/tasks/test_reviewer_assignment_apply_task.py b/qb_site/analyzer/tests/tasks/test_reviewer_assignment_apply_task.py index 8fa3dc09..6d2c3eae 100644 --- a/qb_site/analyzer/tests/tasks/test_reviewer_assignment_apply_task.py +++ b/qb_site/analyzer/tests/tasks/test_reviewer_assignment_apply_task.py @@ -78,6 +78,22 @@ def test_dry_run_records_without_mutating(self) -> None: record = ReviewerAssignmentApplication.objects.get(repository=self.repo, pr_number=101) self.assertEqual(record.status, ReviewerAssignmentApplication.STATUS_SKIPPED_DRY_RUN) + @override_settings( + ANALYZER_REVIEWER_ASSIGNMENT_APPLY_ENABLED=True, + ANALYZER_REVIEWER_ASSIGNMENT_APPLY_DRY_RUN=False, + ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=True, + ) + def test_skips_when_proposals_pipeline_also_enabled(self) -> None: + # Both pipelines on is a misconfiguration: the proposal-unaware apply task must yield to + # the acceptance gate instead of direct-assigning confirm-mode reviewers past it. + self._seed_repo_with_proposal() + + res = apply_reviewer_assignments_task.apply().get() + + self.assertTrue(res["skipped"]) + self.assertEqual(res["reason"], "superseded_by_proposals_pipeline") + self.assertFalse(ReviewerAssignmentApplication.objects.exists()) + @override_settings(ANALYZER_REVIEWER_ASSIGNMENT_APPLY_DRY_RUN=True) def test_repo_filter_miss_returns_not_found(self) -> None: res = apply_reviewer_assignments_task.apply(kwargs={"repository_id": 999999}).get() 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/analyzer/tests/tasks/test_reviewer_attention_task.py b/qb_site/analyzer/tests/tasks/test_reviewer_attention_task.py index d7b32542..df446d0e 100644 --- a/qb_site/analyzer/tests/tasks/test_reviewer_attention_task.py +++ b/qb_site/analyzer/tests/tasks/test_reviewer_attention_task.py @@ -8,12 +8,13 @@ from django.utils import timezone from analyzer.models import ( + AssignmentProposal, QueueRuleSet, QueueSnapshot, ReviewerAttentionAutoUnassignRecord, ReviewerAttentionNotificationRecord, ) -from analyzer.services.reviewer_attention import ReviewerAttentionItem, ReviewerAttentionReport +from analyzer.services.reviewer_attention import ReviewerAttentionItem, ReviewerAttentionReport, ReviewerProposalItem from analyzer.tasks.reviewer_attention import reviewer_attention_daily_task from core.models import Repository, ReviewerPreference, User from core.services.github_assignment import AssignmentMutationError @@ -193,7 +194,7 @@ def test_sends_one_message_per_reviewer_when_delivery_enabled(self, mock_client_ self.assertEqual(mock_client.send_direct_message.call_count, 1) kwargs = mock_client.send_direct_message.call_args.kwargs self.assertEqual(kwargs["to"], [101]) - self.assertIn("Assigned queue PRs that may need your attention", kwargs["content"]) + self.assertIn("Queue PRs that may need your attention", kwargs["content"]) self.assertIn("Settings:", kwargs["content"]) self.assertIn("#### Newly assigned (1)", kwargs["content"]) self.assertIn("#### Queue attention (1)", kwargs["content"]) @@ -955,6 +956,178 @@ def test_delivery_sends_again_when_pr_reenters_queue_with_new_queue_anchor(self, self.assertEqual(res["delivery"]["stats"]["sent"], 1) self.assertEqual(mock_client_cls.return_value.send_direct_message.call_count, 1) + def _make_pending_proposal(self, *, pr_number: int, reviewer_login: str = "alice") -> AssignmentProposal: + return AssignmentProposal.objects.create( + repository=self.repo, + pr_number=pr_number, + reviewer_login=reviewer_login, + state=AssignmentProposal.STATE_PROPOSED, + expires_at=timezone.now() + timedelta(days=5), + ) + + @override_settings( + ANALYZER_REVIEWER_ATTENTION_ENABLED=True, + ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED=False, + ANALYZER_REVIEWER_ATTENTION_DELIVERY_ENABLED=True, + QUEUEBOARD_BASE_URL="https://queueboard.example.org", + ) + @patch("analyzer.tasks.reviewer_attention.ZulipClient") + def test_delivery_sends_proposal_section_stamps_notified_and_dedupes(self, mock_client_cls) -> None: + # End-to-end through the real report builder: a muted reviewer with a pending proposal + # still gets the transactional proposal DM (no nudge machinery involved), the proposal is + # stamped notified, and the next run has nothing new to send. + ReviewerPreference.objects.create(user=self.user, repository=self.repo, notifications_enabled=False) + proposal = self._make_pending_proposal(pr_number=555, reviewer_login="Alice") + + first = reviewer_attention_daily_task.apply().get() + second = reviewer_attention_daily_task.apply().get() + + self.assertEqual(first["totals"]["pending_proposals"], 1) + self.assertEqual(first["totals"]["unnotified_proposals"], 1) + self.assertEqual(first["delivery"]["stats"]["sent"], 1) + self.assertEqual(first["delivery"]["stats"]["proposals_notified"], 1) + self.assertEqual(mock_client_cls.return_value.send_direct_message.call_count, 1) + content = mock_client_cls.return_value.send_direct_message.call_args.kwargs["content"] + self.assertIn("#### Proposed to you, awaiting your response (1)", content) + self.assertIn("https://queueboard.example.org/console/", content) + self.assertIn("PR #555", content) + self.assertIn("expires None: + ReviewerPreference.objects.create(user=self.user, repository=self.repo, notifications_enabled=True) + proposal = self._make_pending_proposal(pr_number=556) + mock_client = mock_client_cls.return_value + mock_client.send_direct_message.side_effect = [ZulipApiError("temporary"), {"result": "success"}] + + first = reviewer_attention_daily_task.apply().get() + proposal.refresh_from_db() + self.assertEqual(first["delivery"]["stats"]["failed"], 1) + self.assertIsNone(proposal.notified_at) + + second = reviewer_attention_daily_task.apply().get() + proposal.refresh_from_db() + self.assertEqual(second["delivery"]["stats"]["sent"], 1) + self.assertEqual(second["delivery"]["stats"]["proposals_notified"], 1) + self.assertIsNotNone(proposal.notified_at) + + @override_settings( + ANALYZER_REVIEWER_ATTENTION_ENABLED=True, + ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED=False, + ANALYZER_REVIEWER_ATTENTION_DELIVERY_ENABLED=True, + ) + @patch("analyzer.tasks.reviewer_attention.build_reviewer_attention_reports") + @patch("analyzer.tasks.reviewer_attention.ZulipClient") + def test_muted_reviewer_dm_carries_proposals_but_no_nudge_sections(self, mock_client_cls, mock_build_reports) -> None: + proposal = self._make_pending_proposal(pr_number=557) + mock_build_reports.return_value = [ + ReviewerAttentionReport( + reviewer_login="alice", + reviewer_user_id=self.user.id, + repository_id=self.repo.id, + notifications_enabled=False, + stale_nudge_days=14, + auto_unassign_days=21, + items=( + ReviewerAttentionItem( + pr_number=101, + pr_title="PR 101", + is_on_queue=True, + last_assigned_at=datetime.now(dt_timezone.utc) - timedelta(days=16), + queue_anchor_at=datetime.now(dt_timezone.utc) - timedelta(days=16), + days_on_queue_since_assignment=16, + total_queue_seconds=16 * 24 * 60 * 60, + total_queue_days=16, + needs_nudge=True, + ), + ), + warnings=(), + proposal_items=( + ReviewerProposalItem( + proposal_id=proposal.id, + pr_number=557, + pr_title="PR 557", + expires_at=proposal.expires_at, + notified=False, + ), + ), + ) + ] + + res = reviewer_attention_daily_task.apply().get() + + self.assertEqual(res["delivery"]["stats"]["sent"], 1) + content = mock_client_cls.return_value.send_direct_message.call_args.kwargs["content"] + self.assertIn("#### Proposed to you, awaiting your response (1)", content) + self.assertNotIn("#### Queue attention", content) + self.assertNotIn("PR #101", content) + # Muted -> the optional nudge was neither claimed nor recorded. + self.assertEqual(ReviewerAttentionNotificationRecord.objects.count(), 0) + + @override_settings( + ANALYZER_REVIEWER_ATTENTION_ENABLED=True, + ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED=False, + ANALYZER_REVIEWER_ATTENTION_DELIVERY_ENABLED=True, + ) + @patch("analyzer.tasks.reviewer_attention.build_reviewer_attention_reports") + @patch("analyzer.tasks.reviewer_attention.ZulipClient") + def test_already_notified_proposal_rides_along_with_triggered_nudge(self, mock_client_cls, mock_build_reports) -> None: + proposal = self._make_pending_proposal(pr_number=558) + AssignmentProposal.objects.filter(id=proposal.id).update(notified_at=timezone.now() - timedelta(days=1)) + mock_build_reports.return_value = [ + ReviewerAttentionReport( + reviewer_login="alice", + reviewer_user_id=self.user.id, + repository_id=self.repo.id, + notifications_enabled=True, + stale_nudge_days=14, + auto_unassign_days=21, + items=( + ReviewerAttentionItem( + pr_number=101, + pr_title="PR 101", + is_on_queue=True, + last_assigned_at=datetime.now(dt_timezone.utc) - timedelta(days=16), + queue_anchor_at=datetime.now(dt_timezone.utc) - timedelta(days=16), + days_on_queue_since_assignment=16, + total_queue_seconds=16 * 24 * 60 * 60, + total_queue_days=16, + needs_nudge=True, + ), + ), + warnings=(), + proposal_items=( + ReviewerProposalItem( + proposal_id=proposal.id, + pr_number=558, + pr_title="PR 558", + expires_at=proposal.expires_at, + notified=True, + ), + ), + ) + ] + + res = reviewer_attention_daily_task.apply().get() + + self.assertEqual(res["delivery"]["stats"]["sent"], 1) + self.assertEqual(res["delivery"]["stats"]["proposals_notified"], 0) + content = mock_client_cls.return_value.send_direct_message.call_args.kwargs["content"] + self.assertIn("#### Queue attention (1)", content) + self.assertIn("#### Proposed to you, awaiting your response (1)", content) + @override_settings( ANALYZER_REVIEWER_ATTENTION_ENABLED=True, ANALYZER_REVIEWER_ATTENTION_ENFORCEMENT_ENABLED=False, 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/console/AGENTS.md b/qb_site/console/AGENTS.md new file mode 100644 index 00000000..6d4450d1 --- /dev/null +++ b/qb_site/console/AGENTS.md @@ -0,0 +1,98 @@ +# 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/`. + +## Styling +- Console pages share the reviewer-facing design system with the `zulip_bot` flows (registration, + prefs, close/label PR): the palette tokens, `.page`/`.hero`/`.card`/`.cta`/`.status`/`.pr-label` + components live in the app-neutral `qb_site/static/shared/shared_pages.css` + (`{% static 'shared/shared_pages.css' %}`). Console-only bits (proposal cards, accept/decline + buttons, load line, assigned-PR roster) live in `qb_site/console/static/console/console.css` and + are built on the shared CSS variables — do not hardcode colors. `templates/console/base.html` + links both stylesheets and wraps content in `
`. +- The shared system is intentionally light-only (matching the other reviewer pages); don't add a + console-specific `prefers-color-scheme` block. When adjusting shared components, remember the + `zulip_bot` templates consume the same file. + +## Home dashboard (`views.home` / `_build_home_context`) +- Beyond pending proposals, the home page is a small per-repo dashboard. A repo earns a section only + when the reviewer has **a proposal or ≥1 assigned open PR** there; each section shows: + - a **load line** from `analyzer.services.reviewer_load` (`reviewer_load_with_breakdown` + + `format_load_line`): the weighted, engine-matching load incl. pending proposals, so the number + agrees with the `assigned-prs` command and the daily digest. Absent a queue snapshot, no load + line is rendered. + - **assigned open PRs with status**, sourced from `analyzer.services.reviewer_attention` + (`build_reviewer_attention_reports`) — the same authority the `assigned-prs` command uses. When + `ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED` is on, the roster is a checkbox form + posting to `console:unassign` (see below). + - the **proposals** to accept/decline. + - a **per-PR load contribution** (`+1` / `+0.1` / `+0`) next to each assigned PR and proposal, from + `reviewer_load.pr_load_breakdown` folded over the *same* snapshot as the aggregate, so the parts + sum to the load line. Rendered only when a load line exists. The load line itself is a + `
` whose disclosure reveals the load legend (`templates/console/_load_legend.html`, + a shared partial) — explanation lives where the number is, not as a standing block up top. +- Do not re-derive load or assigned-PR facts here; reuse `reviewer_load` / `reviewer_attention`. The + load + per-PR breakdown come from one `reviewer_load_with_breakdown` call (single snapshot read). + +## 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_user_from_identity` + (Zulip-agnostic; never touches `zulip_user_id`). **Resolve-only by construction** — an unknown + GitHub account (or a recycled login whose node id no longer matches) 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. + +## Accept / decline / assign-anyway / unassign (the load-bearing handlers) +- All `POST`. Accept and decline 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 via the shared `_github_assign_self` helper + (`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. The "did it actually land?" check + (`applied`, or `already_recorded` only when the row is `APPLIED`) lives in `_github_assign_self`. +- **Assign-anyway** (`console:assign-anyway`): the escape hatch shown on `unavailable.html`. It + **deliberately bypasses the validity gate** — the point is to let a reviewer take a PR whose + proposal lapsed/was superseded/declined — but keeps one honest precondition, `_can_self_assign` + (PR is open AND the reviewer is not already an assignee). That precondition *is* the "all + recoverable states" rule: it admits expired / off-queue / assigned-to-others / opted-out and + excludes closed-merged and already-held PRs, with no per-reason enumeration. Same ownership check, + `ASSIGN_ON_ACCEPT` gate, and `_github_assign_self` mutation as accept; on success it also clears + any active per-PR `ReviewerOptOut` (so the builder won't undo the just-made assignment) and retires + a still-pending proposal to `accepted`. +- **Decline**: marks the proposal `declined` and upserts an active `ReviewerOptOut` (permanent + per-PR "no", enforced by the builder). No GitHub write. +- **Unassign** (`console:unassign`): self-service removal from one or more assigned PRs, gated by + `ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED`. Posts `repo_id` + `pr_numbers[]`. The + login removed is **always the authenticated reviewer's own**, never taken from the request, so this + surface can only ever unassign the person operating it. Uses `GitHubAssignmentClient.unassign` with + the `unassign_pr` operation token, confirms the reviewer actually left the assignee set, enqueues a + per-PR sync per success, and renders `unassigned.html` with the removed/failed split (partial + failures are contained, not fatal). + +## 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`. Do not read the + 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`, `console.views.assign_reviewer_and_record`, + `console.views.GitHubAssignmentClient` (unassign), and `console.views._enqueue_pr_sync`, 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..29195cda --- /dev/null +++ b/qb_site/console/session.py @@ -0,0 +1,52 @@ +"""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: + # Rotate the session key on login promotion (like django.contrib.auth.login) so a + # pre-authentication session key planted in the browser cannot be replayed (session fixation). + request.session.cycle_key() + 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/static/console/console.css b/qb_site/console/static/console/console.css new file mode 100644 index 00000000..794df8d3 --- /dev/null +++ b/qb_site/console/static/console/console.css @@ -0,0 +1,300 @@ +/* Reviewer-console styles, layered on the shared design system (shared/shared_pages.css). + Only console-specific components live here; palette tokens, .page/.hero/.card/.cta/.status + all come from the shared stylesheet so the console matches the other reviewer pages. */ + +/* Signed-in line + sign-out control inside the hero. */ +.signin-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + margin-top: 4px; +} + +button.linklike { + font: inherit; + background: none; + border: none; + padding: 0; + color: #d8f3f2; + text-decoration: underline; + cursor: pointer; +} + +button.linklike:hover { + color: #f8fafc; +} + +/* One card per repository. */ +.repo-card { + margin-bottom: 16px; +} + +.repo-name { + margin: 0 0 4px; + font-size: 1.15rem; + font-weight: 600; + color: var(--ink); +} + +/* The load line doubles as the trigger for the load legend: click it to reveal the explanation + right where the number is, instead of a standing block at the top of the page. */ +.load-toggle { + margin: 0 0 6px; +} + +.load-summary { + list-style: none; + width: fit-content; + cursor: pointer; + font-size: 0.92rem; + color: var(--muted); +} + +.load-summary::-webkit-details-marker { + display: none; +} + +.load-summary::after { + content: "ⓘ"; + margin-left: 6px; + font-size: 0.82em; + opacity: 0.65; +} + +.load-summary:hover { + color: var(--ink); +} + +.load-summary:hover::after { + opacity: 1; +} + +.load-summary.at-capacity { + color: var(--error-fg); + font-weight: 600; +} + +.load-toggle[open] > .load-summary { + margin-bottom: 8px; +} + +.section-title { + margin: 16px 0 8px; + font-size: 0.92rem; + font-weight: 600; + color: var(--ink); +} + +/* A single proposal awaiting a decision, nested inside its repo card. */ +.proposal { + padding: 12px; + margin-bottom: 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; +} + +.proposal:last-child { + margin-bottom: 0; +} + +.proposal .pr-title { + margin: 0 0 6px; +} + +.proposal .pr-title a { + color: var(--accent); +} + +.matches-label { + margin-right: 4px; + color: var(--muted); + font-size: 0.85rem; +} + +.expiry { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.85rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +form.inline { + display: inline; + margin: 0; +} + +button.btn-accept, +button.btn-decline { + font: inherit; + min-height: 40px; + padding: 8px 18px; + border-radius: 10px; + border: 1px solid transparent; + font-weight: 700; + cursor: pointer; +} + +button.btn-accept { + background: var(--accent); + color: #f8fafc; +} + +button.btn-accept:hover { + background: var(--accent-strong); +} + +button.btn-decline { + background: var(--card); + color: var(--error-fg); + border-color: var(--error-border); +} + +button.btn-decline:hover { + background: var(--error-bg); +} + +/* Assigned-PR roster. */ +ul.assigned { + list-style: none; + padding: 0; + margin: 4px 0 0; +} + +.assigned-row { + padding: 7px 0; + border-bottom: 1px solid var(--border); + font-size: 0.92rem; +} + +.assigned-row:last-child { + border-bottom: none; +} + +.assigned-row a { + color: var(--accent); + font-weight: 600; + text-decoration: none; +} + +.assigned-row a:hover { + text-decoration: underline; +} + +.assigned-row.off-queue { + opacity: 0.72; +} + +.assigned-row .status { + margin-left: 6px; + color: var(--muted); + font-size: 0.85rem; +} + +.flag { + display: inline-block; + margin-left: 6px; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.75rem; + background: var(--warn-bg); + color: var(--warn-fg); + border: 1px solid var(--warn-border); +} + +.empty { + margin: 0; + color: var(--muted); +} + +/* Per-PR / per-proposal load contribution (design doc 050): a small muted figure that sums to the + repo's load line. */ +.load-contrib { + display: inline-block; + margin-left: 6px; + font-size: 0.78rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +/* Self-service unassign form on the assigned roster. */ +.assigned-check { + margin-right: 8px; + vertical-align: baseline; + cursor: pointer; +} + +button.btn-unassign { + font: inherit; + min-height: 36px; + padding: 7px 16px; + border-radius: 10px; + border: 1px solid var(--error-border); + background: var(--card); + color: var(--error-fg); + font-weight: 600; + cursor: pointer; +} + +button.btn-unassign:hover { + background: var(--error-bg); +} + +/* Body of the load legend, revealed by clicking the load line (see .load-toggle). */ +.legend-body { + margin-top: 8px; + padding: 12px 14px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; + color: var(--muted); +} + +.legend-body p { + margin: 0 0 8px; +} + +.legend-body p:last-child { + margin-bottom: 0; +} + +.legend-body ul { + margin: 0 0 8px; + padding-left: 18px; +} + +.legend-body strong { + color: var(--ink); +} + +/* A secondary call-to-action shown next to a primary one (e.g. "Back" beside "Assign anyway"). */ +.cta-quiet { + background: var(--card); + color: var(--accent); + border: 1px solid var(--border); +} + +.cta-quiet:hover { + background: var(--bg); +} + +@media (max-width: 760px) { + .actions { + flex-direction: column; + } + + button.btn-accept, + button.btn-decline, + button.btn-unassign, + .cta-quiet { + width: 100%; + text-align: center; + } +} 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..2ab5ff3d --- /dev/null +++ b/qb_site/console/tests/test_views.py @@ -0,0 +1,656 @@ +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, QueueSnapshot, ReviewerAssignmentApplication, ReviewerOptOut +from console.session import SESSION_NONCE_KEY, SESSION_USER_KEY +from core.models import Repository, ReviewerPreference, User +from core.services.github_assignment import AssignmentMutationError +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), + ) + + def _seed_snapshot(self, repo=None, prs=None) -> None: + """Seed the cached queue snapshot the load helper reads (cache_key 'default' with no ruleset).""" + repo = repo or self.repo + payload_prs = prs or {} + QueueSnapshot.objects.create( + repository=repo, + cache_key="default", + generated_at=self.now, + payload={"prs": payload_prs}, + etag="etag", + pr_count=len(payload_prs), + queue_count=0, + ) + + # ---- 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_rotates_session_key(self) -> None: + # Session fixation: the pre-login session key (which an attacker could have planted) must + # not remain valid once the session is promoted to an authenticated reviewer session. + session = self.client.session + session[SESSION_NONCE_KEY] = "nonce-123" + session.save() + pre_login_key = session.session_key + 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.assertNotEqual(self.client.session.session_key, pre_login_key) + self.assertEqual(self.client.session.get(SESSION_USER_KEY), self.reviewer.id) + + def test_oauth_callback_recycled_login_denied(self) -> None: + # The reviewer's old login now belongs to a different GitHub account (different node id); + # that account must not inherit the reviewer's console session. + 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-imposter", + github_login=self.reviewer.github_login, + github_name="Imposter", + 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.assertIsNone(self.client.session.get(SESSION_USER_KEY)) + + 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" + 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._seed_snapshot() # no assigned PRs; the pending proposal alone is 1.0 of load + self._login_session() + + resp = self.client.get(reverse("console:home")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "leanprover-community/mathlib4") # per-repo heading + self.assertContains(resp, "#101") + # Weighted load, incl. the pending proposal (1.0), against capacity 5. + self.assertContains(resp, "Load: 1 / 5 (4 free)") + self.assertContains(resp, "Accept") + self.assertContains(resp, "Decline") + + def test_home_shows_load_and_assigned_prs_without_proposals(self) -> None: + # A reviewer with an assigned PR but no proposals still gets a repo section: load + roster. + self._make_pr(200, assignees=["bob"]) + self._seed_snapshot(prs={"200": {"assignees": ["bob"], "author": "alice", "pr_status": "AwaitingReview"}}) + self._login_session() + + resp = self.client.get(reverse("console:home")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "leanprover-community/mathlib4") + self.assertContains(resp, "Load: 1 / 5 (4 free)") + self.assertContains(resp, "Assigned to you (1)") + self.assertContains(resp, "#200") + self.assertNotContains(resp, "Accept") # no proposals + + def test_home_empty_state_when_nothing(self) -> None: + self._seed_snapshot() + self._login_session() + + resp = self.client.get(reverse("console:home")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "no proposals awaiting acceptance, and no PRs currently assigned") + + def test_home_groups_proposals_by_repo(self) -> None: + # Proposals across repos render under separate per-repo headings, each with its own load + # line, ordered by (owner, name) — so "batteries" sorts before "mathlib4" (doc 050 review). + repo2 = Repository.objects.create(owner="leanprover-community", name="batteries", default_branch="main") + ReviewerPreference.objects.create( + repository=repo2, + user=self.reviewer, + preferred_labels=[], + maximum_capacity=3, + assignment_acceptance=ReviewerPreference.ACCEPTANCE_CONFIRM, + ) + self._make_pr(101) + self._proposal(101) + PullRequest.objects.create( + repository=repo2, + number=5, + state=PullRequestState.OPEN, + is_draft=False, + gh_created_at=self.now, + gh_updated_at=self.now, + base_ref_name="main", + head_ref_name="branch-5", + head_repo_owner_login="leanprover-community", + head_repo_name="batteries", + title="Array lemmas", + body="b", + additions=1, + deletions=0, + changed_files_count=1, + assignees=[], + ) + AssignmentProposal.objects.create( + repository=repo2, + pr_number=5, + reviewer_login="bob", + state=AssignmentProposal.STATE_PROPOSED, + expires_at=self.now + timedelta(days=7), + ) + self._seed_snapshot() + self._seed_snapshot(repo=repo2) + self._login_session() + + resp = self.client.get(reverse("console:home")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "leanprover-community/mathlib4") + self.assertContains(resp, "leanprover-community/batteries") + self.assertContains(resp, "Load: 1 / 5 (4 free)") # mathlib4: one pending proposal + self.assertContains(resp, "Load: 1 / 3 (2 free)") # batteries: one pending proposal + content = resp.content.decode() + self.assertLess(content.index("batteries"), content.index("mathlib4")) + + # ---- 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_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) + 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) + + @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. Opt-outs feed the + # shared proposal_validity predicate, so the expiry sweep retires these the same way. + 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) + 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_lowercases_opt_out_login(self) -> None: + # Proposals carry GitHub's canonical (mixed-case) login; the opt-out row must be written + # lowercase like every other writer so the syncer's exact-match clearing can find it. + mixed_case = User.objects.create(github_login="YaelDillies", github_node_id="node-yael", zulip_user_id=7002) + self._make_pr(102) + proposal = self._proposal(102, login="YaelDillies") + self._login_session(mixed_case) + + resp = self.client.post(reverse("console:decline", args=[proposal.id])) + self.assertEqual(resp.status_code, 200) + opt_out = ReviewerOptOut.objects.get(repository=self.repo, pr_number=102) + self.assertEqual(opt_out.reviewer_login, "yaeldillies") + 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()) + + # ---- assign anyway ------------------------------------------------- + + def test_expired_proposal_accept_offers_assign_anyway(self) -> None: + # A reviewer clicks Accept after the proposal lapsed: the unavailable page invites them to + # self-assign, since the PR is still open and unassigned. + self._make_pr(101) + proposal = self._proposal(101) + AssignmentProposal.objects.filter(id=proposal.id).update(expires_at=self.now - timedelta(days=1)) + self._login_session() + + resp = self.client.post(reverse("console:accept", args=[proposal.id])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Assign myself anyway") + # The "no longer available" page links out to the GitHub PR. + self.assertContains(resp, "https://github.com/leanprover-community/mathlib4/pull/101") + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True) + def test_assign_anyway_assigns_and_clears_opt_out(self) -> None: + # A declined proposal the reviewer changed their mind on: self-assign lands and the opt-out + # the decline created is retracted so the builder won't undo it. + self._make_pr(101) # open, unassigned + proposal = self._proposal(101, state=AssignmentProposal.STATE_DECLINED) + ReviewerOptOut.objects.create( + repository=self.repo, pr_number=101, reviewer_login="bob", active=True, opted_out_at=self.now + ) + 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:assign-anyway", args=[proposal.id])) + + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Assignment accepted") + mock_assign.assert_called_once() + opt_out = ReviewerOptOut.objects.get(repository=self.repo, pr_number=101, reviewer_login="bob") + self.assertFalse(opt_out.active) + self.assertIsNotNone(opt_out.cleared_at) + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=False) + def test_assign_anyway_disabled_does_not_assign(self) -> None: + self._make_pr(101) + proposal = self._proposal(101, state=AssignmentProposal.STATE_EXPIRED) + self._login_session() + + with patch("console.views.assign_reviewer_and_record") as mock_assign: + resp = self.client.post(reverse("console:assign-anyway", args=[proposal.id])) + + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "enabled yet") + mock_assign.assert_not_called() + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True) + def test_assign_anyway_closed_pr_rejected(self) -> None: + self._make_pr(101, state=PullRequestState.CLOSED) + proposal = self._proposal(101, state=AssignmentProposal.STATE_SUPERSEDED) + self._login_session() + + with patch("console.views.assign_reviewer_and_record") as mock_assign: + resp = self.client.post(reverse("console:assign-anyway", args=[proposal.id])) + + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "closed or merged") + mock_assign.assert_not_called() + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True) + def test_assign_anyway_already_assigned_rejected(self) -> None: + self._make_pr(101, assignees=["bob"]) + proposal = self._proposal(101, state=AssignmentProposal.STATE_ACCEPTED) + self._login_session() + + with patch("console.views.assign_reviewer_and_record") as mock_assign: + resp = self.client.post(reverse("console:assign-anyway", args=[proposal.id])) + + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "already assigned") + mock_assign.assert_not_called() + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED=True) + def test_assign_anyway_other_reviewer_forbidden(self) -> None: + self._make_pr(101) + proposal = self._proposal(101, login="carol", state=AssignmentProposal.STATE_EXPIRED) + self._login_session() # signed in as bob + + resp = self.client.post(reverse("console:assign-anyway", args=[proposal.id])) + self.assertEqual(resp.status_code, 403) + + # ---- unassign ------------------------------------------------------ + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED=True) + def test_unassign_removes_selected_self_only(self) -> None: + self._make_pr(200, assignees=["bob"]) + self._make_pr(201, assignees=["bob"]) + self._login_session() + fake_client = MagicMock() + fake_client.unassign.return_value = () # bob is no longer in the resulting assignee set + + with ( + patch("core.services.github_operation_tokens.resolve_github_app_operation_token", return_value="tok"), + patch("console.views.GitHubAssignmentClient", return_value=fake_client), + patch("console.views._enqueue_pr_sync") as mock_sync, + ): + resp = self.client.post(reverse("console:unassign"), {"repo_id": self.repo.id, "pr_numbers": ["200", "201"]}) + + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Removed you from") + self.assertContains(resp, "#200") + self.assertContains(resp, "#201") + # Each removed PR links out to its GitHub page. + self.assertContains(resp, "https://github.com/leanprover-community/mathlib4/pull/200") + self.assertEqual(mock_sync.call_count, 2) + # Self-service only: the login unassigned is always the signed-in reviewer's, never the request's. + for call in fake_client.unassign.call_args_list: + self.assertEqual(call.kwargs["github_login"], "bob") + + def test_unassign_disabled_by_default(self) -> None: + self._make_pr(200, assignees=["bob"]) + self._login_session() + resp = self.client.post(reverse("console:unassign"), {"repo_id": self.repo.id, "pr_numbers": ["200"]}) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "enabled yet") + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED=True) + def test_unassign_reports_partial_failure(self) -> None: + self._make_pr(200, assignees=["bob"]) + self._make_pr(201, assignees=["bob"]) + self._login_session() + fake_client = MagicMock() + + def _unassign(*, owner, repo, number, github_login): + if number == 201: + raise AssignmentMutationError("github_error", "boom") + return () + + fake_client.unassign.side_effect = _unassign + + with ( + patch("core.services.github_operation_tokens.resolve_github_app_operation_token", return_value="tok"), + patch("console.views.GitHubAssignmentClient", return_value=fake_client), + patch("console.views._enqueue_pr_sync"), + ): + resp = self.client.post(reverse("console:unassign"), {"repo_id": self.repo.id, "pr_numbers": ["200", "201"]}) + + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Removed you from") + self.assertContains(resp, "Could not unassign") + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED=True) + def test_unassign_no_selection_redirects_home(self) -> None: + self._login_session() + resp = self.client.post(reverse("console:unassign"), {"repo_id": self.repo.id}) + self.assertEqual(resp.status_code, 302) + self.assertIn(reverse("console:home"), resp["Location"]) + + def test_unassign_without_session_redirects_login(self) -> None: + resp = self.client.post(reverse("console:unassign"), {"repo_id": self.repo.id, "pr_numbers": ["200"]}) + self.assertEqual(resp.status_code, 302) + self.assertIn(reverse("console:login"), resp["Location"]) + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED=True) + def test_home_shows_per_pr_load_contribution(self) -> None: + # An AwaitingReview assigned PR contributes weight 1.0; the console surfaces "+1" per row and + # the roster becomes an unassign form when the flag is on. + self._make_pr(200, assignees=["bob"]) + self._seed_snapshot(prs={"200": {"assignees": ["bob"], "author": "alice", "pr_status": "AwaitingReview"}}) + self._login_session() + + resp = self.client.get(reverse("console:home")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Assigned to you (1)") + self.assertContains(resp, "+1") + self.assertContains(resp, "Unassign selected") diff --git a/qb_site/console/urls.py b/qb_site/console/urls.py new file mode 100644 index 00000000..610e6a18 --- /dev/null +++ b/qb_site/console/urls.py @@ -0,0 +1,20 @@ +"""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//assign-anyway/", views.assign_anyway, name="assign-anyway"), + path("proposals//decline/", views.decline, name="decline"), + path("unassign/", views.unassign, name="unassign"), +] diff --git a/qb_site/console/views.py b/qb_site/console/views.py new file mode 100644 index 00000000..5cef2143 --- /dev/null +++ b/qb_site/console/views.py @@ -0,0 +1,735 @@ +"""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 typing import Iterable + +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.html import format_html +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, ReviewerAssignmentApplication, ReviewerOptOut +from analyzer.services.assignment_proposal_validity import ( + ProposalValidity, + live_proposal_validity, + queue_membership, + resolve_on_queue_exit_policy, +) +from analyzer.services.reviewer_assignment import _opt_outs_for_prs +from analyzer.services.reviewer_assignment_apply import assign_reviewer_and_record +from analyzer.services.reviewer_assignment_engine import _normalize_login +from analyzer.services.reviewer_attention import build_reviewer_attention_reports +from analyzer.services.reviewer_attention_format import ( + format_compact_duration, + sort_by_assignment_recency, + sort_by_queue_age, +) +from analyzer.services.reviewer_load import ( + format_load_contribution, + format_load_line, + reviewer_load_with_breakdown, +) +from console import session as console_session +from core.models import Repository, ReviewerPreference +from core.services.github_assignment import AssignmentMutationError, GitHubAssignmentClient +from core.services.github_identity import resolve_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__) + + +# --- 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) + + # Resolve-only by construction: 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 stranger. + user = resolve_user_from_identity(identity) + 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)) + + +@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") + ) + + # Repos in scope: everywhere the reviewer has a preference (source of assigned-PR status + + # capacity), plus any repo that has a proposal for them (defensive — normally a subset). + preferred_by_repo: dict[int, set[str]] = {} + repos_by_id: dict[int, object] = {} + for pref in ( + ReviewerPreference.objects.filter(user=reviewer) + .select_related("repository") + .only("repository_id", "preferred_labels", "repository__owner", "repository__name") + ): + preferred_by_repo[int(pref.repository_id)] = {str(lbl).lower() for lbl in (pref.preferred_labels or [])} + repos_by_id[int(pref.repository_id)] = pref.repository + for proposal in proposals: + repos_by_id.setdefault(int(proposal.repository_id), proposal.repository) + + # Batch proposal PR metadata + labels once (was a query per proposal). + prs_by_repo_number = _batch_prs(proposals) + labels_by_pr_id = _batch_labels(prs_by_repo_number.values()) + + # Each pending proposal occupies capacity at the same weight the engine / load service count it + # (design doc 050), so surface that contribution next to the proposal. + proposal_weight = float(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSAL_PENDING_LOAD_WEIGHT", 1.0)) + + # Proposals grouped by repo, preserving the (owner, name, expires_at, pr_number) ordering above. + proposals_by_repo: dict[int, list[dict]] = {} + for proposal in proposals: + repo = proposal.repository + pr = prs_by_repo_number.get((repo.id, int(proposal.pr_number))) + labels = labels_by_pr_id.get(pr.id, []) if pr is not None else [] + preferred = preferred_by_repo.get(int(repo.id), set()) + matched = sorted({lbl for lbl in labels if lbl and lbl.lower() in preferred}) + proposals_by_repo.setdefault(int(repo.id), []).append( + { + "proposal": proposal, + "pr_number": proposal.pr_number, + "title": (pr.title if pr is not None else None) or f"PR #{proposal.pr_number}", + "url": f"https://github.com/{repo.owner}/{repo.name}/pull/{proposal.pr_number}", + "matched_labels": matched, + "expires_at": proposal.expires_at, + # ISO-8601 (UTC) for client-side rendering in the viewer's own timezone; the template + # keeps a UTC text fallback for no-JS. + "expires_at_iso": proposal.expires_at.isoformat() if proposal.expires_at else "", + "load_weight": format_load_contribution(proposal_weight), + } + ) + + # Assigned open PRs with status, from the shared reviewer-attention reports (design doc 050: + # the console is a dashboard, not just a proposal inbox). + assigned_by_repo: dict[int, list[dict]] = {} + for repo_id, repo in repos_by_id.items(): + reports = build_reviewer_attention_reports(repository=repo) + report = next((entry for entry in reports if entry.reviewer_user_id == reviewer.id), None) + if report is None or not report.items: + continue + ordered = list(sort_by_queue_age([i for i in report.items if i.is_on_queue])) + list( + sort_by_assignment_recency([i for i in report.items if not i.is_on_queue]) + ) + assigned_by_repo[repo_id] = [_assigned_pr_row(repo, item) for item in ordered] + + # A repo earns a section only when there is something to show: a proposal or an assigned PR. + active_repo_ids = set(proposals_by_repo) | set(assigned_by_repo) + repo_groups: list[dict] = [] + for repo_id in sorted(active_repo_ids, key=lambda rid: (repos_by_id[rid].owner, repos_by_id[rid].name)): + repo = repos_by_id[repo_id] + # Load + per-PR breakdown come from one snapshot read, so the per-row contributions sum to the + # assigned share of the aggregate load line (design doc 050). + load, breakdown = reviewer_load_with_breakdown(repo, reviewer.github_login) + assigned_rows = assigned_by_repo.get(repo_id, []) + for row in assigned_rows: + weight = breakdown.get(int(row["pr_number"])) + row["load_weight"] = format_load_contribution(weight) if weight is not None else None + repo_groups.append( + { + "repo_id": repo_id, + "repo_label": f"{repo.owner}/{repo.name}", + "proposals": proposals_by_repo.get(repo_id, []), + "assigned_prs": assigned_rows, + "load": load, + "load_line": format_load_line(load) if load is not None else None, + } + ) + + return { + "repo_groups": repo_groups, + "logout_url": reverse("console:logout"), + "unassign_enabled": bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED", False)), + } + + +def _assigned_pr_row(repo, item) -> dict: + """Flatten a ``ReviewerAttentionItem`` into template-ready assigned-PR display fields.""" + status_bits: list[str] = ["On queue" if item.is_on_queue else "Not on queue"] + if item.is_on_queue and item.days_on_queue_since_assignment is not None: + consecutive = format_compact_duration(int(item.days_on_queue_since_assignment) * 86400) + status_bits.append(f"{consecutive} since assignment") + if item.total_queue_seconds is not None: + status_bits.append(f"total {format_compact_duration(int(item.total_queue_seconds))}") + + if item.needs_auto_unassign: + flag = "auto-unassign soon" + elif item.needs_nudge: + flag = "stale" + else: + flag = "" + + return { + "pr_number": item.pr_number, + "title": item.pr_title or f"PR #{item.pr_number}", + "url": f"https://github.com/{repo.owner}/{repo.name}/pull/{item.pr_number}", + "is_on_queue": item.is_on_queue, + "status": " · ".join(status_bits), + "flag": flag, + } + + +def _batch_prs(proposals: list[AssignmentProposal]) -> dict[tuple[int, int], PullRequest]: + """Fetch the proposed PRs keyed by ``(repository_id, number)`` in one query per repo.""" + numbers_by_repo: dict[int, set[int]] = {} + for proposal in proposals: + numbers_by_repo.setdefault(int(proposal.repository_id), set()).add(int(proposal.pr_number)) + out: dict[tuple[int, int], PullRequest] = {} + for repo_id, numbers in numbers_by_repo.items(): + for pr in PullRequest.objects.filter(repository_id=repo_id, number__in=numbers).only("id", "number", "title", "state"): + out[(repo_id, int(pr.number))] = pr + return out + + +def _batch_labels(prs: Iterable[PullRequest]) -> dict[int, list[str]]: + """Map ``pull_request_id -> [label name, ...]`` for the given PRs in a single query.""" + pr_ids = [pr.id for pr in prs] + if not pr_ids: + return {} + out: dict[int, list[str]] = {} + rows = ( + PRLabel.objects.filter(pull_request_id__in=pr_ids) + .select_related("label_def") + .values_list("pull_request_id", "label_def__name") + ) + for pr_id, name in rows: + if name: + out.setdefault(int(pr_id), []).append(name) + return out + + +# --- accept / decline -------------------------------------------------------- + + +def _pr_url(repository, number: int) -> str: + """GitHub PR page URL for ``repository`` #``number``.""" + return f"https://github.com/{repository.owner}/{repository.name}/pull/{int(number)}" + + +def _live_pr_for(proposal: AssignmentProposal) -> PullRequest | None: + """Fetch the proposal's PR with just the fields the console needs to reason about it.""" + return ( + PullRequest.objects.filter(repository=proposal.repository, number=int(proposal.pr_number)) + .only("id", "number", "state", "assignees") + .first() + ) + + +def _live_validity(proposal: AssignmentProposal, *, now) -> tuple[ProposalValidity, PullRequest | None]: + """Run the shared validity authority against live facts for one proposal.""" + repo = proposal.repository + pr_number = int(proposal.pr_number) + live_pr = _live_pr_for(proposal) + validity = live_proposal_validity( + proposal, + now=now, + live_pr=live_pr, + membership=queue_membership(repo, now=now), + opt_outs=_opt_outs_for_prs(repo, [pr_number]), + on_queue_exit=resolve_on_queue_exit_policy(), + ) + return validity, live_pr + + +def _can_self_assign(live_pr: PullRequest | None, reviewer) -> bool: + """Whether the reviewer may still assign *themselves* to this PR. + + True exactly when the PR is open and the reviewer is not already an assignee. This is the single + precondition behind "assign myself anyway": it naturally covers every recoverable proposal state + (expired, off-queue, assigned-to-someone-else, opted-out) and excludes the cases where the action + is meaningless — closed/merged PRs and PRs the reviewer already holds (an accepted proposal). + """ + if live_pr is None: + return False + if str(live_pr.state).strip().lower() != "open": + return False + assignees = {_normalize_login(str(login)) for login in (live_pr.assignees or []) if login} + return _normalize_login(reviewer.github_login) not in assignees + + +def _render_proposal_unavailable( + request: HttpRequest, + *, + proposal: AssignmentProposal, + live_pr: PullRequest | None, + reviewer, + reason: str | None, + heading: str | None = None, + status: int = 200, +) -> HttpResponse: + """Render ``unavailable.html`` for a proposal-reason page. + + The message names the PR inline as a link (no separate "PR: …" line), and "assign myself anyway" + is offered when the PR is still self-assignable. + """ + repo = proposal.repository + pr_link = format_html( + '{}', _pr_url(repo, proposal.pr_number), f"{repo.owner}/{repo.name} #{proposal.pr_number}" + ) + template = _UNAVAILABLE_REASON_TEMPLATE.get(reason, _UNAVAILABLE_DEFAULT_TEMPLATE) + + can_assign_anyway = _can_self_assign(live_pr, reviewer) + note = "" + if can_assign_anyway: + others_assigned = bool([login for login in (live_pr.assignees or []) if login]) + if reason == "opted_out": + note = "This will also clear your opt-out for this PR." + elif others_assigned: + note = "You’ll be added as an additional assignee." + return render( + request, + "console/unavailable.html", + { + "message": format_html(template, pr=pr_link), + "heading": heading, + "can_assign_anyway": can_assign_anyway, + "assign_anyway_url": (reverse("console:assign-anyway", args=[proposal.id]) if can_assign_anyway else ""), + "assign_anyway_note": note, + }, + status=status, + ) + + +def _enqueue_pr_sync(repository: Repository, number: int) -> None: + """Enqueue a per-PR sync so a console-driven assignee change converges into our state.""" + try: + from syncer.tasks.sync_tasks import sync_pr_task + + sync_pr_task.delay(int(repository.id), int(number)) + except Exception: # pragma: no cover - defensive enqueue guard + log.warning("console: post-mutation sync enqueue failed", extra={"repo_id": repository.id, "number": number}) + + +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 + + +# Reason -> message, with a ``{pr}`` slot the PR link is woven into so the sentence names the PR +# inline (like the unassigned page) rather than saying a bare "This PR" beside a separate link. +_UNAVAILABLE_REASON_TEMPLATE = { + "already_terminal": "The proposal for {pr} has already been decided.", + "expired": "The proposal for {pr} has expired.", + "pr_assigned": "{pr} already has an assignee.", + "pr_closed": "{pr} is closed or merged.", + "pr_off_queue": "{pr} is no longer on the review queue.", + "opted_out": "You’ve opted out of {pr}, so this proposal no longer applies.", +} +_UNAVAILABLE_DEFAULT_TEMPLATE = "The proposal for {pr} is no longer available." + + +def _github_assign_self(request: HttpRequest, proposal: AssignmentProposal, *, now) -> tuple[bool, HttpResponse | None]: + """Perform the shared 046 GitHub assign for ``proposal`` and confirm it landed. + + Returns ``(True, None)`` when the reviewer is now assigned on GitHub, else ``(False, response)`` + with the appropriate ``unavailable`` page. Callers must have already gated on + ``ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED``. Shared by ``accept`` and + ``assign_anyway`` so the (subtle) "did it actually land?" semantics never drift between them. + """ + 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 False, 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, + ) + # Only treat it as landed when the assignment actually took 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 (an earlier click GitHub rejected) must never be reported to the + # reviewer as an assignment that took. 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 False, render( + 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, + ) + return True, None + + +@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_proposal_unavailable( + request, + proposal=proposal, + live_pr=_live_pr_for(proposal), + reviewer=reviewer, + reason="already_terminal", + ) + + validity, live_pr = _live_validity(proposal, now=now) + if not validity.is_live: + _retire(proposal, validity, now=now) + return _render_proposal_unavailable( + request, + proposal=proposal, + live_pr=live_pr, + reviewer=reviewer, + reason=validity.reason, + ) + + 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."}) + + landed, failure = _github_assign_self(request, proposal, now=now) + if not landed: + return failure # leave the proposal pending so a later daily run (fresh run_date) can retry + + # Assignment landed — 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 assign_anyway(request: HttpRequest, proposal_id: int) -> HttpResponse: + """Self-assign to a proposal's PR even though the proposal itself is no longer acceptable. + + Reached from the "no longer available" page. Deliberately bypasses the proposal validity gate — + that is the whole point — but keeps the one honest precondition (``_can_self_assign``: the PR is + open and the reviewer isn't already on it) and the same per-reviewer ownership check, GitHub + mutation, and audit trail as ``accept``. On success it also clears any active per-PR opt-out so + the next builder/expiry pass doesn't undo the assignment the reviewer just asked for. + """ + loaded = _load_actionable_proposal(request, proposal_id) + if isinstance(loaded, HttpResponse): + return loaded + reviewer, proposal = loaded + now = timezone.now() + + live_pr = _live_pr_for(proposal) + if not _can_self_assign(live_pr, reviewer): + return render( + request, + "console/unavailable.html", + {"message": "This PR is closed or merged, or you’re already assigned to it."}, + ) + + if not bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_ASSIGN_ON_ACCEPT_ENABLED", False)): + return render(request, "console/unavailable.html", {"message": "Assigning isn’t enabled yet — please try again later."}) + + landed, failure = _github_assign_self(request, proposal, now=now) + if not landed: + return failure + + with transaction.atomic(): + # The reviewer explicitly wants this PR now, so retract any active per-PR opt-out (lowercased + # like every other ReviewerOptOut writer) — otherwise the builder would keep excluding them. + ReviewerOptOut.objects.filter( + repository=proposal.repository, + pr_number=int(proposal.pr_number), + reviewer_login=_normalize_login(proposal.reviewer_login), + active=True, + ).update(active=False, cleared_at=now) + # If the proposal is somehow still pending, retire it as accepted for consistency; a no-op + # (0 rows) in the normal case where it is already terminal. + 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_proposal_unavailable( + request, + proposal=proposal, + live_pr=_live_pr_for(proposal), + reviewer=reviewer, + 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). + # Lowercase the login like every other ReviewerOptOut writer/clearer (syncer, backfill): the + # unique constraint is case-sensitive, so a mixed-case row would duplicate the lowercase one + # and the syncer's exact-match assign-clearing would never deactivate it. + ReviewerOptOut.objects.update_or_create( + repository=proposal.repository, + pr_number=int(proposal.pr_number), + reviewer_login=_normalize_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, + }, + ) + + +# --- unassign ---------------------------------------------------------------- + + +@require_POST +def unassign(request: HttpRequest) -> HttpResponse: + """Remove the signed-in reviewer from one or more PRs they are assigned to (self-service only). + + Posts a ``repo_id`` and one or more ``pr_numbers`` from the home dashboard's assigned-PR form. + Gated by ``ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED``. The login removed is always + the *authenticated reviewer's own* — never taken from the request — so this surface can only ever + unassign the person operating it. Each success enqueues a per-PR sync so state converges. + """ + reviewer = console_session.get_reviewer(request) + if reviewer is None: + return redirect(_login_url(reverse("console:home"))) + + if not bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED", False)): + return render(request, "console/unavailable.html", {"message": "Unassigning isn’t enabled yet — please try again later."}) + + repo = Repository.objects.filter(id=request.POST.get("repo_id") or 0).only("id", "owner", "name").first() + if repo is None: + return render(request, "console/unavailable.html", {"message": "That repository was not found."}, status=404) + + numbers: set[int] = set() + for raw in request.POST.getlist("pr_numbers"): + try: + numbers.add(int(raw)) + except (TypeError, ValueError): + continue + if not numbers: + # Nothing selected — just return to the dashboard rather than render an error. + return redirect(reverse("console:home")) + + from core.services.github_operation_tokens import resolve_github_app_operation_token + + token = resolve_github_app_operation_token(operation="unassign_pr", owner=repo.owner, repo=repo.name) + if not token: + return render( + request, "console/unavailable.html", {"message": "Unassignment is temporarily unavailable. Try again later."} + ) + + client = GitHubAssignmentClient(token=token) + login = reviewer.github_login + login_norm = _normalize_login(login) + unassigned: list[int] = [] + failed: list[int] = [] + for number in sorted(numbers): + try: + resulting = client.unassign(owner=repo.owner, repo=repo.name, number=number, github_login=login) + except AssignmentMutationError: + failed.append(number) + continue + # Confirm we actually left the assignee set (DELETE is idempotent, so a login that was never + # there also succeeds — that's fine, the reviewer is not assigned either way). + if login_norm in {_normalize_login(str(assignee)) for assignee in resulting if assignee}: + failed.append(number) + continue + unassigned.append(number) + _enqueue_pr_sync(repo, number) + + return render( + request, + "console/unassigned.html", + { + "repo_label": f"{repo.owner}/{repo.name}", + "unassigned": [{"number": n, "url": _pr_url(repo, n)} for n in unassigned], + "failed": [{"number": n, "url": _pr_url(repo, n)} for n in failed], + }, + ) 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/services/github_identity.py b/qb_site/core/services/github_identity.py new file mode 100644 index 00000000..71d1e10e --- /dev/null +++ b/qb_site/core/services/github_identity.py @@ -0,0 +1,59 @@ +"""Resolve a GitHub OAuth identity to an existing ``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 and **never creates users** — it only resolves people we already know (registered +via the Zulip flow, or ingested by the syncer) and refreshes their GitHub identity fields, so the +public console sign-in URL cannot mint a ``core.User`` row for an arbitrary GitHub account. If a +future caller needs create-on-miss, add it as an explicit separate entry point (the race-safe +savepoint pattern lives in ``zulip_bot.services.registration_linking`` and +``syncer...core_entities_sync.upsert_user_from_github``). +""" + +from __future__ import annotations + +from django.db import transaction + +from core.models import User +from core.services.github_oauth import GitHubUserIdentity + + +@transaction.atomic +def resolve_user_from_identity(identity: GitHubUserIdentity) -> User | None: + """Return the ``core.User`` for ``identity`` (by node id, else login), or ``None`` if unknown. + + A login match whose stored ``github_node_id`` is non-empty and differs from the identity's is + a *recycled username* (the login now belongs to a different GitHub account) and is treated as + no match — resolving it would hand the new account holder the previous owner's user (and, via + the console, their session and proposals). + + 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 not None and user.github_node_id and user.github_node_id != identity.github_node_id: + return None + if user is None: + return None + + 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_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..5f70f0b1 --- /dev/null +++ b/qb_site/core/services/site_urls.py @@ -0,0 +1,28 @@ +"""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 base-URL resolution: they all resolve ``QUEUEBOARD_BASE_URL`` +(``scheme://host`` with no trailing slash) through here. +""" + +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.""" + return str(getattr(settings, "QUEUEBOARD_BASE_URL", "") or "").strip().rstrip("/") + + +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..681d0399 --- /dev/null +++ b/qb_site/core/tests/test_console_oauth_helpers.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from django.test import SimpleTestCase, TestCase, override_settings + +from core.models import User +from core.services.github_identity import resolve_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") + def test_resolves_canonical_base(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="") + 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_matches_by_node_id_and_refreshes_login(self) -> None: + existing = User.objects.create(github_node_id="MDQ6VXNlcjE=", github_login="old-login") + user = resolve_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_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=") + + def test_returns_none_and_creates_nothing_when_absent(self) -> None: + # Resolve-only by construction: an unknown identity must not mint a core.User row + # (console gating, doc 050 review). + user = resolve_user_from_identity(self._identity(login="stranger")) + self.assertIsNone(user) + self.assertFalse(User.objects.filter(github_login__iexact="stranger").exists()) + + def test_returns_existing(self) -> None: + existing = User.objects.create(github_node_id="MDQ6VXNlcjE=", github_login="alice") + user = resolve_user_from_identity(self._identity(login="alice")) + self.assertEqual(user.id, existing.id) + + def test_recycled_login_does_not_resolve_to_previous_owner(self) -> None: + # 'alice' renamed away; a different GitHub account (new node id) now holds the login. The + # login fallback must not hand the new holder the previous owner's user (console takeover). + previous_owner = User.objects.create(github_node_id="MDQ6VXNlcjE=", github_login="alice") + user = resolve_user_from_identity(self._identity(node="MDQ6VXNlcjI=", login="alice")) + self.assertIsNone(user) + previous_owner.refresh_from_db() + self.assertEqual(previous_owner.github_node_id, "MDQ6VXNlcjE=") + self.assertEqual(previous_owner.github_login, "alice") diff --git a/qb_site/core/tests/test_github_oauth.py b/qb_site/core/tests/test_github_oauth.py index 66d55fd0..64ecf904 100644 --- a/qb_site/core/tests/test_github_oauth.py +++ b/qb_site/core/tests/test_github_oauth.py @@ -10,7 +10,6 @@ @override_settings( GITHUB_OAUTH_CLIENT_ID="client-id", GITHUB_OAUTH_CLIENT_SECRET="client-secret", - GITHUB_OAUTH_REDIRECT_URI="https://queueboard.example/api/zulip/register/github/callback/", ) class TestGitHubOAuthClient(SimpleTestCase): def _response(self, payload: dict) -> Mock: 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) diff --git a/qb_site/qb_site/settings/base.py b/qb_site/qb_site/settings/base.py index 2677a91f..3e24d48b 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,19 @@ 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). +# The single base for every feature deep-link (reviewer console, both OAuth callbacks, Zulip +# prefs/registration links), so a deployment sets one variable. 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 this 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", "") 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)) @@ -172,18 +179,19 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int | ZULIP_LABEL_PR_TOKEN_SECRET = os.getenv("ZULIP_LABEL_PR_TOKEN_SECRET", "") ZULIP_LABEL_PR_TOKEN_SALT = os.getenv("ZULIP_LABEL_PR_TOKEN_SALT", "zulip_bot.label_pr") ZULIP_LABEL_PR_TOKEN_TTL_SECONDS = int(os.getenv("ZULIP_LABEL_PR_TOKEN_TTL_SECONDS", 1800)) -ZULIP_ASSIGNMENT_SUCCESS_EMOJI = os.getenv("ZULIP_ASSIGNMENT_SUCCESS_EMOJI", "thumbs_up") # Feature flags: keep in sync with .env.example — a missing entry here silently disables the feature. ZULIP_ASSIGNMENT_MUTATIONS_ENABLED = os.getenv("ZULIP_ASSIGNMENT_MUTATIONS_ENABLED", "") ZULIP_CLOSE_PR_MUTATIONS_ENABLED = os.getenv("ZULIP_CLOSE_PR_MUTATIONS_ENABLED", "") ZULIP_LABEL_PR_MUTATIONS_ENABLED = os.getenv("ZULIP_LABEL_PR_MUTATIONS_ENABLED", "") GITHUB_OAUTH_CLIENT_ID = os.getenv("GITHUB_OAUTH_CLIENT_ID", "") GITHUB_OAUTH_CLIENT_SECRET = os.getenv("GITHUB_OAUTH_CLIENT_SECRET", "") -GITHUB_OAUTH_REDIRECT_URI = os.getenv("GITHUB_OAUTH_REDIRECT_URI", "") GITHUB_OAUTH_AUTHORIZE_URL = os.getenv("GITHUB_OAUTH_AUTHORIZE_URL", "https://github.com/login/oauth/authorize") 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() @@ -396,6 +404,60 @@ 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")) +# Acceptance-gate rollout flags (design doc 050), each independently toggleable like doc 028's +# staged 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. +# 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. +# Proposal DMs ride the daily reviewer-attention report (ANALYZER_REVIEWER_ATTENTION_ENABLED + +# _DELIVERY_ENABLED): pending proposals render as a distinct "Proposed to you" section and an +# un-notified proposal triggers a send even for reviewers with nudges muted (transactional). +# 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). +# Enforced in code: when both are enabled, the apply task skips itself (logging an error) so the +# proposal-unaware path cannot bypass the gate. +ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED = env_bool(os.getenv("ANALYZER_ASSIGNMENT_PROPOSALS_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) +# Console self-service unassign: the assigned-PR roster becomes a form letting a signed-in reviewer +# remove *themselves* (never anyone else) from one or more PRs. Off by default like ASSIGN_ON_ACCEPT +# so the console performs no GitHub writes until an operator opts in. +ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED = env_bool( + os.getenv("ANALYZER_ASSIGNMENT_PROPOSALS_CONSOLE_UNASSIGN_ENABLED"), 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"), @@ -473,11 +535,11 @@ def env_optional_bounded_int(name: str, *, minimum: int, maximum: int) -> int | "task": "syncer.sync_active_repos", "schedule": SYNCER_ACTIVE_REPOS_PERIOD_SECONDS, } -if 900 > 0: - CELERY_BEAT_SCHEDULE["collect_syncer_metrics"] = { - "task": "syncer.collect_metrics", - "schedule": 900, # 15 minutes - } +# Collect syncer metrics every 15 minutes. +CELERY_BEAT_SCHEDULE["collect_syncer_metrics"] = { + "task": "syncer.collect_metrics", + "schedule": 900, # 15 minutes +} if SYNCER_HISTORY_BACKFILL_PERIOD_SECONDS > 0: CELERY_BEAT_SCHEDULE["backfill_repo_history"] = { "task": "syncer.backfill_repo_history_active", @@ -643,6 +705,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( 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/zulip_bot/static/zulip_bot/shared_pages.css b/qb_site/static/shared/shared_pages.css similarity index 100% rename from qb_site/zulip_bot/static/zulip_bot/shared_pages.css rename to qb_site/static/shared/shared_pages.css diff --git a/qb_site/templates/console/_load_legend.html b/qb_site/templates/console/_load_legend.html new file mode 100644 index 00000000..4c1a5aff --- /dev/null +++ b/qb_site/templates/console/_load_legend.html @@ -0,0 +1,16 @@ +{% comment %}Load legend body, revealed by clicking a repo's load line on the home dashboard.{% endcomment %} +
+

Load: used / capacity (free) — your weighted review load against the capacity you set + in /prefs. Open PRs count by review status, not by headcount, so the number can be lower + than how many PRs you hold.

+
    +
  • +1 — awaiting your review (or a merge conflict worth flagging).
  • +
  • +0.1 — waiting on the author; barely counts toward your load.
  • +
  • +0 — blocked, delegated, or otherwise not on you right now.
  • +
  • A pending proposal counts +1 until you accept it or it expires.
  • +
+

The per-PR contributions add up to your load line.

+

Markers: stale — on your queue past your stale-nudge threshold + (default 14 days); auto-unassign soon — past the auto-unassign backstop + (default 21 days), so it may be reassigned. Both count only time the PR has sat awaiting you.

+
diff --git a/qb_site/templates/console/base.html b/qb_site/templates/console/base.html new file mode 100644 index 00000000..86810367 --- /dev/null +++ b/qb_site/templates/console/base.html @@ -0,0 +1,32 @@ +{% load static %} + + + + + + {% 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..b2cfe80e --- /dev/null +++ b/qb_site/templates/console/decided.html @@ -0,0 +1,24 @@ +{% 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 }}. +
+

Back to the console

+
+ {% else %} +
+

Proposal declined

+
+
+

You have declined {{ owner }}/{{ repo }} #{{ pr_number }}. You won't be proposed for this PR again.

+

Back to the console

+
+ {% endif %} +{% endblock %} diff --git a/qb_site/templates/console/error.html b/qb_site/templates/console/error.html new file mode 100644 index 00000000..fa073a79 --- /dev/null +++ b/qb_site/templates/console/error.html @@ -0,0 +1,11 @@ +{% extends "console/base.html" %} +{% block title %}Sign-in problem — Reviewer console{% endblock %} +{% block body %} +
+

Sign-in problem

+
+
+ +

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..06f65f64 --- /dev/null +++ b/qb_site/templates/console/home.html @@ -0,0 +1,82 @@ +{% extends "console/base.html" %} +{% block title %}Reviewer console{% endblock %} +{% block body %} +
+

Reviewer console

+ +
+ + {% if repo_groups %} + {% for group in repo_groups %} +
+

{{ group.repo_label }}

+ {% if group.load_line %} +
+ {{ group.load_line }} + {% include "console/_load_legend.html" %} +
+ {% endif %} + + {% if group.proposals %} +

Proposals awaiting your decision

+ {% for row in group.proposals %} +
+

#{{ row.pr_number }} {{ row.title }}

+ {% if row.matched_labels %} +
+ Matches your areas: + {% for label in row.matched_labels %}{{ label }}{% endfor %} +
+ {% endif %} +

+ Expires + + {% if group.load_line %}{{ row.load_weight }} load{% endif %} +

+
+
+ {% csrf_token %} +
+
+ {% csrf_token %} +
+
+
+ {% endfor %} + {% endif %} + + {% if group.assigned_prs %} +

Assigned to you ({{ group.assigned_prs|length }})

+ {% if unassign_enabled %} +
+ {% csrf_token %} + + {% endif %} +
    + {% for row in group.assigned_prs %} +
  • + {% if unassign_enabled %}{% endif %} + #{{ row.pr_number }} {{ row.title }} + {{ row.status }} + {% if group.load_line and row.load_weight %}{{ row.load_weight }}{% endif %} + {% if row.flag %}{{ row.flag }}{% endif %} +
  • + {% endfor %} +
+ {% if unassign_enabled %} +
+
+ {% endif %} + {% endif %} +
+ {% endfor %} + {% else %} +
+

You have no proposals awaiting acceptance, and no PRs currently assigned to you.

+
+ {% endif %} +{% endblock %} diff --git a/qb_site/templates/console/login.html b/qb_site/templates/console/login.html new file mode 100644 index 00000000..324538d9 --- /dev/null +++ b/qb_site/templates/console/login.html @@ -0,0 +1,12 @@ +{% 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/unassigned.html b/qb_site/templates/console/unassigned.html new file mode 100644 index 00000000..32fe2405 --- /dev/null +++ b/qb_site/templates/console/unassigned.html @@ -0,0 +1,26 @@ +{% extends "console/base.html" %} +{% block title %}Unassigned — Reviewer console{% endblock %} +{% block body %} +
+

Unassigned

+
+
+ {% if unassigned %} +
+ Removed you from {{ repo_label }} + {% for pr in unassigned %}#{{ pr.number }}{% if not forloop.last %}, {% endif %}{% endfor %}. +
+ {% endif %} + {% if failed %} + + {% endif %} + {% if not unassigned and not failed %} +

Nothing was unassigned.

+ {% endif %} +

Back to the console

+
+{% endblock %} diff --git a/qb_site/templates/console/unavailable.html b/qb_site/templates/console/unavailable.html new file mode 100644 index 00000000..e66d8486 --- /dev/null +++ b/qb_site/templates/console/unavailable.html @@ -0,0 +1,21 @@ +{% extends "console/base.html" %} +{% block title %}No longer available — Reviewer console{% endblock %} +{% block body %} +
+

{{ heading|default:"No longer available" }}

+
+
+
{{ message }}
+ {% if can_assign_anyway %} +

You can still take this PR if you want it.{% if assign_anyway_note %} {{ assign_anyway_note }}{% endif %}

+
+
+ {% csrf_token %} +
+ Back to the console +
+ {% else %} +

Back to the console

+ {% endif %} +
+{% endblock %} diff --git a/qb_site/templates/zulip_bot/close_pr_form.html b/qb_site/templates/zulip_bot/close_pr_form.html index b0fcd7c2..330a81bc 100644 --- a/qb_site/templates/zulip_bot/close_pr_form.html +++ b/qb_site/templates/zulip_bot/close_pr_form.html @@ -5,7 +5,7 @@ Queueboard — close pull request - + diff --git a/qb_site/templates/zulip_bot/close_pr_invalid.html b/qb_site/templates/zulip_bot/close_pr_invalid.html index f4137087..fbb810c1 100644 --- a/qb_site/templates/zulip_bot/close_pr_invalid.html +++ b/qb_site/templates/zulip_bot/close_pr_invalid.html @@ -5,7 +5,7 @@ Queueboard — close PR link unavailable - + diff --git a/qb_site/templates/zulip_bot/label_pr_form.html b/qb_site/templates/zulip_bot/label_pr_form.html index e7a0f4d5..dd48d123 100644 --- a/qb_site/templates/zulip_bot/label_pr_form.html +++ b/qb_site/templates/zulip_bot/label_pr_form.html @@ -5,7 +5,7 @@ Queueboard — edit labels - + diff --git a/qb_site/templates/zulip_bot/label_pr_invalid.html b/qb_site/templates/zulip_bot/label_pr_invalid.html index 87ab0b15..863a22fb 100644 --- a/qb_site/templates/zulip_bot/label_pr_invalid.html +++ b/qb_site/templates/zulip_bot/label_pr_invalid.html @@ -5,7 +5,7 @@ Queueboard — label PR link unavailable - + diff --git a/qb_site/templates/zulip_bot/prefs_form.html b/qb_site/templates/zulip_bot/prefs_form.html index 8c598f7e..ea344bec 100644 --- a/qb_site/templates/zulip_bot/prefs_form.html +++ b/qb_site/templates/zulip_bot/prefs_form.html @@ -5,7 +5,7 @@ Queueboard reviewer preferences - + @@ -99,6 +99,22 @@

Auto-Assignment

{% if form.maximum_capacity.help_text %}{{ form.maximum_capacity.help_text }}{% endif %} {% if form.maximum_capacity.errors %}
    {% for error in form.maximum_capacity.errors %}
  • {{ error }}
  • {% endfor %}
{% endif %} + + {% if "assignment_acceptance" in form.fields %} +
+ +
+ {% for option in form.assignment_acceptance %} + + {% endfor %} +
+ {% if form.assignment_acceptance.help_text %}{{ form.assignment_acceptance.help_text }}{% endif %} + {% if form.assignment_acceptance.errors %}
    {% for error in form.assignment_acceptance.errors %}
  • {{ error }}
  • {% endfor %}
{% endif %} +
+ {% endif %} diff --git a/qb_site/templates/zulip_bot/register_callback.html b/qb_site/templates/zulip_bot/register_callback.html index ea8115ef..04be7346 100644 --- a/qb_site/templates/zulip_bot/register_callback.html +++ b/qb_site/templates/zulip_bot/register_callback.html @@ -5,7 +5,7 @@ Queueboard registration - + diff --git a/qb_site/templates/zulip_bot/register_start.html b/qb_site/templates/zulip_bot/register_start.html index 9039190a..6f749339 100644 --- a/qb_site/templates/zulip_bot/register_start.html +++ b/qb_site/templates/zulip_bot/register_start.html @@ -5,7 +5,7 @@ Queueboard registration - + diff --git a/qb_site/zulip_bot/AGENTS.md b/qb_site/zulip_bot/AGENTS.md index 17aabf35..ab451bf9 100644 --- a/qb_site/zulip_bot/AGENTS.md +++ b/qb_site/zulip_bot/AGENTS.md @@ -17,8 +17,9 @@ 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. +- Commands live in `commands/`: `assign`, `unassign`, `assigned-prs`, `pr-info`, `prefs`, `console`, `help`, `echo`, `register_test`, `close-pr`, `label-pr`. +- `console`: replies in place with the stable, token-less reviewer console URL (`build_site_url(reverse("console:home"))`, design doc 050) where a reviewer accepts/declines assignment proposals. The link is non-secret and identical for everyone (the console self-authenticates via GitHub OAuth), so it is an in-place reply, not a proactive DM. +- `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`, diff --git a/qb_site/zulip_bot/commands/assigned_prs.py b/qb_site/zulip_bot/commands/assigned_prs.py index e2f4c08a..9858c120 100644 --- a/qb_site/zulip_bot/commands/assigned_prs.py +++ b/qb_site/zulip_bot/commands/assigned_prs.py @@ -19,9 +19,7 @@ from core.utils.zulip_time import format_global_time from syncer.models import PRLabel, PullRequest from zulip_bot.commands import CommandContext, CommandResult, register_command -from zulip_bot.services.zulip_client import ZulipApiError, ZulipClient - -MAX_MESSAGE_CHARS = 9000 +from zulip_bot.services.zulip_client import MAX_MESSAGE_CHARS, ZulipApiError, ZulipClient, split_message_chunks _NO_REQUIRED_FAILURES = "no_required_failures" @@ -100,7 +98,7 @@ def assigned_prs_command(context: CommandContext, args: str) -> CommandResult: mention_map=mention_map, load_by_repo_id=load_by_repo_id, ) - chunks = _split_message_chunks(content=content, max_chars=MAX_MESSAGE_CHARS) + chunks = split_message_chunks(content=content, max_chars=MAX_MESSAGE_CHARS) try: client = ZulipClient() @@ -420,38 +418,6 @@ def _ci_emoji(ci_status: str, ci_requires_success: bool) -> str: # --------------------------------------------------------------------------- -def _split_message_chunks(*, content: str, max_chars: int) -> list[str]: - if len(content) <= max_chars: - return [content] - - lines = content.splitlines() - chunks: list[str] = [] - current: list[str] = [] - current_len = 0 - for line in lines: - 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 - if not current and line_len > max_chars: - start = 0 - while start < len(line): - end = min(start + max_chars, len(line)) - chunks.append(line[start:end]) - start = end - current = [] - current_len = 0 - continue - current.append(line) - current_len += line_len - - if current: - chunks.append("\n".join(current)) - return chunks - - def _now_utc_unix() -> int: return int(datetime.now(timezone.utc).timestamp()) diff --git a/qb_site/zulip_bot/commands/console.py b/qb_site/zulip_bot/commands/console.py new file mode 100644 index 00000000..14da5252 --- /dev/null +++ b/qb_site/zulip_bot/commands/console.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from django.urls import reverse + +from core.services.site_urls import build_site_url +from zulip_bot.commands import CommandContext, CommandResult, register_command + + +@register_command( + name="console", + description="Get a link to the reviewer console (accept or decline assignment proposals).", +) +def console_command(context: CommandContext, args: str) -> CommandResult: + # The console URL is stable, token-less, and identical for every reviewer — the page + # self-authenticates via GitHub OAuth (design doc 050). Nothing here is sender-specific + # or secret, so we reply in place rather than DMing a private link (cf. commands/prefs.py). + del context, args + console_url = build_site_url(reverse("console:home")) + return CommandResult( + content=( + f"Open the [reviewer console]({console_url}) to accept or decline the assignment " + "proposals made to you. Sign in with GitHub — the link is stable and bookmarkable." + ) + ) 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/forms.py b/qb_site/zulip_bot/forms.py index 8ca8eb09..89eb94a3 100644 --- a/qb_site/zulip_bot/forms.py +++ b/qb_site/zulip_bot/forms.py @@ -5,6 +5,7 @@ from datetime import tzinfo from django import forms +from django.conf import settings from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -16,6 +17,7 @@ REVIEWER_PREFERENCE_EDITABLE_FIELDS: tuple[str, ...] = ( "maximum_capacity", "auto_assign", + "assignment_acceptance", "notifications_enabled", "away_until", "preferred_labels", @@ -68,6 +70,17 @@ def prepare_value(self, value: object) -> str: class ReviewerPreferenceForm(forms.ModelForm): + # Acceptance-gate mode (design doc 050). Exposed as a two-option radio; the values are the + # model's own choices ("auto"/"confirm") so the ModelForm persists it without any conversion. + assignment_acceptance = forms.ChoiceField( + required=True, + choices=( + (ReviewerPreference.ACCEPTANCE_AUTO, "Assign PRs to me directly"), + (ReviewerPreference.ACCEPTANCE_CONFIRM, "Propose PRs and let me accept them first"), + ), + widget=forms.RadioSelect, + label="New assignment handling", + ) away_until = forms.DateTimeField( required=False, input_formats=["%Y-%m-%dT%H:%M"], @@ -117,6 +130,11 @@ def __init__( **kwargs: object, ) -> None: super().__init__(*args, **kwargs) + # The acceptance-gate mode only does anything when the proposals pipeline is enabled + # (design doc 050). Hide the control entirely when the feature is off rather than explaining + # the caveat in help text; the stored value is left untouched and cannot be changed via POST. + if not bool(getattr(settings, "ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED", False)): + self.fields.pop("assignment_acceptance", None) self._user_timezone = user_timezone or timezone.get_current_timezone() self.legacy_preferred_labels: tuple[str, ...] = () diff --git a/qb_site/zulip_bot/services/close_pr_links.py b/qb_site/zulip_bot/services/close_pr_links.py index 1abb8cb9..6063f366 100644 --- a/qb_site/zulip_bot/services/close_pr_links.py +++ b/qb_site/zulip_bot/services/close_pr_links.py @@ -11,6 +11,8 @@ from cryptography.fernet import Fernet, InvalidToken from django.conf import settings +from core.services.site_urls import build_site_url + @dataclass(frozen=True) class ClosePRLinkClaims: @@ -37,11 +39,7 @@ class ClosePRTokenInvalid(ClosePRTokenError): def build_close_pr_link(*, claims: ClosePRLinkClaims) -> str: token = issue_close_pr_token(claims=claims) - url_base = getattr(settings, "ZULIP_PREFS_URL_BASE", "").strip().rstrip("/") - path = f"/api/zulip/close-pr/{quote(token, safe='')}/" - if url_base: - return f"{url_base}{path}" - return path + return build_site_url(f"/api/zulip/close-pr/{quote(token, safe='')}/") def issue_close_pr_token(*, claims: ClosePRLinkClaims) -> str: diff --git a/qb_site/zulip_bot/services/label_pr_links.py b/qb_site/zulip_bot/services/label_pr_links.py index 2b9a1672..442938c4 100644 --- a/qb_site/zulip_bot/services/label_pr_links.py +++ b/qb_site/zulip_bot/services/label_pr_links.py @@ -11,6 +11,8 @@ from cryptography.fernet import Fernet, InvalidToken from django.conf import settings +from core.services.site_urls import build_site_url + @dataclass(frozen=True) class LabelPRLinkClaims: @@ -37,11 +39,7 @@ class LabelPRTokenInvalid(LabelPRTokenError): def build_label_pr_link(*, claims: LabelPRLinkClaims) -> str: token = issue_label_pr_token(claims=claims) - url_base = getattr(settings, "ZULIP_PREFS_URL_BASE", "").strip().rstrip("/") - path = f"/api/zulip/label-pr/{quote(token, safe='')}/" - if url_base: - return f"{url_base}{path}" - return path + return build_site_url(f"/api/zulip/label-pr/{quote(token, safe='')}/") def issue_label_pr_token(*, claims: LabelPRLinkClaims) -> str: diff --git a/qb_site/zulip_bot/services/prefs_links.py b/qb_site/zulip_bot/services/prefs_links.py index 3669396e..1ae8f8b1 100644 --- a/qb_site/zulip_bot/services/prefs_links.py +++ b/qb_site/zulip_bot/services/prefs_links.py @@ -11,6 +11,8 @@ from cryptography.fernet import Fernet, InvalidToken from django.conf import settings +from core.services.site_urls import build_site_url + @dataclass(frozen=True) class PrefsLinkClaims: @@ -35,11 +37,7 @@ class PrefsTokenInvalid(PrefsTokenError): def build_prefs_link(*, claims: PrefsLinkClaims) -> str: token = issue_prefs_token(claims=claims) - url_base = getattr(settings, "ZULIP_PREFS_URL_BASE", "").strip().rstrip("/") - path = f"/api/zulip/prefs/{quote(token, safe='')}/" - if url_base: - return f"{url_base}{path}" - return path + return build_site_url(f"/api/zulip/prefs/{quote(token, safe='')}/") def issue_prefs_token(*, claims: PrefsLinkClaims) -> str: diff --git a/qb_site/zulip_bot/services/registration_links.py b/qb_site/zulip_bot/services/registration_links.py index 48039c56..e81ba239 100644 --- a/qb_site/zulip_bot/services/registration_links.py +++ b/qb_site/zulip_bot/services/registration_links.py @@ -12,6 +12,8 @@ from cryptography.fernet import Fernet, InvalidToken from django.conf import settings +from core.services.site_urls import build_site_url + @dataclass(frozen=True) class RegistrationLinkClaims: @@ -37,11 +39,7 @@ class RegistrationTokenInvalid(RegistrationTokenError): def build_registration_link(*, claims: RegistrationLinkClaims) -> str: token = issue_registration_token(claims=claims) - url_base = getattr(settings, "ZULIP_PREFS_URL_BASE", "").strip().rstrip("/") - path = f"/api/zulip/register/{quote(token, safe='')}/" - if url_base: - return f"{url_base}{path}" - return path + return build_site_url(f"/api/zulip/register/{quote(token, safe='')}/") def issue_registration_token(*, claims: RegistrationLinkClaims) -> str: 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/services/zulip_client.py b/qb_site/zulip_bot/services/zulip_client.py index 566cd464..4c30ba11 100644 --- a/qb_site/zulip_bot/services/zulip_client.py +++ b/qb_site/zulip_bot/services/zulip_client.py @@ -7,6 +7,39 @@ import requests from django.conf import settings +# Zulip rejects messages over 10000 characters; leave headroom for server-side rendering overhead. +MAX_MESSAGE_CHARS = 9000 + + +def split_message_chunks(*, content: str, max_chars: int = MAX_MESSAGE_CHARS) -> list[str]: + """Split a long message on line boundaries so each chunk fits Zulip's size ceiling. + + The shared chunker for every multi-message Zulip send (assigned-prs command, reviewer + attention DMs) — do not re-implement per call site. A single + line longer than ``max_chars`` is hard-split mid-line so no chunk can ever exceed the + ceiling and fail the whole send. + """ + 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 = [] + current_len = 0 + if line_len > max_chars: + for start in range(0, len(line), max_chars): + chunks.append(line[start : start + max_chars]) + continue + current.append(line) + current_len += line_len + if current: + chunks.append("\n".join(current)) + return chunks + @dataclass(frozen=True) class ZulipApiError(RuntimeError): diff --git a/qb_site/zulip_bot/static/zulip_bot/prefs_form.css b/qb_site/zulip_bot/static/zulip_bot/prefs_form.css index d0136ecd..7419d3a1 100644 --- a/qb_site/zulip_bot/static/zulip_bot/prefs_form.css +++ b/qb_site/zulip_bot/static/zulip_bot/prefs_form.css @@ -117,6 +117,24 @@ input[type="checkbox"] { background: #fff; } +.acceptance-selector { + display: flex; + flex-direction: column; + gap: 8px; +} + +.acceptance-option { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 8px 10px; + min-height: 42px; + background: #fff; + font-weight: 500; +} + .warning { color: #92400e; } @@ -215,7 +233,8 @@ button:disabled { gap: 6px; } - .topic-label-option { + .topic-label-option, + .acceptance-option { min-height: 44px; } diff --git a/qb_site/zulip_bot/tests/commands/test_assigned_prs_command.py b/qb_site/zulip_bot/tests/commands/test_assigned_prs_command.py index e90075aa..1de17580 100644 --- a/qb_site/zulip_bot/tests/commands/test_assigned_prs_command.py +++ b/qb_site/zulip_bot/tests/commands/test_assigned_prs_command.py @@ -4,12 +4,13 @@ from django.test import TestCase, override_settings -from analyzer.models import PRQueueWindow, QueueRuleSet, QueueSnapshot +from analyzer.models import AssignmentProposal, PRQueueWindow, QueueRuleSet, QueueSnapshot from analyzer.services.reviewer_attention_format import format_compact_duration from core.models import Repository, ReviewerPreference, User from syncer.models import LabelDef, PRLabel, PullRequest, PRTimelineEvent, PRTimelineEventType from zulip_bot.commands import CommandContext -from zulip_bot.commands.assigned_prs import _split_message_chunks, assigned_prs_command +from zulip_bot.commands.assigned_prs import assigned_prs_command +from zulip_bot.services.zulip_client import split_message_chunks def _dt(year: int, month: int, day: int, hour: int = 0) -> datetime: @@ -335,15 +336,58 @@ def test_shows_at_capacity_summary(self) -> None: self.assertIn("Load: 1 / 1 ⚠ at capacity", content) self.assertIn("⚠ At capacity in 1 of 1 repos.", content) + def test_load_counts_pending_proposals(self) -> None: + # Uniform semantics (design doc 050): a pending proposal occupies capacity here just as it + # does in the console and the assignment engine. + user = User.objects.create(github_login="alice", zulip_user_id=101) + repo = Repository.objects.create(owner="leanprover-community", name="mathlib4", default_branch="master") + ReviewerPreference.objects.create(user=user, repository=repo, maximum_capacity=10) + rules = QueueRuleSet.objects.create( + repository=repo, + version=1, + require_open=True, + require_not_draft=True, + require_ci_success=False, + required_label_names=[], + forbidden_label_names=[], + is_active=True, + ) + self._seed_snapshot(repo, rules, {"1": {"assignees": ["alice"], "author": "bob", "pr_status": "AwaitingReview"}}) + AssignmentProposal.objects.create( + repository=repo, + pr_number=42, + reviewer_login="alice", + state=AssignmentProposal.STATE_PROPOSED, + expires_at=datetime.now(dt_timezone.utc) + timedelta(days=7), + ) + + with patch("zulip_bot.commands.assigned_prs.ZulipClient.send_direct_message") as mock_send: + assigned_prs_command(self._context(), "") + + content = mock_send.call_args.kwargs["content"] + # 1 assigned (weight 1.0) + 1 pending proposal (weight 1.0) = 2.0 of 10. + self.assertIn("Load: 2 / 10 (8 free)", content) + class TestSplitMessageChunks(TestCase): def test_splits_when_message_exceeds_limit(self) -> None: content = "line-1\nline-2\nline-3\nline-4" - chunks = _split_message_chunks(content=content, max_chars=12) + chunks = split_message_chunks(content=content, max_chars=12) self.assertGreater(len(chunks), 1) self.assertTrue(all(len(chunk) <= 12 for chunk in chunks)) + def test_single_oversized_line_is_hard_split(self) -> None: + # No chunk may ever exceed the ceiling — an oversized chunk fails the whole Zulip send. + content = "short\n" + "x" * 30 + "\nshort-2" + chunks = split_message_chunks(content=content, max_chars=12) + + self.assertTrue(all(len(chunk) <= 12 for chunk in chunks)) + self.assertEqual("".join(chunks).replace("\n", ""), content.replace("\n", "")) + + def test_short_message_is_untouched(self) -> None: + self.assertEqual(split_message_chunks(content="hello\nthere", max_chars=100), ["hello\nthere"]) + class TestFormatDuration(TestCase): def test_format_duration_thresholds(self) -> None: diff --git a/qb_site/zulip_bot/tests/commands/test_console_command.py b/qb_site/zulip_bot/tests/commands/test_console_command.py new file mode 100644 index 00000000..2c6cfa67 --- /dev/null +++ b/qb_site/zulip_bot/tests/commands/test_console_command.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from django.test import SimpleTestCase, override_settings + +from zulip_bot.commands import CommandContext +from zulip_bot.commands.console import console_command + + +class TestConsoleCommand(SimpleTestCase): + def _context(self) -> CommandContext: + return CommandContext( + sender_id=101, + sender_email="reviewer@example.com", + sender_full_name="Reviewer User", + message_content="console", + message_id=1, + stream_id=None, + topic=None, + is_private=True, + allowed_command_names=frozenset({"console"}), + ) + + @override_settings(QUEUEBOARD_BASE_URL="https://queue.example.org") + def test_returns_absolute_console_link(self) -> None: + result = console_command(self._context(), "") + # In-place reply (non-sensitive link), not a proactive DM. + self.assertFalse(result.response_not_required) + self.assertIn("[reviewer console](https://queue.example.org/console/)", result.content) 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", diff --git a/qb_site/zulip_bot/tests/commands/test_prefs_command.py b/qb_site/zulip_bot/tests/commands/test_prefs_command.py index a9fd7f6a..fbf7ae83 100644 --- a/qb_site/zulip_bot/tests/commands/test_prefs_command.py +++ b/qb_site/zulip_bot/tests/commands/test_prefs_command.py @@ -10,7 +10,7 @@ from zulip_bot.commands.prefs import prefs_command -@override_settings(ZULIP_PREFS_URL_BASE="https://queueboard.example") +@override_settings(QUEUEBOARD_BASE_URL="https://queueboard.example") class TestPrefsCommand(TestCase): def _context(self, *, sender_id: int) -> CommandContext: return CommandContext( diff --git a/qb_site/zulip_bot/tests/commands/test_register_test_command.py b/qb_site/zulip_bot/tests/commands/test_register_test_command.py index e2d5e919..798664c1 100644 --- a/qb_site/zulip_bot/tests/commands/test_register_test_command.py +++ b/qb_site/zulip_bot/tests/commands/test_register_test_command.py @@ -9,7 +9,7 @@ from zulip_bot.commands.register_test import register_test_command -@override_settings(ZULIP_PREFS_URL_BASE="https://queueboard.example") +@override_settings(QUEUEBOARD_BASE_URL="https://queueboard.example") class TestRegisterTestCommand(SimpleTestCase): def _context(self, *, sender_id: int | None) -> CommandContext: return CommandContext( diff --git a/qb_site/zulip_bot/tests/test_close_pr_links.py b/qb_site/zulip_bot/tests/test_close_pr_links.py index 21f2c9d8..30eaecae 100644 --- a/qb_site/zulip_bot/tests/test_close_pr_links.py +++ b/qb_site/zulip_bot/tests/test_close_pr_links.py @@ -37,7 +37,7 @@ def test_round_trip(self) -> None: self.assertIsNotNone(claims.iat) self.assertIsNotNone(claims.exp) - @override_settings(ZULIP_PREFS_URL_BASE="https://queueboard.example") + @override_settings(QUEUEBOARD_BASE_URL="https://queueboard.example") def test_build_link_uses_url_base(self) -> None: link = build_close_pr_link(claims=self._claims()) self.assertTrue(link.startswith("https://queueboard.example/api/zulip/close-pr/")) diff --git a/qb_site/zulip_bot/tests/test_policy.py b/qb_site/zulip_bot/tests/test_policy.py index 835166a0..3476fa47 100644 --- a/qb_site/zulip_bot/tests/test_policy.py +++ b/qb_site/zulip_bot/tests/test_policy.py @@ -128,7 +128,7 @@ def test_help_lists_only_allowed_commands(self) -> None: ZULIP_COMMAND_POLICY={ "prefs": {"allowed_groups": ["all"], "allowed_contexts": ["dm"]}, }, - ZULIP_PREFS_URL_BASE="https://queueboard.example", + QUEUEBOARD_BASE_URL="https://queueboard.example", ) @patch("zulip_bot.commands.prefs.ZulipClient") def test_prefs_command_via_webhook(self, MockZulipClient: MagicMock) -> None: diff --git a/qb_site/zulip_bot/tests/test_prefs_form.py b/qb_site/zulip_bot/tests/test_prefs_form.py index 1ecb7c83..68372946 100644 --- a/qb_site/zulip_bot/tests/test_prefs_form.py +++ b/qb_site/zulip_bot/tests/test_prefs_form.py @@ -83,6 +83,7 @@ def _post_data(self) -> tuple[dict[str, str | list[str]], dict[int, int]]: data[f"form-{idx}-id"] = str(pref.id) data[f"form-{idx}-maximum_capacity"] = str(pref.maximum_capacity) data[f"form-{idx}-auto_assign"] = "on" if pref.auto_assign else "" + data[f"form-{idx}-assignment_acceptance"] = pref.assignment_acceptance data[f"form-{idx}-notifications_enabled"] = "on" if pref.notifications_enabled else "" settings = pref.notification_settings or {} data[f"form-{idx}-stale_nudge_days"] = str(settings.get("stale_nudge_days", DEFAULT_STALE_NUDGE_DAYS)) @@ -148,6 +149,65 @@ def test_post_valid_updates_preferences_and_redirects(self) -> None: self.assertEqual(self.pref2.free_form, "updated note") self.assertTrue(self.pref2.auto_assign) + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=True) + def test_get_shows_assignment_acceptance_when_proposals_enabled(self) -> None: + token = self._token() + response = self.client.get(reverse("zulip-prefs-form", kwargs={"token": token})) + self.assertEqual(response.status_code, 200) + self.assertIn('name="form-0-assignment_acceptance"', response.content.decode("utf-8")) + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=False) + def test_get_hides_assignment_acceptance_when_proposals_disabled(self) -> None: + token = self._token() + response = self.client.get(reverse("zulip-prefs-form", kwargs={"token": token})) + self.assertEqual(response.status_code, 200) + self.assertNotIn("assignment_acceptance", response.content.decode("utf-8")) + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=True) + def test_post_updates_assignment_acceptance(self) -> None: + # Rows created in setUp default to "confirm"; flip pref1 to "auto" and pin pref2 to "confirm". + self.assertEqual(self.pref1.assignment_acceptance, ReviewerPreference.ACCEPTANCE_CONFIRM) + token = self._token() + data, index_by_id = self._post_data() + data[f"form-{index_by_id[self.pref1.id]}-assignment_acceptance"] = ReviewerPreference.ACCEPTANCE_AUTO + data[f"form-{index_by_id[self.pref2.id]}-assignment_acceptance"] = ReviewerPreference.ACCEPTANCE_CONFIRM + + response = self.client.post(reverse("zulip-prefs-form", kwargs={"token": token}), data=data, follow=True) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Preferences saved") + self.pref1.refresh_from_db() + self.pref2.refresh_from_db() + self.assertEqual(self.pref1.assignment_acceptance, ReviewerPreference.ACCEPTANCE_AUTO) + self.assertEqual(self.pref2.assignment_acceptance, ReviewerPreference.ACCEPTANCE_CONFIRM) + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=True) + def test_post_rejects_unknown_assignment_acceptance(self) -> None: + token = self._token() + data, index_by_id = self._post_data() + data[f"form-{index_by_id[self.pref1.id]}-assignment_acceptance"] = "sometimes" + + response = self.client.post(reverse("zulip-prefs-form", kwargs={"token": token}), data=data) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Select a valid choice") + self.pref1.refresh_from_db() + self.assertEqual(self.pref1.assignment_acceptance, ReviewerPreference.ACCEPTANCE_CONFIRM) + + @override_settings(ANALYZER_ASSIGNMENT_PROPOSALS_ENABLED=False) + def test_post_ignores_assignment_acceptance_when_proposals_disabled(self) -> None: + # With the feature off the field is not part of the form, so a crafted POST value must not + # change the stored mode. + token = self._token() + data, index_by_id = self._post_data() + data[f"form-{index_by_id[self.pref1.id]}-assignment_acceptance"] = ReviewerPreference.ACCEPTANCE_AUTO + + response = self.client.post(reverse("zulip-prefs-form", kwargs={"token": token}), data=data) + + self.assertEqual(response.status_code, 302) + self.pref1.refresh_from_db() + self.assertEqual(self.pref1.assignment_acceptance, ReviewerPreference.ACCEPTANCE_CONFIRM) + def test_post_can_be_submitted_multiple_times_before_expiry(self) -> None: token = self._token() data, index_by_id = self._post_data() diff --git a/qb_site/zulip_bot/tests/test_registration_callback_linking.py b/qb_site/zulip_bot/tests/test_registration_callback_linking.py index 4983db54..3b1cd6dc 100644 --- a/qb_site/zulip_bot/tests/test_registration_callback_linking.py +++ b/qb_site/zulip_bot/tests/test_registration_callback_linking.py @@ -15,7 +15,7 @@ @override_settings( GITHUB_OAUTH_CLIENT_ID="client-id", GITHUB_OAUTH_CLIENT_SECRET="client-secret", - GITHUB_OAUTH_REDIRECT_URI="https://queueboard.example/api/zulip/register/github/callback/", + QUEUEBOARD_BASE_URL="https://queueboard.example", ) class TestRegistrationCallbackLinking(TestCase): def _token_and_state(self, *, zulip_user_id: int = 101, nonce: str = "nonce-123") -> tuple[str, str]: diff --git a/qb_site/zulip_bot/tests/test_registration_links.py b/qb_site/zulip_bot/tests/test_registration_links.py index ce53b167..b9cbf90c 100644 --- a/qb_site/zulip_bot/tests/test_registration_links.py +++ b/qb_site/zulip_bot/tests/test_registration_links.py @@ -15,7 +15,7 @@ class TestRegistrationLinks(SimpleTestCase): - @override_settings(ZULIP_PREFS_URL_BASE="https://queueboard.example") + @override_settings(QUEUEBOARD_BASE_URL="https://queueboard.example") def test_build_registration_link_uses_prefs_base(self) -> None: link = build_registration_link( claims=RegistrationLinkClaims( 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) diff --git a/qb_site/zulip_bot/tests/test_registration_start.py b/qb_site/zulip_bot/tests/test_registration_start.py index 855ec2b9..e93aa375 100644 --- a/qb_site/zulip_bot/tests/test_registration_start.py +++ b/qb_site/zulip_bot/tests/test_registration_start.py @@ -58,7 +58,7 @@ def test_get_with_oauth_not_configured_shows_message(self) -> None: @override_settings( GITHUB_OAUTH_CLIENT_ID="client-id", GITHUB_OAUTH_CLIENT_SECRET="client-secret", - GITHUB_OAUTH_REDIRECT_URI="https://queueboard.example/api/zulip/register/github/callback/", + QUEUEBOARD_BASE_URL="https://queueboard.example", ) def test_register_github_start_redirects_to_github_authorize(self) -> None: token = self._token() @@ -88,7 +88,7 @@ def test_register_github_start_without_oauth_config_returns_forbidden(self) -> N @override_settings( GITHUB_OAUTH_CLIENT_ID="client-id", GITHUB_OAUTH_CLIENT_SECRET="client-secret", - GITHUB_OAUTH_REDIRECT_URI="https://queueboard.example/api/zulip/register/github/callback/", + QUEUEBOARD_BASE_URL="https://queueboard.example", ) def test_register_github_callback_with_missing_params_returns_forbidden(self) -> None: response = self.client.get(reverse("zulip-register-github-callback")) @@ -99,7 +99,7 @@ def test_register_github_callback_with_missing_params_returns_forbidden(self) -> @override_settings( GITHUB_OAUTH_CLIENT_ID="client-id", GITHUB_OAUTH_CLIENT_SECRET="client-secret", - GITHUB_OAUTH_REDIRECT_URI="https://queueboard.example/api/zulip/register/github/callback/", + QUEUEBOARD_BASE_URL="https://queueboard.example", ) def test_register_github_callback_with_valid_state_renders_verified_page(self) -> None: token = self._token() @@ -134,7 +134,7 @@ def test_register_github_callback_with_valid_state_renders_verified_page(self) - @override_settings( GITHUB_OAUTH_CLIENT_ID="client-id", GITHUB_OAUTH_CLIENT_SECRET="client-secret", - GITHUB_OAUTH_REDIRECT_URI="https://queueboard.example/api/zulip/register/github/callback/", + QUEUEBOARD_BASE_URL="https://queueboard.example", ) def test_register_github_callback_with_invalid_state_returns_forbidden(self) -> None: response = self.client.get( diff --git a/qb_site/zulip_bot/views.py b/qb_site/zulip_bot/views.py index d4eafef9..a4eb126a 100644 --- a/qb_site/zulip_bot/views.py +++ b/qb_site/zulip_bot/views.py @@ -23,6 +23,7 @@ from zulip_bot.commands import assign as _assign # noqa: F401 from zulip_bot.commands import assigned_prs as _assigned_prs # noqa: F401 from zulip_bot.commands import close_pr as _close_pr # noqa: F401 +from zulip_bot.commands import console as _console # noqa: F401 from zulip_bot.commands import echo as _echo # noqa: F401 from zulip_bot.commands import help as _help # noqa: F401 from zulip_bot.commands import label_pr as _label_pr # noqa: F401 @@ -33,6 +34,7 @@ from zulip_bot.forms import ReviewerPreferenceForm from zulip_bot.services.registration_bootstrap import ensure_default_preferences_for_user from core.services.github_oauth import GitHubOAuthClient, GitHubOAuthError +from core.services.site_urls import resolve_site_base_url from zulip_bot.services.close_pr_execution import ( add_pr_labels, ClosePRError, @@ -920,10 +922,12 @@ def _github_oauth_is_configured() -> bool: def _github_oauth_redirect_uri(request: HttpRequest) -> str: - configured = getattr(settings, "GITHUB_OAUTH_REDIRECT_URI", "").strip() - if configured: - return configured - return request.build_absolute_uri(reverse("zulip-register-github-callback")) + # Derive the registration callback from the canonical site base (QUEUEBOARD_BASE_URL), mirroring + # the reviewer console's callback so one setting configures both OAuth flows (design doc 050). + # Fall back to the live request host in local dev where no base URL is configured. + path = reverse("zulip-register-github-callback") + base = resolve_site_base_url() + return f"{base}{path}" if base else request.build_absolute_uri(path) def _load_authorized_preferences(user_id: int, zulip_user_id: int, preference_ids: tuple[int, ...]) -> list[ReviewerPreference]: 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",