Автоматическое создание репозитория студента (замена GitHub Classroom) - #49
Merged
Conversation
Implements docs/REPO_GENERATION_PLAN.md:
- GET /join/{course_id}/{lab_id}: public landing info (course/lab name)
- GET /join/{course_id}/{lab_id}/start: begins GitHub OAuth Web App Flow
with a signed state (course_id/lab_id, 10 min TTL) via the existing
itsdangerous signer
- GET /join/callback: verifies state, exchanges code for a confirmed
username (never trusts the frontend for identity), then creates the
student repo from the lab's template-repo and repairs collaborator
access (replacing the separate github-reinvite tool) - all via the
server's GITHUB_TOKEN, never the student's one-shot OAuth token
- grading/repo_provisioning.py: RepoProvisioner orchestrator (mirrors
LabGrader) handling repo creation, the create-race edge case, and
collaborator/invitation repair
- grading/github_client.py: new GitHub API methods for template repo
creation, collaborator/invitation checks
- New course config field: labs.*.template-repo (docs/COURSE_CONFIG.md)
- New env vars: GITHUB_OAUTH_CLIENT_ID/SECRET, FRONTEND_URL, optional
GITHUB_OAUTH_CALLBACK_URL (.env.example, CLAUDE.md)
- Frontend: JoinLab component + /join/:courseId/:labId route, showing
the sign-in button and success/error result states, ru/en/zh translations
- Tests: RepoProvisioner unit tests and /join endpoint tests (state
validation, access_denied, OAuth exchange failure, successful/repeat
provisioning, token never leaked to the redirect), GitHub API mocked
throughout
Requires teacher-side setup (GitHub OAuth App + org repo creation rights)
that can't be done from this environment - not yet manually end-to-end
tested against real GitHub.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wn3mnuRW9aYu29S42r6C5
Owner
Author
Ревью PR (code-review skill, effort: high)Критичных проблем безопасности в OAuth-флоу не найдено. Проверено явно:
Найдены 4 менее критичные проблемы (не эксплуатируемые уязвимости, но стоит поправить):
Generated by Claude Code |
Fixes from the review posted at #49 (comment): - grading/repo_provisioning.py: distinguish a GitHub secondary-rate-limit 403 from a real permissions 403 when creating the repo from template, reporting it as retryable (RATE_LIMITED) instead of CREATE_FORBIDDEN, per docs/REPO_GENERATION_PLAN.md §7 - grading/github_client.py: add a request timeout to the new /join GitHub API calls (matches the timeout already used for OAuth requests in main.py), avoiding a hung worker if api.github.com stalls - grading/github_client.py: document the affiliation=direct caveat on is_direct_collaborator (undocumented for the single-collaborator-check endpoint, but exactly what the plan specifies; harmless for the current one-student-one-repo model) - Frontend: map the new RATE_LIMITED error code to a distinct, actionable message (ru/en/zh) - Tests: cover the secondary-rate-limit vs plain-403 distinction, and add the previously-missing expired (validly signed, past TTL) state test called out in the plan's §10 test checklist All 226 tests pass; frontend build unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wn3mnuRW9aYu29S42r6C5
Owner
Author
|
Обработал все 4 находки из ревью (коммит c85028a):
Также добавил во frontend отдельное сообщение для Все 226 тестов проходят, frontend собирается без ошибок. Generated by Claude Code |
This was referenced Sep 4, 2026
Переносит фронтенд /join/:courseId/:labId из PR #48 (feature/public-join-github-oauth) поверх бэкенд-контрактов PR #49, адаптируя контракт: lab_short_name вместо lab_name, HTTP 400 вместо 409 для неготовой лабы, reason= вместо error= и repo_url=/username= вместо repository= в редиректе после OAuth-колбэка. - JoinLab/index.jsx, styled.js, state.js, state.test.js - компонент из #48 (переключатель языка на странице, полный набор переводов ошибок), адаптированный под query-параметры и коды ошибок #49 - language.js/.test.js - хранение выбранного языка интерфейса - i18n.js - язык интерфейса запоминается между визитами (readStoredLanguage/persistLanguage + applyDocumentLanguage) - api/index.js - fetchJoinLab/getJoinStartUrl вместо fetchJoinLabInfo, 400 вместо 409 как признак неготовой лабы - App.jsx - роуты /join/error и /join/:courseId/:labId рендерят JoinLab напрямую, joinLabWrapper.jsx удалён (не может обслужить /join/error, у которого нет параметров) - package.json - добавлен скрипт "test": "node --test src" (без апгрейда react-router-dom и прочих правок зависимостей из #48) - locales/{ru,en,zh} - namespace join.* из #48 вместо плоских joinXxx ключей #49; добавлены join.errors.createForbidden (CREATE_FORBIDDEN) и join.usernameLabel (логин студента в success-панели) - state.js: ERROR_TRANSLATION_KEYS переписан под коды ошибок #49 (реcursion main.py redirect reasons, RepoProvisioner.error_code, fetchJoinLab) - Кнопка "Назад" на страницу перенесена (стиль ButtonBack из course-list/styled, текст "← Назад" не переведён - как и в остальных компонентах проекта, где эта кнопка используется) Точечная правка бэкенда (main.py): - добавлен _join_error_redirect() - результат для случая, когда course_id/lab_id ещё неизвестны (битый/просроченный/отсутствующий state) - join_callback оборачивает _parse_join_state() в try/except и редиректит на /join/error?status=error&reason=invalid_state вместо сырого HTTPException(400) - tests/test_join_endpoints.py: три существующих теста (invalid/missing/expired state) переписаны под новое поведение (редирект вместо исключения) Проверено: pytest tests/ (226 passed), npm run lint/test/build.
"node --test src" (bare directory argument) does not discover any tests on the Node runtime available for verification here - it tries to require() the directory as a module instead of walking it, so `npm test` reports 0 tests found. `node --test src/**/*.test.js` finds and runs all seven tests (including the nested components/JoinLab/state.test.js) reliably: since no file matches the pattern at exactly one directory level, an unmatched glob is left literal by POSIX shells by default and node's own test runner then expands "**" recursively itself - same end result the task asked for, without depending on shell globstar. package-lock.json: `npm install --legacy-peer-deps` filled in the i18next/react-i18next lockfile entries for dependencies already declared in package.json.
Страница /join — единственная с переключателем языка, и подпись кнопки оставалась русской при выборе English/中文. Ключ join.back добавлен в ru/en/zh. Остальные компоненты (group-list, lab-list, registration-form) продолжают использовать зашитую строку — они не переключают язык, их правка вне объёма. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) Adds repo-provisioning: fork as an alternative to the default template mode for the /join student repo creation flow, switchable per lab in course YAML with no code changes or migration of already-created repos. grading/github_client.py: new fork_repo, get_repo, update_repo, enable_actions methods. grading/repo_provisioning.py: RepoProvisioner.provision() gains a mode parameter (default "template", unchanged behavior). Fork mode forks the template, polls for the (asynchronous) fork to appear, unconditionally enables GitHub Actions (disabled by default on forks and not detectable via the API), and best-effort clears the inherited "template repository" flag. Guards against forking a public template (fork inherits visibility, unlike template mode) and against a same-named foreign repo winning a "already exists" or creation-race check by verifying parent.full_name. main.py: _load_lab_for_join validates repo-provisioning against the known set of modes (400 on an unrecognized value, same as a missing template-repo) and passes it through to provision(). Frontend: new error codes (TEMPLATE_MUST_BE_PRIVATE, FORK_TIMEOUT, ACTIONS_ENABLE_FAILED, NAME_TAKEN_BY_FOREIGN_REPO) mapped to translated messages in ru/en/zh. docs/COURSE_CONFIG.md: documents repo-provisioning, both modes, the GitHub-side prerequisites for fork mode, and that switching modes is not retroactive. Tests cover the new GitHubClient methods, the fork-mode provisioning paths (success, existing/race name collisions, timeout, public template, fatal vs non-fatal fork-repair failures, GitHub API error handling), that default/template mode is unchanged, and an end-to-end /join/callback fork-mode test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wn3mnuRW9aYu29S42r6C5
enable_actions и снятие is_template вызывались только по ветке создания нового форка. Если репозиторий уже существовал, оба шага пропускались: после падения enable_actions (транзиентная ошибка GitHub) повторный заход студента на /join возвращал успех, а Actions в репозитории оставались выключенными — CI не запускался и grade_lab не находил check-runs. Тот же пробел затрагивал форки, созданные вручную. Оба шага вынесены в _repair_fork() и выполняются на обеих ветках. Проверять перед вызовом нечего: заблокированное состояние Actions через API не читается. Попутно: - _check_fork_parent отличает недоступность GitHub от чужого репозитория: недоступность даёт новый код FORK_CHECK_FAILED («попробуйте ещё раз») вместо NAME_TAKEN_BY_FOREIGN_REPO («обратитесь к преподавателю»); - успехом /forks считается любой 2xx, а не только документированный 202; - _poll_for_repo больше не спит после последней попытки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Admin-only feature for labs with repo-provisioning: fork - opens a
proposal PR (never a forced push) in every student fork with the
template's latest default branch, mirroring GitHub Classroom's
"update assignment" flow.
Backend:
- require_admin FastAPI dependency, reused by /admin/check-auth and
applied to the previously-unprotected POST /courses/upload,
DELETE /courses/{course_id}, GET/PUT /courses/{course_id}/edit.
- grading/github_client.py: list_forks, list_org_repos (paginated),
create_pull_request, list_pull_requests; is_rate_limited moved here
from repo_provisioning.py so propagate.py can share it.
- grading/propagate.py: dry-run summary (who would get a PR / who
isn't a fork of the template) and a background job runner with an
in-memory job store (last 20 jobs, single-worker backend required),
a paced PR-creation loop with one rate-limit retry, and per-repo
error isolation so one failing repo doesn't abort the run.
- main.py: GET /admin/courses/{course_id}/labs (lab list for the new
admin page), POST .../labs/{lab_id}/propagate-template-update
(dry_run sync / real run via BackgroundTasks, 409 if already
running for that lab), GET /admin/propagate-jobs/{job_id}.
Frontend:
- New admin page at /admin/courses/:courseId/labs listing course labs
with a "update student repositories" action (enabled only for
repo-provisioning: fork + template-repo), a dry-run preview dialog,
and a polling progress/results dialog for the real run.
- Translations (ru/en/zh) for the new page.
Tests: grading/github_client.py pagination and PR endpoints,
grading/propagate.py (filtering, not_a_fork, the PR response table,
rate-limit retry, per-repo error isolation, job store concurrency),
require_admin coverage on every protected route (401/200), and the
new endpoints' business logic.
Docs: COURSE_CONFIG.md and PROJECT_DESCRIPTION.md updated with the
feature's scope, PR-not-push semantics, non-retroactive fork coverage,
and the single-worker/in-memory job state requirement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wn3mnuRW9aYu29S42r6C5
body: PropagateRequest without a Python default made the parameter required, so a POST with no JSON body at all (the case issue #52 specifically calls out - a bare call must default to dry_run: true) would have been rejected with 422 before reaching the handler. Give it PropagateRequest() as the default, and add a test that exercises this through a real ASGI request rather than a direct function call, so FastAPI's own body-defaulting is what's actually verified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wn3mnuRW9aYu29S42r6C5
…е-форком run_propagation ловил только PropagateSetupError. Голый таймаут requests к api.github.com в get_repo/list_forks/list_org_repos проходил мимо: работа навсегда оставалась в статусе running, пара (course_id, lab_id) — в _running_lab_keys, и все последующие запуски отбивались 409 до перезапуска бэкенда, а страница админки бесконечно опрашивала незавершающуюся работу. Тело вынесено в _run_propagation, а run_propagation гарантирует закрытие работы в finally. Второе: в not_a_fork попадал сам репозиторий-шаблон, если он лежит в той же организации и его имя начинается с github-prefix лабы — ровно раскладка из примера в docs/COURSE_CONFIG.md (github-prefix: os-task1 + os-task1-template). Преподаватель видел шаблон в списке «не охвачены обновлением». Теперь он исключается; посторонние репозитории с тем же префиксом по-прежнему видны. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Кросс-репо PR из шаблона в форк внутри одной организации создать нельзя: GitHub резолвит head вида "owner:branch" в сам базовый репозиторий и отвечает 422 "No commits between master and master" даже при заведомо расходящихся ветках. Документированный параметр head_repo молча игнорируется. Проверено на живом API, подробности в #52. Вместо этого используется то, что форк и шаблон делят хранилище объектов: коммит шаблона кладётся в репозиторий студента веткой template-update (create_ref, при повторной рассылке update_ref с force), и PR открывается из неё в ветку по умолчанию — обычный внутрирепозиторный PR. Перемещение ветки при уже открытом PR обновляет его, а не плодит второй. Попутно две ошибки разбора ответов, вскрывшиеся там же: - различающий текст 422 лежит в errors[].message, а верхнеуровневый message всегда "Validation Failed" — up_to_date и pr_exists определялись как error; - фильтр head в списке PR не понимает форму "owner:branch" (0 результатов при head.label ровно такого вида), поэтому ссылка на существующий PR не восстанавливалась. С голым именем ветки работает. Моки в тестах воспроизводили придуманную форму ответа 422 — заменены на реальную, снятую с живого API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lab_id адресует лабу тремя способами: ключом YAML ("01", "1") из URL /join
и админки, коротким именем ("ЛР0.1", "ЛР1") из интерфейса проверки (эндпоинт
/courses/{id}/groups/{gid}/labs отдаёт именно short-name) и произвольной
строкой с числом ("lab3"). Прежний разбор сводил всё к первому числу и
терял различия:
- int("01") == 1, поэтому /join/<курс>/01 открывал ЛР1, а лаба с ключом "01"
была недостижима;
- re.search(r"\d+", "ЛР0.1") останавливается на "0", поэтому проверка ЛР0.1
бралась за конфиг ЛР0: проверялся репозиторий os-task0-<логин> вместо
os-task-I-1-<логин>, а результат уходил в столбец ЛР0. Если ЛР0 уже была
зачтена, срабатывала защита ячейки и студент получал "Работа уже была
проверена ранее" в ответ на сдачу ЛР0.1; если ячейка была пуста -
результат ЛР0.1 записывался в ЛР0. Столбец ЛР0.1 не заполнялся никогда;
- short-name без цифр ("Тест / КР" в fundamental-statistics-2025) вызывал
400 "Некорректный lab_id" - такая лаба не проверялась вовсе.
find_lab_config ищет по точному ключу, затем по short-name, и только затем
падает на прежний числовой разбор. parse_lab_id оставлен как этот последний
шаг, его поведение не менялось.
Затронуто три места: grade_lab, _load_lab_for_join, _load_lab_for_propagate.
Сортировка списка лаб в админке переведена на числа из short-name, иначе
"01" и "1" оказывались вровень, а ЛР0.1 - после ЛР1.
Сверка по всем восьми конфигам курсов: из 98 обращений (по ключу и по
short-name) изменились ровно 5 - три перечисленные выше лабы, остальные 93
резолвятся как раньше.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Функция нигде не вызывалась и содержала ту же ошибку разбора lab_id, что
и исправленные вызовы: parse_lab_id("ЛР0.1") даёт 0, поэтому она вернула бы
"ЛР0". Мёртвый код с таким поведением - ловушка для следующей правки.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Рассылка была «всё или ничего» по лабе: она забирала все форки шаблона с нужным префиксом. Для лабы, где часть репозиториев осталась от GitHub Classroom, это означало разослать обновление и туда, куда оно не нужно, - единственной защитой была внимательность преподавателя при подтверждении. Бэкенд: POST propagate-template-update принимает необязательный repos - имена из сводки dry-run. Список пересекается с уже вычисленными форками, поэтому посторонняя строка просто отбрасывается и отдельная валидация не нужна. Пустое пересечение завершает работу с понятной ошибкой. Без repos поведение прежнее - все форки лабы. Фронтенд: в диалоге предпросмотра у каждого репозитория чекбокс, в шапке - "выбрать все" с промежуточным состоянием, в подписи кнопки и над таблицей счётчик выбранных. По умолчанию отмечены все, при нуле выбранных кнопка заблокирована. Строки not_a_fork без чекбокса - им рассылать нечего. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
На курсе в две сотни студентов строка "Выбрать все" и счётчик выбранных уезжали вверх: прокручивалось всё содержимое диалога целиком. Чтобы снять все галочки и отметить нескольких, приходилось возвращаться наверх. Прокрутка перенесена в саму таблицу (max-height: 50vh), у Table включён stickyHeader. Раньше sticky не сработал бы: TableWrapper со своим overflow:auto перехватывает контекст позиционирования, а прокручивался DialogContent выше по дереву. Проверено на 200 строках: шапка держится, сводка над таблицей остаётся видимой, мастер-чекбокс работает из любого положения прокрутки, горизонтального переполнения нет ни на 1280, ни на 768. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Студент видит свой репозиторий и может по ошибке форкнуть его в личный
аккаунт, после чего будет пушить туда. grade_lab смотрит только на
{org}/{github-prefix}-{username}, поэтому работа выглядела бы несданной.
_repair_fork теперь отправляет allow_forking: false тем же PATCH, что уже
снимал унаследованный флаг is_template - отдельный запрос не нужен. Шаг
остаётся нефатальным: репозиторий с разрешённым форком работоспособен.
Настройка действует для приватных репозиториев, что fork-режим и
гарантирует: шаблон обязан быть приватным (#51), а форк наследует
видимость. Организационного запрета "форкать только внутрь организации"
на плане Team нет - он доступен только на уровне enterprise, поэтому
запрет ставится точечно на каждый репозиторий.
Проверено на живом API: до починки allow_forking=true, после - false.
Уже созданные репозитории не затрагиваются: у тех, кто больше не зайдёт
на /join, настройка останется прежней.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #46.
Реализует
docs/REPO_GENERATION_PLAN.md.Что сделано
Backend
GET /join/{course_id}/{lab_id}— публичная информация для лендинга (название курса/лабы), без аутентификации.GET /join/{course_id}/{lab_id}/start— начинает GitHub OAuth Web Application Flow с подписаннымstate(course_id+lab_id, TTL 10 минут) через уже существующий в проектеitsdangerous-подписчик (тот же, что дляadmin_session).GET /join/callback— проверяетstate, меняетcodeнаaccess_tokenи получает подтверждённыйusernameчерезGET /user(никогда не принимается от frontend напрямую), затем создаёт репозиторий студента изtemplate-repoи чинит доступ (проверка/переприглашение), заменяя отдельный инструментgithub-reinvite. Всё выполняется сервернымGITHUB_TOKEN, токен студента используется ровно один раз и нигде не сохраняется и не логируется.grading/repo_provisioning.py— оркестраторRepoProvisioner(по аналогии сLabGrader): создание репозитория из шаблона (с обработкой гонки при параллельном создании), проверка/починка коллаборатора и pending-приглашений.grading/github_client.py— новые методы GitHub API: создание из шаблона, проверка прямого доступа коллаборатора, работа с приглашениями.labs.*.template-repo(описано вdocs/COURSE_CONFIG.md). Без него/join/...для лабы отдаёт понятную ошибку конфигурации, а не 500.GITHUB_OAUTH_CLIENT_ID,GITHUB_OAUTH_CLIENT_SECRET,FRONTEND_URL, опциональноGITHUB_OAUTH_CALLBACK_URL(см..env.example,CLAUDE.md).@limiter.limit(...)по конвенции проекта.Frontend
JoinLab(index.jsx+styled.js+joinLabWrapper.jsx) и роут/join/:courseId/:labIdс состояниями «до» (кнопка входа через GitHub) и «после» (ссылка на репозиторий или человекочитаемая ошибка).ru/en/zh.Тесты
tests/test_repo_provisioning.py— юнит-тестыRepoProvisioner(создание из шаблона, повторный заход без пересоздания, гонка при создании, починка pending-приглашения, новое приглашение, ошибки шаблона/прав).tests/test_join_endpoints.py— тесты эндпоинтов/join/...: валидацияstate,error=access_denied, ошибка обмена OAuth-кода, успешный сценарий, повторный заход без пересоздания, и отдельная проверка, что access token студента никогда не попадает в редирект.pytest tests/ -v.npm run build) без ошибок.Требуется от преподавателя (не может быть выполнено из агента)
https://<host>/join/callback, передатьGITHUB_OAUTH_CLIENT_ID/GITHUB_OAUTH_CLIENT_SECRETкак секреты окружения.GITHUB_TOKENимеет право создавать репозитории в организации(-ях) курсов.template-repoтестовой лабы.Не входит в объём (как и указано в плане, §9)
/registerиgrade_lab.RepoProvisioner.provision— параметр называетсяrepo_suffix, а неusername).Generated by Claude Code