Команда — шесть разработчиков, полтора десятка параллельных проектов. Задачи жили в Excel, чатах и головах. В какой‑то момент стало понятно, что «а кто это делает?» задаётся чаще, чем «как это сделать», — пора заводить трекер.
Дальше случилось то, что случалось у многих: мы честно прошлись по рынку, везде что‑то мешало, и в понедельник я открыл пустой репозиторий. Во вторник команда уже вела задачи в своём трекере.
А самое интересное произошло между этими точками: канбан‑доска — то, ради чего всё затевалось, — прожила в интерфейсе два с половиной часа. По истории коммитов видно точно.
Расскажу по порядку: почему не подошло готовое, что выкинули, что оставили и почему «свой трекер за два дня» — это не подвиг, а холодный расчёт про длину петли обратной связи.
Почему не готовое
Сразу дисклеймер: всё перечисленное ниже — нормальные инструменты. Не подошли они нам из‑за наших ограничений, а не потому что плохие.
Jira и Linear отпали первыми: данные о внутренних проектах не должны жить во внешнем облаке, а с обслуживанием российских аккаунтов у обоих вендоров всё сложно. Kaiten, YouGile, Weeek — приличные российские альтернативы, но это опять облако с чужим хранением, а по деньгам на команду — подписка за то, чем мы будем пользоваться на пять процентов.
Self‑hosted Plane был ближе всего, но тащить и обслуживать чужой комбайн ради шести человек не хотелось.
GitHub Issues мы используем и любим, но тут споткнулись о главное: трекер нужен не только разработчикам. Руководителю нужны карточки, сроки и картинка загрузки — а не list view с лейблами. Фраза, убившая этот вариант, звучала так: «Issues не умеют пользоваться менеджеры». Это не претензия к менеджерам — это факт о интерфейсе.
И тут важный момент честности. Решающим аргументом за «написать своё» была не экономия и не уникальные требования. Нам хотелось это написать. Если у вас нет такого пункта в мотивации — берите Kaiten и живите спокойно. Но если есть, дальше будет интересно, потому что бюджет эксперимента — два дня.

Стек: скучный, и это осознанно
FastAPI + SQLAlchemy 2 + Alembic на бэкенде, Next.js 15 + React 19 + TanStack Query на фронте, PostgreSQL 16, Redis, nginx. Шесть контейнеров docker compose.
Свободного сервера не было — развернули на виртуалке, где уже жил другой наш сервис. Правила совместного проживания простые: отдельный compose‑проект со своими томами и сетью, наружу — один‑единственный порт 8443, системный nginx соседа не трогаем. Живут вместе, друг о друге не знают.

Утро первого дня: канбан. Обед первого дня: похороны канбана
Первый коммит — классика жанра: доска, колонки, карточки, drag‑and‑drop. Всё как у больших, dnd‑kit подключён, карточки летают.
Коммит через два с половиной часа называется «Отказ от досок».
Что произошло: мы начали переносить в трекер реальные задачи и смотреть, как их видят реальные люди. И выяснилось, что доска отвечает на вопрос, который у нас никто не задаёт. «Что в какой стадии по проекту X» — это вопрос компании, где над одним продуктом работает десять человек. У нас другая структура: шесть человек, у каждого по два‑три проекта. Вопросы звучат иначе:
у разработчика: «что моё и что горит?»
у ответственного за проект: «что я поставил и сколько оно уже висит?»
у руководителя: «кто перегружен и какие проекты стоят?»
Ни один из этих вопросов не отвечается доской. Все три отвечаются списком с фильтрами и отметками времени.
Доску выкинули из интерфейса целиком. Вместо неё: плитки проектов на главной, внутри проекта — список задач со стадиями, отдельно — сводный список по всем проектам и дашборд загрузки.
А вот из модели данных канбан не ушёл — и это, пожалуй, главный архитектурный вывод этих двух дней. Колонки остались в базе как этапы жизненного цикла с машиночитаемым признаком:
Project → Board → BoardColumn(kind: поставлено | в работе | на проверке | готово) → Task
Стадия задачи — это её колонка. Кнопки интерфейса смотрят на kind, а не на название, поэтому колонки можно переименовывать, не ломая логику. Канбан оказался моделью данных, а не интерфейсом: этапы и переходы — ценность, визуализация столбиками — вкусовщина, которую каждая команда готовит по‑своему.
Жизненный цикл вместо перетаскивания
Вместо драг‑энд‑дропа — четыре кнопки с правами:

