@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/lib/sync.js ADDED
@@ -0,0 +1,369 @@
1
+ // Сверка задач с Gitea и применение автопереходов.
2
+ //
3
+ // Ни таймера, ни планировщика здесь нет: когда сверять — решает `lib/index.js`.
4
+ // Здесь только «сверить вот эти задачи и применить, что выведется». Поэтому
5
+ // модуль проверяется тестами с заглушкой клиента, без сети и без ожиданий.
6
+
7
+ import { deriveColumn, resolveTransition, branchOfTask, pullsOfTask, resolveConflict } from './transitions.js'
8
+ import { watchedRepos, shouldTake, archiveBefore, isWatched } from './intake.js'
9
+ import { colorsOfIssue } from './filters.js'
10
+ import { issueToTask, issueBornAt, issueAuthor } from './import.js'
11
+
12
+ /**
13
+ * Наблюдения по одному репозиторию.
14
+ *
15
+ * Читается ОДИН раз на репозиторий, а не на задачу: у десяти задач одного
16
+ * репозитория наблюдения общие, и десять одинаковых запросов — это просто
17
+ * десятикратная нагрузка на инстанс.
18
+ *
19
+ * Ошибка НЕ проглатывается. Неполное наблюдение хуже отсутствующего: не
20
+ * прочитались PR — доска решит, что их нет, и увезёт карточку из ревью назад в
21
+ * работу. Не прочитались ветки — сочтёт, что cleanup сделан, и объявит задачу
22
+ * завершённой. Поэтому репозиторий с неудачным чтением пропускается до
23
+ * следующей сверки целиком.
24
+ */
25
+ export async function readRepo({ gitea, owner, repo }) {
26
+ const [pulls, branches] = await Promise.all([
27
+ gitea.listPulls({ owner, repo, state: 'all' }),
28
+ gitea.listBranches({ owner, repo }),
29
+ ])
30
+ return { pulls, branches }
31
+ }
32
+
33
+ /**
34
+ * Что Gitea говорит про одну задачу.
35
+ *
36
+ * @returns {{column: string|undefined, branch: string|undefined, pull: object|undefined}}
37
+ */
38
+ export function observeTask(task, repoState, issue) {
39
+ const branch = branchOfTask(task, repoState?.branches)
40
+ const pulls = pullsOfTask(task, repoState?.pulls, branch)
41
+ const column = deriveColumn({ issue, pulls, branchExists: branch !== undefined })
42
+ const pull = pulls.find((p) => p?.merged === true || p?.merged_at)
43
+ ?? pulls.find((p) => p?.state === 'open')
44
+ return { column, branch, pull }
45
+ }
46
+
47
+ /** Пояснение к переходу — чтобы журнал читался, а не расшифровывался. */
48
+ export function explain({ column, branch, pull }) {
49
+ switch (column) {
50
+ case 'review': return pull?.number ? `PR #${pull.number} снят с WIP` : 'PR готов к ревью'
51
+ case 'deploy': return pull?.number ? `PR #${pull.number} влит` : 'PR влит'
52
+ case 'cleanup': return branch ? `issue закрыт, ветка ${branch} ещё есть` : 'issue закрыт'
53
+ case 'done': return 'issue закрыт, ветка удалена'
54
+ case 'in-progress': return branch ? `появилась ветка ${branch}` : 'работа началась'
55
+ default: return ''
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Поля, которые доска забирает у Gitea при каждой сверке.
61
+ *
62
+ * Метки и заголовок — источник правды в Gitea: переименовали issue или сменили
63
+ * `priority/high` на `priority/critical` — доска обязана это увидеть, иначе
64
+ * отбор по меткам работает по вчерашней разметке и молча врёт.
65
+ *
66
+ * **Тело НЕ забираем.** Его правит и человек — заметкой из чипа, — и сверка,
67
+ * затирающая тело каждые две минуты, съедала бы заметки. Тело обновляется по
68
+ * кнопке «обновить», где это осознанное решение человека.
69
+ *
70
+ * Возвращаем только изменившееся: лишняя запись поднимает `updatedAt`, а по
71
+ * нему разрешается спор о том, кто двигал задачу позже.
72
+ */
73
+ function remoteFields(task, issue) {
74
+ const patch = {}
75
+ if (typeof issue?.title === 'string' && issue.title !== task.title) patch.title = issue.title
76
+ if (Array.isArray(issue?.labels)) {
77
+ const names = issue.labels
78
+ .map((l) => (typeof l === 'string' ? l : l?.name))
79
+ .filter((n) => typeof n === 'string')
80
+ const same = names.length === (task.labels ?? []).length
81
+ && names.every((n) => (task.labels ?? []).includes(n))
82
+ if (!same) patch.labels = names
83
+ // Цвет метки в Gitea меняют реже имени, но меняют: сверка обязана
84
+ // подхватывать и его, иначе карточка красится по позавчерашней палитре.
85
+ const colors = colorsOfIssue(issue.labels)
86
+ const before = task.labelColors ?? {}
87
+ const sameColors = Object.keys(colors).length === Object.keys(before).length
88
+ && Object.entries(colors).every(([k, v]) => before[k] === v)
89
+ if (!sameColors) patch.labelColors = colors
90
+ }
91
+ // Дату заведения тоже подхватываем: карточки, приехавшие до этой правки,
92
+ // помнят день подхвата, и починить их может только сверка.
93
+ const born = issueBornAt(issue)
94
+ if (born !== undefined && born !== task.createdAt) patch.createdAt = born
95
+ const author = issueAuthor(issue)
96
+ if (author !== '' && author !== task.author) patch.author = author
97
+ return patch
98
+ }
99
+
100
+ /**
101
+ * Свести наблюдения и применить переход, если он нужен.
102
+ *
103
+ * Наблюдённые ветка и PR записываются в карточку независимо от того, случился
104
+ * перевод или нет: это наблюдение о задаче, полезное само по себе.
105
+ *
106
+ * Если обе стороны двигались с последней сверки, побеждает более позднее
107
+ * изменение, а проигравшее ОБЯЗАТЕЛЬНО попадает в журнал задачи. Синхронизация,
108
+ * тихо отменяющая правку человека, хуже отсутствующей: расхождение остаётся, а
109
+ * узнать о нём неоткуда.
110
+ *
111
+ * @returns {object|undefined} применённый переход либо undefined
112
+ */
113
+ export function applyObservation({ store, task, observation, issue, remoteUpdatedAt, now = Date.now() }) {
114
+ const patch = { syncedAt: now, ...remoteFields(task, issue) }
115
+ if (observation.branch !== undefined && observation.branch !== task.branch) patch.branch = observation.branch
116
+ store.updateTask(task.id, patch)
117
+
118
+ const verdict = resolveConflict({
119
+ localColumn: task.column,
120
+ remoteColumn: observation.column,
121
+ localUpdatedAt: task.updatedAt,
122
+ remoteUpdatedAt: Date.parse(remoteUpdatedAt ?? '') || 0,
123
+ syncedAt: task.syncedAt,
124
+ })
125
+
126
+ if (verdict.winner === 'local') {
127
+ // Доска победила, но если Gitea говорил другое — молчать нельзя.
128
+ if (verdict.overridden !== undefined) {
129
+ store.addTransition({
130
+ taskId: task.id,
131
+ fromCol: task.column,
132
+ toCol: task.column,
133
+ source: 'gitea',
134
+ detail: `расхождение: Gitea считает «${verdict.overridden}», доска правилась позже`,
135
+ })
136
+ }
137
+ return undefined
138
+ }
139
+ if (verdict.winner === 'none') return undefined
140
+
141
+ const move = resolveTransition(task.column, [
142
+ { column: verdict.column, source: 'gitea', detail: explain(observation) },
143
+ ])
144
+ if (move === undefined) return undefined
145
+
146
+ store.moveTask(task.id, { column: move.column })
147
+ store.addTransition({
148
+ taskId: task.id,
149
+ fromCol: task.column,
150
+ toCol: move.column,
151
+ source: move.source,
152
+ detail: verdict.overridden !== undefined
153
+ ? `${move.detail} (перекрыто: на доске было «${verdict.overridden}»)`
154
+ : move.detail,
155
+ })
156
+ return move
157
+ }
158
+
159
+ /**
160
+ * Сверить все задачи, за которыми стоит issue.
161
+ *
162
+ * Ошибка по одной задаче не останавливает остальные: недоступный репозиторий —
163
+ * причина пропустить его до следующей сверки, а не бросить всю доску.
164
+ *
165
+ * Отчёт называет НЕ ТОЛЬКО числа, но и причину первой неудачи: «сверено ноль
166
+ * из пяти» человеку ничего не говорит, а «репозиторий не найден» говорит всё.
167
+ *
168
+ * @returns {{checked: number, moved: number, failed: number,
169
+ * repos: number, reposFailed: number, problem: {where: string, message: string}|undefined}}
170
+ */
171
+ export async function syncAll({ gitea, store, logger, only, now = Date.now() }) {
172
+ const tasks = store.listWatchable().filter((t) => (only ? only(t) : true))
173
+ const byRepo = new Map()
174
+ for (const task of tasks) {
175
+ const key = `${task.owner}/${task.repo}`
176
+ if (!byRepo.has(key)) byRepo.set(key, { owner: task.owner, repo: task.repo, tasks: [] })
177
+ byRepo.get(key).tasks.push(task)
178
+ }
179
+
180
+ let checked = 0
181
+ let moved = 0
182
+ let failed = 0
183
+ let reposFailed = 0
184
+ // Первая беда важнее последней: при протухшем токене все репозитории
185
+ // отвалятся одинаково, и показывать надо причину, а не последний по счёту
186
+ // репозиторий.
187
+ let problem
188
+ const note = (where, error) => {
189
+ if (problem === undefined) problem = { where, message: String(error?.message ?? error ?? 'без причины') }
190
+ }
191
+
192
+ for (const group of byRepo.values()) {
193
+ let repoState
194
+ try {
195
+ repoState = await readRepo({ gitea, owner: group.owner, repo: group.repo })
196
+ } catch (error) {
197
+ failed += group.tasks.length
198
+ reposFailed += 1
199
+ note(`${group.owner}/${group.repo}`, error)
200
+ logger?.warn?.(`dsh-kanban: репозиторий ${group.owner}/${group.repo} не прочитан: ${error?.message}`)
201
+ continue
202
+ }
203
+
204
+ for (const task of group.tasks) {
205
+ checked += 1
206
+ try {
207
+ const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber })
208
+ const observation = observeTask(task, repoState, issue)
209
+ const applied = applyObservation({
210
+ store, task, observation, issue, remoteUpdatedAt: issue?.updated_at,
211
+ })
212
+ if (applied !== undefined) moved += 1
213
+ } catch (error) {
214
+ failed += 1
215
+ note(`${task.owner}/${task.repo}#${task.issueNumber}`, error)
216
+ logger?.warn?.(`dsh-kanban: задача ${task.id} не сверена: ${error?.message}`)
217
+ }
218
+ }
219
+ }
220
+
221
+ return { checked, moved, failed, repos: byRepo.size, reposFailed, problem, at: now }
222
+ }
223
+
224
+ /**
225
+ * Какие репозитории опрашивать.
226
+ *
227
+ * Названные поимённо — их и берём. Пустой список означает ВСЕ репозитории
228
+ * владельца: доска обязана показывать то, что есть в Gitea, а не то, что ей
229
+ * перечислили руками.
230
+ *
231
+ * Из всех берутся только те, где есть открытые задачи. Число приходит в том же
232
+ * ответе, и отсев бесплатен: полсотни запросов впустую каждые две минуты — это
233
+ * не осторожность, а расточительство.
234
+ *
235
+ * @returns {Promise<Array<{owner: string, repo: string}>>}
236
+ */
237
+ export async function resolveRepos({ gitea, config, owner }) {
238
+ const named = watchedRepos({ config, owner })
239
+ if (named.length > 0) return named
240
+ if (owner === undefined || owner === '') {
241
+ throw new Error('владелец не определён: назовите организацию в настройках')
242
+ }
243
+ const rows = await gitea.listOrgRepos({ owner })
244
+ return rows
245
+ .filter((r) => r.archived !== true && r.openIssues > 0)
246
+ .map((r) => ({ owner, repo: r.name }))
247
+ }
248
+
249
+ /**
250
+ * Забрать из отслеживаемых репозиториев задачи, которых на доске ещё нет.
251
+ *
252
+ * Отказ по одному репозиторию не останавливает остальные — как и в сверке:
253
+ * недоступный репозиторий это повод пропустить его до следующего прохода, а не
254
+ * бросить всю доску.
255
+ *
256
+ * @returns {{added: number, skipped: number, failed: number, problem: object|undefined}}
257
+ */
258
+ export async function intakeAll({ gitea, store, config, owner, logger, only, repos: given }) {
259
+ let added = 0
260
+ let skipped = 0
261
+ let failed = 0
262
+ let problem
263
+
264
+ // `only` сужает проход до одного репозитория: событие вебхука говорит про
265
+ // один, и полный обход означал бы обращение ко всем при каждом чихе в любом.
266
+ let repos
267
+ try {
268
+ repos = (given ?? await resolveRepos({ gitea, config, owner }))
269
+ .filter((pair) => (only ? only(pair) : true))
270
+ } catch (error) {
271
+ return {
272
+ added, skipped, failed: 1,
273
+ problem: { where: 'список репозиториев', message: String(error?.message ?? error) },
274
+ }
275
+ }
276
+
277
+ for (const pair of repos) {
278
+ let issues
279
+ try {
280
+ issues = await gitea.listIssues({ owner: pair.owner, repo: pair.repo, state: 'open', limit: 50 })
281
+ } catch (error) {
282
+ failed += 1
283
+ if (problem === undefined) {
284
+ problem = { where: `${pair.owner}/${pair.repo}`, message: String(error?.message ?? error) }
285
+ }
286
+ logger?.warn?.(`dsh-kanban: задачи из ${pair.owner}/${pair.repo} не забраны: ${error?.message}`)
287
+ continue
288
+ }
289
+
290
+ for (const issue of issues ?? []) {
291
+ const verdict = shouldTake({ store, owner: pair.owner, repo: pair.repo, issue })
292
+ if (!verdict.take) { skipped += 1; continue }
293
+ store.createTask(issueToTask(issue, {
294
+ // Подхват всегда идёт на проектную доску: импорт issue на простую
295
+ // осознанно отвергается (README «Две доски»), и параметра доски здесь
296
+ // нет — он был мёртвым.
297
+ board: 'main', column: 'backlog', owner: pair.owner, repo: pair.repo,
298
+ }))
299
+ added += 1
300
+ }
301
+ }
302
+
303
+ return { added, skipped, failed, problem }
304
+ }
305
+
306
+ /**
307
+ * Убрать в архив то, что достояло в «Выполнено» свой срок.
308
+ *
309
+ * Не удаляет: удаление — отдельное решение человека, и оно на доске уже есть.
310
+ * Архив обязан быть обратимым, иначе им не станут пользоваться из страха.
311
+ */
312
+ export function archiveOverdue({ store, config, now = Date.now() }) {
313
+ const before = archiveBefore({ now, afterDays: config?.archiveAfterDays })
314
+ if (before === undefined) return { archived: 0 }
315
+ const due = store.listArchivable(before)
316
+ for (const task of due) store.setArchived(task.id, now)
317
+ return { archived: due.length }
318
+ }
319
+
320
+ /**
321
+ * Состояние сверки для показа человеку.
322
+ *
323
+ * Живёт в памяти процесса, а не в базе: это положение текущего запуска, а не
324
+ * свойство задач. После перезапуска честнее сказать «сверки ещё не было», чем
325
+ * показать вчерашний успех как сегодняшний.
326
+ *
327
+ * Неудача НЕ затирает время последнего успеха. Иначе теряется ответ на главный
328
+ * вопрос — когда доска последний раз видела правду.
329
+ */
330
+ export function createSyncState() {
331
+ let okAt
332
+ let failedAt
333
+ let problem
334
+ let running = false
335
+
336
+ return {
337
+ started() { running = true },
338
+
339
+ /** Записать итог прохода. */
340
+ finished(report) {
341
+ running = false
342
+ if (report?.problem === undefined) {
343
+ okAt = report?.at ?? Date.now()
344
+ failedAt = undefined
345
+ problem = undefined
346
+ return
347
+ }
348
+ failedAt = report?.at ?? Date.now()
349
+ problem = report.problem
350
+ },
351
+
352
+ /** Сверка не начиналась вовсе — тоже беда, и своя. */
353
+ failedToStart(where, message) {
354
+ running = false
355
+ failedAt = Date.now()
356
+ problem = { where, message: String(message ?? 'без причины') }
357
+ },
358
+
359
+ snapshot() {
360
+ return {
361
+ running,
362
+ okAt,
363
+ failedAt,
364
+ problem,
365
+ state: running ? 'running' : problem !== undefined ? 'failed' : okAt !== undefined ? 'ok' : 'never',
366
+ }
367
+ },
368
+ }
369
+ }
package/lib/time.js ADDED
@@ -0,0 +1,45 @@
1
+ // Относительное время для доски.
2
+ //
3
+ // Формат относительный, потому что «третий день» читается мгновенно, а точная
4
+ // дата требует чтения и сравнения с сегодняшним числом. Скорость чтения тут и
5
+ // есть смысл: на доску смотрят, чтобы за секунду понять, что застряло.
6
+ //
7
+ // Модуль отдаёт числа и единицу, а не готовую строку: собирает и переводит её
8
+ // браузерная половина.
9
+
10
+ const MINUTE = 60_000
11
+ const HOUR = 60 * MINUTE
12
+ const DAY = 24 * HOUR
13
+
14
+ /**
15
+ * Насколько давно это было.
16
+ *
17
+ * Будущее (расхождение часов между машинами) не выдумываем «через N минут», а
18
+ * считаем настоящим моментом: показывать доске будущее незачем.
19
+ *
20
+ * @returns {{unit: 'now'|'min'|'hour'|'day', value: number}|undefined}
21
+ * `undefined` — отметки времени нет.
22
+ */
23
+ export function relativeParts(now, then) {
24
+ if (typeof then !== 'number' || !Number.isFinite(then) || then <= 0) return undefined
25
+ const delta = Math.max(0, now - then)
26
+ if (delta < MINUTE) return { unit: 'now', value: 0 }
27
+ if (delta < HOUR) return { unit: 'min', value: Math.floor(delta / MINUTE) }
28
+ if (delta < DAY) return { unit: 'hour', value: Math.floor(delta / HOUR) }
29
+ return { unit: 'day', value: Math.floor(delta / DAY) }
30
+ }
31
+
32
+ /**
33
+ * Пора ли тревожиться о молчании задачи.
34
+ *
35
+ * Отдельного опроса живости сессий не заводим: это второй источник правды и
36
+ * лишняя нагрузка. Смотрим на то, что и так знаем, — когда задачу последний
37
+ * раз трогали.
38
+ */
39
+ export function isStale({ now, updatedAt, sessionId, afterMinutes }) {
40
+ const limit = Number(afterMinutes)
41
+ if (!Number.isFinite(limit) || limit <= 0) return false
42
+ if (typeof sessionId !== 'string' || sessionId === '') return false
43
+ if (typeof updatedAt !== 'number' || !Number.isFinite(updatedAt)) return false
44
+ return now - updatedAt >= limit * MINUTE
45
+ }
@@ -0,0 +1,162 @@
1
+ // Автоперемещение карточек: вывод колонки из наблюдений и приоритет источников.
2
+ //
3
+ // Модуль чистый — ни сети, ни хранилища, ни харнесса. Наблюдения приходят
4
+ // готовыми, решение возвращается значением. Поэтому вся логика переходов
5
+ // проверяется тестами, а места, где она вызывается, остаются тонкими.
6
+
7
+ /**
8
+ * Приоритет источников. Без него «PR влит → deploy» через секунду перебивалось
9
+ * бы «ход завершился → in-progress», и карточка прыгала бы туда-сюда.
10
+ *
11
+ * Меньше число — сильнее источник.
12
+ */
13
+ const PRIORITY = { gitea: 0, tool: 1, session: 2, manual: 3 }
14
+
15
+ /** Pull request считается черновиком, пока с него не снят признак WIP. */
16
+ export function isDraftPull(pull) {
17
+ if (pull?.draft === true) return true
18
+ return /^\s*(wip|draft)\b[:\s-]/i.test(String(pull?.title ?? ''))
19
+ }
20
+
21
+ /**
22
+ * Колонка, которую диктуют наблюдения в Gitea.
23
+ *
24
+ * Порядок проверок идёт от самого позднего состояния к самому раннему: задача
25
+ * не может «вернуться» в in-progress только потому, что ветка ещё цела, если
26
+ * issue уже закрыт.
27
+ *
28
+ * WIP PR намеренно НЕ двигает карточку в review: он открывается рано и означает
29
+ * «работа идёт», а не «смотрите».
30
+ *
31
+ * @param {object} obs наблюдения
32
+ * @param {object} [obs.issue] issue из Gitea (`state`)
33
+ * @param {Array} [obs.pulls] pull request-ы, связанные с задачей
34
+ * @param {boolean} [obs.branchExists] существует ли ветка задачи
35
+ * @returns {string|undefined} колонка либо undefined, если наблюдения молчат
36
+ */
37
+ export function deriveColumn(obs) {
38
+ const issueClosed = obs?.issue?.state === 'closed'
39
+ const pulls = Array.isArray(obs?.pulls) ? obs.pulls : []
40
+ const branchExists = obs?.branchExists === true
41
+
42
+ // Задача завершена, только когда cleanup сделан: issue закрыт И ветки нет.
43
+ // Пока ветка цела, работа не закончена, сколько бы issue ни был закрыт.
44
+ if (issueClosed && !branchExists) return 'done'
45
+ if (issueClosed) return 'cleanup'
46
+
47
+ if (pulls.some((p) => p?.merged === true || p?.merged_at)) return 'deploy'
48
+ if (pulls.some((p) => p?.state === 'open' && !isDraftPull(p))) return 'review'
49
+ if (branchExists) return 'in-progress'
50
+ return undefined
51
+ }
52
+
53
+ /**
54
+ * Выбрать перевод из нескольких предложений разных источников.
55
+ *
56
+ * Предложение, совпадающее с текущей колонкой, отбрасывается: перевод в ту же
57
+ * колонку — не перевод, а лишняя строка в журнале.
58
+ *
59
+ * @param {string} current текущая колонка
60
+ * @param {Array<{column: string, source: string, detail?: string}>} candidates
61
+ * @returns {{column: string, source: string, detail: string}|undefined}
62
+ */
63
+ export function resolveTransition(current, candidates) {
64
+ const usable = (candidates ?? [])
65
+ .filter((c) => c && typeof c.column === 'string' && c.column !== '' && c.column !== current)
66
+ .filter((c) => PRIORITY[c.source] !== undefined)
67
+ if (usable.length === 0) return undefined
68
+ const best = usable.reduce((a, b) => (PRIORITY[a.source] <= PRIORITY[b.source] ? a : b))
69
+ return { column: best.column, source: best.source, detail: best.detail ?? '' }
70
+ }
71
+
72
+ /**
73
+ * Нужен ли человек по событию сессии.
74
+ *
75
+ * Колонки «Блокировано» в согласованном наборе нет, поэтому запрос разрешения и
76
+ * вопрос агента не двигают карточку, а поднимают на ней признак ожидания.
77
+ * Двигать в несуществующую колонку нельзя, а прятать «агент стоит и ждёт» —
78
+ * значит терять главное, ради чего смотрят на доску.
79
+ *
80
+ * @returns {boolean|undefined} true — поднять, false — снять, undefined — событие не про это
81
+ */
82
+ export function waitingFromEvent(type) {
83
+ switch (type) {
84
+ case 'approval/asked':
85
+ case 'question/requested':
86
+ return true
87
+ case 'approval/decided':
88
+ case 'turn/start':
89
+ case 'user/message':
90
+ return false
91
+ default:
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ /** Имя ветки, по которому задача узнаёт свою: то, что наблюдалось, либо ничего. */
97
+ export function branchOfTask(task, branches) {
98
+ const known = typeof task?.branch === 'string' && task.branch !== '' ? task.branch : undefined
99
+ const names = (branches ?? []).map((b) => (typeof b === 'string' ? b : b?.name)).filter(Boolean)
100
+ if (known !== undefined) return names.includes(known) ? known : undefined
101
+
102
+ // Ветку задачи заводит агент и называет как хочет, но номер issue в имени —
103
+ // общее правило воркфлоу. Ищем по номеру, а не по угаданному шаблону.
104
+ if (typeof task?.issueNumber !== 'number') return undefined
105
+ const marker = String(task.issueNumber)
106
+ return names.find((n) => new RegExp(`(^|[^0-9])${marker}([^0-9]|$)`).test(n))
107
+ }
108
+
109
+ /** Pull request-ы, относящиеся к задаче: по ветке либо по упоминанию issue. */
110
+ export function pullsOfTask(task, pulls, branch) {
111
+ const number = typeof task?.issueNumber === 'number' ? task.issueNumber : undefined
112
+ return (pulls ?? []).filter((p) => {
113
+ if (branch !== undefined && p?.head?.ref === branch) return true
114
+ if (number === undefined) return false
115
+ const haystack = `${p?.title ?? ''} ${p?.body ?? ''}`
116
+ return new RegExp(`#${number}([^0-9]|$)`).test(haystack)
117
+ })
118
+ }
119
+
120
+ /**
121
+ * Кто победил, когда обе стороны изменились с последней сверки.
122
+ *
123
+ * Правило простое: побеждает более позднее изменение. Важно другое —
124
+ * проигравшая сторона НЕ исчезает молча, а возвращается в поле `overridden`,
125
+ * чтобы вызывающий записал её в журнал задачи. Синхронизация, которая тихо
126
+ * отменяет правку человека, хуже синхронизации, которой нет: расхождение
127
+ * остаётся, а узнать о нём неоткуда.
128
+ *
129
+ * @param {object} state
130
+ * @param {string} state.localColumn колонка на доске
131
+ * @param {string} [state.remoteColumn] колонка, выведенная из Gitea
132
+ * @param {number} state.localUpdatedAt когда правили на доске
133
+ * @param {number} state.remoteUpdatedAt когда правили в Gitea
134
+ * @param {number} state.syncedAt время последней удачной сверки
135
+ * @returns {{winner: 'local'|'remote'|'none', column?: string, overridden?: string}}
136
+ */
137
+ export function resolveConflict({ localColumn, remoteColumn, localUpdatedAt, remoteUpdatedAt, syncedAt }) {
138
+ if (remoteColumn === undefined || remoteColumn === null || remoteColumn === '') return { winner: 'local' }
139
+ if (remoteColumn === localColumn) return { winner: 'none' }
140
+
141
+ const since = Number(syncedAt) || 0
142
+
143
+ // До первой сверки «изменилось с прошлого раза» не имеет смысла: прошлого
144
+ // раза не было. Карточку только что завели или импортировали, защищать в ней
145
+ // нечего, и первая сверка обязана принять положение дел, а не спорить с ним.
146
+ const firstSync = since === 0
147
+ const localChanged = !firstSync && (Number(localUpdatedAt) || 0) > since
148
+ const remoteChanged = firstSync || (Number(remoteUpdatedAt) || 0) > since
149
+
150
+ if (localChanged && remoteChanged) {
151
+ // Обе стороны двигались. Побеждает более позднее, проигравшее называется.
152
+ if ((Number(localUpdatedAt) || 0) > (Number(remoteUpdatedAt) || 0)) {
153
+ return { winner: 'local', overridden: remoteColumn }
154
+ }
155
+ return { winner: 'remote', column: remoteColumn, overridden: localColumn }
156
+ }
157
+
158
+ // Двигалась только доска — Gitea не имеет права тянуть карточку назад.
159
+ if (localChanged) return { winner: 'local' }
160
+
161
+ return { winner: 'remote', column: remoteColumn }
162
+ }
package/lib/webhook.js ADDED
@@ -0,0 +1,54 @@
1
+ // Приём событий Gitea.
2
+ //
3
+ // Вебхук даёт мгновенную сверку вместо ожидания следующего опроса. Опрос при
4
+ // этом остаётся: вебхук может быть не настроен, недоступен снаружи или просто
5
+ // потерян по дороге. Мгновенность — ускорение, а не замена надёжности.
6
+ //
7
+ // Модуль чистый: разбор и проверка подписи без сети и без хранилища.
8
+
9
+ import { createHmac, timingSafeEqual } from 'node:crypto'
10
+
11
+ /**
12
+ * Проверить подпись доставки.
13
+ *
14
+ * Сравнение постоянного времени: обычное посимвольное сравнение по времени
15
+ * ответа выдаёт, сколько первых байтов угадано, и подпись подбирается за
16
+ * считанные попытки.
17
+ *
18
+ * @param {string} secret общий секрет
19
+ * @param {string|Buffer} body сырое тело запроса
20
+ * @param {string} signature значение заголовка X-Gitea-Signature
21
+ */
22
+ export function verifySignature(secret, body, signature) {
23
+ if (!secret) return false
24
+ const provided = String(signature ?? '').trim().toLowerCase()
25
+ if (!/^[0-9a-f]{64}$/.test(provided)) return false
26
+ const expected = createHmac('sha256', secret).update(body ?? '').digest('hex')
27
+ return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(provided, 'hex'))
28
+ }
29
+
30
+ /**
31
+ * Что за задачу затронуло событие.
32
+ *
33
+ * Возвращается адрес задачи, а не готовое решение: решать, куда двинуть
34
+ * карточку, — дело сверки, у которой перед глазами полная картина. Вебхук
35
+ * только говорит «вот здесь что-то произошло, посмотри сейчас, а не потом».
36
+ *
37
+ * @returns {{owner: string, repo: string, issueNumber?: number}|undefined}
38
+ */
39
+ export function parseEvent(payload) {
40
+ const repository = payload?.repository
41
+ const owner = repository?.owner?.login ?? repository?.owner?.username
42
+ const repo = repository?.name
43
+ if (typeof owner !== 'string' || owner === '' || typeof repo !== 'string' || repo === '') return undefined
44
+
45
+ // Номер issue приходит по-разному в зависимости от события; PR при этом сам
46
+ // является issue с тем же номером.
47
+ const number = payload?.issue?.number
48
+ ?? payload?.pull_request?.number
49
+ ?? (typeof payload?.number === 'number' ? payload.number : undefined)
50
+
51
+ const out = { owner, repo }
52
+ if (typeof number === 'number') out.issueNumber = number
53
+ return out
54
+ }