Разработчик — натура творческая. У него нет времени на рутинные задачи, о которых может позаботиться машина. Поэтому все, что можно автоматизировать, должно быть автоматизировано.

Привет! Меня зовут Никита. Я разработчик Taiga UI, библиотеки Angular-компонентов, которая активно используется в нашей компании «Тинькофф». Я расскажу про решение одной из таких рутинных задач на нашем проекте с помощью написания с нуля своего Github App на Node.js.

Постановка проблемы

На проекте мы активно пишем скриншотные тесты с использованием фреймворка Cypress.

После внесения правок в код и открытия Pull Request в CI начинается Github Workflow на запуск всех тестов, которые и спасают наш будущий релиз от внесения багов в компоненты UI Kit. Как только какой-либо тест падает, все скриншоты прикрепляются архивом как артефакты к данному workflow, которые разработчик может скачать и изучить. К сожалению, мы живем не в идеальном мире, где не допускаем ошибок, и тесты периодически падают. Когда тестов слишком много, такое, казалось бы, простое действие, как скачивание архива и поиск скриншотов с различиями состояний «до»/«после», становится изнурительным занятием. А как было бы круто, если бы была возможность упростить этот процесс!

В Cypress для этого есть официальный платный инструмент — Dashboard. Но тарифы, которые там предлагаются, выглядят чрезмерно дорогими для наших нужд. 

Есть альтернативное неофициальное решение, которое успело набрать популярность, — Sorry Cypress. Его авторы предлагают свой вариант Dashboard, но уже с более низкими ценами или с возможностью хостинга всей инфраструктуры на свои сервера. Этот неофициальный вариант уже кажется более приемлемым. Но мы решили написать простенький Github-бот.

Как работают Github Apps

Если сильно упрощать, то Github App — это набор функций-колбэков, которые вызываются при срабатывании нужного события (webhook-event) в репозитории. Список всех доступных событий представлен на странице Github Docs. Сами функции-колбэки обычно внутри себя дергают API Github, которые и приводят к созданию в репозитории новых комментариев, веток, файлов и т. п. 

Всю работу с прослушиванием нужных событий и отправкой нужных API-запросов можно выполнять и на нативном js. Но гораздо проще это сделать с помощью уже готовых популярных решений, предоставляющих некоторое абстрагирование от всего этого. Мы воспользуемся фреймворком Probot, созданным для написания Github-приложений. 

Процесс инициализации нового приложения через cli-команды и шаги его запуска хорошо описаны на официальной странице фреймворка, разбирать мы их не будем. При создании приложения рекомендуем выбирать шаблон, написанный на Typescript: строгая типизация позволит вам избежать некоторых ошибок (о том, как можно выжать максимум из возможностей данного языка, читайте в этой статье). При создании текущего приложения мы также будем использовать шаблон с Typescript.

Прослушиваем события репозитория

Нашему боту достаточно прослушивать только три типа событий: когда workflow начинается и завершается, а также когда PR закрывается. Открываем в сгенерированном приложении index.ts файл и добавляем следующий код:

import {Probot} from 'probot';

export = (app: Probot) => {
    app.on('workflow_run.requested', async context => {
       // ...
    });

    app.on('workflow_run.completed', async context => {
       // ...
    });

    app.on('pull_request.closed', async context => {
       // ...
    });
};

Примечание: не забываем в Github на странице настроек приложения дать боту права на прослушивание событий workflow_run и pull_request.

В коде видно, что каждая функция, пробрасываемая как колбэк на события репозитория, принимает аргумент context.

Этот контекст содержит множество полезной информации о «прослушиваемом» событии. Например, так будет выглядеть утилита-селектор для получения имени workflow, который вызвал данный webhook-event:

import {Context} from 'probot/lib/context';
import {
  EventPayloads
} from '@octokit/webhooks/dist-types/generated/event-payloads';

type WorkflowRunContext = Context<EventPayloads.WebhookPayloadWorkflowRun>;

export const getWorkflowName = (context: WorkflowRunContext): string =>
	context.payload.workflow?.name || '';

Также внутри context.payload содержится нужная нам информация: id workflow, название ветки, на которой сработал данный workflow, номер открытого pull request и множество другой информации.

Используем Github API

Фреймворк Probot внутри себя использует Node.js-модуль '@octokit/rest'. Чтобы получить доступ к REST-API-методам Github, достаточно обратиться к context.octokit…. Весь перечень доступных действий смотрите здесь.

Нашему боту для создания комментариев к PR нужны следующие методы:

  1. context.octokit.issues.createComment (создать новый комментарий к PR).

  2. context.octokit.rest.issues.updateComment (отредактировать контент уже существующего комментария к PR).

Пусть вас не смущает, что мы используем методы из объекта issue. Pull request — это issue, содержащий код. Поэтому все методы, применимые к issue, применимы и к pull requests.

Для загрузки артефактов со скриншотами упавших тестов мы используем методы:

  1. context.octokit.actions.listWorkflowRunArtifacts (перечень метаинформации о доступных артефактах данного workflow).

  2. context.octokit.actions.downloadArtifact (загрузка архивов-артефактов по их id).

Итак, у нас есть файлы со скриншотами, и мы знаем, как создавать комментарии. Комментарии понимают Markdown-синтаксис, а в данный формат можно вставлять изображения как base64-строки. Кажется, что еще полшага — и все будет готово… но нет. Markdown, который использует Github, не поддерживает возможность вставить таким образом изображения: только по ссылке из внешнего источника.

