@goodandready/dsh-kanban 0.2.19 → 0.2.20
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/board-tool.js +18 -48
- package/lib/client.js +85 -176
- package/lib/commands.js +21 -38
- package/lib/config.js +29 -54
- package/lib/cron.js +13 -13
- package/lib/events.js +1 -1
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +51 -109
- package/lib/github.js +27 -31
- package/lib/import.js +46 -67
- package/lib/index.js +66 -132
- package/lib/intake.js +18 -26
- package/lib/launcher.js +100 -167
- package/lib/lifecycle.js +11 -17
- package/lib/markdown.js +16 -23
- package/lib/metrics.js +23 -25
- package/lib/order.js +25 -28
- package/lib/outbound.js +25 -26
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +86 -145
- package/lib/store.js +59 -77
- package/lib/sync.js +56 -98
- package/lib/time.js +8 -17
- package/lib/transitions.js +36 -57
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +57 -57
- package/lib/worktree.js +58 -58
- package/package.json +1 -1
package/lib/sync.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Task synchronization with Gitea and automated column transitions.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Timing and scheduling are managed by `lib/index.js`.
|
|
4
|
+
// This module provides the pure synchronization logic, testable with mock clients
|
|
5
|
+
// without network dependencies or wait timers.
|
|
6
6
|
|
|
7
7
|
import { deriveColumn, resolveTransition, branchOfTask, pullsOfTask, resolveConflict } from './transitions.js'
|
|
8
8
|
import { watchedRepos, shouldTake, archiveBefore, isWatched } from './intake.js'
|
|
@@ -10,9 +10,9 @@ import { colorsOfIssue } from './filters.js'
|
|
|
10
10
|
import { issueToTask, issueBornAt, issueAuthor, issueAssignee, issueMilestone } from './import.js'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* Execute an async operation with exponential backoff on network failures.
|
|
14
14
|
* @template T
|
|
15
|
-
* @param {() => Promise<T>} fn
|
|
15
|
+
* @param {() => Promise<T>} fn async operation
|
|
16
16
|
* @param {{retries?: number, baseMs?: number, maxMs?: number}} [opts]
|
|
17
17
|
* @returns {Promise<T>}
|
|
18
18
|
*/
|
|
@@ -33,17 +33,11 @@ export async function withBackoff(fn, { retries = 2, baseMs = 50, maxMs = 500 }
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
36
|
+
* Observations for a single repository (pull requests and branches).
|
|
37
37
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* Ошибка НЕ проглатывается. Неполное наблюдение хуже отсутствующего: не
|
|
43
|
-
* прочитались PR — доска решит, что их нет, и увезёт карточку из ревью назад в
|
|
44
|
-
* работу. Не прочитались ветки — сочтёт, что cleanup сделан, и объявит задачу
|
|
45
|
-
* завершённой. Поэтому репозиторий с неудачным чтением пропускается до
|
|
46
|
-
* следующей сверки целиком.
|
|
38
|
+
* Read once per repository rather than per task to minimize API calls.
|
|
39
|
+
* Failures are not silently swallowed: incomplete observations (missing PRs or branches)
|
|
40
|
+
* would lead to erroneous column transitions. If reading fails, the repository is skipped.
|
|
47
41
|
*/
|
|
48
42
|
async function readRepo({ gitea, owner, repo }) {
|
|
49
43
|
const [pulls, branches] = await Promise.all([
|
|
@@ -54,7 +48,7 @@ async function readRepo({ gitea, owner, repo }) {
|
|
|
54
48
|
}
|
|
55
49
|
|
|
56
50
|
/**
|
|
57
|
-
*
|
|
51
|
+
* Observed state from Gitea for a single task.
|
|
58
52
|
*
|
|
59
53
|
* @returns {{column: string|undefined, branch: string|undefined, pull: object|undefined}}
|
|
60
54
|
*/
|
|
@@ -67,31 +61,24 @@ export function observeTask(task, repoState, issue) {
|
|
|
67
61
|
return { column, branch, pull }
|
|
68
62
|
}
|
|
69
63
|
|
|
70
|
-
/**
|
|
64
|
+
/** Human-readable explanation for automated column transitions. */
|
|
71
65
|
export function explain({ column, branch, pull }) {
|
|
72
66
|
switch (column) {
|
|
73
|
-
case 'review': return pull?.number ? `PR #${pull.number}
|
|
74
|
-
case 'deploy': return pull?.number ? `PR #${pull.number}
|
|
75
|
-
case 'cleanup': return branch ? `issue
|
|
76
|
-
case 'done': return 'issue
|
|
77
|
-
case 'in-progress': return branch ?
|
|
67
|
+
case 'review': return pull?.number ? `PR #${pull.number} ready for review` : 'PR ready for review'
|
|
68
|
+
case 'deploy': return pull?.number ? `PR #${pull.number} merged` : 'PR merged'
|
|
69
|
+
case 'cleanup': return branch ? `issue closed, branch ${branch} still exists` : 'issue closed'
|
|
70
|
+
case 'done': return 'issue closed, branch deleted'
|
|
71
|
+
case 'in-progress': return branch ? `branch ${branch} appeared` : 'work started'
|
|
78
72
|
default: return ''
|
|
79
73
|
}
|
|
80
74
|
}
|
|
81
75
|
|
|
82
76
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* Метки и заголовок — источник правды в Gitea: переименовали issue или сменили
|
|
86
|
-
* `priority/high` на `priority/critical` — доска обязана это увидеть, иначе
|
|
87
|
-
* отбор по меткам работает по вчерашней разметке и молча врёт.
|
|
77
|
+
* Remote fields synced from Gitea on each reconciliation run.
|
|
88
78
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* Возвращаем только изменившееся: лишняя запись поднимает `updatedAt`, а по
|
|
94
|
-
* нему разрешается спор о том, кто двигал задачу позже.
|
|
79
|
+
* Title, labels, author, assignee, milestone are updated if changed.
|
|
80
|
+
* Task body is deliberately NOT overwritten to preserve user notes made from the UI chip.
|
|
81
|
+
* Returns only modified fields to minimize unnecessary database writes.
|
|
95
82
|
*/
|
|
96
83
|
function remoteFields(task, issue) {
|
|
97
84
|
const patch = {}
|
|
@@ -103,22 +90,19 @@ function remoteFields(task, issue) {
|
|
|
103
90
|
const same = names.length === (task.labels ?? []).length
|
|
104
91
|
&& names.every((n) => (task.labels ?? []).includes(n))
|
|
105
92
|
if (!same) patch.labels = names
|
|
106
|
-
//
|
|
107
|
-
// подхватывать и его, иначе карточка красится по позавчерашней палитре.
|
|
93
|
+
// Label colors are updated if modified in Gitea.
|
|
108
94
|
const colors = colorsOfIssue(issue.labels)
|
|
109
95
|
const before = task.labelColors ?? {}
|
|
110
96
|
const sameColors = Object.keys(colors).length === Object.keys(before).length
|
|
111
97
|
&& Object.entries(colors).every(([k, v]) => before[k] === v)
|
|
112
98
|
if (!sameColors) patch.labelColors = colors
|
|
113
99
|
}
|
|
114
|
-
//
|
|
115
|
-
// помнят день подхвата, и починить их может только сверка.
|
|
100
|
+
// Issue creation timestamp is updated to fix tasks imported prior to timestamp tracking.
|
|
116
101
|
const born = issueBornAt(issue)
|
|
117
102
|
if (born !== undefined && born !== task.createdAt) patch.createdAt = born
|
|
118
103
|
const author = issueAuthor(issue)
|
|
119
104
|
if (author !== '' && author !== task.author) patch.author = author
|
|
120
|
-
//
|
|
121
|
-
// видно на доске, сняли — тоже видно.
|
|
105
|
+
// Assignee changes are synced bidirectionally (assigned or unassigned).
|
|
122
106
|
if (issue?.assignee !== undefined || Array.isArray(issue?.assignees)) {
|
|
123
107
|
const assignee = issueAssignee(issue)
|
|
124
108
|
if (assignee !== (task.assignee ?? '')) patch.assignee = assignee
|
|
@@ -131,17 +115,13 @@ function remoteFields(task, issue) {
|
|
|
131
115
|
}
|
|
132
116
|
|
|
133
117
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* Наблюдённые ветка и PR записываются в карточку независимо от того, случился
|
|
137
|
-
* перевод или нет: это наблюдение о задаче, полезное само по себе.
|
|
118
|
+
* Apply observations and transition column if needed.
|
|
138
119
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* узнать о нём неоткуда.
|
|
120
|
+
* Observed branch and PR are updated on the task regardless of whether column changes.
|
|
121
|
+
* Conflicts between local board edits and remote Gitea updates are resolved by timestamp,
|
|
122
|
+
* recording overridden changes in the transition history.
|
|
143
123
|
*
|
|
144
|
-
* @returns {object|undefined}
|
|
124
|
+
* @returns {object|undefined} applied transition or undefined
|
|
145
125
|
*/
|
|
146
126
|
export function applyObservation({ store, task, observation, issue, remoteUpdatedAt, now = Date.now() }) {
|
|
147
127
|
const patch = { syncedAt: now, ...remoteFields(task, issue) }
|
|
@@ -157,14 +137,14 @@ export function applyObservation({ store, task, observation, issue, remoteUpdate
|
|
|
157
137
|
})
|
|
158
138
|
|
|
159
139
|
if (verdict.winner === 'local') {
|
|
160
|
-
//
|
|
140
|
+
// Board won the conflict; log transition note if Gitea had a different column.
|
|
161
141
|
if (verdict.overridden !== undefined) {
|
|
162
142
|
store.addTransition({
|
|
163
143
|
taskId: task.id,
|
|
164
144
|
fromCol: task.column,
|
|
165
145
|
toCol: task.column,
|
|
166
146
|
source: 'gitea',
|
|
167
|
-
detail:
|
|
147
|
+
detail: `conflict: Gitea has "${verdict.overridden}", board edited later`,
|
|
168
148
|
})
|
|
169
149
|
}
|
|
170
150
|
return undefined
|
|
@@ -183,20 +163,17 @@ export function applyObservation({ store, task, observation, issue, remoteUpdate
|
|
|
183
163
|
toCol: move.column,
|
|
184
164
|
source: move.source,
|
|
185
165
|
detail: verdict.overridden !== undefined
|
|
186
|
-
? `${move.detail} (
|
|
166
|
+
? `${move.detail} (overridden: board had "${verdict.overridden}")`
|
|
187
167
|
: move.detail,
|
|
188
168
|
})
|
|
189
169
|
return move
|
|
190
170
|
}
|
|
191
171
|
|
|
192
172
|
/**
|
|
193
|
-
*
|
|
173
|
+
* Reconcile all tasks backed by an active Gitea issue.
|
|
194
174
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* Отчёт называет НЕ ТОЛЬКО числа, но и причину первой неудачи: «сверено ноль
|
|
199
|
-
* из пяти» человеку ничего не говорит, а «репозиторий не найден» говорит всё.
|
|
175
|
+
* A failure on one repository does not abort others; report identifies total counts
|
|
176
|
+
* and specific problem details.
|
|
200
177
|
*
|
|
201
178
|
* @returns {{checked: number, moved: number, failed: number,
|
|
202
179
|
* repos: number, reposFailed: number, problem: {where: string, message: string}|undefined}}
|
|
@@ -214,12 +191,10 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
|
|
|
214
191
|
let moved = 0
|
|
215
192
|
let failed = 0
|
|
216
193
|
let reposFailed = 0
|
|
217
|
-
//
|
|
218
|
-
// отвалятся одинаково, и показывать надо причину, а не последний по счёту
|
|
219
|
-
// репозиторий.
|
|
194
|
+
// Record first failure detail for clear error reporting.
|
|
220
195
|
let problem
|
|
221
196
|
const note = (where, error) => {
|
|
222
|
-
if (problem === undefined) problem = { where, message: String(error?.message ?? error ?? '
|
|
197
|
+
if (problem === undefined) problem = { where, message: String(error?.message ?? error ?? 'unknown error') }
|
|
223
198
|
}
|
|
224
199
|
|
|
225
200
|
for (const group of byRepo.values()) {
|
|
@@ -230,7 +205,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
|
|
|
230
205
|
failed += group.tasks.length
|
|
231
206
|
reposFailed += 1
|
|
232
207
|
note(`${group.owner}/${group.repo}`, error)
|
|
233
|
-
logger?.warn?.(`dsh-kanban:
|
|
208
|
+
logger?.warn?.(`dsh-kanban: repository ${group.owner}/${group.repo} read failed: ${error?.message}`)
|
|
234
209
|
continue
|
|
235
210
|
}
|
|
236
211
|
|
|
@@ -246,7 +221,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
|
|
|
246
221
|
} catch (error) {
|
|
247
222
|
failed += 1
|
|
248
223
|
note(`${task.owner}/${task.repo}#${task.issueNumber}`, error)
|
|
249
|
-
logger?.warn?.(`dsh-kanban:
|
|
224
|
+
logger?.warn?.(`dsh-kanban: task ${task.id} sync failed: ${error?.message}`)
|
|
250
225
|
}
|
|
251
226
|
}
|
|
252
227
|
}
|
|
@@ -255,15 +230,10 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
|
|
|
255
230
|
}
|
|
256
231
|
|
|
257
232
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* Названные поимённо — их и берём. Пустой список означает ВСЕ репозитории
|
|
261
|
-
* владельца: доска обязана показывать то, что есть в Gitea, а не то, что ей
|
|
262
|
-
* перечислили руками.
|
|
233
|
+
* Resolve repositories to monitor for intake.
|
|
263
234
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* не осторожность, а расточительство.
|
|
235
|
+
* Returns explicitly configured repositories, or all organization repos with open issues
|
|
236
|
+
* if no explicit list is configured.
|
|
267
237
|
*
|
|
268
238
|
* @returns {Promise<Array<{owner: string, repo: string}>>}
|
|
269
239
|
*/
|
|
@@ -271,7 +241,7 @@ async function resolveRepos({ gitea, config, owner }) {
|
|
|
271
241
|
const named = watchedRepos({ config, owner })
|
|
272
242
|
if (named.length > 0) return named
|
|
273
243
|
if (owner === undefined || owner === '') {
|
|
274
|
-
throw new Error('
|
|
244
|
+
throw new Error('owner not specified: set organization in settings')
|
|
275
245
|
}
|
|
276
246
|
const rows = await gitea.listOrgRepos({ owner })
|
|
277
247
|
return rows
|
|
@@ -280,11 +250,7 @@ async function resolveRepos({ gitea, config, owner }) {
|
|
|
280
250
|
}
|
|
281
251
|
|
|
282
252
|
/**
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* Отказ по одному репозиторию не останавливает остальные — как и в сверке:
|
|
286
|
-
* недоступный репозиторий это повод пропустить его до следующего прохода, а не
|
|
287
|
-
* бросить всю доску.
|
|
253
|
+
* Ingest new issues from tracked repositories that do not yet exist on the board.
|
|
288
254
|
*
|
|
289
255
|
* @returns {{added: number, skipped: number, failed: number, problem: object|undefined}}
|
|
290
256
|
*/
|
|
@@ -294,8 +260,7 @@ export async function intakeAll({ gitea, store, config, owner, logger, only, rep
|
|
|
294
260
|
let failed = 0
|
|
295
261
|
let problem
|
|
296
262
|
|
|
297
|
-
//
|
|
298
|
-
// один, и полный обход означал бы обращение ко всем при каждом чихе в любом.
|
|
263
|
+
// Filter by single repository if requested (e.g. from webhook event).
|
|
299
264
|
let repos
|
|
300
265
|
try {
|
|
301
266
|
repos = (given ?? await resolveRepos({ gitea, config, owner }))
|
|
@@ -303,7 +268,7 @@ export async function intakeAll({ gitea, store, config, owner, logger, only, rep
|
|
|
303
268
|
} catch (error) {
|
|
304
269
|
return {
|
|
305
270
|
added, skipped, failed: 1,
|
|
306
|
-
problem: { where: '
|
|
271
|
+
problem: { where: 'repository list', message: String(error?.message ?? error) },
|
|
307
272
|
}
|
|
308
273
|
}
|
|
309
274
|
|
|
@@ -316,7 +281,7 @@ export async function intakeAll({ gitea, store, config, owner, logger, only, rep
|
|
|
316
281
|
if (problem === undefined) {
|
|
317
282
|
problem = { where: `${pair.owner}/${pair.repo}`, message: String(error?.message ?? error) }
|
|
318
283
|
}
|
|
319
|
-
logger?.warn?.(`dsh-kanban:
|
|
284
|
+
logger?.warn?.(`dsh-kanban: tasks from ${pair.owner}/${pair.repo} fetch failed: ${error?.message}`)
|
|
320
285
|
continue
|
|
321
286
|
}
|
|
322
287
|
|
|
@@ -324,9 +289,7 @@ export async function intakeAll({ gitea, store, config, owner, logger, only, rep
|
|
|
324
289
|
const verdict = shouldTake({ store, owner: pair.owner, repo: pair.repo, issue })
|
|
325
290
|
if (!verdict.take) { skipped += 1; continue }
|
|
326
291
|
store.createTask(issueToTask(issue, {
|
|
327
|
-
//
|
|
328
|
-
// осознанно отвергается (README «Две доски»), и параметра доски здесь
|
|
329
|
-
// нет — он был мёртвым.
|
|
292
|
+
// Imported issues always go to the 'main' project board in the 'backlog' column.
|
|
330
293
|
board: 'main', column: 'backlog', owner: pair.owner, repo: pair.repo,
|
|
331
294
|
}))
|
|
332
295
|
added += 1
|
|
@@ -337,10 +300,9 @@ export async function intakeAll({ gitea, store, config, owner, logger, only, rep
|
|
|
337
300
|
}
|
|
338
301
|
|
|
339
302
|
/**
|
|
340
|
-
*
|
|
303
|
+
* Archive tasks that have remained in 'done' past the retention threshold.
|
|
341
304
|
*
|
|
342
|
-
*
|
|
343
|
-
* Архив обязан быть обратимым, иначе им не станут пользоваться из страха.
|
|
305
|
+
* Archiving is reversible (does not hard-delete).
|
|
344
306
|
*/
|
|
345
307
|
export function archiveOverdue({ store, config, now = Date.now() }) {
|
|
346
308
|
const before = archiveBefore({ now, afterDays: config?.archiveAfterDays })
|
|
@@ -351,14 +313,10 @@ export function archiveOverdue({ store, config, now = Date.now() }) {
|
|
|
351
313
|
}
|
|
352
314
|
|
|
353
315
|
/**
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* Живёт в памяти процесса, а не в базе: это положение текущего запуска, а не
|
|
357
|
-
* свойство задач. После перезапуска честнее сказать «сверки ещё не было», чем
|
|
358
|
-
* показать вчерашний успех как сегодняшний.
|
|
316
|
+
* In-memory sync status for UI telemetry.
|
|
359
317
|
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
318
|
+
* Stored in process memory rather than SQLite to reflect current run state.
|
|
319
|
+
* Sync failures do not wipe the last successful sync timestamp.
|
|
362
320
|
*/
|
|
363
321
|
export function createSyncState() {
|
|
364
322
|
let okAt
|
|
@@ -369,7 +327,7 @@ export function createSyncState() {
|
|
|
369
327
|
return {
|
|
370
328
|
started() { running = true },
|
|
371
329
|
|
|
372
|
-
/**
|
|
330
|
+
/** Record sync run outcome. */
|
|
373
331
|
finished(report) {
|
|
374
332
|
running = false
|
|
375
333
|
if (report?.problem === undefined) {
|
|
@@ -382,11 +340,11 @@ export function createSyncState() {
|
|
|
382
340
|
problem = report.problem
|
|
383
341
|
},
|
|
384
342
|
|
|
385
|
-
/**
|
|
343
|
+
/** Record failure when sync cannot start. */
|
|
386
344
|
failedToStart(where, message) {
|
|
387
345
|
running = false
|
|
388
346
|
failedAt = Date.now()
|
|
389
|
-
problem = { where, message: String(message ?? '
|
|
347
|
+
problem = { where, message: String(message ?? 'unknown error') }
|
|
390
348
|
},
|
|
391
349
|
|
|
392
350
|
snapshot() {
|
package/lib/time.js
CHANGED
|
@@ -1,24 +1,18 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Формат относительный, потому что «третий день» читается мгновенно, а точная
|
|
4
|
-
// дата требует чтения и сравнения с сегодняшним числом. Скорость чтения тут и
|
|
5
|
-
// есть смысл: на доску смотрят, чтобы за секунду понять, что застряло.
|
|
6
|
-
//
|
|
7
|
-
// Модуль отдаёт числа и единицу, а не готовую строку: собирает и переводит её
|
|
8
|
-
// браузерная половина.
|
|
1
|
+
// Relative time formatting for board.
|
|
2
|
+
// Returns duration values; frontend translates and renders them.
|
|
9
3
|
|
|
10
4
|
const MINUTE = 60_000
|
|
11
5
|
const HOUR = 60 * MINUTE
|
|
12
6
|
const DAY = 24 * HOUR
|
|
13
7
|
|
|
14
8
|
/**
|
|
15
|
-
*
|
|
9
|
+
* How long ago the timestamp occurred.
|
|
16
10
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* Future timestamps are clamped to present moment.
|
|
12
|
+
* Board does not project future timestamps.
|
|
19
13
|
*
|
|
20
14
|
* @returns {{unit: 'now'|'min'|'hour'|'day', value: number}|undefined}
|
|
21
|
-
* `undefined`
|
|
15
|
+
* `undefined` - no timestamp.
|
|
22
16
|
*/
|
|
23
17
|
export function relativeParts(now, then) {
|
|
24
18
|
if (typeof then !== 'number' || !Number.isFinite(then) || then <= 0) return undefined
|
|
@@ -30,11 +24,8 @@ export function relativeParts(now, then) {
|
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* Отдельного опроса живости сессий не заводим: это второй источник правды и
|
|
36
|
-
* лишняя нагрузка. Смотрим на то, что и так знаем, — когда задачу последний
|
|
37
|
-
* раз трогали.
|
|
27
|
+
* Whether task has been idle past threshold.
|
|
28
|
+
* Relies on last activity timestamp rather than active pinging.
|
|
38
29
|
*/
|
|
39
30
|
export function isStale({ now, updatedAt, sessionId, afterMinutes }) {
|
|
40
31
|
const limit = Number(afterMinutes)
|
package/lib/transitions.js
CHANGED
|
@@ -1,46 +1,36 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// готовыми, решение возвращается значением. Поэтому вся логика переходов
|
|
5
|
-
// проверяется тестами, а места, где она вызывается, остаются тонкими.
|
|
1
|
+
// Card auto-movement: derive column from observations and source priority.
|
|
2
|
+
// Pure module without network, storage, or harness dependencies.
|
|
3
|
+
// Observations arrive as data, verdicts return as values.
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Меньше число — сильнее источник.
|
|
6
|
+
* Source priority.
|
|
7
|
+
* Lower number indicates higher priority source.
|
|
12
8
|
*/
|
|
13
9
|
const PRIORITY = { gitea: 0, tool: 1, session: 2, manual: 3 }
|
|
14
10
|
|
|
15
|
-
/** Pull request
|
|
11
|
+
/** Pull request is draft while marked with WIP prefix or draft flag. */
|
|
16
12
|
export function isDraftPull(pull) {
|
|
17
13
|
if (pull?.draft === true) return true
|
|
18
14
|
return /^\s*(wip|draft)\b[:\s-]/i.test(String(pull?.title ?? ''))
|
|
19
15
|
}
|
|
20
16
|
|
|
21
17
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* issue
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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, если наблюдения молчат
|
|
18
|
+
* Target column derived from forge observations.
|
|
19
|
+
* Evaluated from latest lifecycle state to earliest.
|
|
20
|
+
* WIP PR intentionally does NOT move card to review.
|
|
21
|
+
* @param {object} obs observations
|
|
22
|
+
* @param {object} [obs.issue] issue from Gitea (`state`)
|
|
23
|
+
* @param {Array} [obs.pulls] pull requests related to task
|
|
24
|
+
* @param {boolean} [obs.branchExists] whether task branch exists
|
|
25
|
+
* @returns {string|undefined} column or undefined if observations silent
|
|
36
26
|
*/
|
|
37
27
|
export function deriveColumn(obs) {
|
|
38
28
|
const issueClosed = obs?.issue?.state === 'closed'
|
|
39
29
|
const pulls = Array.isArray(obs?.pulls) ? obs.pulls : []
|
|
40
30
|
const branchExists = obs?.branchExists === true
|
|
41
31
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
32
|
+
// Task is finished only when cleanup is done: issue closed AND branch deleted.
|
|
33
|
+
// Task is finished only when cleanup is done: issue closed AND branch deleted.
|
|
44
34
|
if (issueClosed && !branchExists) return 'done'
|
|
45
35
|
if (issueClosed) return 'cleanup'
|
|
46
36
|
|
|
@@ -51,12 +41,10 @@ export function deriveColumn(obs) {
|
|
|
51
41
|
}
|
|
52
42
|
|
|
53
43
|
/**
|
|
54
|
-
*
|
|
44
|
+
* Select candidate transition from multiple sources.
|
|
55
45
|
*
|
|
56
|
-
* Предложение, совпадающее с текущей колонкой, отбрасывается: перевод в ту же
|
|
57
|
-
* колонку — не перевод, а лишняя строка в журнале.
|
|
58
46
|
*
|
|
59
|
-
* @param {string} current
|
|
47
|
+
* @param {string} current current column
|
|
60
48
|
* @param {Array<{column: string, source: string, detail?: string}>} candidates
|
|
61
49
|
* @returns {{column: string, source: string, detail: string}|undefined}
|
|
62
50
|
*/
|
|
@@ -70,14 +58,10 @@ export function resolveTransition(current, candidates) {
|
|
|
70
58
|
}
|
|
71
59
|
|
|
72
60
|
/**
|
|
73
|
-
*
|
|
61
|
+
* Whether human intervention is required.
|
|
74
62
|
*
|
|
75
|
-
* Колонки «Блокировано» в согласованном наборе нет, поэтому запрос разрешения и
|
|
76
|
-
* вопрос агента не двигают карточку, а поднимают на ней признак ожидания.
|
|
77
|
-
* Двигать в несуществующую колонку нельзя, а прятать «агент стоит и ждёт» —
|
|
78
|
-
* значит терять главное, ради чего смотрят на доску.
|
|
79
63
|
*
|
|
80
|
-
* @returns {boolean|undefined} true
|
|
64
|
+
* @returns {boolean|undefined} true - raise, false - clear, undefined - not applicable
|
|
81
65
|
*/
|
|
82
66
|
export function waitingFromEvent(type) {
|
|
83
67
|
switch (type) {
|
|
@@ -93,20 +77,20 @@ export function waitingFromEvent(type) {
|
|
|
93
77
|
}
|
|
94
78
|
}
|
|
95
79
|
|
|
96
|
-
/**
|
|
80
|
+
/** Branch name identified for task: observed branch or undefined. */
|
|
97
81
|
export function branchOfTask(task, branches) {
|
|
98
82
|
const known = typeof task?.branch === 'string' && task.branch !== '' ? task.branch : undefined
|
|
99
83
|
const names = (branches ?? []).map((b) => (typeof b === 'string' ? b : b?.name)).filter(Boolean)
|
|
100
84
|
if (known !== undefined) return names.includes(known) ? known : undefined
|
|
101
85
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
86
|
+
// Agent creates branch matching workflow conventions with issue number.
|
|
87
|
+
// Look up by issue number rather than fixed string template.
|
|
104
88
|
if (typeof task?.issueNumber !== 'number') return undefined
|
|
105
89
|
const marker = String(task.issueNumber)
|
|
106
90
|
return names.find((n) => new RegExp(`(^|[^0-9])${marker}([^0-9]|$)`).test(n))
|
|
107
91
|
}
|
|
108
92
|
|
|
109
|
-
/** Pull
|
|
93
|
+
/** Pull requests related to task: matched by branch or issue reference. */
|
|
110
94
|
export function pullsOfTask(task, pulls, branch) {
|
|
111
95
|
const number = typeof task?.issueNumber === 'number' ? task.issueNumber : undefined
|
|
112
96
|
return (pulls ?? []).filter((p) => {
|
|
@@ -118,20 +102,16 @@ export function pullsOfTask(task, pulls, branch) {
|
|
|
118
102
|
}
|
|
119
103
|
|
|
120
104
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* проигравшая сторона НЕ исчезает молча, а возвращается в поле `overridden`,
|
|
125
|
-
* чтобы вызывающий записал её в журнал задачи. Синхронизация, которая тихо
|
|
126
|
-
* отменяет правку человека, хуже синхронизации, которой нет: расхождение
|
|
127
|
-
* остаётся, а узнать о нём неоткуда.
|
|
105
|
+
* Conflict resolution when both local and remote changed since last sync.
|
|
106
|
+
* Rule: latest timestamp wins, and overridden side is reported in `overridden`
|
|
107
|
+
* to record the conflict explicitly in task history.
|
|
128
108
|
*
|
|
129
109
|
* @param {object} state
|
|
130
|
-
* @param {string} state.localColumn
|
|
131
|
-
* @param {string} [state.remoteColumn]
|
|
132
|
-
* @param {number} state.localUpdatedAt
|
|
133
|
-
* @param {number} state.remoteUpdatedAt
|
|
134
|
-
* @param {number} state.syncedAt
|
|
110
|
+
* @param {string} state.localColumn board column
|
|
111
|
+
* @param {string} [state.remoteColumn] column derived from forge
|
|
112
|
+
* @param {number} state.localUpdatedAt local update timestamp
|
|
113
|
+
* @param {number} state.remoteUpdatedAt remote update timestamp
|
|
114
|
+
* @param {number} state.syncedAt last successful sync timestamp
|
|
135
115
|
* @returns {{winner: 'local'|'remote'|'none', column?: string, overridden?: string}}
|
|
136
116
|
*/
|
|
137
117
|
export function resolveConflict({ localColumn, remoteColumn, localUpdatedAt, remoteUpdatedAt, syncedAt }) {
|
|
@@ -140,22 +120,21 @@ export function resolveConflict({ localColumn, remoteColumn, localUpdatedAt, rem
|
|
|
140
120
|
|
|
141
121
|
const since = Number(syncedAt) || 0
|
|
142
122
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// нечего, и первая сверка обязана принять положение дел, а не спорить с ним.
|
|
123
|
+
// Prior to first sync, 'changed since last time' has no prior baseline;
|
|
124
|
+
// accept remote state initially.
|
|
146
125
|
const firstSync = since === 0
|
|
147
126
|
const localChanged = !firstSync && (Number(localUpdatedAt) || 0) > since
|
|
148
127
|
const remoteChanged = firstSync || (Number(remoteUpdatedAt) || 0) > since
|
|
149
128
|
|
|
150
129
|
if (localChanged && remoteChanged) {
|
|
151
|
-
//
|
|
130
|
+
// Both sides modified: latest wins, overridden is recorded.
|
|
152
131
|
if ((Number(localUpdatedAt) || 0) > (Number(remoteUpdatedAt) || 0)) {
|
|
153
132
|
return { winner: 'local', overridden: remoteColumn }
|
|
154
133
|
}
|
|
155
134
|
return { winner: 'remote', column: remoteColumn, overridden: localColumn }
|
|
156
135
|
}
|
|
157
136
|
|
|
158
|
-
//
|
|
137
|
+
// Only board moved: remote sync does not pull card backward.
|
|
159
138
|
if (localChanged) return { winner: 'local' }
|
|
160
139
|
|
|
161
140
|
return { winner: 'remote', column: remoteColumn }
|