«Принять» и «Завершить» может только исполнитель. «Принять работу» и «Вернуть» — только ответственный за проект. Каждый переход пишет отметку времени: started_at, submitted_at, completed_at.
Из этих трёх полей интерфейс считает то, ради чего всё затевалось: сколько задача провисела до того, как её взяли, и сколько была в работе. Это единственная метрика, которую у нас реально спрашивают. Не burndown, не velocity — «почему это висит третью неделю».
Две детали про роли, которых нам не хватало в готовых трекерах:
Ответственность — свойство проекта, а не человека. Один и тот же сотрудник ведёт один проект и рядовой исполнитель в двух других. В моделях это один флажок
ProjectMember.is_lead, в жизни — отсутствие вечного вопроса «а кто тут главный».Замещение на отпуск. Ответственный назначает временного заместителя на период: внутри периода у того все права ведущего, после — всё возвращается само. Ни в одном лёгком трекере из нашего списка такого нет, а в жизни отпуска случаются чаще, чем миграции на новый трекер.
Второй день: петля обратной связи длиной в вечер
Вечером первого дня команда начала вести в трекере реальные задачи. Утром второго дня в истории коммитов появляется «Правки по замечаниям команды»: человеческие сообщения об ошибках валидации вместо стандартных, создание проектов из интерфейса вместо скрипта, шестерёнка настроек там, где её искали, а не там, где я её положил.
Между «нам неудобно» и «поправлено» прошло два часа. И вот тут — главный аргумент всей истории.
У вендора этот путь выглядит так: тикет в поддержку → «спасибо, передали команде» → роадмап → может быть, через квартал. У self‑hosted опенсорса: issue → обсуждение → мейнтейнер занят → форкать самим? В своём инструменте: услышал за завтраком → закоммитил к обеду.
Свой трекер — это не про «мы напишем лучше Atlassian». Не напишем. Это про длину петли обратной связи: часы вместо кварталов. Для инструмента, которым команда пользуется каждый день, короткая петля перевешивает длинный список фич, которыми не пользуется никто.
Интеграция с GitHub: вебхуки не нужны
Задачи задачами, но код живёт в GitHub, и связка «задача ↔ pull request» была обязательным требованием. Каноничный путь — вебхуки: GitHub стучится к вам при каждом событии. Для этого нужен белый адрес и доверенный TLS‑сертификат. У нашей виртуалки нет ни того ни другого — и заводить их ради трекера не хотелось.
Решение скучное и оттого прекрасное: опрос. Раз в пять минут сервер сам ходит в GitHub API и забирает состояние pull request'ов. Входящих соединений — ноль, сертификат не нужен, плата — задержка в пять минут, которую никто ни разу не заметил. Есть кнопка «Обновить сейчас» для нетерпеливых.
Что происходит на каждом проходе:
1. PR связывается с задачей разработчика по имени ветки. Трекер в карточке задачи подсказывает имя ветки вида KEY-12-... — если разработчик так её и назвал, связь возникает сама:
def tasknumber_from_branch(project_key: str, branch: str) -> int | None: """Из имени ветки достаёт номер задачи: KEY-12-opisanie → 12.""" match = re.match(rf"^{re.escape(project_key)}-(\d+)(?:-|$)", branch, re.IGNORECASE) return int(match.group(1)) if match else None
2. На каждый новый PR автоматически заводится задача «Проверить PR #N» ответственному за проект — с высоким приоритетом и уведомлением. Ревью перестало быть тем, о чём нужно помнить: оно приходит как обычная задача.
3. Состояние PR (открыт / влит / закрыт) обновляется в карточке само.
Отдельно про токены. У каждого проекта — свой fine‑grained токен с правами read‑only на конкретный репозиторий; общего «токена системы» нет в принципе. Хранится он в базе зашифрованным, причём без отдельного секрета — ключ шифрования выводится из уже существующего SECRET_KEY:
def _cipher() -> Fernet: digest = hashlib.sha256(get_settings().secret_key.encode()).digest() return Fernet(base64.urlsafe_b64encode(digest)) def mask(value: str) -> str: """Показ токена в интерфейсе: подтверждает наличие, не раскрывая сам токен.""" if len(value) <= 8: return "••••" return f"{value[:4]}…{value[-4:]}"
Логика простая: у базы есть бэкапы, у бэкапов — своя жизнь, и токены в открытом виде в них лежать не должны. А заводить второй секрет ради шифрования первого — путь в бесконечность. Обратно токен интерфейс не отдаёт никогда — только первые и последние четыре символа.

