Рабочий понедельник начался со следующего диалога:
Руководитель (P): У тебя в команде не понятно, кто чем занимается.
Я (Я): Это да, у нас нет инструмента, который бы отображал общую картину работы над задачами. В гитлабе есть канбан-доски, но они только в контексте проектов и групп. Общая канбан-доска решила бы проблему.
Р: Тогда сделай доску.
Я: К утру будет готово.
В жизни начинающего тимлида рано
а) создание механической руки и контроллера к ней, который позволит удалённо переклеивать стикеры на доске, при этом, чтобы не усложнять решение, писать на стикерах нам придётся за нашего недосягаемого коллегу, что несправедливо;
б) реализация программной канбан-доски, которая собирала бы все задачи с нашего гитлаба.
Конечно, душа лежала к ламповой физической доске. Я даже начал размышлять об использовании DualShock 4 в качестве контроллера механической руки, но я сам себе обозначил дедлайн утром следующего дня, поэтому пришлось обойтись бездушным программным решением.

Перед тем как приступить к описанию технической части истории, расскажу про то, как мы в Онланте используем гитлаб. Группы в гитлабе у нас соответствуют внутреннему/внешнему заказчику или отдельному большому проекту, а проект — это репозиторий для кода конкретного сервиса. Например, у нас есть группа панели управления биллингом, в которой четыре проекта — сервисы и веб-приложения, а есть группа маркетинга, в которой находится проект для корпоративного сайта, микросервис для интеграции с amoCRM, всякие лендинги и прочее. Активных групп на текущий момент у нас девять штук, а программистов меньше, поэтому все прогеры участвуют во всех группах.
И нам всего-то нужна была страница со всеми задачами из гитлаба, со всех групп и проектов, но такого функционала в GitLab, к величайшему сожалению, нет. Быстрый гуглёж не помог найти standalone-решение, поэтому
Нам понадобится:
— бэкэнд на Node.js — для сбора задач с GitLab и передачи их клиенту;
— клиент на Vue.js — чтобы сделать быстро и красиво;
— приправим всё это TypeScript, конечно!
— PostgreSQL — там, где мы будем хранить информацию о задачах и их позицию на доске;
— 8 часов рабочего времени;
— хорошее настроение.
Самое главное условие — разработка должна даваться легко и приносить удовольствие, назовём это «лёгким программированием».
Зоопарк
По понятным причинам я не могу использовать проекты и задачи из нашего корпоративного гитлаба для иллюстраций к статье на Хабре, поэтому я развернул свой гитлаб, с обезьянками и крокодилами. Да, это гитлаб для вымышленного зоопарка, но там будут поставлены не менее серьёзные задачи, чем в любом другом гитлабе. Взгляните на список проектов:

karcass
За годы разработки у меня выработалось своё видение того, как должно строиться backend-приложение на Node.js, и этот подход оформился в виде каркаса — набора файлов. Для создания нового проекта я просто копировал директорию с шаблоном. Это не могло продолжаться вечно, и я сделал npm-пакет karcass (имя carcass уже было занято таким же энтузиастом-велосипедостроителем, как и я), который автоматизирует процесс создания основы для приложения:

Теперь нам нужно разобраться с получением и хранением групп, проектов, задач и, конечно же, исполнителей.
TypeORM
«Я вижу какие-то сущности...»
— из обсуждений на форуме экстрасенсов
Поскольку нам нужно представить данные из гитлаба в удобном для нас виде, то в процессе между сбором этих данных и представлением, их нужно как-то хранить. Недавно я открыл для себя удивительную для экосистемы JS по своей красоте библиотеку для работы с БД — TypeORM. Она ещё зелёная, но имеет все шансы потеснить Sequelize с престола.
За пару десятков минут появляются миграции и классы для пользователей, групп, проектов и задач. Вот один из них:
import { Entity, PrimaryColumn, Column } from 'typeorm' @Entity({ name: 'group' }) export class Group { @PrimaryColumn('integer') public id!: number @Column('varchar') public name!: string @Column('varchar') public url!: string }
А для создания миграций я опять сделал

