Skip to content

Поддержать создание репозитория студента через fork как альтернативу template, выбор в конфиге лабы #51

Description

@markpolyak

Зависит от #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

  1. get_repo(template_owner, template_name) → если private равно False, вернуть ProvisionResult(ERROR, error_code="TEMPLATE_MUST_BE_PRIVATE").
  2. fork_repo(...) → ожидаемый успех 202. Коды 403/404/422 разбирать так же, как в _ensure_repo_created для template (включая отличие secondary rate limit от отказа в правах через _is_rate_limited).
  3. Ожидание появления репозитория. Форк создаётся асинхронно. Опрашивать repo_exists(org, name) — до 15 попыток с интервалом 2 секунды (30 секунд суммарно; дольше держать браузер студента нельзя). Не появился → ProvisionResult(ERROR, error_code="FORK_TIMEOUT") с текстом вида «GitHub ещё создаёт репозиторий, обновите страницу через минуту». Именно ошибка, а не успех: _ensure_access на несуществующем репозитории всё равно упадёт.
  4. Включить Actions: enable_actions(org, name) — безусловно, без предварительной проверки (см. таблицу выше: состояние блокировки через API не читается). Неуспех — фатальная ошибка провижининга, error_code="ACTIONS_ENABLE_FAILED": без этого шага CI в репозитории студента не запустится и grade_lab не найдёт check-runs.
  5. Снять флаг шаблона: 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_codeTEMPLATE_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 и снимает флаг шаблона у репозитория студента.

Не входит в объём

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions