@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/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
|
+
}
|