Зависит от #50 (перенос UI /join из #48 в #49) — эта задача делается поверх его результата, в той же ветке claude/student-repo-auto-creation-stp506.
Контекст
Сейчас (#49) репозиторий студента создаётся через GitHub API generate (template repository) — это создаёт независимый репозиторий без связи с источником: нет баннера «forked from», нельзя открыть кросс-репо PR из шаблона в репозиторий студента.
GitHub Classroom исторически давал этот UX через настоящий fork: баннер «forked from», сравнение веток, возможность позже открыть PR из шаблона в репозиторий студента (задел под #52 — propagate обновлений шаблона, в объём этой задачи не входит).
Хотим попробовать fork-механизм в проде, но с возможностью безопасно откатиться на template, если fork окажется неудобным — поэтому реализуем оба механизма и переключаем через конфиг лабы, а не заменяем один другим.
Поведение GitHub: проверено экспериментально
Проверка проведена вручную на suai-os-2026 (шаблоны github-starter-course и os-course-task1-new) до постановки задачи. Результаты — основание для требований ниже, перепроверять не нужно.
| Что проверялось |
Факт |
| Несколько форков одного репозитория в одну организацию |
Работает. POST /repos/{owner}/{repo}/forks с разными name создаёт независимые репозитории, у каждого parent.full_name равен исходному шаблону. Ограничение «один форк на аккаунт» параметром name снимается. |
| Видимость |
Наследуется от шаблона и у форка не меняется. Приватный шаблон → приватные форки. |
| Флаг шаблона |
Наследуется. Форк шаблонного репозитория сам помечен is_template: true (плашка «private template»). Снимается через PATCH /repos/{owner}/{repo} с {"is_template": false}. В template-режиме (generate) этой проблемы нет. |
| GitHub Actions в форке |
Заблокированы по умолчанию. На вкладке Actions висит баннер «Workflows aren't being run on this forked repository… we have disabled them from running on this fork», push не запускает workflow. После PUT /repos/{owner}/{repo}/actions/permissions с {"enabled": true} баннер исчезает и workflow запускается на следующем push. |
| Детектируется ли блокировка через API |
Нет. GET /repos/{owner}/{repo}/actions/permissions возвращает {"enabled": true, ...}, пока баннер ещё висит. Определить состояние заранее нельзя — вызов PUT нужно делать безусловно (он идемпотентный). |
Практический вывод: fork-режим ломает оценивание, если после создания форка не включить Actions явно. Это обязательный шаг провижининга, а не улучшение.
Конфиг
Новое необязательное поле лабы, обратная совместимость по умолчанию:
labs:
"1":
github-prefix: os-task1
template-repo: suai-os-2026/github-starter-course
repo-provisioning: fork # template (по умолчанию, текущее поведение) | fork
Откат — правка одного значения в YAML, без редеплоя и без миграции уже созданных репозиториев.
Неизвестное значение (опечатка вида forks) не должно молча откатываться на template: _load_lab_for_join в main.py отвергает его так же, как отсутствующий template-repo — HTTP 400, студент получает редирект с reason=config и понятный текст. Стартовая валидация validate_course_index() поля лаб не разбирает, добавлять её туда не требуется.
Ограничение по видимости. Так как форк наследует видимость шаблона, а template-режим создаёт репозиторий с private=True независимо от шаблона, режим fork для публичного шаблона молча сделал бы репозитории студентов публичными. Поэтому _create_from_fork перед созданием проверяет private у шаблона и при false возвращает ошибку TEMPLATE_MUST_BE_PRIVATE вместо создания публичного репозитория.
Задокументировать в docs/COURSE_CONFIG.md: переключение влияет только на репозитории, создаваемые после смены режима — уже созданный через generate репозиторий нельзя задним числом связать с форк-сетью, и наоборот. Для #52 это означает, что репозитории, созданные до переключения, обновлениями шаблона охвачены не будут.
Реализация
grading/github_client.py
Новые методы (все с timeout=self.DEFAULT_TIMEOUT, стиль — как у существующих):
def fork_repo(self, owner: str, repo: str, org: str, name: str) -> requests.Response
# POST /repos/{owner}/{repo}/forks, тело {"organization": org, "name": name}
# возвращает сырой Response (ожидаемый успех — 202 Accepted)
def get_repo(self, owner: str, repo: str) -> dict | None
# GET /repos/{owner}/{repo}; None при не-200
# нужен для parent.full_name, private и default_branch
def update_repo(self, owner: str, repo: str, payload: dict) -> requests.Response
# PATCH /repos/{owner}/{repo}; используется для {"is_template": False}
def enable_actions(self, owner: str, repo: str) -> requests.Response
# PUT /repos/{owner}/{repo}/actions/permissions, тело {"enabled": True}
grading/repo_provisioning.py
Текущий метод создания называется _ensure_repo_created (не _create_from_template). Разложить его так:
provision(...) получает новый параметр режима со значением по умолчанию "template" — существующие вызовы и тесты остаются валидными:
def provision(self, org, github_prefix, template_repo, repo_suffix, mode: str = "template") -> ProvisionResult
_ensure_repo_created(...) остаётся точкой входа (проверка «репозиторий уже существует» и разбор ответа общие), но сам вызов создания диспетчеризуется по режиму:
_create_from_template(...) — вынести текущий вызов create_repo_from_template без изменений логики;
_create_from_fork(...) — новый, см. ниже.
_ensure_access(...) — общий для обоих режимов, не меняется.
Порядок шагов _create_from_fork
get_repo(template_owner, template_name) → если private равно False, вернуть ProvisionResult(ERROR, error_code="TEMPLATE_MUST_BE_PRIVATE").
fork_repo(...) → ожидаемый успех 202. Коды 403/404/422 разбирать так же, как в _ensure_repo_created для template (включая отличие secondary rate limit от отказа в правах через _is_rate_limited).
- Ожидание появления репозитория. Форк создаётся асинхронно. Опрашивать
repo_exists(org, name) — до 15 попыток с интервалом 2 секунды (30 секунд суммарно; дольше держать браузер студента нельзя). Не появился → ProvisionResult(ERROR, error_code="FORK_TIMEOUT") с текстом вида «GitHub ещё создаёт репозиторий, обновите страницу через минуту». Именно ошибка, а не успех: _ensure_access на несуществующем репозитории всё равно упадёт.
- Включить Actions:
enable_actions(org, name) — безусловно, без предварительной проверки (см. таблицу выше: состояние блокировки через API не читается). Неуспех — фатальная ошибка провижининга, error_code="ACTIONS_ENABLE_FAILED": без этого шага CI в репозитории студента не запустится и grade_lab не найдёт check-runs.
- Снять флаг шаблона:
update_repo(org, name, {"is_template": False}). Неуспех — не фатален: залогировать logger.error и продолжить. Репозиторий с флагом шаблона работоспособен, ломать из-за косметики уже созданный репозиторий смысла нет.
Коллизия имён при гонке
В template-режиме такой проверки сейчас нет — _ensure_repo_created полагается только на repo_exists, который вернёт True для любого чужого репозитория с совпавшим именем. (Реализация есть в PR #48: repository_uses_template сверяет template_repository.full_name; в #49 она не переносилась.)
Для fork-режима сделать её сразу: если repo_exists вернул True, через get_repo(org, name) убедиться, что parent.full_name совпадает с template-repo лабы (сравнение регистронезависимое). Не совпадает — ProvisionResult(ERROR, error_code="NAME_TAKEN_BY_FOREIGN_REPO"), а не «репозиторий уже готов». Аналогичную проверку для template-режима в эту задачу не тащим — отдельная задача.
Вызывающий код
В main.py (обработчик /join/callback, вызов provisioner.provision(org, github_prefix, template_repo, username)) добавить пятым аргументом lab_config.get("repo-provisioning", "template"). Проверить, что тесты tests/test_join_endpoints.py не сломались.
Коды ошибок и UI
Каждый новый error_code — TEMPLATE_MUST_BE_PRIVATE, FORK_TIMEOUT, ACTIONS_ENABLE_FAILED, NAME_TAKEN_BY_FOREIGN_REPO — обязан попасть в ERROR_TRANSLATION_KEYS в frontend/courses-front/src/components/JoinLab/state.js и получить переводы в src/locales/{ru,en,zh}/translation.json, иначе студент увидит общее «непредвиденная ошибка». Формат словаря и таблицу существующих кодов см. в #50.
Тексты по смыслу: первые два — «обратитесь к преподавателю» (проблема конфигурации/на стороне сервиса), FORK_TIMEOUT — «попробуйте обновить страницу через минуту», NAME_TAKEN_BY_FOREIGN_REPO — «репозиторий с таким именем уже занят, обратитесь к преподавателю».
Предварительные требования на стороне GitHub (не код)
- На репозитории-шаблоне:
Settings → Allow forking.
- В организации:
Settings → Member privileges → Repository forking policy — разрешить форки приватных репозиториев внутри организации.
Без этого fork-режим предсказуемо вернёт 403/404 при создании, а template-режим продолжит работать без изменений — это и есть механизм отката «не понравилось — вернулись».
Тесты
tests/test_repo_provisioning.py: параметризовать существующий класс TestCreateFromTemplate (или продублировать) под mode="fork" — успешное создание (202 → poll → enable actions → снятие is_template), существующий репозиторий, гонка при создании, гонка с чужим репозиторием через parent.full_name, таймаут poll, публичный шаблон, провал enable_actions (фатален), провал снятия is_template (не фатален — провижининг завершается успехом), ошибки 403/404/422 и отличие secondary rate limit от отказа в правах.
- Отдельный тест:
mode="template" по умолчанию (вызов provision без пятого аргумента) даёт ровно прежнее поведение — ни enable_actions, ни update_repo не вызываются.
tests/test_github_client.py: fork_repo, get_repo, update_repo, enable_actions на моках requests.
tests/test_join_endpoints.py: лаба с repo-provisioning: fork доводит до вызова fork-ветки; лаба с неизвестным значением поля отдаёт 400 / reason=config.
- Poll в тестах не должен реально спать — интервал вынести в константу модуля и патчить (или патчить
time.sleep).
Документация
docs/COURSE_CONFIG.md: описать repo-provisioning, оба значения, поведение по умолчанию, неретроактивность переключения, требование приватного шаблона для fork-режима, требования к настройкам GitHub (allow forking, forking policy) и то, что в fork-режиме сервис автоматически включает Actions и снимает флаг шаблона у репозитория студента.
Не входит в объём
Зависит от #50 (перенос UI
/joinиз #48 в #49) — эта задача делается поверх его результата, в той же веткеclaude/student-repo-auto-creation-stp506.Контекст
Сейчас (#49) репозиторий студента создаётся через GitHub API
generate(template repository) — это создаёт независимый репозиторий без связи с источником: нет баннера «forked from», нельзя открыть кросс-репо PR из шаблона в репозиторий студента.GitHub Classroom исторически давал этот UX через настоящий fork: баннер «forked from», сравнение веток, возможность позже открыть PR из шаблона в репозиторий студента (задел под #52 — propagate обновлений шаблона, в объём этой задачи не входит).
Хотим попробовать fork-механизм в проде, но с возможностью безопасно откатиться на template, если fork окажется неудобным — поэтому реализуем оба механизма и переключаем через конфиг лабы, а не заменяем один другим.
Поведение GitHub: проверено экспериментально
Проверка проведена вручную на
suai-os-2026(шаблоныgithub-starter-courseиos-course-task1-new) до постановки задачи. Результаты — основание для требований ниже, перепроверять не нужно.POST /repos/{owner}/{repo}/forksс разнымиnameсоздаёт независимые репозитории, у каждогоparent.full_nameравен исходному шаблону. Ограничение «один форк на аккаунт» параметромnameснимается.is_template: true(плашка «private template»). Снимается черезPATCH /repos/{owner}/{repo}с{"is_template": false}. В template-режиме (generate) этой проблемы нет.PUT /repos/{owner}/{repo}/actions/permissionsс{"enabled": true}баннер исчезает и workflow запускается на следующем push.GET /repos/{owner}/{repo}/actions/permissionsвозвращает{"enabled": true, ...}, пока баннер ещё висит. Определить состояние заранее нельзя — вызовPUTнужно делать безусловно (он идемпотентный).Практический вывод: fork-режим ломает оценивание, если после создания форка не включить Actions явно. Это обязательный шаг провижининга, а не улучшение.
Конфиг
Новое необязательное поле лабы, обратная совместимость по умолчанию:
Откат — правка одного значения в YAML, без редеплоя и без миграции уже созданных репозиториев.
Неизвестное значение (опечатка вида
forks) не должно молча откатываться на template:_load_lab_for_joinвmain.pyотвергает его так же, как отсутствующийtemplate-repo— HTTP 400, студент получает редирект сreason=configи понятный текст. Стартовая валидацияvalidate_course_index()поля лаб не разбирает, добавлять её туда не требуется.Ограничение по видимости. Так как форк наследует видимость шаблона, а
template-режим создаёт репозиторий сprivate=Trueнезависимо от шаблона, режимforkдля публичного шаблона молча сделал бы репозитории студентов публичными. Поэтому_create_from_forkперед созданием проверяетprivateу шаблона и приfalseвозвращает ошибкуTEMPLATE_MUST_BE_PRIVATEвместо создания публичного репозитория.Задокументировать в
docs/COURSE_CONFIG.md: переключение влияет только на репозитории, создаваемые после смены режима — уже созданный черезgenerateрепозиторий нельзя задним числом связать с форк-сетью, и наоборот. Для #52 это означает, что репозитории, созданные до переключения, обновлениями шаблона охвачены не будут.Реализация
grading/github_client.pyНовые методы (все с
timeout=self.DEFAULT_TIMEOUT, стиль — как у существующих):grading/repo_provisioning.pyТекущий метод создания называется
_ensure_repo_created(не_create_from_template). Разложить его так:provision(...)получает новый параметр режима со значением по умолчанию"template"— существующие вызовы и тесты остаются валидными:_ensure_repo_created(...)остаётся точкой входа (проверка «репозиторий уже существует» и разбор ответа общие), но сам вызов создания диспетчеризуется по режиму:_create_from_template(...)— вынести текущий вызовcreate_repo_from_templateбез изменений логики;_create_from_fork(...)— новый, см. ниже._ensure_access(...)— общий для обоих режимов, не меняется.Порядок шагов
_create_from_forkget_repo(template_owner, template_name)→ еслиprivateравноFalse, вернутьProvisionResult(ERROR, error_code="TEMPLATE_MUST_BE_PRIVATE").fork_repo(...)→ ожидаемый успех 202. Коды 403/404/422 разбирать так же, как в_ensure_repo_createdдля template (включая отличие secondary rate limit от отказа в правах через_is_rate_limited).repo_exists(org, name)— до 15 попыток с интервалом 2 секунды (30 секунд суммарно; дольше держать браузер студента нельзя). Не появился →ProvisionResult(ERROR, error_code="FORK_TIMEOUT")с текстом вида «GitHub ещё создаёт репозиторий, обновите страницу через минуту». Именно ошибка, а не успех:_ensure_accessна несуществующем репозитории всё равно упадёт.enable_actions(org, name)— безусловно, без предварительной проверки (см. таблицу выше: состояние блокировки через API не читается). Неуспех — фатальная ошибка провижининга,error_code="ACTIONS_ENABLE_FAILED": без этого шага CI в репозитории студента не запустится иgrade_labне найдёт check-runs.update_repo(org, name, {"is_template": False}). Неуспех — не фатален: залогироватьlogger.errorи продолжить. Репозиторий с флагом шаблона работоспособен, ломать из-за косметики уже созданный репозиторий смысла нет.Коллизия имён при гонке
В template-режиме такой проверки сейчас нет —
_ensure_repo_createdполагается только наrepo_exists, который вернётTrueдля любого чужого репозитория с совпавшим именем. (Реализация есть в PR #48:repository_uses_templateсверяетtemplate_repository.full_name; в #49 она не переносилась.)Для fork-режима сделать её сразу: если
repo_existsвернулTrue, черезget_repo(org, name)убедиться, чтоparent.full_nameсовпадает сtemplate-repoлабы (сравнение регистронезависимое). Не совпадает —ProvisionResult(ERROR, error_code="NAME_TAKEN_BY_FOREIGN_REPO"), а не «репозиторий уже готов». Аналогичную проверку для template-режима в эту задачу не тащим — отдельная задача.Вызывающий код
В
main.py(обработчик/join/callback, вызовprovisioner.provision(org, github_prefix, template_repo, username)) добавить пятым аргументомlab_config.get("repo-provisioning", "template"). Проверить, что тестыtests/test_join_endpoints.pyне сломались.Коды ошибок и UI
Каждый новый
error_code—TEMPLATE_MUST_BE_PRIVATE,FORK_TIMEOUT,ACTIONS_ENABLE_FAILED,NAME_TAKEN_BY_FOREIGN_REPO— обязан попасть вERROR_TRANSLATION_KEYSвfrontend/courses-front/src/components/JoinLab/state.jsи получить переводы вsrc/locales/{ru,en,zh}/translation.json, иначе студент увидит общее «непредвиденная ошибка». Формат словаря и таблицу существующих кодов см. в #50.Тексты по смыслу: первые два — «обратитесь к преподавателю» (проблема конфигурации/на стороне сервиса),
FORK_TIMEOUT— «попробуйте обновить страницу через минуту»,NAME_TAKEN_BY_FOREIGN_REPO— «репозиторий с таким именем уже занят, обратитесь к преподавателю».Предварительные требования на стороне GitHub (не код)
Settings → Allow forking.Settings → Member privileges → Repository forking policy— разрешить форки приватных репозиториев внутри организации.Без этого fork-режим предсказуемо вернёт 403/404 при создании, а template-режим продолжит работать без изменений — это и есть механизм отката «не понравилось — вернулись».
Тесты
tests/test_repo_provisioning.py: параметризовать существующий классTestCreateFromTemplate(или продублировать) подmode="fork"— успешное создание (202 → poll → enable actions → снятиеis_template), существующий репозиторий, гонка при создании, гонка с чужим репозиторием черезparent.full_name, таймаут poll, публичный шаблон, провалenable_actions(фатален), провал снятияis_template(не фатален — провижининг завершается успехом), ошибки 403/404/422 и отличие secondary rate limit от отказа в правах.mode="template"по умолчанию (вызовprovisionбез пятого аргумента) даёт ровно прежнее поведение — ниenable_actions, ниupdate_repoне вызываются.tests/test_github_client.py:fork_repo,get_repo,update_repo,enable_actionsна мокахrequests.tests/test_join_endpoints.py: лаба сrepo-provisioning: forkдоводит до вызова fork-ветки; лаба с неизвестным значением поля отдаёт 400 /reason=config.time.sleep).Документация
docs/COURSE_CONFIG.md: описатьrepo-provisioning, оба значения, поведение по умолчанию, неретроактивность переключения, требование приватного шаблона для fork-режима, требования к настройкам GitHub (allow forking, forking policy) и то, что в fork-режиме сервис автоматически включает Actions и снимает флаг шаблона у репозитория студента.Не входит в объём
repo-provisioning: fork.repository_uses_template) — отдельная задача./register,grade_lab, старых модулях оценивания.