@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.
@@ -0,0 +1,161 @@
1
+ // Разбор тела задачи в дерево узлов.
2
+ //
3
+ // Тело приходит из Gitea и пишет его кто угодно. Поэтому разбор отдаёт ДЕРЕВО,
4
+ // а не строку HTML: браузерная половина собирает его элементами React, и
5
+ // подстановка чужой разметки становится невозможной по построению, а не по
6
+ // бдительности того, кто вспомнит про экранирование.
7
+ //
8
+ // Полноценный markdown здесь не нужен и не будет: заголовки, списки, выделение,
9
+ // код и ссылки покрывают почти всякое тело issue. Всё непонятое остаётся
10
+ // текстом как есть — это честнее, чем угадывать.
11
+
12
+ /** Ссылки открываем только по этим схемам: `javascript:` в теле — тот же обход. */
13
+ const SAFE_LINK = /^https?:\/\//i
14
+
15
+ /**
16
+ * Разобрать тело в список блоков.
17
+ *
18
+ * @returns {Array<object>} блоки вида
19
+ * `{kind:'heading', level, spans}`, `{kind:'para', spans}`,
20
+ * `{kind:'list', ordered, items:[spans]}`, `{kind:'code', text}`,
21
+ * `{kind:'quote', spans}`, `{kind:'rule'}`
22
+ */
23
+ export function parseBody(text) {
24
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n')
25
+ const blocks = []
26
+ let i = 0
27
+
28
+ while (i < lines.length) {
29
+ const line = lines[i]
30
+
31
+ if (line.trim() === '') { i += 1; continue }
32
+
33
+ // Блок кода: всё внутри дословно, включая то, что похоже на разметку.
34
+ const fence = /^\s*```(.*)$/.exec(line)
35
+ if (fence !== null) {
36
+ const body = []
37
+ i += 1
38
+ while (i < lines.length && !/^\s*```/.test(lines[i])) { body.push(lines[i]); i += 1 }
39
+ i += 1
40
+ blocks.push({ kind: 'code', lang: fence[1].trim(), text: body.join('\n') })
41
+ continue
42
+ }
43
+
44
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { blocks.push({ kind: 'rule' }); i += 1; continue }
45
+
46
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
47
+ if (heading !== null) {
48
+ blocks.push({ kind: 'heading', level: heading[1].length, spans: parseSpans(heading[2]) })
49
+ i += 1
50
+ continue
51
+ }
52
+
53
+ if (/^\s*>\s?/.test(line)) {
54
+ const body = []
55
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
56
+ body.push(lines[i].replace(/^\s*>\s?/, ''))
57
+ i += 1
58
+ }
59
+ blocks.push({ kind: 'quote', spans: parseSpans(body.join(' ')) })
60
+ continue
61
+ }
62
+
63
+ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line)
64
+ const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line)
65
+ if (bullet !== null || numbered !== null) {
66
+ const ordered = numbered !== null
67
+ const items = []
68
+ while (i < lines.length) {
69
+ const m = ordered
70
+ ? /^\s*\d+[.)]\s+(.*)$/.exec(lines[i])
71
+ : /^\s*[-*+]\s+(.*)$/.exec(lines[i])
72
+ if (m === null) break
73
+ items.push(parseSpans(m[1]))
74
+ i += 1
75
+ }
76
+ blocks.push({ kind: 'list', ordered, items })
77
+ continue
78
+ }
79
+
80
+ // Абзац: строки до пустой склеиваются, как это делает markdown.
81
+ const body = []
82
+ while (i < lines.length && lines[i].trim() !== ''
83
+ && !/^\s*(#{1,6})\s+/.test(lines[i])
84
+ && !/^\s*[-*+]\s+/.test(lines[i])
85
+ && !/^\s*\d+[.)]\s+/.test(lines[i])
86
+ && !/^\s*```/.test(lines[i])
87
+ && !/^\s*>\s?/.test(lines[i])) {
88
+ body.push(lines[i])
89
+ i += 1
90
+ }
91
+ blocks.push({ kind: 'para', spans: parseSpans(body.join(' ')) })
92
+ }
93
+
94
+ return blocks
95
+ }
96
+
97
+ /**
98
+ * Разобрать строку на куски: обычный текст, код, выделение, ссылка.
99
+ *
100
+ * @returns {Array<{kind: 'text'|'code'|'strong'|'em'|'link', text: string, href?: string}>}
101
+ */
102
+ export function parseSpans(text) {
103
+ const out = []
104
+ const src = String(text ?? '')
105
+ // Код первым: внутри обратных кавычек разметки нет, там всё дословно.
106
+ const re = /(`[^`]+`)|(\[[^\]]+\]\([^)\s]+\))|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g
107
+ let at = 0
108
+ let m = re.exec(src)
109
+
110
+ while (m !== null) {
111
+ if (m.index > at) out.push({ kind: 'text', text: src.slice(at, m.index) })
112
+ const piece = m[0]
113
+
114
+ if (piece.startsWith('`')) {
115
+ out.push({ kind: 'code', text: piece.slice(1, -1) })
116
+ } else if (piece.startsWith('[')) {
117
+ const cut = piece.indexOf('](')
118
+ const label = piece.slice(1, cut)
119
+ const href = piece.slice(cut + 2, -1)
120
+ // Небезопасная схема — не ссылка, а текст. Молча выбрасывать нельзя:
121
+ // человек должен видеть, что там было написано.
122
+ if (SAFE_LINK.test(href)) out.push({ kind: 'link', text: label, href })
123
+ else out.push({ kind: 'text', text: piece })
124
+ } else if (piece.startsWith('**') || piece.startsWith('__')) {
125
+ out.push({ kind: 'strong', text: piece.slice(2, -2) })
126
+ } else {
127
+ out.push({ kind: 'em', text: piece.slice(1, -1) })
128
+ }
129
+
130
+ at = m.index + piece.length
131
+ m = re.exec(src)
132
+ }
133
+
134
+ if (at < src.length) out.push({ kind: 'text', text: src.slice(at) })
135
+ return mergeText(out)
136
+ }
137
+
138
+ /**
139
+ * Склеить соседние куски текста.
140
+ *
141
+ * Разбор дробит строку там, где нашёл разметку и передумал: `[клик](javascript:…)`
142
+ * не стал ссылкой, но оставил после себя два куска. Дробление наружу не
143
+ * выпускаем — оно не значит ничего, а читателю мешает.
144
+ */
145
+ function mergeText(spans) {
146
+ const out = []
147
+ for (const span of spans) {
148
+ const last = out[out.length - 1]
149
+ if (span.kind === 'text' && last !== undefined && last.kind === 'text') {
150
+ last.text += span.text
151
+ continue
152
+ }
153
+ out.push(span)
154
+ }
155
+ return out
156
+ }
157
+
158
+ /** Безопасна ли ссылка. Наружу — чтобы браузерная половина не решала это заново. */
159
+ export function isSafeHref(href) {
160
+ return SAFE_LINK.test(String(href ?? ''))
161
+ }
package/lib/order.js ADDED
@@ -0,0 +1,66 @@
1
+ // Дробный индекс порядка карточек.
2
+ //
3
+ // Наивный подход хранит целочисленный индекс, и перетаскивание карточки в
4
+ // начало колонки из десяти строк требует переписать все десять. Здесь порядок
5
+ // хранится строкой, а новое значение выдаётся МЕЖДУ двумя соседями, поэтому
6
+ // перетаскивание — всегда один UPDATE одной строки, сколько бы карточек ни
7
+ // было в колонке.
8
+ //
9
+ // Реализация строковая намеренно. Вариант на числах с плавающей точкой
10
+ // проходит поверхностную проверку и разваливается примерно на пятидесятой
11
+ // вставке в одно и то же место, когда кончается разрядность.
12
+ //
13
+ // Ключ читается как дробь после подразумеваемого «0.»: сравнение обычное
14
+ // лексикографическое. Ключ никогда не заканчивается первым символом алфавита —
15
+ // иначе следующее деление слева стало бы невозможным.
16
+
17
+ /** Алфавит в порядке возрастания кодов символов. */
18
+ const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz'
19
+ const FIRST = DIGITS[0]
20
+
21
+ /** Значение строго между `a` и `b`; `b === undefined` означает «до конца». */
22
+ function midpoint(a, b) {
23
+ if (b !== undefined && a >= b) {
24
+ throw new Error(`порядок нарушен: ${JSON.stringify(a)} не меньше ${JSON.stringify(b)}`)
25
+ }
26
+ if (a.endsWith(FIRST) || (b !== undefined && b.endsWith(FIRST))) {
27
+ throw new Error('ключ порядка не может заканчиваться первым символом алфавита')
28
+ }
29
+
30
+ if (b !== undefined) {
31
+ // Общий префикс переносится в результат как есть, деление продолжается на
32
+ // остатке — иначе для близких соседей пришлось бы наращивать длину с нуля.
33
+ let n = 0
34
+ while ((a[n] ?? FIRST) === b[n]) n += 1
35
+ if (n > 0) return b.slice(0, n) + midpoint(a.slice(n), b.slice(n))
36
+ }
37
+
38
+ const from = a === '' ? 0 : DIGITS.indexOf(a[0])
39
+ const to = b === undefined ? DIGITS.length : DIGITS.indexOf(b[0])
40
+ if (from < 0 || to < 0) throw new Error('ключ порядка содержит символ вне алфавита')
41
+
42
+ if (to - from > 1) {
43
+ return DIGITS[Math.round(0.5 * (from + to))]
44
+ }
45
+ if (b !== undefined && b.length > 1) {
46
+ return b.slice(0, 1)
47
+ }
48
+ // Соседние символы: занимаем разряд `a` и делим следующий.
49
+ return DIGITS[from] + midpoint(a.slice(1), undefined)
50
+ }
51
+
52
+ /**
53
+ * Ключ порядка строго между двумя соседями.
54
+ *
55
+ * Оба соседа необязательны: без обоих выдаётся стартовое значение, без правого
56
+ * — значение после `a`, без левого — значение перед `b`. Результат всегда
57
+ * можно снова разделить.
58
+ *
59
+ * @param {string} [a] ключ карточки слева
60
+ * @param {string} [b] ключ карточки справа
61
+ * @returns {string} ключ строго между ними
62
+ * @throws если `a` не меньше `b` либо ключ содержит символ вне алфавита
63
+ */
64
+ export function keyBetween(a, b) {
65
+ return midpoint(a ?? '', b ?? undefined)
66
+ }
@@ -0,0 +1,88 @@
1
+ // Канбан → Gitea: закрытие issue.
2
+ //
3
+ // Отправка идёт очередью, а не прямо из обработчика переноса. Причина простая:
4
+ // Gitea может быть недоступен, а доска обязана двигать карточки и при
5
+ // недоступном Gitea. Перенос, ждущий сети, превратил бы доску в заложника
6
+ // чужого времени ответа.
7
+ //
8
+ // Неудачная отправка не теряется и не молчит: она повторяется, а после
9
+ // нескольких неудач попадает в журнал задачи как проблема. Молча съеденное
10
+ // расхождение — худшее, что может сделать синхронизация.
11
+ //
12
+ // Меток колонок доска НЕ ставит. Она их никогда и не читала: колонка выводится
13
+ // из состояния issue, ветки и pull request, а метка лишь пересказывала в Gitea
14
+ // то, что там уже написано. Взамен доска не трогает чужую разметку вовсе.
15
+
16
+ export function planOutbound(task, column, config) {
17
+ if (typeof task?.issueNumber !== 'number') return []
18
+ // Закрытие issue — единственное, что доска сообщает Gitea: это факт, а не
19
+ // пересказ фактов, которые в issue и так видны.
20
+ return column === 'done'
21
+ ? [{ kind: 'close', owner: task.owner, repo: task.repo, index: task.issueNumber }]
22
+ : []
23
+ }
24
+
25
+ /**
26
+ * Очередь отправки с повторами.
27
+ *
28
+ * @param {object} options
29
+ * @param {object} options.gitea клиент
30
+ * @param {object} options.store хранилище — туда пишется отчёт о неудаче
31
+ * @param {number} [options.maxAttempts] после скольких неудач сдаться и записать
32
+ */
33
+ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
34
+ const queue = []
35
+
36
+ return {
37
+ size: () => queue.length,
38
+
39
+ push(taskId, ops) {
40
+ for (const op of ops) queue.push({ taskId, op, attempts: 0 })
41
+ },
42
+
43
+ /**
44
+ * Попробовать отправить всё накопленное.
45
+ *
46
+ * Успех убирает операцию, неудача возвращает её в очередь до предела
47
+ * попыток. Исчерпав попытки, операция НЕ исчезает молча: о ней пишется в
48
+ * журнал задачи, чтобы расхождение с Gitea было видно человеку.
49
+ */
50
+ async flush() {
51
+ const pending = queue.splice(0)
52
+ let sent = 0
53
+ let retried = 0
54
+ let dropped = 0
55
+
56
+ for (const item of pending) {
57
+ try {
58
+ if (item.op.kind === 'close') {
59
+ await gitea.closeIssue({ owner: item.op.owner, repo: item.op.repo, index: item.op.index })
60
+ }
61
+ sent += 1
62
+ } catch (error) {
63
+ item.attempts += 1
64
+ if (item.attempts < maxAttempts) {
65
+ queue.push(item)
66
+ retried += 1
67
+ continue
68
+ }
69
+ dropped += 1
70
+ logger?.warn?.(`dsh-kanban: отправка в Gitea не удалась окончательно: ${error?.message}`)
71
+ try {
72
+ const task = store.getTask(item.taskId)
73
+ if (task !== undefined) {
74
+ store.addTransition({
75
+ taskId: item.taskId,
76
+ fromCol: task.column,
77
+ toCol: task.column,
78
+ source: 'gitea',
79
+ detail: `не отправлено в Gitea после ${item.attempts} попыток: ${item.op.kind}`,
80
+ })
81
+ }
82
+ } catch { /* журнал не должен ронять отправку */ }
83
+ }
84
+ }
85
+ return { sent, retried, dropped }
86
+ },
87
+ }
88
+ }
package/lib/plan.js ADDED
@@ -0,0 +1,153 @@
1
+ // План работы агента и состояние задачи.
2
+ //
3
+ // План получаем ПРЯМО — своим инструментом, а не разбором сообщений агента:
4
+ // разбор это гадание, а инструмент `todo_list` принадлежит чужому плагину, и
5
+ // опираться на его события значит завести зависимость.
6
+ //
7
+ // Состояние тоже факт, а не догадка по таймауту. Порог тишины в настройки не
8
+ // выносим: он был бы догадкой там, где есть точный признак.
9
+
10
+ /** Пункты плана дальше этого числа не храним: план — не журнал. */
11
+ export const MAX_ITEMS = 50
12
+
13
+ /** Длиннее этого пункт обрезаем: он едет на карточку в одну строку. */
14
+ export const MAX_ITEM_LENGTH = 120
15
+
16
+ const EMPTY = { items: [], current: 0 }
17
+
18
+ /** Приведение пункта к строке разумной длины. */
19
+ function cleanItem(value) {
20
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim()
21
+ return text.length > MAX_ITEM_LENGTH ? text.slice(0, MAX_ITEM_LENGTH - 1) + '…' : text
22
+ }
23
+
24
+ /**
25
+ * Разобрать план из хранилища.
26
+ *
27
+ * Испорченное поле не должно ронять чтение всей доски, поэтому любой сбой
28
+ * разбора превращается в пустой план.
29
+ */
30
+ export function parsePlan(raw) {
31
+ if (typeof raw !== 'string' || raw === '') return EMPTY
32
+ try {
33
+ const value = JSON.parse(raw)
34
+ const items = Array.isArray(value?.items)
35
+ ? value.items.map(cleanItem).filter((t) => t !== '').slice(0, MAX_ITEMS)
36
+ : []
37
+ return { items, current: clampCurrent(value?.current, items.length) }
38
+ } catch {
39
+ return EMPTY
40
+ }
41
+ }
42
+
43
+ export function serializePlan(plan) {
44
+ return JSON.stringify({ items: plan.items, current: plan.current })
45
+ }
46
+
47
+ /**
48
+ * Указатель на пункт в работе.
49
+ *
50
+ * Ноль означает «плана нет либо он ещё не начат»; значение больше длины плана —
51
+ * «всё сделано», и удерживать его на последнем пункте нельзя: тогда законченный
52
+ * план вечно показывал бы последний пункт как текущий.
53
+ */
54
+ function clampCurrent(value, total) {
55
+ const n = Number(value)
56
+ if (!Number.isFinite(n) || n < 0) return 0
57
+ return Math.min(Math.floor(n), total + 1)
58
+ }
59
+
60
+ /**
61
+ * Наложить правку инструмента на прежний план.
62
+ *
63
+ * Пункты необязательны: агент публикует план один раз, а дальше двигает только
64
+ * указатель. Требовать список на каждом шаге значило бы гонять его целиком ради
65
+ * одного числа.
66
+ */
67
+ export function applyPlan(previous, patch) {
68
+ const prev = previous ?? EMPTY
69
+ const items = Array.isArray(patch?.items)
70
+ ? patch.items.map(cleanItem).filter((t) => t !== '').slice(0, MAX_ITEMS)
71
+ : prev.items
72
+ const current = patch?.current === undefined || patch?.current === null
73
+ ? clampCurrent(prev.current, items.length)
74
+ : clampCurrent(patch.current, items.length)
75
+ return { items, current }
76
+ }
77
+
78
+ /**
79
+ * Что показать о плане.
80
+ *
81
+ * Числа и текст пункта отдаём отдельно: строку «3 из 7» собирает браузер, он
82
+ * же её и переводит.
83
+ *
84
+ * @returns {{total: number, done: number, current: number, text: string}|undefined}
85
+ */
86
+ export function planProgress(plan) {
87
+ const items = plan?.items ?? []
88
+ if (items.length === 0) return undefined
89
+ const current = clampCurrent(plan?.current, items.length)
90
+ // Всё, что до текущего пункта, считается сделанным. Модель линейная: агент
91
+ // двигает одно число, а не ведёт список галочек.
92
+ const done = current === 0 ? 0 : Math.min(current - 1, items.length)
93
+ const text = current >= 1 && current <= items.length ? items[current - 1] : ''
94
+ return { total: items.length, done, current, text }
95
+ }
96
+
97
+ /** Отметки пунктов для окна карточки. */
98
+ export function planItems(plan) {
99
+ const items = plan?.items ?? []
100
+ const current = clampCurrent(plan?.current, items.length)
101
+ return items.map((text, i) => ({
102
+ text,
103
+ done: current > i + 1,
104
+ active: current === i + 1,
105
+ }))
106
+ }
107
+
108
+ /**
109
+ * Состояние задачи.
110
+ *
111
+ * `stopped` — не догадка по тишине, а факт: у задачи записана сессия, но живого
112
+ * агента за ней нет. Так выглядит прерванная работа после перезапуска харнесса.
113
+ *
114
+ * `queued` — задача отдана сессии, но агент до неё не дошёл: она ждёт своей
115
+ * очереди. Выводится из отметки постановки, а не хранится отдельным полем.
116
+ *
117
+ * @param {{task: object, live: 'running'|'idle'|undefined}} args
118
+ * `live` — состояние живого агента; `undefined` означает, что агента нет.
119
+ */
120
+ export function taskState({ task, live }) {
121
+ const sessionId = typeof task?.sessionId === 'string' ? task.sessionId : ''
122
+ if (sessionId === '') return 'none'
123
+ // Задача отдана агенту, но он до неё ещё не дошёл. Без этого состояния
124
+ // десять карточек в бэклоге ничем не отличаются от нетронутых, и через час
125
+ // никто не вспомнит, какие уже отданы.
126
+ if (typeof task?.queuedAt === 'number' && task.queuedAt > 0) return 'queued'
127
+ // Ожидание человека важнее хода: агент технически «работает», но без ответа
128
+ // не сдвинется, и именно это надо показать.
129
+ if (task?.waiting === true) return 'waiting'
130
+ if (live === undefined) return 'stopped'
131
+ return live === 'running' ? 'running' : 'idle'
132
+ }
133
+
134
+ /**
135
+ * Номер задачи в очереди своей сессии.
136
+ *
137
+ * Считается на лету, а не хранится: агент берёт задачи одну за другой, и у
138
+ * оставшихся третий сам становится вторым. Хранимый номер пришлось бы
139
+ * пересчитывать, и однажды его бы забыли.
140
+ *
141
+ * Порядок — по отметке постановки, а НЕ по положению карточки в колонке.
142
+ * Перетаскивание мышью — про то, как задачи лежат; очередь — про то, в каком
143
+ * порядке их получит агент, и менять второе первым нельзя.
144
+ *
145
+ * @returns {number|undefined} место начиная с единицы
146
+ */
147
+ export function queuePosition(task, sameSession) {
148
+ const at = task?.queuedAt
149
+ if (typeof at !== 'number' || at <= 0) return undefined
150
+ const ahead = (sameSession ?? [])
151
+ .filter((x) => typeof x?.queuedAt === 'number' && x.queuedAt > 0 && x.queuedAt < at)
152
+ return ahead.length + 1
153
+ }