Скучная надёжность, которая окупилась сразу
Три решения, принятые в первый день «на всякий случай»:
Уведомления — через таблицу и воркер. Интерфейс никогда не отправляет письмо или сообщение в Telegram сам — он кладёт запись в таблицу, а отдельный воркер разгребает очередь с тремя попытками. Когда SMTP однажды задумается, никто этого не заметит: задачи ставятся, кнопки нажимаются, письма догонят.
Время — только UTC. Все отметки — timestamptz, перевод в местное время — в одном‑единственном модуле фронтенда. Правило звучит занудно ровно до первого «почему задача создана завтра».
История изменений — с первого дня. Каждое действие пишется в ActivityLog. Не для аудита в бюрократическом смысле — для ответа на вопрос «кто и когда перевесил задачу», который возникает на третий день жизни любого трекера.
Плюс гигиена по мелочи: пароли — Argon2, сессия — httpOnly‑cookie, саморегистрации нет (учётки заводит администратор — для внутреннего инструмента это фича, а не недостаток).
Чего нет — и почему пока не больно
Честный список из README, раздел «Что пока не сделано»: вложений к задачам нет (модель заложена, интерфейса нет), смены пароля самим сотрудником нет, напоминаний о сроке нет.
И — да: тестов нет. Знаю. Для сервиса на шесть доверенных пользователей во внутренней сети это осознанный размен: цена ошибки — «перезагрузили контейнер», а не «потеряли деньги клиента». Правило, которым я это для себя оформил: тесты появляются вместе с первым пользователем, которому нельзя сказать «ой, сейчас поправлю». Если трекер переживёт квартал — появятся.
Как это писалось: два дня и девять коммитов
Весь трекер — девять коммитов, между первым и последним — около 28 часов календарного времени.

Что в итоге
Трекер в бою с первого дня, команда внутри, Excel с задачами торжественно забыт. Из хотелок — напоминания о сроках и вложения; из планов — прикрутить бота, который по истории ActivityLog будет раз в неделю писать сводку «что зависло и у кого».
Если вычесть эмоции, история раскладывается в три пункта:
Считайте не стоимость подписки, а стоимость несделанных фич. Замещение ответственного на отпуск и автозадача на каждый PR — вещи, которые нам нужны каждую неделю и которых нет ни в одном готовом лёгком трекере. Не потому что вендоры глупые — потому что это фичи ровно нашей структуры команды.
Канбан — это модель данных. Этапы, переходы, отметки времени — берите. Доску с колонками — только если у вас правда есть вопрос «что в какой стадии», а не потому что так выглядят все трекеры.
Свой инструмент оправдывается одной метрикой — длиной петли обратной связи. Если «нам неудобно» превращается в «поправлено» за часы, инструмент живёт. Если у вас нет ресурса держать эту петлю короткой — не начинайте, возьмите готовое.
Ну и главное. Мы два дня писали то, что можно было купить за пару тысяч рублей в месяц. Жалеем ли? Судя по тому, что руководитель теперь сам смотрит задачи и дашборд загрузки вместо того, чтобы спрашивать в чате «а кто это делает», — ни секунды.

