Skip to content

Автоматическое создание репозитория студента (замена GitHub Classroom) - #49

Merged
markpolyak merged 20 commits into
mainfrom
claude/student-repo-auto-creation-stp506
Sep 7, 2026
Merged

Автоматическое создание репозитория студента (замена GitHub Classroom)#49
markpolyak merged 20 commits into
mainfrom
claude/student-repo-auto-creation-stp506

Conversation

@markpolyak

Copy link
Copy Markdown
Owner

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 студента никогда не попадает в редирект.
  • Все 223 теста (199 существующих + 24 новых) проходят: pytest tests/ -v.
  • Frontend собирается (npm run build) без ошибок.

Требуется от преподавателя (не может быть выполнено из агента)

  1. Завести GitHub OAuth App (Settings → Developer settings → OAuth Apps) с Authorization callback URL https://<host>/join/callback, передать GITHUB_OAUTH_CLIENT_ID/GITHUB_OAUTH_CLIENT_SECRET как секреты окружения.
  2. Убедиться, что аккаунт GITHUB_TOKEN имеет право создавать репозитории в организации(-ях) курсов.
  3. Для теста — завести тестовый репозиторий-шаблон, отмеченный как Template repository, и указать его в template-repo тестовой лабы.
  4. Пройти полный сценарий вручную в браузере (этап 5 чек-листа плана) — это не может быть выполнено из данной среды.

Не входит в объём (как и указано в плане, §9)

  • Изменения в /register и grade_lab.
  • Фоновая/периодическая проверка приглашений по расписанию.
  • Групповые/командные репозитории (задел на будущее заложен в сигнатуре RepoProvisioner.provision — параметр называется repo_suffix, а не username).

Generated by Claude Code

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

Copy link
Copy Markdown
Owner Author

Ревью PR (code-review skill, effort: high)

Критичных проблем безопасности в OAuth-флоу не найдено. Проверено явно:

  • state подписан через itsdangerous с проверкой срока действия
  • username студента берётся исключительно из подтверждённого GET /user — нигде не из frontend/query
  • токен студента используется ровно один раз (для получения username) и не логируется/не сохраняется
  • создание репо и коллабораторов везде идёт через серверный GITHUB_TOKEN, токен студента для этого не используется
  • join_callback оборачивает всю оркестрацию в except Exception, так что ошибки GitHub API/конфига не всплывают как 500
  • тесты покрывают race-condition при создании (422 → recheck), повторный визит без пересоздания, pending-invitation (delete+reinvite), первое приглашение, access_denied, отсутствие утечки токена

Найдены 4 менее критичные проблемы (не эксплуатируемые уязвимости, но стоит поправить):

  1. grading/repo_provisioning.py:137 — 403 от GitHub при создании репо из template всегда трактуется как «недостаточно прав» (не ретраится), хотя GitHub также отдаёт 403 при secondary rate limiting. Противоречит §7 плана, где rate-limit ошибки должны быть retryable.
  2. grading/github_client.py:191 — новые запросы к GitHub API (repo_exists, create_repo_from_template, is_direct_collaborator, list_invitations, delete_invitation, add_collaborator) не задают timeout=, в отличие от OAuth-запросов в main.py (там timeout=10). Риск зависания воркера при проблемах на стороне api.github.com.
  3. grading/github_client.py:210is_direct_collaborator шлёт affiliation=direct на single-user check-collaborator endpoint, который такой параметр не поддерживает (он есть только у list-collaborators). Сейчас безвредно (модель «один студент — один репо»), но вводит в заблуждение при возможном расширении на групповые лабы.
  4. tests/test_join_endpoints.py:116 — нет теста именно на просроченный (валидно подписанный, но старше JOIN_STATE_MAX_AGE) state — только на «мусорный»/подделанный и на отсутствующий. §10 плана явно требует тест на просроченный state.

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

Copy link
Copy Markdown
Owner Author

Обработал все 4 находки из ревью (коммит c85028a):

  1. 403 rate-limit vs forbiddengrading/repo_provisioning.py: добавлена проверка _is_rate_limited() (по Retry-After, X-RateLimit-Remaining: 0 или тексту сообщения), теперь такой 403 репортится как retryable RATE_LIMITED, а не CREATE_FORBIDDEN. Плюс тесты на оба случая (rate-limit vs обычный 403).
  2. Отсутствие timeout= — добавлен timeout=self.DEFAULT_TIMEOUT (10с, как в OAuth-запросах в main.py) во все 6 новых методов GitHubClient.
  3. affiliation=direct на single-collaborator-check endpoint — не менял поведение (это ровно то, что задаёт §4 плана), добавил докстринг с пояснением, что параметр не задокументирован GitHub для этого эндпоинта, но безвреден при текущей модели «один студент — один репозиторий»; отметил, что для будущих групповых лаб стоит перепроверить.
  4. Тест на просроченный state — добавлен test_expired_state_returns_400_not_500 (подписывает state с подменённым временем через itsdangerous, затем проверяет реальным временем).

Также добавил во frontend отдельное сообщение для RATE_LIMITED (ru/en/zh) вместо общего fallback-текста.

Все 226 тестов проходят, frontend собирается без ошибок.


Generated by Claude Code

claude and others added 18 commits September 4, 2026 08:29
Переносит фронтенд /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>
@markpolyak
markpolyak merged commit e6da26c into main Sep 7, 2026
3 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.

Автоматическое создание репозитория студента (замена GitHub Classroom)

2 participants