Skip to content

October 2026 integration: import check (#656), quorum reduction (#617), final-review PRs, sentry/timeout/export/import-lock fixes - #659

Merged
mahmoud merged 62 commits into
masterfrom
beta/2026-10-integration
Oct 10, 2026
Merged

mahmoud merged 62 commits into
masterfrom
beta/2026-10-integration

Conversation

@mahmoud

@mahmoud mahmoud commented Oct 10, 2026

Copy link
Copy Markdown
Member

Everything with the final review label plus #617 and four small ops/perf fixes, integrated on master (073f0bf) with one merge per PR so each PR's conflict resolution is in its merge commit message. Merge with a merge commit so GitHub marks the contained PRs merged.

Contains

Not included: the import worker (#630, #631); #587 and #533.

Verified

  • pytest montage/tests/ -q: 171 passed, 3 xfailed at 33c0c00; frontend format:check, lint:check, build clean.
  • The import lock was exercised against a real MariaDB 10.6 with two concurrent imports into one round: the waiter is refused (400) and nothing is duplicated; the same run with the lock alone, or with no lock, stored every file twice. A lock wait timeout comes back as a retryable 400.

Deploy notes

Known follow-ups

Closes #635, #640, #642, #653.

Ayush Shukla and others added 30 commits April 16, 2026 04:38
- README: add GitHub Issues link, mark Phabricator as archived, add pointer to dev.md
- dev.md: fix Node.js version (v16→v18), rdb.py line number (113→119), and project tree (filenames, removed non-existent config/ dir)
- .gitignore: add .claude/ and tmp/
- app.py: fix /a/ StaticFileRoute to point to static/index.html (not static/a/index.html which no longer exists)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…static files

- dev.md: document debug:true and userid options for bypassing OAuth locally
- config.default.yaml: add debug:false as an optional documented field
- Remove montage/static/a/index.html and static/dist/ (obsolete pre-Vue frontend files)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…keys

Add debug_userid and debug_username config options so developers can set
which account they are auto-logged in as when debug: true, instead of
always defaulting to Slaporte. Document the /complete_login workaround
for bypassing OAuth locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- deployment.md: correct db_url format (mysql+pymysql://), document
  log paths for both production and beta instances separately
- dev.md: remove non-existent config/ directory from project tree,
  fix Dockerfile -> dockerfile reference in Docker files section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The juror round listing was filtering on campaign status only, so
finalized and cancelled rounds inside active campaigns still appeared.
Jurors clicking them got a confusing 400 "round not active" error.

Now only active and paused rounds are shown in the active view.

Suspected cause: e3069dd (PR #350, merged 2025-10-23) added a
finalize_round endpoint that can finalize individual rounds without
advancing, creating a state the juror listing never filtered for.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Paused rounds were still appearing in the juror round list because
the only_active filter allowed both ACTIVE_STATUS and PAUSED_STATUS.
However confirm_active() only permits ACTIVE_STATUS, so clicking a
paused round always produced a 400 "round not active" error.

Fix restricts the juror index to strictly active rounds only.

Suspected introduced by e3069dd (#350).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
get_index() accepted an only_active parameter but hard-coded True
when calling get_all_rounds_task_counts, so get_all_campaigns()
(the archive endpoint at /juror/campaigns/all) was always returning
an empty list instead of finalized rounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rounds could dead-end when a juror dropped out mid-round: modify_jurors
refused to shrink the jury below quorum, and modify_quorum rejected any
decrease (NotImplementedResponse, unchanged since 2017). The blocker was
"whose completed vote do you discard?"; the answer is "none": results are
a per-entry mean over completed votes, rounds close when no active votes
remain, and unequal per-entry vote counts are already a supported state.
Lowering quorum therefore only cancels excess not-yet-cast tasks, treating
quorum as a coverage floor (incomplete block design posture), never
discarding completed votes.

New capabilities:
- modify_quorum now supports decreases (cancel excess active tasks, then
  rebalance without reassign_all, so jurors keep what they hold) and its
  increase path actually creates tasks mid-round (see bug 1 below).
- New module-level helpers: create_rating_tasks_for_coverage (top every
  non-DQ entry up to a target of non-cancelled votes, fewest-open-tasks
  juror first) and cancel_excess_rating_tasks (cancel above-target active
  votes from the most-loaded jurors first; max-min entry coverage is
  preserved by construction).
- CoordinatorDAO.remove_juror + POST /admin/round/<id>/remove_juror:
  remove one juror from a paused round, cancelling their open tasks;
  their completed votes are kept unless discard_completed is set (values
  retained on the rows for audit; scoring only reads completed votes).
  Quorum implicitly drops to the remaining jury size when needed (or to
  an explicit new_quorum); affected entries are re-covered by remaining
  jurors who have not seen them.
- edit_round applies quorum before jurors, so one RoundEdit.vue save can
  lower the quorum and shrink the jury together; error responses roll
  back both (covered by a rollback test). No frontend change needed: the
  quorum input has no client-side lower bound.
- get_round_damped_rating_map: IMDb-style damped (Bayesian) mean with
  prior weight m = quorum, shown as damped_ratings beside the plain
  ratings in preview_results, to spot low-coverage entries whose score
  is unstable. Advancement still uses the plain mean.
- tools/admin.py: round edit-quorum rewritten (it called nonexistent
  MaintainerDAO methods; now uses CoordinatorDAO.from_round like its
  siblings, prints created/cancelled counts) and new round remove-juror
  command.

Latent bugs fixed in the 2016-17 assignment plumbing (5 and 6 were
demonstrated red-first; 1 is pinned by a task-count regression test):
1. modify_quorum increases silently created zero tasks mid-round
   (create_initial_rating_tasks only covers vote-less entries).
2. reassign_ranking_tasks returned None when juror sets matched; callers
   subscript the result.
3. create_initial_rating_tasks' quorum ceiling counted inactive jurors.
4. get_rating_advancing_group averaged votes of any status; now filters
   completed like get_round_average_rating_map (this also guarantees
   discarded votes stay out of advancement).
5. modify_jurors' same-size swap path assumed exactly one added and one
   removed juror; replacing two-plus jurors at once stranded a removed
   juror's tasks. Multi-swaps now fall through to reassign_tasks.
6. reassign_rating_tasks crashed (ValueError in weighted_choice) when an
   active vote had no eligible juror; such orphaned tasks are now
   cancelled (coverage is already met) and reported via a new
   cancelled_task_count key.
Also: Round.to_details_dict juror_details is no longer a 1-tuple
(trailing comma).

Tests: red-first regressions for bugs 1/5/6 (test_reassign.py is a new
DAO-free unit test against in-memory sqlite), quorum decrease and
combined-edit lifecycle coverage, remove_juror keep/discard flows, error
paths (active round, unknown juror, last juror, oversized new_quorum,
rollback), and damped-ratings presence/bounds. Full suite green
(15 passed, 1 xfailed). CLI smoke-tested against a scratch DB.

Design references: incomplete block designs (Niu & Rosenberger,
arXiv:1806.00034), PeerReview4All max-min fairness (JMLR 22(163)),
IMDb weighted rating / Bayesian average, and slaporte's 2017 Montage
work-allocation gist (its "preserve" default is adopted; its discard
question is dissolved by keep-everything).

Workstream-Session-Id: 01a01955-97bb-7000-82f4-f3188a675ce2
Discovered live testing the quorum-decrease UI: the round-edit form was
unusable for its own core cases, independent of the quorum-decrease
feature itself.

- Round.to_info_dict() never included quorum (only to_details_dict()
  did), but GET /admin/campaign/<id> -- the endpoint that populates the
  round list RoundEdit.vue reads its initial `round` prop from -- uses
  to_info_dict(). So the quorum field always opened blank. Worse,
  saving with a blank field sends quorum: '' to PATCH /round/<id>/edit,
  which edit_round's `if new_quorum and new_quorum != rnd.quorum`
  silently treats as "no change" (200 OK, nothing applied, no error
  toast) -- indistinguishable from the backend just not working.
- RoundEdit.vue seeded its juror chips from the unfiltered jurors list
  (every juror ever added to the round, active or not -- intentional
  at the to_info_dict() layer, since the finalized-campaign report's
  all_jurors credit list at rdb.py depends on seeing removed jurors
  too). The edit form needs only the active roster; each juror already
  carries is_active, so filtered on the frontend instead of narrowing
  the shared serializer.
- saveRound() never reloaded the page on success, unlike its own
  sibling deleteRound() (and RoundInfo's activate/pause). Round-level
  panels refresh independently via their own fetch, but the `round`
  prop RoundEdit reads from is fetched once at the parent and was
  otherwise never refreshed for the rest of the SPA session -- so even
  a successful save would show reverted-looking quorum/jurors on the
  next Edit open.

Fix: add quorum to Round.to_info_dict() (drop the now-redundant
reassignment in to_details_dict()); filter jurors to is_active before
seeding RoundEdit's form; reload after a successful save.

Verified live against the seeded scratch round from the earlier
screenshot session: quorum now pre-fills with the true current value,
a removed juror's chip and the new quorum both persist correctly on
reopening Edit with no page refresh, matching the exact
remove-juror-then-lower-quorum flow the quorum-decrease feature exists
to support.

Workstream-Session-Id: 01a01955-97bb-7000-82f4-f3188a675ce2
…tainers only

/meta and /meta/json/ are public. clastic's resource section shows the
first 70 characters of repr() of each app resource, which for 'config'
showed the start of the config dict to anyone. The resource section now
lists each resource by name and type only (ResourceNamesPeripheral);
the rest of /meta (start time, routes, host, process) is unchanged, so
deploy.sh's smoke test keeps working.

GET /v1/logs/feel (the front-end error log tail: usernames and error
payloads) had no permission check; it now requires a maintainer, like
/logs/api and /logs/api_exc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- utils: ImportSourceInvalid/CheckBlocked/CheckExpired/CheckRequired (400 with
  error_type); config key import_check_path (MONTAGE_IMPORT_CHECK_PATH)
- labs: one query text for lookups by name and by file_id; get_files_info_by_ids
- loaders: fetch_source_text, parse_source_rows (filename > img_name, optional
  file_id, BOM/case-insensitive headers), parse_name_list, lookups; old
  full-CSV/name-list loaders removed (#509, #517); only gist links are
  rewritten (#208)

Refs #510, #509, #208

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #510, #645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- POST /admin/campaign/<id>/import/check, GET .../import/check/<token>/download
- import_entries requires check_token for csv/gistcsv/category/selected
  and imports exactly the checked list; blocked check -> 400 before writes
- add_checked_entries replaces the three DAO loaders; file_id lookup in
  every campaign (FILE_ID_LOOKUP_CUTOFF removed)
- app refuses to start on beta/prod/devlabs without MONTAGE_IMPORT_CHECK_PATH
- no 'all disqualified' warning for an empty import (#208)
- tests import through check + token

Refs #510, #208

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Check button + ImportCheckResult panel (text only), download link
- Save only after a non-blocking check; source change clears the check;
  stale check responses ignored; source locked while checking
- the round is created once; a failed import is retried into it, with the
  round settings locked
- i18n (en + qqq) for the new texts and the import warning dialog
- Cypress: check before Add Round (not run)

Refs #510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by pr-check's delete-the-fix step: reverting the guard left every
test green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll (#447)

A first round was created by one request and filled by a second one; when
the import brought no files (an empty category, a CSV with no valid
names) or failed, an empty round stayed behind and blocked creating
another one.

- add_round takes an optional import {import_method, check_token}: the
  round and its files are saved in one transaction; an import that
  fails, is blocked or brings no files (import_empty) rolls the round
  back
- the round form sends the checked import with the round and keeps Save
  disabled when the check found no importable file; on an error nothing
  was saved and the form stays as entered (the locked-settings retry of
  #510 is no longer needed and is removed)

Based on feat/import-check-510.

Fixes #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- check folder: cleanup deletes only Montage's own check files (token-
  named, .tmp-), at most MAX_CHECK_FILES kept; an existing folder keeps
  its mode; startup refuses a relative, unwritable or shared folder and
  any unconfigured folder outside dev/devtest; warns outside /data/project
- source links: https only, every address of the host (and of each of at
  most 3 redirects) must be public; 20 MB and 100,000-row caps; source
  fields must be text
- same-name keys: database keys on mysql/mariadb, the test-only stand-in
  only for SQLite in dev/tests, otherwise an error; NULL keys refused
- file names canonical like MediaWiki File: titles (prefix in any case,
  runs of spaces/underscores, first letter upper case)
- round sources store a check fingerprint (check_id), not the token, and
  a new check of the same source reuses the round source
- issues report download (every row that is not ok)
- add_round accepts only checked import methods
- checked imports skip add_entries' lower-case dedupe (matched by
  file_id); Entry.file_id declared indexed
- tools/admin.py create_round: source and advance params
- unused imports and cutoff names removed; _ImportError renamed

Refs #510, #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- deploy.sh stops before building when a required envvar is missing
- deployment.md: $HOME-based commands, folder of its own, cleanup rules,
  troubleshooting (missing variable, timeout, old files) and rollback
- docs/api.md: import/check, download, issues, add_round's import, the
  new error types

Refs #510, #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- while a round is saved: source fields and Cancel disabled, Save says
  what it is doing; Cancel reloads the campaign
- save errors handled by type: blocked -> check again, no files ->
  nothing to import, expired / stale page -> check again or reload,
  other errors -> a message next to Save, no answer -> reload the
  campaign and say the round may exist
- a check older than 7 days is refused before saving
- the warnings dialog closed with X/Escape closes the form too
  (dialogService onClose); dialog text keeps its line breaks
- screen readers: a live region announces check results, errors and a
  cleared check; focus moves to the result; Save is described by the
  reason it is disabled; the issues table is a named, focusable region
  with a caption; blocking rows are marked in text; server English is
  marked lang=en; the pair separator is hidden
- category checks: own summary, no 'row' labels, same-name rows not
  shown as blocking; a typed but unpicked category gets a hint
- issues report link; download links use the download attribute
- i18n: no concatenated strings; no plurals (none in the project)
- Cypress: the save is intercepted, its body asserted, a double click
  saves once, a refused import keeps the form (not run)

Refs #510, #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… days

Files are renamed and deleted on Commons all the time, and the check and
the save happen in one sitting, so a check can be imported for 1 hour
(was 7 days). The check file and its downloads (upload file, issues
report) stay at most 7 days: every check, import, download and the app
start delete older files, so they also go after a quiet spell.

Refs #510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…help texts

The File List box read every line as a file name, so a pasted CSV (e.g.
the check's download, meant to be the next source) came back as "not on
Commons" line by line. Now:
- a first line that is a header with filename, img_name or file_id makes
  the box read as a CSV, exactly like a File List URL source;
- a line of digits only is a file_id (a Commons name always has an
  extension), so a plain list of file_ids works, mixed with names;
- a CSV may have only a file_id column (names come from Commons).
The same-name rule and the file_id lookup apply as for every list.

Help texts: file list explains names, file_ids and pasted CSVs; File
List URL names the columns and the sharing setting; the download says it
can be pasted into File List; category example is 2026.

Found while testing on montage-dev (2026-10-07). #510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lgelauff and others added 27 commits October 10, 2026 14:33
…heck

Where entries.name has a unique index (beta, installs built from the
model), a new file whose name the database treats as equal to that of a
different file already in Montage cannot be stored: saving a round with
the WLM 2026 Russia category on beta failed with IntegrityError 1062 on
"Челябинск_2026,_пр._Ленина_71А.jpg" (beta already had "...71а.jpg"),
2026-10-10. The check compared names only within one import.

The check now also looks the names up in entries (the database's own
comparison, through that unique index) and marks the clashes as
same_name_existing: left out of a category with a warning, blocking a
list. The same file (same file_id) and an old row of exactly this name
without a file_id are reused by the import, as before, and are not
clashes. Production has no index on entries.name (#650): there both rows
are stored, so the lookup (a full table scan there) is skipped.

New status label and reasons (translated); tests for the app-level case,
the database comparison and the skip without a unique index.

#510, #645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Testing on beta (2026-10-10) showed the "ambiguous id" check blocking an
ordinary file ID: almost every file_id is also the page ID of some other
file page (Commons API: 4 of 4 file_ids from our tests), so a bare
number cannot be told apart and the check blocked nearly every list of
file IDs.

Now a bare number that is a file_id is taken as one, and shown as
"Found by file ID" with the file's name (status by_file_id, imported),
so a wrong number is seen before saving. Only a number that is no
file_id is looked up as a page ID / revision ID of a file page, as a
courtesy in the error message ("... is the page ID of File:X, not its
file ID"). The ambiguous_id status is gone.

The help texts and errors now say plainly that a file ID is the number
of the file itself (the file_id column), not the page ID shown under
Page information.

#510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…file ID

The yellow message after a check with only warnings said rows were "left
out or changed". Rows found by a bare file ID are also listed there and
are neither: they are imported, and shown so a wrong number is seen. The
message now reads "You can save the round. Check the rows listed below:
some are left out, changed, or found by file ID." The qqq entry explains
the three kinds.

#510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Procfile ran gunicorn with no --timeout, so the 30 s default applied. Sync workers do not heartbeat during a request, so this flag is effectively a per-request deadline; prod's exception log shows imports killed at 30 s (SystemExit from handle_abort on POST /admin/round/<id>/import; #618, #650).

With prod's ix_entries_name restored, Lodewijk measured a 26.4k-file import at ~18 s in the request (#650), so 120 s is headroom, not the plan. Set in the Procfile rather than a GUNICORN_CMD_ARGS envvar so it is versioned and applies to montage, montage-beta and montage-dev alike (CLI flags also override that envvar).

Caveat: the Toolforge front-proxy request timeout is undocumented; measure on beta whether it cuts before 120 s. Workers stay at 4; the 3Gi memory is a Toolforge setting, not code.

Verified: gunicorn --print-config shows timeout = 120, workers = 4.
Workstream-Session-Id: 01a126ed-002a-7000-99d5-a8700aed81a9
…per entry (#640)

GET /admin/round/<id>/entries and /entries/download loaded every
RoundEntry, then lazy-loaded its Entry (and RoundSource) one row at a
time via RoundEntry.to_export_dict(). On a 15k-entry round that is ~15k
sequential SELECTs, which outlasts gunicorn's request timeout (#640).

CoordinatorDAO.get_round_entry_export_rows() replaces get_round_entries()
plus Entry/RoundEntry.to_export_dict(): one joined column query for the
round's non-disqualified entries (now ordered by round_entries.id), and
one query for the referenced round sources, whose params are
json.dumps'd once per source instead of once per row. Output keys and
values are unchanged; a round entry with a NULL round_source_id now
exports source_method=None / source_params='null' instead of raising
AttributeError. Both endpoints use it; the CSV is still built in memory.

New test test_round_entries_export_query_count (n=450, three import
chunks) counts SELECTs per request:
  before: list 457, download 458
  after:  list 7,   download 8
and checks row counts, CSV fieldnames, source_method and source_params.
Full suite: 57 passed, 1 xfailed (master: 56 passed, 1 xfailed).

Still open: #639, file-list imports repeat their (large) source_params
in every exported row.

Workstream-Session-Id: 01a126ed-002a-7000-99d5-a8700aed81a9
… the deprecated Hub API

On Python 3.13 (buildservice, since #564) sentry-sdk 2.8.0 crashes while
building events: it does copy(frame.f_locals), and on 3.13 f_locals is a
FrameLocalsProxy, which can't be copied. Every exception report dies, so
Sentry has shown nothing for prod's import timeouts and OOMs (#642, #653).
Fixed upstream from 2.11; pin the latest 2.x, 2.71.0. Only the sentry-sdk
line in requirements.txt changes (no recompile; urllib3 2.5.0 and
certifi 2024.2.2 already satisfy 2.71's requirements).

clastic_sentry.py moves to the 2.x scope API: the per-request isolation
scope that SentryWsgiMiddleware already opens, the transaction name set
on the current scope (setting it on the isolation scope loses to the
WSGI middleware's "generic WSGI request"), and sentry_sdk.capture_event
with mechanism handled=False. The old module no longer imports under
2.71 (_should_send_default_pii is gone from sentry_sdk.hub). The
sentry_hub dependency is dropped from provides; nothing consumes it.
mw/__init__.py's sentry_scope._user use still works (Scope._user exists).

Smoke test (clastic app with SentryMiddleware, /boom raises ValueError):
- before, 2.8.0: /boom 500 with FrameLocalsProxy in the body, 0 events
- after, 2.71.0: /boom 500, 1 event, ValueError, transaction '/boom';
  /ok 200, 0 events
pytest: 56 passed, 1 xfailed, same as origin/master in the same venv.

Workstream-Session-Id: 01a126ed-002a-7000-99d5-a8700aed81a9
…ry file twice (#635)

A slow import (minutes on prod without ix_entries_name, #650) outlives
the browser; coordinators retry, and two imports of the same round ran
side by side. Neither locked the round, so both inserted every file
(#635).

A row lock alone isn't enough on MySQL/MariaDB. Under InnoDB REPEATABLE
READ a request's snapshot is fixed at its first plain read (the user
lookup in UserMiddleware), so a request that waits on the lock and then
reads plainly still sees the round's entries as they were before the
other import committed. add_round_entries and add_entries both read
plainly, so the waiting import would still double-insert.

So CoordinatorDAO.lock_round_for_import:
- counts the round's entries in the request's snapshot,
- takes the round's row lock (_lock_round, FOR UPDATE, as #626 does
  for activation) and refuses unless the round is paused,
- counts again on a separate connection (its own fresh snapshot, so it
  sees what was committed while this request waited),
- refuses with a 400 if the two counts differ: another import finished
  meanwhile; the coordinator refreshes and re-imports only if files are
  missing.

import_entries takes this lock first and holds it across the fetch,
the inserts and autodisqualify, ending with a flush, all inside
lock_errors_as_busy. Only that round's row is locked: a second import
or activation of the same round waits (innodb_lock_wait_timeout, 50 s
by default) and then gets a "server is busy, please retry" 400 instead
of a 500; other rounds are unaffected. activate_round gets the same
guard plus a trailing flush, so activating while an import holds the
lock is a 400, not a 500.

MYSQL_LOCK_ERROR_CODES, is_lock_error and lock_errors_as_busy are a
verbatim port from #630 (commit 1a19b5d, rdb.py) so a later rebase of
the worker merges cleanly.

Test: test_import_waiting_on_concurrent_import_is_refused commits one
extra round entry from another connection right after the request
takes the round lock (the other import finishing during the wait). The
import is refused (400, nothing added), and a later import without the
race returns 200 with the usual "duplicate import" warning. With
master's rdb.py/admin_endpoints.py the same test fails: the second
import returns success (200). Full suite: 57 passed, 1 xfailed (master:
56 passed, 1 xfailed).

Interplay with #656: it rewrites import_entries around check tokens
(_import_into_round, also used by add_round's first-round import).
Whichever lands second must keep lock_round_for_import as the first
statement of the import path, inside lock_errors_as_busy, including
the check-token import into an existing round that #656 adds.

Workstream-Session-Id: 01a126ed-002a-7000-99d5-a8700aed81a9
… artifacts, update dev docs (beta integration)

PR #468 (docs/improve-setup-docs, @lgelauff, labelled 'final review'), head 575f93a.
Merged for the montage-beta drill of the WLM 2026 import fixes; the PR itself
still lands on master through GitHub.

Conflicts, resolved to master's side:
- montage/public_endpoints.py complete_login: master already reads
  debug_userid/debug_username and, since the OAuth 2.0 + dev-only
  impersonation change, gates debug login on env_name == 'dev' with
  defaults 0 / '__montage_debug__'. #468's version (config-driven
  Slaporte defaults, no env gate) predates that; master's wins so a stray
  debug flag can't bypass OAuth on beta/prod.
- dev.md repo tree: master lists Procfile; both sides dropped config/*.ini.
- config.default.yaml (auto-merged from #468): the comment's defaults
  updated to master's __montage_debug__ / 0 to match the code.

Kept from #468: /a/ serves the main index.html, stale montage/static/a and
logo_white_fat.svg removed, README/dev.md setup docs.

pytest montage/tests/ -q: 56 passed, 1 xfailed.
…ration)

PR #571 (fix/juror-round-visibility, @lgelauff, labelled 'final review'), head 69d6903.
Juror index: pass only_active through, hide paused/finalized/cancelled
rounds from the juror's active view (juror_endpoints.py, rdb.py, test).

Conflict: frontend/src/components/Round/RoundEdit.vue, the unused
vue-router import. Master (#588) and #571 both deleted it; kept master's
text (one blank line after the imports).

pytest montage/tests/ -q: 56 passed, 1 xfailed.
…ilters in get_campaign_rounds (#399) (beta integration)

PR #500 (fix-get-campaign-rounds-399, @ayushshukla1807, labelled 'final review'), head af95219.
Merged for the montage-beta drill of the WLM 2026 import fixes; the PR itself
still lands on master through GitHub.

Changes (2 files changed, 95 insertions(+), 2 deletions(-)):
- montage/rdb.py
- montage/tests/test_dao_unit.py
…ed entries in i18n (beta integration)

PR #593 (block-edit-when-active-round, @SSpirate11, labelled 'final review'), head 2d27134.
Merged for the montage-beta drill of the WLM 2026 import fixes; the PR itself
still lands on master through GitHub.

Changes (3 files changed, 13 insertions(+), 5 deletions(-)):
- frontend/src/components/Round/RoundView.vue
- frontend/src/i18n/en.json
- frontend/src/i18n/qqq.json
The quorum-reduction steps added to test_home_client end the yes/no
round at quorum 1 and then reactivate it. With 20 entries and 5
jurors, the decrease (cancel_excess_rating_tasks plus a non-
reassign_all rebalance) and the two-for-two juror swaps can leave
Jimbo Wales with only 1-2 open tasks. The juror steps that follow need
at least 3 (submit tasks[0], skip tasks[1], then expect the skipped
vote not to come back), so the test failed nondeterministically:
3/15 runs on e72ae58 (before the master merge) and 8/20 runs after
merging origin/master, as either `assert <id> not in [<id>]` (only the
skipped task left, which #371 serves again by design) or an
IndexError on tasks[1].

Raising the quorum back to 4 (exercising the coverage-based increase
path once more) gives every juror a full queue. 25/25 runs pass.
No production code changes.
… legacy Py2 code (beta integration)

PR #551 (refactor/modernize-utils-typing, @ayushshukla1807, labelled 'final review'), head 35d88d6.
Type hints and Py2 cleanup in montage/utils.py only.

Conflicts in montage/utils.py, resolved by keeping master's behavior with
#551's signatures:
- get_env_name() -> str: master's body (MONTAGE_ENV first, a clear
  RuntimeError when getpass fails in containers), #551's annotation.
- load_env_config(env_name: str | None = None) -> dict[str, Any]: #551's
  signature; master's _load_config_from_env (envvar config, not touched
  by #551) kept unchanged above it.

Tests do NOT pass at this commit: #551 deletes utils.basestring, which
rdb.py and mw/__init__.py still import (4 collection ImportErrors, on
#551's own head too). Fixed in the next commit.
…oved

#551 deletes the Py2 aliases `basestring` and `unicode` from
montage/utils.py (both were `str` on Python 3), but five modules still
imported them, so test collection failed with 4 ImportErrors after the
#551 merge (the same on #551's own head).

Replaced each use with `str`, which is exactly what the aliases were:
- montage/rdb.py, montage/mw/__init__.py: basestring
- montage/imgutils.py, montage/loaders.py, montage/tests/test_web_basic.py:
  unicode

No behavior change. TimingMiddleware's `isinstance(ret.data, str)` is
still never true for werkzeug's bytes body, as before.

pytest montage/tests/ -q: 57 passed, 1 xfailed. pyflakes over montage/
and tools/ finds no undefined names besides tools/trim_csv.py's
pre-existing `file`.

Workstream-Session-Id: 01a12672-ec39-7000-9f0c-c9b9269890b3
Workstream-Plan-Id: 2026-10-10-c5476fcaee
…-end error log for maintainers only (beta integration)

PR #647 (fix/meta-no-resource-values, @lgelauff, labelled 'final review'), head e358a26.
Merged for the montage-beta drill of the WLM 2026 import fixes; the PR itself
still lands on master through GitHub.

Changes (3 files changed, 59 insertions(+), 3 deletions(-)):
- montage/app.py
- montage/meta_endpoints.py
- montage/tests/test_web_basic.py
…c_sentry on the 2.x scope API (#642, #653) (beta integration)

Sentry has been blind on Python 3.13: sentry-sdk 2.8.0 crashes copying a
FrameLocalsProxy while building events. Branch tip 489c813.
Not yet a PR; goes to master as its own PR after the beta checks.

Changes: 2 files changed, 24 insertions(+), 32 deletions(-).
…f the default 30 s (beta integration)

The 30 s default kill is what ends large imports on prod (#618, #650).
Branch tip bde63a8.
Not yet a PR; goes to master as its own PR after the beta checks.

Changes: 1 file changed, 1 insertion(+), 1 deletion(-).
…download in two queries (#640) (beta integration)

Per-row lazy loads made 457/458 SELECTs for 450 entries; now 7/8.
Branch tip f51a686.
Not yet a PR; goes to master as its own PR after the beta checks.

Changes: 3 files changed, 131 insertions(+), 29 deletions(-).
…t round with its files (#510, #509, #447) (beta integration)

PR #656 (feat/import-check-510, @lgelauff, labelled 'final review'), head 4f12cf2.
Mandatory import Check (POST /admin/campaign/<id>/import/check, check
files under MONTAGE_IMPORT_CHECK_PATH), MediaWiki-style name
canonicalization, lookup by file_id, https/public-only fetch guard, atomic
first-round save in add_round (#447), WEIGHT_STRING same-name check.
Removes the gist path that calls prod's own /v1/utils//file ~79 times
per 15k-row CSV (today's prod kills on rounds 5051/5052).

Conflict: montage/loaders.py, both hunks taken from #656:
- imports: #656's list (it drops utils.unicode too, matching the
  previous integration fix);
- load_partial_csv/load_name_list: #656 replaces them with
  parse_source_rows and friends; the only change on our side was the
  unicode -> str swap in code #656 deletes.
Result is byte-identical to pr/656's loaders.py.

Tests at this commit: 1 failed, 161 passed, 3 xfailed. The failure is
test_round_entries_export_query_count (from perf/round-entries-export-640),
which imports through POST /round/<id>/import without a check_token; #656
makes that a 400. Adapted in the next commit.
test_round_entries_export_query_count (perf/round-entries-export-640) set
up its round with a bare POST /admin/round/<id>/import, which #656 turns
into a 400 (import_check_required). It now uses the suite's
fetch_checked_import helper from #656: check the category, then import
with the check_token, as the round form does.

#656 also stores a fingerprint of the check in round_sources.params
(import_check.source_params adds CHECK_ID_KEY), so the CSV's
source_params is no longer exactly {"category": "Batch_import"}. The test
now checks the category key and that every row carries the same params
string (serialized once per source, which is the point of #640).

SELECT counts are unchanged after #656: list 7, download 8 at n=450.
pytest montage/tests/ -q: 162 passed, 3 xfailed.

Workstream-Session-Id: 01a12672-ec39-7000-9f0c-c9b9269890b3
Workstream-Plan-Id: 2026-10-10-c5476fcaee
… (beta integration)

Branch tip 997a619 (not yet a PR). Ported onto #656's import path, per
the integration rule that #656's import semantics win:

- montage/admin_endpoints.py: #656 moved the import into
  _import_into_round, used by both POST /round/<id>/import and
  add_round's first-round import. Its body (round advance + checked
  methods, add_round_entries, warnings, autodisqualify) now runs inside
  lock_errors_as_busy('import into this round'), with
  coord_dao.lock_round_for_import(round_id) first and
  rdb_session.flush() last. G's version of the old csv/gist/category/
  selected dispatch is dropped; #656 replaced it with check tokens.
  For add_round the round is new and uncommitted, so the lock is on the
  request's own row and both counts are 0.
- montage/rdb.py: G's MYSQL_LOCK_ERROR_CODES / is_lock_error /
  lock_errors_as_busy kept verbatim; the ENV_NAME = get_env_name() line
  that sat next to them stays deleted, as #656 has it.
- test_import_waiting_on_concurrent_import_is_refused: all three imports
  go through fetch_checked_import (check, then import with the
  check_token). It still asserts the patched _lock_round ran, the 400
  text from lock_round_for_import, and that the refused import added
  nothing.

pytest montage/tests/ -q: 163 passed, 3 xfailed.

Workstream-Session-Id: 01a12672-ec39-7000-9f0c-c9b9269890b3
Workstream-Plan-Id: 2026-10-10-c5476fcaee
…gian names match

Proposed change to #656, found in the backend review. normalise_name
upper-cased the first character with str.upper(). Since Unicode 11 that
maps Georgian Mkhedruli (U+10D0-U+10FF) to the Mtavruli capitals
(U+1C90-), but MediaWiki's Language::ucfirst falls back to
mb_convert_case($first, MB_CASE_TITLE) (core includes/Language/
Language.php, read 2026-10-10), and Mkhedruli's titlecase is the letter
itself. So Commons stores 'ქართული.jpg' lower-case first (11,123 File:
pages on the replica; prod Montage has 1,488 such entries), and
normalise_name produced 'Ქართული.jpg', which file.file_name (binary(255))
never matches.

Effect before this fix: every Georgian-named row of a CSV, gist, Sheet or
pasted list without a file_id was classified unknown_name and left out
(with a file_id it was imported but flagged renamed). Categories were
unaffected (names come from Commons). master's load_name_list never
touched the first letter, so this was a regression in #656.

str.title() on one character is the same mapping as MB_CASE_TITLE for
the cases that matter here; the len(first) == 1 guard still leaves 'ß'
alone ('ß'.title() is 'Ss'). New case in
test_names_are_canonical_like_mediawiki: 'ქართული.jpg' stays as is
(failed before, passes now).

pytest montage/tests/ -q: 164 passed, 3 xfailed.

Workstream-Session-Id: 01a12672-ec39-7000-9f0c-c9b9269890b3
Workstream-Plan-Id: 2026-10-10-c5476fcaee
…er, as Commons does

Follow-up to 329a85b. MediaWiki's Language::ucfirst applies
mb_convert_case($first, MB_CASE_TITLE), the full Unicode titlecase
mapping including the multi-character SpecialCasing expansions, unless
$wgOverrideUcfirstCharacters says otherwise. Wikimedia's override table
(wmf-config/UcfirstOverrides.php) pins 'ß' => 'ß' (T292552); its other
55 entries are Unicode 16/17 lowercase letters that Python 3.13's tables
(Unicode 15.1) do not case either.

normalise_name kept a `len(first) == 1` guard, which happened to be
right for ß but wrong for the 47 other code points whose titlecase is
more than one character: ʼn, ǰ, ẖ ẗ ẘ ẙ ẚ, the ff..st ligatures, Armenian և
and ﬓ..ﬗ, and 27 polytonic Greek forms. Commons expands those (checked
with the API today: File:fixyz.jpg -> File:Fixyz.jpg, ʼn -> ʼN, և -> Եւ,
ß stays ß), so a list naming such a file could never match the replica.

Now: first.title() unless first is ß. Exhaustive comparison of this
rule against the Unicode 14 full titlecase data plus the WMF overrides
gives no difference for any code point. Tests: dž -> Dž (digraph), an
already-capital Mtavruli first letter, ʼn, fi and և expansions, and ß
kept; the three expansion cases failed with the previous guard.

pytest montage/tests/ -q: 170 passed, 3 xfailed.

Workstream-Session-Id: 01a12715-1528-7000-961b-c78ee7776f73
…d-mean display (#617) (beta integration)

Branch tip 39f9367 (PR #617, draft; master 073f0bf merged in f49e2a2,
test flake fix 39f9367). Added to the integration on Mahmoud's call:
the integration carries everything but the worker (#630/#631).

No conflicts: git merge-tree --write-tree reported a clean tree before
the merge, and the merge touches only #617's six files (rdb.py,
admin_endpoints.py, tools/admin.py, test_web_basic.py, test_reassign.py,
RoundEdit.vue). #632's _insert_tasks and #617's active-juror guard
compose in create_initial_rating_tasks.

Known follow-up (review doc section 6): edit_round/modify_quorum/
remove_juror/modify_jurors take no round lock yet; a double-submitted
quorum raise on a paused round creates coverage tasks twice.
…file ID (beta integration)

#656 moved after 24e747d merged it at 1206105: one commit, 4f12cf2
(en.json/qqq.json, the warnings-only check message). Merged so the PR's
head is an ancestor of the integration branch again and GitHub marks
#656 merged when the integration PR merges with a merge commit. No
conflicts, i18n only.
@mahmoud
mahmoud merged commit 8ac4cfd into master Oct 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Two simultaneous imports into one round add every file twice

3 participants