@goodandready/dsh-kanban 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +455 -0
- package/cordis.patch.yml +4 -0
- package/lib/board-tool.js +178 -0
- package/lib/client.js +3197 -0
- package/lib/commands.js +167 -0
- package/lib/config.js +170 -0
- package/lib/filters.js +243 -0
- package/lib/gitea.js +309 -0
- package/lib/import.js +214 -0
- package/lib/index.js +764 -0
- package/lib/intake.js +94 -0
- package/lib/launcher.js +432 -0
- package/lib/lifecycle.js +32 -0
- package/lib/markdown.js +161 -0
- package/lib/order.js +66 -0
- package/lib/outbound.js +88 -0
- package/lib/plan.js +153 -0
- package/lib/routes.js +256 -0
- package/lib/store.js +366 -0
- package/lib/sync.js +369 -0
- package/lib/time.js +45 -0
- package/lib/transitions.js +162 -0
- package/lib/webhook.js +54 -0
- package/package.json +89 -0
package/lib/commands.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Перенос карточки — это КОМАНДА агенту, а не запись о состоянии.
|
|
2
|
+
//
|
|
3
|
+
// Владелец не двигает карточки вручную ради учёта: если он двинул, значит
|
|
4
|
+
// просит запустить тот этап, куда двинул. Поэтому таблица ниже описывает не
|
|
5
|
+
// «что стало», а «что сделать», и текст уходит в чат задачи.
|
|
6
|
+
//
|
|
7
|
+
// Тексты последствий для окна подтверждения живут в клиентской половине:
|
|
8
|
+
// они переводятся, а инструкция агенту — нет.
|
|
9
|
+
|
|
10
|
+
/** Виды досок. Простая доска появляется в #63; таблица под неё уже здесь. */
|
|
11
|
+
export const BOARD_KINDS = ['project', 'simple']
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Колонка `done` для инструмента агента закрыта.
|
|
15
|
+
*
|
|
16
|
+
* Путь агента к завершению — получить согласие, закрыть issue и удалить ветку;
|
|
17
|
+
* сверка с Gitea сама увидит закрытый issue без ветки и передвинет карточку.
|
|
18
|
+
* Карточка двигается фактом, а не заявлением, и подделать это нечем.
|
|
19
|
+
*/
|
|
20
|
+
export const TOOL_FORBIDDEN_COLUMNS = ['done']
|
|
21
|
+
|
|
22
|
+
const TABLE = {
|
|
23
|
+
project: {
|
|
24
|
+
'backlog': { stops: true },
|
|
25
|
+
'in-progress': {
|
|
26
|
+
instruction: 'Начни или продолжи реализацию этой задачи по стандартному воркфлоу.',
|
|
27
|
+
},
|
|
28
|
+
'review': {
|
|
29
|
+
instruction: 'Доведи работу до ревью: сними с pull request пометку черновика '
|
|
30
|
+
+ 'и запроси проверку.',
|
|
31
|
+
},
|
|
32
|
+
'deploy': {
|
|
33
|
+
// Воркфлоу требует явного «ок» человека перед deploy. Сказать это надо
|
|
34
|
+
// прямо, иначе агент будет спрашивать разрешение, которое уже получил.
|
|
35
|
+
instruction: 'Влей pull request и выкати. Перенос карточки в Deploy — это и есть '
|
|
36
|
+
+ 'явное «ок» человека на deploy, отдельно спрашивать не нужно.',
|
|
37
|
+
},
|
|
38
|
+
'cleanup': {
|
|
39
|
+
instruction: 'Прибери за задачей: удали ветку в Gitea, worktree и локальную ветку, '
|
|
40
|
+
+ 'запиши сделанное в issue.',
|
|
41
|
+
},
|
|
42
|
+
'done': { humanOnly: true },
|
|
43
|
+
},
|
|
44
|
+
simple: {
|
|
45
|
+
'backlog': { stops: true },
|
|
46
|
+
'in-progress': { instruction: 'Начни или продолжи работу по этой задаче.' },
|
|
47
|
+
'review': { instruction: 'Покажи результат и жди ответа человека.' },
|
|
48
|
+
'done': { humanOnly: true },
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Что означает перенос в колонку.
|
|
54
|
+
*
|
|
55
|
+
* @returns {{stops: boolean, humanOnly: boolean, instruction: string}|undefined}
|
|
56
|
+
* `undefined` — такой колонки на этой доске нет.
|
|
57
|
+
*/
|
|
58
|
+
export function commandFor(column, kind = 'project') {
|
|
59
|
+
const board = TABLE[kind]
|
|
60
|
+
if (board === undefined) return undefined
|
|
61
|
+
const found = board[column]
|
|
62
|
+
if (found === undefined) return undefined
|
|
63
|
+
return {
|
|
64
|
+
stops: found.stops === true,
|
|
65
|
+
humanOnly: found.humanOnly === true,
|
|
66
|
+
instruction: found.instruction ?? '',
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Колонки доски в том виде, в каком их понимает таблица команд. */
|
|
71
|
+
export function columnsOfKind(kind = 'project') {
|
|
72
|
+
return Object.keys(TABLE[kind] ?? {})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Исполнить команду, стоящую за переносом карточки.
|
|
77
|
+
*
|
|
78
|
+
* Ничего не двигает: карточку уже передвинул `applyMove`. Здесь только
|
|
79
|
+
* последствие переноса — остановка идущего хода либо сообщение в чат задачи.
|
|
80
|
+
*
|
|
81
|
+
* Отсутствие сессии не ошибка: карточку без чата двигают как обычную пометку.
|
|
82
|
+
*
|
|
83
|
+
* @returns {{acted: 'stopped'|'sent'|'idle'|'no-session'|'human'|'unknown'}}
|
|
84
|
+
*/
|
|
85
|
+
export function dispatchMove({ agents, task, column, kind = 'project', createMessage, logger }) {
|
|
86
|
+
const command = commandFor(column, kind)
|
|
87
|
+
if (command === undefined) return { acted: 'unknown' }
|
|
88
|
+
if (command.humanOnly) return { acted: 'human' }
|
|
89
|
+
|
|
90
|
+
const sessionId = typeof task?.sessionId === 'string' ? task.sessionId : ''
|
|
91
|
+
if (sessionId === '') return { acted: 'no-session' }
|
|
92
|
+
|
|
93
|
+
let agent
|
|
94
|
+
try { agent = agents.get(sessionId) } catch { agent = undefined }
|
|
95
|
+
if (agent === undefined) return { acted: 'no-session' }
|
|
96
|
+
|
|
97
|
+
if (command.stops) {
|
|
98
|
+
// Карточка в бэклоге при работающем агенте — это ложь: работа идёт, а
|
|
99
|
+
// доска говорит, что нет. Останавливаем по-настоящему.
|
|
100
|
+
if (agent.status !== 'running') return { acted: 'idle' }
|
|
101
|
+
try {
|
|
102
|
+
agent.cancel({ kind: 'user' })
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger?.warn?.(`dsh-kanban: ход по задаче ${task.id} не прервался: ${error?.message}`)
|
|
105
|
+
return { acted: 'idle' }
|
|
106
|
+
}
|
|
107
|
+
return { acted: 'stopped' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
agent.followup(createMessage({
|
|
111
|
+
// Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список.
|
|
112
|
+
content: [{ type: 'text', text: command.instruction }],
|
|
113
|
+
source: { kind: 'plugin', plugin: 'dsh-kanban', form: 'board-command' },
|
|
114
|
+
}))
|
|
115
|
+
return { acted: 'sent' }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Остановить ход агента, не двигая карточку.
|
|
120
|
+
*
|
|
121
|
+
* Прежде остановка была возможна только переносом в бэклог, то есть вместе с
|
|
122
|
+
* отменой этапа. Здесь останавливается только ход: колонка — отдельное
|
|
123
|
+
* решение человека, и доска не вправе принимать его за него.
|
|
124
|
+
*
|
|
125
|
+
* @returns {{acted: 'stopped'|'idle'|'no-session'}}
|
|
126
|
+
*/
|
|
127
|
+
export function stopWork({ agents, task, logger }) {
|
|
128
|
+
const sessionId = typeof task?.sessionId === 'string' ? task.sessionId : ''
|
|
129
|
+
if (sessionId === '') return { acted: 'no-session' }
|
|
130
|
+
|
|
131
|
+
let agent
|
|
132
|
+
try { agent = agents.get(sessionId) } catch { agent = undefined }
|
|
133
|
+
if (agent === undefined) return { acted: 'no-session' }
|
|
134
|
+
// Остановить не идущего нечем, и говорить об этом надо прямо: молчаливое
|
|
135
|
+
// «готово» на месте «он и так стоял» — ложь о том, что случилось.
|
|
136
|
+
if (agent.status !== 'running') return { acted: 'idle' }
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
agent.cancel({ kind: 'user' })
|
|
140
|
+
} catch (error) {
|
|
141
|
+
logger?.warn?.(`dsh-kanban: ход по задаче ${task.id} не прервался: ${error?.message}`)
|
|
142
|
+
return { acted: 'idle' }
|
|
143
|
+
}
|
|
144
|
+
return { acted: 'stopped' }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Пояснения к остановке в журнале задачи. */
|
|
148
|
+
export const STOP_DETAIL = {
|
|
149
|
+
stopped: 'человек остановил ход агента',
|
|
150
|
+
idle: 'агент не работал, останавливать было нечего',
|
|
151
|
+
'no-session': 'у задачи нет сессии',
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Пояснение к переходу в журнале задачи.
|
|
156
|
+
*
|
|
157
|
+
* Одна строка на один перенос: писать вторым рядом «а ещё мы остановили ход»
|
|
158
|
+
* значило бы задваивать событие, которое было одним.
|
|
159
|
+
*/
|
|
160
|
+
export const MOVE_DETAIL = {
|
|
161
|
+
stopped: 'перенос прервал идущий ход агента',
|
|
162
|
+
sent: 'команда отправлена в чат задачи',
|
|
163
|
+
idle: 'агент не работал, останавливать было нечего',
|
|
164
|
+
'no-session': 'у задачи нет сессии, карточка просто передвинута',
|
|
165
|
+
human: 'решение человека',
|
|
166
|
+
unknown: '',
|
|
167
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Чистая часть настроек: значения по умолчанию, порядок колонок и доступ к
|
|
2
|
+
// полям. Ни одной зависимости — поэтому проверяется без харнесса, без сети и
|
|
3
|
+
// без установленных пакетов ядра. Обвязка cordis живёт в `lib/index.js`.
|
|
4
|
+
//
|
|
5
|
+
// Клиентский API настроек пишет ТОЛЬКО скалярные поля, поэтому здесь нет ни
|
|
6
|
+
// массивов, ни словарей: метки колонок и пределы разложены по отдельным
|
|
7
|
+
// скалярным полям. Доски живут в хранилище задач, а не в настройках.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Порядок колонок повторяет реальный воркфлоу, а не абстрактное
|
|
11
|
+
* «в работе → готово». Колонка `cleanup` существует потому, что задача не
|
|
12
|
+
* завершена, пока ветка, worktree и локальная ветка не удалены и это не
|
|
13
|
+
* записано в итоговом комментарии issue. Доска, отправляющая карточку в
|
|
14
|
+
* `done` по merge, врёт ровно там, где копятся забытые ветки.
|
|
15
|
+
*/
|
|
16
|
+
export const COLUMN_ORDER = ['backlog', 'in-progress', 'review', 'deploy', 'cleanup', 'done']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Наборы колонок по виду доски.
|
|
20
|
+
*
|
|
21
|
+
* `review`, `deploy` и `cleanup` осмысленны только там, где за задачей стоит
|
|
22
|
+
* issue: у свободной заметки нет ни ветки, ни PR, и Gitea о ней ничего не
|
|
23
|
+
* скажет. Три колонки, которые никогда не заполнятся, не «на будущее», а
|
|
24
|
+
* помеха.
|
|
25
|
+
*
|
|
26
|
+
* Вид — свойство доски, заданное явно. Выводить его из содержимого нельзя:
|
|
27
|
+
* доска, меняющая число колонок от того, что на неё положили, ведёт себя
|
|
28
|
+
* непредсказуемо.
|
|
29
|
+
*/
|
|
30
|
+
export const BOARD_KINDS = ['project', 'simple']
|
|
31
|
+
|
|
32
|
+
/** Вид доски по умолчанию: неизвестный вид откатывается сюда, а не в пустоту. */
|
|
33
|
+
export const DEFAULT_KIND = 'project'
|
|
34
|
+
|
|
35
|
+
const COLUMNS_BY_KIND = {
|
|
36
|
+
project: COLUMN_ORDER,
|
|
37
|
+
simple: ['backlog', 'in-progress', 'review', 'done'],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Колонки доски. Неизвестный вид — проектная доска, а не пустой набор. */
|
|
41
|
+
export function columnsOf(kind) {
|
|
42
|
+
return COLUMNS_BY_KIND[kind] ?? COLUMNS_BY_KIND[DEFAULT_KIND]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Приведение вида к известному значению. */
|
|
46
|
+
export function normalizeKind(kind) {
|
|
47
|
+
return BOARD_KINDS.includes(kind) ? kind : DEFAULT_KIND
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Заводимые при первом запуске доски. Их ровно две, и обе нужны сразу. */
|
|
51
|
+
export const DEFAULT_BOARDS = [
|
|
52
|
+
{ id: 'main', title: 'Проектная доска', kind: 'project' },
|
|
53
|
+
{ id: 'simple', title: 'Простая доска', kind: 'simple' },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/** Значения по умолчанию. Из них же собирается схема в `lib/index.js`. */
|
|
57
|
+
export const CONFIG_DEFAULTS = {
|
|
58
|
+
giteaUrl: '',
|
|
59
|
+
giteaTokenRef: 'GITEA_TOKEN',
|
|
60
|
+
defaultProjectRoot: '',
|
|
61
|
+
startPrompt: '',
|
|
62
|
+
replyInstruction: 'Отвечай по-русски.',
|
|
63
|
+
wipInProgress: 3,
|
|
64
|
+
wipReview: 0,
|
|
65
|
+
syncIntervalSec: 120,
|
|
66
|
+
staleAfterMin: 60,
|
|
67
|
+
giteaOwner: '',
|
|
68
|
+
columnNames: '',
|
|
69
|
+
watchRepos: '',
|
|
70
|
+
archiveAfterDays: 7,
|
|
71
|
+
boardToolEnabled: false,
|
|
72
|
+
webhookSecretRef: '',
|
|
73
|
+
pushToGitea: true,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Пояснения к полям; повторно используются схемой и карточкой настроек. */
|
|
77
|
+
export const CONFIG_HINTS = {
|
|
78
|
+
giteaUrl: 'Адрес инстанса Gitea или Forgejo, например https://gitea.example.com. Пусто — импорт недоступен.',
|
|
79
|
+
giteaTokenRef: 'ИМЯ учётной записи DSH, в которой лежит токен. Сам токен сюда не вводить.',
|
|
80
|
+
defaultProjectRoot: 'Корень, в котором лежат рабочие копии проектов. Пусто — рабочая папка харнесса.',
|
|
81
|
+
startPrompt: 'Шаблон первого сообщения агенту. Пусто — встроенный шаблон.',
|
|
82
|
+
replyInstruction: 'Постоянная приписка к первому сообщению — например язык ответа. Пусто — ничего не добавляется.',
|
|
83
|
+
wipInProgress: 'Предел числа карточек в колонке «В работе». Ноль — без предела.',
|
|
84
|
+
wipReview: 'Предел числа карточек в колонке «Ревью». Ноль — без предела.',
|
|
85
|
+
syncIntervalSec: 'Как часто сверяться с Gitea, в секундах. Ноль — не сверяться.',
|
|
86
|
+
staleAfterMin: 'После скольких минут молчания задача с сессией помечается тревожно. Ноль — не помечать.',
|
|
87
|
+
giteaOwner: 'Организация Gitea, чьи репозитории смотрит доска. Пусто — определяется сама, если организация одна.',
|
|
88
|
+
columnNames: 'Свои названия колонок парами «идентификатор=Название» через запятую. Пусто — обычные названия.',
|
|
89
|
+
watchRepos: 'Сузить подхват до этих репозиториев, через запятую. Пусто — все репозитории организации, где есть открытые задачи.',
|
|
90
|
+
archiveAfterDays: 'Через сколько дней в колонке «Выполнено» карточка уходит в архив. Ноль — не убирать.',
|
|
91
|
+
boardToolEnabled: 'Разрешить агенту двигать карточки инструментом board_move. Выключено, пока скилл воркфлоу запрещает CLI трогать канбан.',
|
|
92
|
+
webhookSecretRef: 'ИМЯ учётной записи DSH с секретом вебхука Gitea. Пусто — вебхук не принимается, остаётся только опрос.',
|
|
93
|
+
pushToGitea: 'Закрывать issue в Gitea, когда карточка уходит в колонку «Выполнено».',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Поле настроек с пределом числа карточек; задан не у всех колонок. */
|
|
97
|
+
const WIP_FIELD = {
|
|
98
|
+
'in-progress': 'wipInProgress',
|
|
99
|
+
'review': 'wipReview',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Фактический корень проектов: настройка либо рабочая папка харнесса.
|
|
104
|
+
*
|
|
105
|
+
* Единственное место, где считается «что будет, если корень не задан».
|
|
106
|
+
* Браузер показывает это значение в окне запуска, чтобы сессия не
|
|
107
|
+
* поднималась молча в неожиданном каталоге.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} config эффективные настройки
|
|
110
|
+
* @param {() => string} [cwdOf] рабочая папка (в тестах подменяется)
|
|
111
|
+
* @returns {{path: string, set: boolean}} путь и признак «задан настройкой»
|
|
112
|
+
*/
|
|
113
|
+
export function rootOf(config, cwdOf = () => process.cwd()) {
|
|
114
|
+
const named = String(config?.defaultProjectRoot ?? '').trim()
|
|
115
|
+
return named !== '' ? { path: named, set: true } : { path: cwdOf(), set: false }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Наложить значения по умолчанию на сырые настройки. Поля неверного типа
|
|
120
|
+
* отбрасываются в пользу умолчания: настройки приходят из файла и из
|
|
121
|
+
* браузера, и одно испорченное поле не должно ронять плагин целиком.
|
|
122
|
+
* @param {object} [raw] сырые значения
|
|
123
|
+
* @returns {object} полный набор скалярных полей
|
|
124
|
+
*/
|
|
125
|
+
export function withDefaults(raw) {
|
|
126
|
+
const out = {}
|
|
127
|
+
for (const [key, fallback] of Object.entries(CONFIG_DEFAULTS)) {
|
|
128
|
+
const value = raw?.[key]
|
|
129
|
+
out[key] = typeof value === typeof fallback && value !== null ? value : fallback
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Предел числа карточек в колонке. Ноль и отсутствие поля означают «без
|
|
136
|
+
* предела» и одинаково дают `undefined`, чтобы вызывающему не приходилось
|
|
137
|
+
* различать два способа сказать одно и то же.
|
|
138
|
+
* @param {object} config эффективные настройки
|
|
139
|
+
* @param {string} column идентификатор колонки
|
|
140
|
+
* @returns {number|undefined} предел либо undefined
|
|
141
|
+
*/
|
|
142
|
+
export function wipLimitField(config, column) {
|
|
143
|
+
const field = WIP_FIELD[column]
|
|
144
|
+
if (field === undefined) return undefined
|
|
145
|
+
const value = config?.[field]
|
|
146
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
|
147
|
+
return value
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Свои названия колонок из настройки.
|
|
152
|
+
*
|
|
153
|
+
* Пары `идентификатор=Название` через запятую. Одно поле на все колонки, а не
|
|
154
|
+
* шесть отдельных: переименовывают их раз в жизни, а карточку настроек шесть
|
|
155
|
+
* полей засорили бы навсегда.
|
|
156
|
+
*
|
|
157
|
+
* Название неизвестной колонки не отбрасываем молча — она может появиться
|
|
158
|
+
* позже, и тогда настройка сработает сама.
|
|
159
|
+
*/
|
|
160
|
+
export function columnNamesOf(config) {
|
|
161
|
+
const out = {}
|
|
162
|
+
for (const pair of String(config?.columnNames ?? '').split(',')) {
|
|
163
|
+
const at = pair.indexOf('=')
|
|
164
|
+
if (at <= 0) continue
|
|
165
|
+
const id = pair.slice(0, at).trim()
|
|
166
|
+
const name = pair.slice(at + 1).trim()
|
|
167
|
+
if (id !== '' && name !== '') out[id] = name
|
|
168
|
+
}
|
|
169
|
+
return out
|
|
170
|
+
}
|
package/lib/filters.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Отборы доски: репозиторий и пространства меток.
|
|
2
|
+
//
|
|
3
|
+
// Пространства не зашиты списком. Метки живут в Gitea и меняются без нашего
|
|
4
|
+
// участия — набор уже менялся дважды за час, — поэтому пространства собираются
|
|
5
|
+
// из того, что реально пришло на карточки. Зашитый список начал бы врать при
|
|
6
|
+
// первой же правке разметки.
|
|
7
|
+
|
|
8
|
+
/** Отбор по репозиторию стоит в одном ряду с метками, но метка ему не нужна. */
|
|
9
|
+
export const REPO = 'repo'
|
|
10
|
+
|
|
11
|
+
/** Автор задачи — такое же измерение отбора, как метка, но живёт своим полем. */
|
|
12
|
+
export const AUTHOR = 'author'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Значение для задач без автора.
|
|
16
|
+
*
|
|
17
|
+
* Свои задачи заводятся на доске и автора не имеют. Выбросить их из отбора
|
|
18
|
+
* значило бы отвечать на вопрос «кто завёл» неполным списком: «никто» — тоже
|
|
19
|
+
* ответ, и по нему тоже отбирают.
|
|
20
|
+
*/
|
|
21
|
+
export const NO_AUTHOR = '—'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Порядок известных пространств.
|
|
25
|
+
*
|
|
26
|
+
* Алфавит поставил бы `priority` перед `type`, а смотрят в первую очередь на
|
|
27
|
+
* тип и срочность. Незнакомые пространства дописываются в конец по алфавиту:
|
|
28
|
+
* заведут в Gitea новое — оно появится само, без правки кода.
|
|
29
|
+
*/
|
|
30
|
+
export const KNOWN_ORDER = ['type', 'priority', 'status', 'scope', 'risk', 'signal', 'release']
|
|
31
|
+
|
|
32
|
+
/** Разбор метки на пространство и значение; без `/` — пространства нет. */
|
|
33
|
+
export function splitLabel(name) {
|
|
34
|
+
const text = String(name ?? '')
|
|
35
|
+
const at = text.indexOf('/')
|
|
36
|
+
if (at <= 0 || at === text.length - 1) return undefined
|
|
37
|
+
return { ns: text.slice(0, at), value: text.slice(at + 1) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Из чего собрать отборы.
|
|
42
|
+
*
|
|
43
|
+
* Метки без пространства (`epic`, `hotfix`) своего отбора не получают: список
|
|
44
|
+
* из двух значений ради двух меток — мебель. На карточках они видны и находятся
|
|
45
|
+
* поиском.
|
|
46
|
+
*
|
|
47
|
+
* Считаем по ВСЕМ переданным задачам, а не по уже отобранным: счётчик рядом со
|
|
48
|
+
* значением отвечает на вопрос «сколько там всего», а не «сколько осталось
|
|
49
|
+
* после моего же выбора».
|
|
50
|
+
*
|
|
51
|
+
* @returns {Array<{ns: string, values: Array<{value: string, count: number}>}>}
|
|
52
|
+
*/
|
|
53
|
+
export function facetsOf(tasks) {
|
|
54
|
+
const byNs = new Map()
|
|
55
|
+
const add = (ns, value) => {
|
|
56
|
+
if (value === '') return
|
|
57
|
+
if (!byNs.has(ns)) byNs.set(ns, new Map())
|
|
58
|
+
const values = byNs.get(ns)
|
|
59
|
+
values.set(value, (values.get(value) ?? 0) + 1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const task of tasks ?? []) {
|
|
63
|
+
add(REPO, typeof task?.repo === 'string' ? task.repo : '')
|
|
64
|
+
add(AUTHOR, typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR)
|
|
65
|
+
for (const name of task?.labels ?? []) {
|
|
66
|
+
const parsed = splitLabel(name)
|
|
67
|
+
if (parsed !== undefined) add(parsed.ns, parsed.value)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return [...byNs.entries()]
|
|
72
|
+
.map(([ns, values]) => ({
|
|
73
|
+
ns,
|
|
74
|
+
values: [...values.entries()]
|
|
75
|
+
.map(([value, count]) => ({ value, count }))
|
|
76
|
+
.sort((a, b) => a.value.localeCompare(b.value)),
|
|
77
|
+
}))
|
|
78
|
+
.sort((a, b) => rank(a.ns) - rank(b.ns) || a.ns.localeCompare(b.ns))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Репозиторий первым, затем известные пространства, затем всё прочее. */
|
|
82
|
+
function rank(ns) {
|
|
83
|
+
if (ns === REPO) return -2
|
|
84
|
+
if (ns === AUTHOR) return -1
|
|
85
|
+
const at = KNOWN_ORDER.indexOf(ns)
|
|
86
|
+
return at === -1 ? KNOWN_ORDER.length : at
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Подходит ли задача под выбранное.
|
|
91
|
+
*
|
|
92
|
+
* Между пространствами — И, внутри пространства — ИЛИ. «Срочные баги» это
|
|
93
|
+
* `priority/high` И `type/bug`; а выбор `high` вместе с `critical` означает
|
|
94
|
+
* «любая из двух», потому что срочность у задачи одна и «И» внутри
|
|
95
|
+
* пространства всегда давало бы пусто.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} selected карта пространство -> список выбранных значений
|
|
98
|
+
*/
|
|
99
|
+
export function matchesFilters(task, selected) {
|
|
100
|
+
for (const [ns, values] of Object.entries(selected ?? {})) {
|
|
101
|
+
const wanted = values ?? []
|
|
102
|
+
if (wanted.length === 0) continue
|
|
103
|
+
if (!hasAny(task, ns, wanted)) return false
|
|
104
|
+
}
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hasAny(task, ns, wanted) {
|
|
109
|
+
if (ns === REPO) return wanted.includes(task?.repo ?? '')
|
|
110
|
+
if (ns === AUTHOR) {
|
|
111
|
+
const author = typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR
|
|
112
|
+
return wanted.includes(author)
|
|
113
|
+
}
|
|
114
|
+
for (const name of task?.labels ?? []) {
|
|
115
|
+
const parsed = splitLabel(name)
|
|
116
|
+
if (parsed !== undefined && parsed.ns === ns && wanted.includes(parsed.value)) return true
|
|
117
|
+
}
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Выбрано ли хоть что-нибудь: пустой набор отборов доску не сужает. */
|
|
122
|
+
export function anySelected(selected) {
|
|
123
|
+
return Object.values(selected ?? {}).some((values) => (values ?? []).length > 0)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Переключить одно значение.
|
|
128
|
+
*
|
|
129
|
+
* Возвращает новую карту, а не правит прежнюю: состояние отборов живёт в
|
|
130
|
+
* React, и правка на месте не вызвала бы перерисовку.
|
|
131
|
+
*/
|
|
132
|
+
export function toggleValue(selected, ns, value) {
|
|
133
|
+
const current = (selected ?? {})[ns] ?? []
|
|
134
|
+
const next = current.includes(value)
|
|
135
|
+
? current.filter((v) => v !== value)
|
|
136
|
+
: [...current, value]
|
|
137
|
+
const out = { ...(selected ?? {}) }
|
|
138
|
+
if (next.length === 0) delete out[ns]
|
|
139
|
+
else out[ns] = next
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Снять всё разом. */
|
|
144
|
+
export function clearFilters() {
|
|
145
|
+
return {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Метки задачи, разобранные по пространствам.
|
|
150
|
+
*
|
|
151
|
+
* Едет на карточке вместе с задачей: браузерная половина — отдельный бандл и
|
|
152
|
+
* позвать этот модуль не может, а разбор, написанный там во второй раз,
|
|
153
|
+
* однажды разойдётся с этим.
|
|
154
|
+
*
|
|
155
|
+
* @returns {object} карта пространство -> список значений
|
|
156
|
+
*/
|
|
157
|
+
export function facetsOfTask(task) {
|
|
158
|
+
const out = {}
|
|
159
|
+
for (const name of task?.labels ?? []) {
|
|
160
|
+
const parsed = splitLabel(name)
|
|
161
|
+
if (parsed === undefined) continue
|
|
162
|
+
if (out[parsed.ns] === undefined) out[parsed.ns] = []
|
|
163
|
+
if (!out[parsed.ns].includes(parsed.value)) out[parsed.ns].push(parsed.value)
|
|
164
|
+
}
|
|
165
|
+
return out
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Цвет метки из ответа Gitea.
|
|
170
|
+
*
|
|
171
|
+
* Приходит шестизначным кодом без решётки (`0e8a16`), но встречается и с ней.
|
|
172
|
+
* Всё, что не похоже на шестнадцатеричный код, считаем отсутствием цвета:
|
|
173
|
+
* значение попадает прямо в стиль, и пускать туда что попало нельзя.
|
|
174
|
+
*
|
|
175
|
+
* @returns {string|undefined} код без решётки
|
|
176
|
+
*/
|
|
177
|
+
export function labelColor(value) {
|
|
178
|
+
const text = String(value ?? '').trim().replace(/^#/, '')
|
|
179
|
+
return /^[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : undefined
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Карта имя метки -> цвет из списка меток issue.
|
|
184
|
+
*
|
|
185
|
+
* Держим отдельно от имён: имена читают отборы, сверка, чип и поиск, и менять
|
|
186
|
+
* их устройство ради оформления было бы дорого и ни к чему.
|
|
187
|
+
*/
|
|
188
|
+
export function colorsOfIssue(labels) {
|
|
189
|
+
const out = {}
|
|
190
|
+
for (const l of labels ?? []) {
|
|
191
|
+
const name = typeof l === 'string' ? l : l?.name
|
|
192
|
+
if (typeof name !== 'string' || name === '') continue
|
|
193
|
+
const color = labelColor(typeof l === 'string' ? undefined : l?.color)
|
|
194
|
+
if (color !== undefined) out[name] = color
|
|
195
|
+
}
|
|
196
|
+
return out
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Вес задачи при подъёме наверх колонки.
|
|
201
|
+
*
|
|
202
|
+
* Ждущие впереди идущих намеренно: идущая работа идёт сама, а ждущая стоит
|
|
203
|
+
* из-за человека, и он должен увидеть её первой.
|
|
204
|
+
*
|
|
205
|
+
* Ручной порядок не переписывается — внутри каждой ступени он сохраняется, а
|
|
206
|
+
* подъём работает поверх него.
|
|
207
|
+
*/
|
|
208
|
+
export function liveRank(task) {
|
|
209
|
+
const state = task?.state
|
|
210
|
+
if (task?.waiting === true || state === 'waiting') return 0
|
|
211
|
+
if (state === 'running') return 1
|
|
212
|
+
if (state === 'stopped') return 2
|
|
213
|
+
return 3
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Поднять идущее наверх, не теряя прежнего порядка. */
|
|
217
|
+
export function sortByLife(tasks) {
|
|
218
|
+
return (tasks ?? [])
|
|
219
|
+
.map((task, at) => ({ task, at }))
|
|
220
|
+
.sort((a, b) => liveRank(a.task) - liveRank(b.task) || a.at - b.at)
|
|
221
|
+
.map((x) => x.task)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Разложить задачи по проектам.
|
|
226
|
+
*
|
|
227
|
+
* Крупные группы впереди: проект с двадцатью задачами не должен теряться под
|
|
228
|
+
* однозадачными. При равенстве — по имени, чтобы порядок не плясал между
|
|
229
|
+
* обновлениями.
|
|
230
|
+
*
|
|
231
|
+
* @returns {Array<{repo: string, tasks: Array<object>}>}
|
|
232
|
+
*/
|
|
233
|
+
export function groupByRepo(tasks) {
|
|
234
|
+
const byRepo = new Map()
|
|
235
|
+
for (const task of tasks ?? []) {
|
|
236
|
+
const repo = typeof task?.repo === 'string' && task.repo !== '' ? task.repo : ''
|
|
237
|
+
if (!byRepo.has(repo)) byRepo.set(repo, [])
|
|
238
|
+
byRepo.get(repo).push(task)
|
|
239
|
+
}
|
|
240
|
+
return [...byRepo.entries()]
|
|
241
|
+
.map(([repo, list]) => ({ repo, tasks: list }))
|
|
242
|
+
.sort((a, b) => b.tasks.length - a.tasks.length || a.repo.localeCompare(b.repo))
|
|
243
|
+
}
|