DEV Community

Cover image for Вечер на автоматизацию GitHub-ачивок
Dmitry
Dmitry

Posted on

Вечер на автоматизацию GitHub-ачивок

Началось всё банально: хотел собрать ачивки на GitHub-профиле.

Процесс оказался несложным по сути, но раздражающим по исполнению — куча однотипных ручных шагов, ожидание ревьюеров, непрозрачные условия засчитывания. Типичная задача на «автоматизировать и забыть».

Я взял её как мини-проект на вечер. Вечер растянулся.


Что хотелось получить

Публичный репозиторий, куда любой может открыть PR, и он автоматически мёрджится — без мейнтейнера, без ожидания, без магии.

Звучит просто. Но сразу встал вопрос: как сделать это безопасно?

Открытый авто-мёрдж без ограничений — это либо спам, либо кто-то пропушит что-то лишнее. Нужна была система проверок.


Первое нетривиальное решение: pull_request_target

Для авто-мёрджа форков нужен доступ к секретам репозитория. Обычный pull_request такого доступа не даёт — он намеренно изолирован для безопасности.

Правильный ответ — pull_request_target. Он запускается в контексте целевого репозитория, а не форка, поэтому имеет доступ к токенам. Но это же делает его опасным, если не выстроить валидацию до любых действий.

on:
  pull_request_target:
    types: [opened, synchronize, reopened]
    paths:
      - "contributors/**"
Enter fullscreen mode Exit fullscreen mode

Вся логика валидации идёт первой, до мёрджа — иначе это дыра.


Валидация: динамический regex из логина автора

Я хотел, чтобы каждый пользователь мог добавить только свой файл. Не чужой, не случайный — именно username.md.

Жёсткий список заранее не составишь. Поэтому имя файла проверяется динамически: regex строится прямо из логина автора PR.

const author = context.payload.pull_request.user.login.toLowerCase();
const validPattern = new RegExp(`^contributors/${author}(-\\d+)?\\.md$`, 'i');

const invalidFiles = files.filter(f => !validPattern.test(f.filename));
Enter fullscreen mode Exit fullscreen mode

Паттерн допускает username.md, username-2.md, username-3.md — для случаев, когда нужно несколько PR от одного пользователя. Всё остальное — отказ с понятным сообщением прямо в комментарии к PR.


Galaxy Brain: правильный ответ спрятан в HTML-комментарии

Для ачивки Galaxy Brain нужно, чтобы кто-то ответил на вопрос в Discussions и ответ был помечен как правильный.

Автоматизировать проверку ответа — задача нетривиальная. Хранить правильные ответы в отдельном файле/базе? Лишние сущности. Сравнивать с внешним API в момент ответа? Медленно и ненадёжно.

Элегантное решение: правильный ответ прячется прямо в теле discussion в HTML-комментарии, который пользователь не видит, а workflow читает.

<!-- ANSWER: JavaScript -->
Enter fullscreen mode Exit fullscreen mode

Когда кто-то отвечает в треде, workflow проверяет, содержит ли комментарий нужную строку:

const answerMatch = discussion.body.match(/<!--\s*ANSWER:\s*(.+?)\s*-->/i);
const correctAnswer = answerMatch[1].trim().toLowerCase();
const commentBody = context.payload.comment.body.toLowerCase();

if (!commentBody.includes(correctAnswer)) {
  // неправильный ответ — ничего не делаем
  return;
}
Enter fullscreen mode Exit fullscreen mode

Никакой внешней базы. Данные живут там, где они нужны.


Discussions API: только GraphQL

GitHub REST API не поддерживает принятие ответа в Discussions. Это доступно только через GraphQL — markDiscussionCommentAsAnswer.

const mutation = `
  mutation($commentId: ID!) {
    markDiscussionCommentAsAnswer(input: { id: $commentId }) {
      discussion { number url }
    }
  }
`;
await github.graphql(mutation, { commentId });
Enter fullscreen mode Exit fullscreen mode

Аналогично для создания самих вопросов и добавления реакций — всё через GraphQL. REST здесь просто не работает.


Идемпотентность: не создавать вопросы, пока есть неотвеченные

Workflow создания вопросов запускается три раза в день. Но создавать новые вопросы, пока старые висят без ответа — бессмысленно и засоряет Discussions.

Перед созданием — проверка:

const unanswered = result.repository.discussions.totalCount;

if (unanswered > 0) {
  // пропускаем этот запуск
  return;
}
Enter fullscreen mode Exit fullscreen mode

Запуск идемпотентен: повторный прогон не ломает состояние.


Схема всего

PR pipeline:

User opens PR
  → pull_request_target
  → Validation
      ✅ pass → Squash merge
      ❌ fail → Comment with reason
Enter fullscreen mode Exit fullscreen mode

Galaxy Brain pipeline:

Cron 3x/day
  → Unanswered questions exist?
      yes → skip
      no  → fetch from OpenTDB
           → create discussions with hidden ANSWER tag

New discussion comment
  → correct answer? → markDiscussionCommentAsAnswer
  → wrong answer?   → ignore
Enter fullscreen mode Exit fullscreen mode

Что получилось в итоге

Репозиторий, где:

  • 🦈 Pull Shark — PR в contributors/ → авто-мёрдж
  • 👥 Pair Extraordinaire — PR в pairs/ → авто-мёрдж с Co-authored-by
  • 🧠 Galaxy Brain — отвечай на вопросы в Discussions → бот принимает правильные
  • Quickdraw и 🤠 YOLO — инструкции за 2 клика

Без ожидания, без мейнтейнера, без магии.


Дисклеймер

Проект создан в образовательных и развлекательных целях.
Ачивки — косметика профиля, не метрика уровня инженера.

Используй в рамках GitHub Terms of Service.


Если идея понравилась — посмотри GitHub Achievement Farm и поставь ⭐, если зашло.

Если есть вопросы по деталям реализации или нашёл edge-case — welcome в issues.

Top comments (0)