Skip to content

Распространение обновлений стартового репозитория на репозитории студентов через fork-PR (админка) #52

Description

@markpolyak

Зависит от #51 (fork как альтернатива template) — имеет смысл только для лаб с repo-provisioning: fork.

Статус: реализовано в ветке claude/student-repo-auto-creation-stp506 (коммиты 2af6a19, fbb4742, e14888f), но механизм создания PR требует переделки. Ручная проверка на живом GitHub показала, что кросс-репо PR из шаблона в форк внутри одной организации создать нельзя, а разбор ответов 422 читает не то поле. Раздел «Поведение GitHub: проверено экспериментально» и обновлённый алгоритм ниже — то, что нужно привести в соответствие. Всё остальное (авторизация, фоновое выполнение, dry-run, пагинация, фильтрация, UI, документация) сделано и переделке не подлежит.

Контекст

В GitHub Classroom при обновлении стартового кода лабы преподаватель мог запушить изменения в шаблон и разослать их во все репозитории студентов. Это возможно только когда репозитории студентов реально являются форками шаблона (см. #51): именно общая fork-сеть даёт репозиториям общее хранилище объектов, без которого коммит шаблона нельзя перенести в репозиторий студента.

PR в репозиторий студента — это предложение изменений, а не принудительный push: слияние остаётся на усмотрение студента (или преподавателя, если у него есть доступ). Автором PR будет владелец сервисного токена (GITHUB_TOKEN), а не преподаватель лично.

Поведение GitHub: проверено экспериментально

Проверка проведена вручную на suai-os-2026 (шаблоны os-course-task1-new, github-starter-course, форки forktest-task2, forktest-beta). Результаты — основание для требований ниже, перепроверять не нужно.

1. Кросс-репо PR внутри одной организации создать нельзя

head="{owner}:{branch}" резолвится в сам базовый репозиторий, а не во второй репозиторий fork-сети, когда владелец у обоих один. При заведомо расходящихся ветках (шаблон впереди на один коммит) POST /repos/{org}/{fork}/pulls отвечает:

{"message":"Validation Failed","errors":[{"resource":"PullRequest","code":"custom",
 "message":"No commits between master and master"}],"status":"422"}

Read-only подтверждение на независимой паре, где родитель впереди ровно на один коммит:

GET /repos/suai-os-2026/forktest-beta/compare/main...suai-os-2026:main
-> {"status":"identical","ahead_by":0,"behind_by":0}

Документированный параметр head_repo, который должен разрешать эту неоднозначность, молча игнорируется: заведомо несуществующее значение head_repo даёт побайтово тот же ответ. Проверены все комбинации head (master, owner:branch, owner:repo:branch) с head_repo (имя и owner/repo) — результат одинаковый.

Так как шаблон и репозитории студентов по дизайну лежат в одной организации, механизм кросс-репо PR неприменим.

2. Рабочий механизм: ветка в форке + внутренний PR

Форк и шаблон делят хранилище объектов, поэтому коммит шаблона можно положить веткой прямо в репозиторий студента и открыть обычный PR внутри него. Проверено, оба шага возвращают 201:

POST /repos/{org}/{fork}/git/refs   ref=refs/heads/template-update  sha=<tip шаблона>
POST /repos/{org}/{fork}/pulls      head=template-update  base=<default branch форка>

Требование fork-связи при этом сохраняется: для репозитория, созданного через generate, коммита шаблона в хранилище нет и создание ветки падает с 422 Object does not exist. То есть not_a_fork остаётся осмысленным статусом.

3. Различающий текст ошибки лежит в errors[].message, а не в message

Верхнеуровневый message всегда равен "Validation Failed". Разбирать нужно errors[0].message:

Случай errors[0].message
нечего обновлять No commits between master and master
PR уже открыт A pull request already exists for suai-os-2026:template-update.

4. Фильтр head в списке PR не понимает форму owner:branch

GET /repos/{org}/{fork}/pulls?state=open&head=suai-os-2026:template-update  -> 0 результатов
GET /repos/{org}/{fork}/pulls?state=open&head=template-update               -> 1 результат

При том что у самого PR поле head.label равно suai-os-2026:template-update. Фильтровать нужно по имени ветки без префикса владельца.

Авторизация

Сделано, переделке не подлежит: зависимость require_admin в main.py, check_auth переписан на неё, зависимость навешена на POST /courses/upload, DELETE /courses/{course_id}, GET/PUT /courses/{course_id}/edit и на все эндпоинты этой фичи.

Backend

Эндпоинты

Сделано, переделке не подлежит:

  • POST /admin/courses/{course_id}/labs/{lab_id}/propagate-template-update, тело {"dry_run": true} по умолчанию (вызов без тела ничего не рассылает). Проверки: курс и лаба существуют, у лабы задан template-repo и repo-provisioning: fork, иначе 400. dry_run: true — синхронно, только чтение, 200 со сводкой. dry_run: false — 202 и job_id, работа уходит в фон. Повторный запуск для той же пары — 409.
  • GET /admin/propagate-jobs/{job_id} — состояние работы.
  • GET /admin/courses/{course_id}/labs — список лаб для страницы админки.

Модель выполнения

Сделано, переделке не подлежит: состояние в памяти процесса (последние 20 работ), один uvicorn-воркер, обработчик и фоновая функция — обычные def, пауза между PR в константе модуля, изоляция ошибок по репозиториям, гарантированное завершение работы даже при неожиданном исключении.

Алгоритм — переделать шаг создания PR

  1. Прочитать шаблон: get_repo(template_owner, template_name)default_branch.
  2. Новое: получить sha вершины ветки шаблона — GET /repos/{owner}/{repo}/git/ref/heads/{branch}object.sha. Именно этот коммит переносится в репозитории студентов.
  3. list_forks(template_owner, template_name) с пагинацией; отфильтровать по владельцу (== организация курса, регистронезависимо) и префиксу {github-prefix}-.
  4. list_org_repos(org) с пагинацией → репозитории с тем же префиксом, которых нет среди форков, попадают в сводку со статусом not_a_fork. Сам репозиторий-шаблон из этого списка исключается.
  5. Для каждого форка:
    • создать или обновить служебную ветку template-update в форке на sha из п.2: POST /repos/{org}/{fork}/git/refs; если ветка уже есть (422 Reference already exists) — PATCH /repos/{org}/{fork}/git/refs/heads/template-update с force: true. Обновление ветки при уже открытом PR — штатный случай: PR автоматически подхватывает новые коммиты. Неудача на этом шаге — статус error по этому репозиторию.
    • create_pull_request(org, fork_name, head="template-update", base=fork_default_branch, title=..., body=...). fork_default_branch берётся из поля default_branch элемента списка форков.

Имя служебной ветки — константа модуля. Один репозиторий соответствует одной лабе, поэтому фиксированного имени достаточно.

Разбор ответов на создание PR — переделать

Различающий текст брать из errors[].message (склеить все элементы), с фолбэком на верхнеуровневый message, если массива нет.

Ответ GitHub Статус в сводке
201 pr_created, pr_url из html_url
422, в errors[].message есть «No commits between» up_to_date — форк уже содержит коммит шаблона, это не ошибка
422, в errors[].message есть «A pull request already exists» pr_exists — PR с прошлого запуска ещё открыт (и уже обновлён новой веткой). URL достать через list_pull_requests(org, fork_name, head="template-update", state="open")фильтр по имени ветки без префикса владельца
403 с признаками rate limit подождать по Retry-After и повторить один раз; при повторной неудаче — error
прочее error с текстом ответа, обрезанным до 500 символов

Конфликт слияния созданию PR не мешает: GitHub откроет PR и пометит его как конфликтный — статус pr_created, разрешение остаётся студенту.

grading/github_client.py

Уже есть: list_forks, list_org_repos, create_pull_request, list_pull_requests, общий is_rate_limited, пагинация в _get_all_pages.

Добавить:

def get_ref(self, owner: str, repo: str, ref: str) -> dict | None       # GET /repos/{o}/{r}/git/ref/{ref}, ref вида "heads/master"
def create_ref(self, owner: str, repo: str, ref: str, sha: str) -> requests.Response   # POST /repos/{o}/{r}/git/refs, ref вида "refs/heads/..."
def update_ref(self, owner: str, repo: str, ref: str, sha: str, force: bool = True) -> requests.Response  # PATCH /repos/{o}/{r}/git/refs/{ref}

Frontend (админка)

Сделано, переделке не подлежит: страница /admin/courses/:courseId/labs под ProtectedRoute, переход из карточки курса, таблица лаб, dry-run с подтверждением, опрос статуса и таблица результатов, переводы ru/en/zh, запросы через /api/v1/... с credentials: "include".

Тесты — дополнить

  • Формы ответов GitHub из раздела «Поведение GitHub» воспроизвести в моках буквально: 422 с {"message": "Validation Failed", "errors": [{"message": "No commits between ..."}]} и то же для «A pull request already exists». Существующие моки кладут различающий текст в верхнеуровневый message — из-за этого дефект и не был пойман.
  • Создание служебной ветки: успех; ветка уже существует (422 Reference already exists) → PATCH с force; неудача создания/обновления → статус error по этому репозиторию, остальные обрабатываются дальше.
  • list_pull_requests вызывается с именем ветки без префикса владельца.
  • Сохранить существующие тесты: пагинация, фильтрация по владельцу и префиксу, исключение шаблона из not_a_fork, изоляция ошибок, rate-limit с одним повтором, require_admin на всех защищённых маршрутах, 409 при повторном запуске, dry-run без создания PR, гарантированное завершение работы.

Документация — дополнить

К уже написанному в docs/COURSE_CONFIG.md и docs/PROJECT_DESCRIPTION.md добавить: в репозитории студента появляется служебная ветка template-update, которую сервис перезаписывает при каждой рассылке; PR открывается из неё в ветку по умолчанию. Это внутренняя механика — работать студенту с ней не нужно, но ветка видна в списке веток репозитория.

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

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