Но и эту проблему можно решить: можно загрузить нужный файл (который мы планируем прикрепить к отчету об упавших тестах) на отдельную ветку репозитория и получить доступ к этому изображению через https://raw.githubusercontent.com/.... Код для решения данной проблемы будет следующий:

const GITHUB_CDN_DOMAIN = 'https://raw.githubusercontent.com';

const getFile = async (path: string, branch?: string) => {
   return context.octokit.repos.getContent({
       ...context.repo(),
       path,
       ref: branch
   }).catch(() => null);
}

// returns url to uploaded file
const uploadFile = async ({file, path, branch, commitMessage}: {
    file: Buffer,
    path: string,
    commitMessage: string,
    branch: string
}): Promise<string> => {
    const {repo, owner} = context.repo();
    const content = file.toString('base64');
    const oldFileVersion = await getFile(path, branch);
    const sha = oldFileVersion && 'sha' in oldFileVersion.data
        ? oldFileVersion.data.sha
        : undefined
    const fileUrl = `${GITHUB_CDN_DOMAIN}/${owner}/${repo}/${branch}/${path}`;

    return context.octokit.repos
        .createOrUpdateFileContents({
            owner,
            repo,
            content,
            path,
            branch,
            sha,
            message: commitMessage,
        })
        .then(() => fileUrl);
}

// returns urls to uploaded images
const uploadImages = async (
  images: Buffer[],
	pr: number,
	workflowId: number,
	i: number
): Promise<string[]> => {
   const {repo, owner} = context.repo();
   const path = `__bot-screens/${owner}-${repo}-${pr}/${workflowId}-${i}.png`;

   return Promise.all(images.map(
       (file, index) => uploadFile({
           file,
           path,
           commitMessage: 'chore: upload images of failed screenshot tests',
           branch: 'screenshot-bot-storage',
       })
   ));
}

После закрытия PR загруженные изображения всегда можно удалить. И для всех этих действий также есть свои методы в библиотеке @octokit/rest.

Деплой готового кода

Деплой — неизбежный этап в жизни каждого приложения. Официальная документация фреймворка Probot предлагает подробную инструкцию, как осуществить развертывание вашего готового приложения на различные популярные сервисы. Мы свое Node.js-приложение развернули на Glitch. Этот сервис предоставляет возможность бесплатного хостинга, а ограничения бесплатного аккаунта несущественны для такого простого приложения, как Github-бот.

Исходный код получившегося бота, который мы активно используем в нашем проекте, можно изучить на репозитории Github. Разработка получила название Argus (многоглазый великан из древнегреческой мифологии). Она проработана гораздо глубже, чем можно описать в этой статье, но основное ядро получившегося приложения было описано выше.

Вместо заключения

Написание Github-бота — очень простое занятие. Оно практически не требует глубоких знаний языка или фреймворка. Весь процесс создания в основном сводится к изучению документации со списком webhook-событий репозитория, а также документации REST-API-методов Github, чтобы найти и применить их под вашу задачу.

В этой статье мы построили Github-приложение, которое следит за workflow, содержащим скриншотные тесты. Если тесты падают, то бот загружает артефакты, находит в них скриншоты с разницей состояний «до»/«после», а потом прикрепляет их как комментарий к PR. 

Полученный код мы задеплоили как бота под названием Lumberjack (лесоруб). Он уже активно следит за нашим проектом Taiga UI. Но бот написан таким образом, что вы легко его сможете настроить и под свой проект — достаточно пригласить его в свой репозиторий и указать, за какими workflow ему стоит следить.

Комментарии (2)


  1. mark_ablov
    02.10.2021 14:19
    +1

    Вместо getContent лучше использовать другой метод для получения SHA текущей версии файла.

    /*
     * Return content for specific file from repo
     *
     * It has limit by 1MB
     * Encoded in base64 by default
     */
    const getFileContent = authorizeFn(
      (
        repo,
        path,
        branch,
      ) => (
        githubClient.repos.getContent({
          owner: ORGANIZATION_NAME,
          repo,
          path,
          ...branch && { ref: `refs/heads/${branch}` },
        })
      ),
    );
    
    /*
     * Return info (sha/size/...) for file
     */
    const getFileInfo = async (repo, path, branch) => {
      const folder = path.split('/').slice(0, -1).join('/');
      const folderContent = await getFileContent(repo, folder, branch);
      const fileEntry = folderContent.find(({ path: filePath }) => filePath === path);
      if (!fileEntry) {
        throw Error(`File ${path} not found!`);
      }
      return fileEntry;
    };
    
    /*
     * Get SHA of existing file
     */
    const getSHAForExistingFile = async (repo, path, branch) => {
      try {
        const { sha } = await getFileInfo(repo, path, branch);
        return sha;
      } catch (err) {
        return undefined;
      }
    };

    Заливка файлов не так важна у вас, но можно в один коммит включать несколько файлов, хоть это и более муторно - нужно создать git blob'ы для каждого файла, затем git tree, создать сам коммит и обновить branch head на sha новосоздонного коммита.


    1. nsbarsukov Автор
      03.10.2021 12:58

      Спасибо за советы, учту при доработке бота!