Приоритет задачи в BugDump раньше был четырьмя фиксированными значениями: low, medium, high, critical. Когда я перенёс приоритет в то же хранилище, что и всё остальное, — одна строка на вариант, одна строка на значение, — я оставил у каждого варианта metadata.key, чтобы внешнему миру было чем стабильно его называть. Виджет отправляет critical, payload вебхука несёт critical, маппинг в Jira делает switch по critical.
Это казалось осторожным решением. Именно оно сделало набор вариантов неизменяемым.
Идентификатор и отображение были одной строкой
Замысел был обычный: ID для базы данных, ключ для машин, название для людей. Три слоя, у каждого одна задача.
На деле ключ и название всегда совпадали, поэтому ничто не заставляло их разойтись, и ключ просочился в путь чтения. Запрос, который загружает значения задачи, выбирал ключ:
optionKey: sql<string | null>`${CustomPropertyOption.metadata}->>'key'`
а маппер записывал его прямо в поле, которое читает каждый клиент:
case 'priority':
values.priority = row.optionKey;
Так что metadata.key не был машинным именем, тихо лежащим рядом с отображаемым. Он и был отображаемым именем — для бейджа на карточке, панели задачи, публичного портала, админской таблицы и MCP-сервера. Удалите ключ — и приоритет каждой задачи разом станет null везде.
Пока я планировал, я этого не заметил. В первом черновике плана было написано, что исходящие данные менять не нужно: priority в payload вебхука уже имеет тип string | null, — а это совсем другой путь в коде. Ошибку поймал повторный проход ревью. Спутать их легко, потому что и то и другое — «приоритет в виде строки», и отличаются они только тем, какая функция их создала.
Общая закономерность: стабильный идентификатор, который всегда равен отображаемому значению, — это не два поля. Это одно поле, которое две подсистемы считают своим. Узнаёшь об этом, когда пытаешься поменять что-то одно.
Класс багов, которые не падают громко
Та часть, которая принесла бы в релиз регрессию, меньше и противнее.
Четыре функции переводят приоритет в уровень важности (severity) другой системы:
function mapPriorityToJira(priority: string | null): string {
switch (priority) {
case 'critical': return 'Highest';
case 'high': return 'High';
case 'medium': return 'Medium';
case 'low': return 'Low';
default: return 'Medium';
}
}
Миграция пишет названия с заглавной буквы, потому что название теперь — отображаемая строка, а low читается как машинное имя. 'Low' не совпадает с case 'low'. Каждая из этих функций проваливается в default.
Никаких исключений. Параметр — string | null, так что компилятор доволен. Jira по-прежнему получает валидный тикет — с приоритетом Medium. Slack по-прежнему публикует сообщение — серым. Freshdesk по-прежнему заводит обращение — с severity 2. В логе нечего найти, ни один запрос не падает, ошибки типов нет. Функция просто тихо начинает работать неправильно, у всех и в ту сторону, за которой никто не следит.
Вот что делает ветки default в маппинге строк опасными. default — правильный ответ на действительно неизвестный ввод: у проекта, который придумал приоритет Blocker, нет аналога в Jira, и угадывать было бы хуже, чем откатываться на задокументированное значение. Но тот же default с тем же молчанием поглощает и ваши собственные баги.
У этих четырёх функций не было тестов. Теперь есть, и тест фиксирует обе половины: названия из начальных данных переводятся в конкретные уровни, а то, что перевести нельзя, уходит в запасной вариант. Если откатить исправление, шесть тестов краснеют, включая тот, который требует, чтобы High не превращался в Medium.
Что я изменил
Идентификатором стал ID, отображением — название. TaskSystemValues.priority из string | null стал { id, label, color } | null — так же, как уже был устроен статус. Фильтры сопоставляют ID вариантов. Переименование теперь меняет название и больше ничего, и в этом весь смысл: задача с приоритетом Critical сразу показывает новое слово, потому что строка значения ссылается на вариант, а не хранит копию его названия.
Отдельный канал фильтрации я удалил, а не ослабил. У приоритета был написанный вручную подзапрос, который сравнивал metadata->>'key'. Вместо того чтобы научить его сравнивать названия, я его убрал: приоритет теперь обычное свойство, и он проходит через тот же общий фильтр по свойствам, что и всё остальное. Заодно у ИИ-агентов сама собой появилась фильтрация по приоритету, потому что propertyFilters MCP-сервера уже говорил на этом языке.
Сопоставление на границе не учитывает регистр и пробелы. Виджеты, встроенные несколько месяцев назад, по-прежнему отправляют critical. Они сопоставляются с Critical и продолжают работать, как и ссылки ?priority=high, которыми люди успели поделиться. Совместимость на краю, точные ID внутри.
Нераспознанные значения обрабатываются по-разному в зависимости от канала. Анонимный отчёт, в котором указан приоритет, с тех пор переименованный в проекте, заводится без приоритета: потерять баг-репорт из-за приоритета, с которым всё равно ничего не сделать, было бы худшим сбоем. Запрос от авторизованного клиента, включая MCP-инструмент set_priority, отклоняется, потому что ответ { success: true } без записи исказил бы то, что произошло.
Миграция
Три SQL-запроса, и каждый ограничен так, что и повторный запуск, и проект, где варианты уже отредактировали, ничего не меняют:
UPDATE "CustomPropertyOption" o
SET "label" = initcap(o."label"), "updatedAt" = now()
FROM "CustomProperty" p
WHERE o."propertyId" = p."id"
AND p."system" = 'priority'
AND o."label" IN ('low', 'medium', 'high', 'critical');
Затем цвета там, где они не заданы, затем metadata - 'key'. Я проверил миграцию так: восстановил копию продакшена, переименовал и перекрасил приоритеты одного проекта и прогнал все запросы заново. Отредактированный проект остался нетронутым.
Идемпотентность здесь — не формальность. Миграция данных, которая при втором запуске перезаписывает правки пользователей, — это миграция, которую можно запустить только один раз, правильно и навсегда.
Что я сказал бы себе раньше
Не добавляйте стабильный ключ, пока двум вещам не понадобится различаться. Я добавил его, потому что «интеграциям нужно что-то стабильное», — в целом это верно, но здесь это ещё не требовалось. Ключ не дал ничего, чего не давал ID варианта, и стоил возможности переименовывать.
ID — лучший стабильный идентификатор, чем имя. ID и так уникальны, неизменяемы и есть в каждом ответе. Вторая неизменяемая строка существует в основном для того, чтобы её случайно показали пользователю.
Ищите grep'ом строку, а не символ. Опасные места чтения не были ошибками типов. Это были case 'critical': в файле, который никто не связывал с приоритетами. switch по параметру типа string невидим для переименования, а default прячет последствия.
Тестируйте маппинги, на которые никто не смотрит. Маппинг уровней важности месяцами работал в продакшене без тестов, потому что это были четырёхстрочные функции с очевидным поведением. Очевидное поведение с молчаливым запасным вариантом — как раз то, что нужно закрепить тестом.
Читайте также
Уровни приоритета багов: почему четыре фиксированных варианта никогда не подходят — зачем вообще нужна шкала приоритетов, сколько в ней должно быть уровней и почему слова в ней должны быть вашими.
Пользовательские поля в баг-трекере: что добавить, а от чего отказаться — как отличить поле, которое окупится, от поля, которое через шесть недель будет пустым.
Что я понял, пока делал MCP-сервер для инструмента разработчика — почему инструменты для агента проектируют иначе, чем REST API, и как сделать так, чтобы агент пользовался ими правильно с первой попытки.