Зависит от #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
- Прочитать шаблон:
get_repo(template_owner, template_name) → default_branch.
- Новое: получить sha вершины ветки шаблона —
GET /repos/{owner}/{repo}/git/ref/heads/{branch} → object.sha. Именно этот коммит переносится в репозитории студентов.
list_forks(template_owner, template_name) с пагинацией; отфильтровать по владельцу (== организация курса, регистронезависимо) и префиксу {github-prefix}-.
list_org_repos(org) с пагинацией → репозитории с тем же префиксом, которых нет среди форков, попадают в сводку со статусом not_a_fork. Сам репозиторий-шаблон из этого списка исключается.
- Для каждого форка:
- создать или обновить служебную ветку
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 открывается из неё в ветку по умолчанию. Это внутренняя механика — работать студенту с ней не нужно, но ветка видна в списке веток репозитория.
Не входит в объём
Зависит от #51 (fork как альтернатива template) — имеет смысл только для лаб с
repo-provisioning: fork.Контекст
В 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 подтверждение на независимой паре, где родитель впереди ровно на один коммит:
Документированный параметр
head_repo, который должен разрешать эту неоднозначность, молча игнорируется: заведомо несуществующее значениеhead_repoдаёт побайтово тот же ответ. Проверены все комбинацииhead(master,owner:branch,owner:repo:branch) сhead_repo(имя иowner/repo) — результат одинаковый.Так как шаблон и репозитории студентов по дизайну лежат в одной организации, механизм кросс-репо PR неприменим.
2. Рабочий механизм: ветка в форке + внутренний PR
Форк и шаблон делят хранилище объектов, поэтому коммит шаблона можно положить веткой прямо в репозиторий студента и открыть обычный PR внутри него. Проверено, оба шага возвращают 201:
Требование fork-связи при этом сохраняется: для репозитория, созданного через
generate, коммита шаблона в хранилище нет и создание ветки падает с422 Object does not exist. То естьnot_a_forkостаётся осмысленным статусом.3. Различающий текст ошибки лежит в
errors[].message, а не вmessageВерхнеуровневый
messageвсегда равен"Validation Failed". Разбирать нужноerrors[0].message:errors[0].messageNo commits between master and masterA pull request already exists for suai-os-2026:template-update.4. Фильтр
headв списке PR не понимает формуowner:branchПри том что у самого 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
get_repo(template_owner, template_name)→default_branch.GET /repos/{owner}/{repo}/git/ref/heads/{branch}→object.sha. Именно этот коммит переносится в репозитории студентов.list_forks(template_owner, template_name)с пагинацией; отфильтровать по владельцу (== организация курса, регистронезависимо) и префиксу{github-prefix}-.list_org_repos(org)с пагинацией → репозитории с тем же префиксом, которых нет среди форков, попадают в сводку со статусомnot_a_fork. Сам репозиторий-шаблон из этого списка исключается.template-updateв форке на sha из п.2:POST /repos/{org}/{fork}/git/refs; если ветка уже есть (422Reference 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, если массива нет.pr_created,pr_urlизhtml_urlerrors[].messageесть «No commits between»up_to_date— форк уже содержит коммит шаблона, это не ошибкаerrors[].messageесть «A pull request already exists»pr_exists— PR с прошлого запуска ещё открыт (и уже обновлён новой веткой). URL достать черезlist_pull_requests(org, fork_name, head="template-update", state="open")— фильтр по имени ветки без префикса владельцаRetry-Afterи повторить один раз; при повторной неудаче —errorerrorс текстом ответа, обрезанным до 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.Добавить:
Frontend (админка)
Сделано, переделке не подлежит: страница
/admin/courses/:courseId/labsподProtectedRoute, переход из карточки курса, таблица лаб, dry-run с подтверждением, опрос статуса и таблица результатов, переводы ru/en/zh, запросы через/api/v1/...сcredentials: "include".Тесты — дополнить
{"message": "Validation Failed", "errors": [{"message": "No commits between ..."}]}и то же для «A pull request already exists». Существующие моки кладут различающий текст в верхнеуровневыйmessage— из-за этого дефект и не был пойман.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 открывается из неё в ветку по умолчанию. Это внутренняя механика — работать студенту с ней не нужно, но ветка видна в списке веток репозитория.Не входит в объём