В классе Issue кроме полей, хранящих данные из Gitlab, мы добавляем поля из-за которых весь сыр-бор и начался, то есть, указывающие на положение задачи на канбан-доске:
export class Issue extends AbstractEntity { /* ... */ @Column('varchar', { name: 'kanban_status' }) public kanbanStatus?: 'new'|'planed'|'working'|'checking'|'done' @Column('int', { name: 'kanban_order' }) public kanbanOrder?: number }
Нам нужна информация
Когда я только начал своё знакомство с GitLab, он мне показался простым (читай «ненавороченным») продуктом, но работая с ним каждый день, я понимал насколько я ошибался. Вот и в этот раз я был удивлён — оказывается у GitLab очень богатый API, который покрывает почти весь функционал, доступный через пользовательский интерфейс. Но для решения нашей задачи понадобятся всего четыре метода, названия которых говорят сами за себя: /users, /groups, /projects, /projects/:id/issues. За непосредственное взаимодействие с GitLab будет отвечать GitlabService, а остальные классы будут к нему обращаться. Например, так выглядит имплементация метода updateGroups у класса GroupService:
export class GroupService extends AbstractService { /* ... */ public async updateGroups() { for (const data of await this.app.gitlabService.getGroups()) { // <= тут мы обращаемся к GitlabService let group = await this.getGroup(data.id) if (!group) { group = this.groupRepository.create({ id: data.id, }) } group.name = data.name group.url = data.web_url await this.groupRepository.save(group) } } /* ... */ }
За операцию получения сведений из гитлаба и обновления соответствующей информации в нашей БД будет отвечать команда UpdateProjectsCommand, которую можно вызвать из консоли, но наше приложение будет само её запускать с периодичностью, заданной в конфиге:
export class Application { /* ... */ protected initCron() { if (this.config.gitlab.updateInterval) { setInterval(async () => { if (!this.updateProjectsCommand) { this.updateProjectsCommand = new UpdateProjectsCommand(this) } await this.updateProjectsCommand.execute() }, this.config.gitlab.updateInterval * 1000) } } /* ... */ }
Количество, цвет и название колонок на доске хардкодить не будем, а сделаем их настраиваемыми в config.js. Это у нас их пять, а кому-то, может, всего две надо и кислотного цвета:
columns: [ { key: 'new', title: 'Новые', color: 'rgb(255, 255, 219)' }, { key: 'planed', title: 'Запланировано', color: 'rgb(236, 236, 191)' }, { key: 'working', title: 'В работе', color: 'rgb(253, 214, 162)' }, { key: 'checking', title: 'На проверке', color: 'rgb(162, 226, 253)' }, { key: 'done', title: 'Выполнено', color: 'rgb(162, 253, 200)' }, ],
Главное, чтобы их было не меньше двух, иначе приложение не запустится.
Для взаимодействия с фронтом нам нужен метод, который получает задачи из БД и сортирует их по «колонкам»:
Показать IssueService.ts
export class IssueService extends AbstractService { /* ... */ public async getKanban() { let issues = await this.issueRepository.find({ where: { updatedTimestamp: MoreThanOrEqual(new Date().getTimestamp() - 60 * 60 * 24 * 30) }, order: { kanbanOrder: 'ASC', updatedTimestamp: 'DESC' }, }) const keys = this.app.config.columns.map(c => c.key) const result: { [key: string]: Issue[] } = {} for (let ki = keys.length - 1; ki >= 0; ki--) { const key = keys[ki] if (ki === keys.length - 1) { // Закрытые задачи автоматом помещаем в последнюю колонку result[key] = issues.filter(i => i.closed) } else if (ki === 0) { // Всё, что осталось, добавляем в первую колонку, чтобы не потерялось result[key] = issues } else { result[key] = issues.filter(i => i.kanbanStatus === key) } issues = issues.filter(i => !result[key].includes(i)) // Убираем уже отфильтрованные задачи } return result } /* ... */ }
И контроллер, который сохраняет новые позиции задач:
Показать IssueController.ts
export default class IssueController extends AbstractController { /* ... */ public async kanbanUpdate(data: IQueryData) { const keys = this.app.config.columns.map(c => c.key) for (const c of data.params as { key: string, title: string, color: string, issues: number[] }[]) { if (keys.indexOf(c.key) < 0) { continue } let index = 0 for (const i of c.issues) { index++ const issue = await this.app.issueService.getIssue(i) if (!issue) { continue } issue.kanbanStatus = c.key issue.kanbanOrder = index this.app.issueService.issueRepository.save(issue) } } } }
Штош. Информацию из GitLab мы собираем, данные для отображения доски подготавливаем, даже обновлённые позиции задач на доске умеем сохранять. Дело осталось за малым: сделать саму доску. И тут мы вооружаемся мощнейшим инструментом:
Vue CLI — вместо тысячи слов

TSX
Для облегчения рефакторинга, поиска ошибок, линтинга и прочего душевного спокойствия мы используем tsx-шаблоны, которые Vue.js поддерживает из коробки* почти. Вот, например, самый главный в нашей доске компонент, компонент представления задачи:
Показать код Issue.tsx
import { Vue, Component, Prop, Watch } from 'vue-property-decorator' import './Issue.css' import { CreateElement, VNode } from 'vue' interface IIssue { groupId: number groupName: string groupUrl: string projectName: string projectUrl: string url: string title: string executor: { color: string, name: string } spent: number estimate: number } @Component export default class extends Vue { @Prop() public issue!: IIssue public issueValue!: IIssue public selectUser = false @Watch('issue', { immediate: true }) public onIssueChange() { this.issueValue = this.issue } // eslint-disable-next-line @typescript-eslint/no-unused-vars public render(h: CreateElement): VNode { return <div class="kvcIssue"> <div class="kvciTitle"> { this.issueValue.groupId ? <small><a href={ this.issueValue.groupUrl } target="_blank"> { this.issueValue.groupName } </a> / </small> : undefined } <a href={ this.issueValue.projectUrl } target="_blank">{ this.issueValue.projectName }</a> </div> <div class="kvciText"><a href={ this.issueValue.url } target="_blank">{ this.issueValue.title }</a></div> <div class={ ['kvciExecutor', this.issueValue.executor ? '' : 'none'] }> <span class="timeTd"> <span title="Времени потрачено">{ this.issueValue.spent.time() }</span> / <span title="Времени планировалось"> { this.issueValue.estimate.time() }</span> </span> { this.issueValue.executor ? <span class="kvcieUser" style={ { background: this.issueValue.executor.color } }> { this.issueValue.executor.name } </span> : <span class="kvcieSelect">не определён</span> } </div> </div> } }
Этот компонент отображает плашку с задачей. Обратите внимание, что у задачи есть фактически потраченное и запланированное время:

Ещё есть компоненты Column.tsx и Kanban.tsx. Всего три компонента обеспечивают представление задач на доске:

За перемещение задач на доске отвечает Vue.Draggable, ноги которого растут из SortableJS. В использовании крайне прост:
import Draggable from 'vuedraggable' /* ... */ export default class extends Vue { /* ... */ public render(h: CreateElement): VNode { /* ... */ <Draggable class="kvcIssues" vModel={ this.issuesValue } group={ { name: 'issues', pull: true, put: true } } onEnd={ this.onDrag } onAdd={ this.onDrag } > { this.issuesValue.map(i => <Issue key={ i.id } issue={ i } />) } </Draggable> /* ... */ } }
Наверняка вы заметили, что у каждого пользователя свой цвет, который используется для отображения логина. Это очень удобно — среди кучи задач, можно быстро найти свои. Добиться «разноцветности» можно двумя путями: задать цвет руками для каждого пользователя или генерировать его на основе чего-то.
Назови мне свой логин, и я скажу, какой у тебя цвет
За генерацию цвета отвечает геттер color у класса User. Я специально реализовал этот функционал на бэкэнде, потому что мне понадобилась криптография, а тащить на фронт целую библиотеку ради такой мелкой задачи — преступление. Да и правильнее, когда за характеристики сущностей отвечает бэк, а не фронт:
export class User extends AbstractEntity { /* ... */ public get color() { const hash = crypto.createHash('md5').update(this.username).digest() // Старичка md5 не стоит раньше времени сбрасывать со счетов. Для некоторых задач он по-прежнему хорош и быстр. const result: number[] = [] for (let i = 0; i < 3; i++) { result.push(Math.round(200 - hash[i] / 255 * 150)) // Фон должен быть не сильно ярким, но и не сильно тёмным, чтобы на нём читался логин пользователя } return `rgb(${result.join(', ')})` } }
Для желающих узнать, какой цвет им предначертан судьбой, я не поленился запилить сниппет на CodePen.
Вот и всё
Рабочий вторник начался со следующего диалога:
Р: Ну как доска, готова?..
Я: Да, вот.
Р: О, круто!
Результат моего «трудового будня» лежит здесь: https://github.com/onlanta/kanban. Его можно не только посмотреть-потрогать, но и использовать (инструкции там же). А можно на его основе построить целый таймтрекер для гитлаба, что мы в компании и сделали.
Почему я решил написать эту заметку? Это один из недавних ярких моментов в моей профессиональной деятельности, когда задача была важной, интересной, при этом достаточно простой, а её решение получилось лёгким и элегантным. Предлагаю в комментариях повспоминать аналогичные задачи из вашей практики.
Ну и куда ж мы без вакансий.
