@goodandready/dsh-kanban 0.2.19 → 0.2.21

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 CHANGED
@@ -1,8 +1,8 @@
1
- // Сверка задач с Gitea и применение автопереходов.
1
+ // Task synchronization with Gitea and automated column transitions.
2
2
  //
3
- // Ни таймера, ни планировщика здесь нет: когда сверять — решает `lib/index.js`.
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
- * Выполнить операцию с экспоненциальной задержкой (exponential backoff) при сбоях сети.
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
- * Что Gitea говорит про одну задачу.
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} снят с WIP` : 'PR готов к ревью'
74
- case 'deploy': return pull?.number ? `PR #${pull.number} влит` : 'PR влит'
75
- case 'cleanup': return branch ? `issue закрыт, ветка ${branch} ещё есть` : 'issue закрыт'
76
- case 'done': return 'issue закрыт, ветка удалена'
77
- case 'in-progress': return branch ? `появилась ветка ${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
- * Поля, которые доска забирает у Gitea при каждой сверке.
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
- // Цвет метки в Gitea меняют реже имени, но меняют: сверка обязана
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
- // Ответственного сверка подхватывает В ОБЕ стороны: назначили в Gitea —
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} применённый переход либо 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
- // Доска победила, но если Gitea говорил другое — молчать нельзя.
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: `расхождение: Gitea считает «${verdict.overridden}», доска правилась позже`,
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} (перекрыто: на доске было «${verdict.overridden}»)`
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
- * Сверить все задачи, за которыми стоит issue.
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: репозиторий ${group.owner}/${group.repo} не прочитан: ${error?.message}`)
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: задача ${task.id} не сверена: ${error?.message}`)
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
- // `only` сужает проход до одного репозитория: событие вебхука говорит про
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: 'список репозиториев', message: String(error?.message ?? error) },
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: задачи из ${pair.owner}/${pair.repo} не забраны: ${error?.message}`)
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
- // Подхват всегда идёт на проектную доску: импорт issue на простую
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
- * Будущее (расхождение часов между машинами) не выдумываем «через N минут», а
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)
@@ -1,46 +1,40 @@
1
- // Автоперемещение карточек: вывод колонки из наблюдений и приоритет источников.
2
- //
3
- // Модуль чистый — ни сети, ни хранилища, ни харнесса. Наблюдения приходят
4
- // готовыми, решение возвращается значением. Поэтому вся логика переходов
5
- // проверяется тестами, а места, где она вызывается, остаются тонкими.
1
+ function escapeRegex(str) {
2
+ return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
3
+ }
4
+
5
+ // Card auto-movement: derive column from observations and source priority.
6
+ // Pure module without network, storage, or harness dependencies.
7
+ // Observations arrive as data, verdicts return as values.
6
8
 
7
9
  /**
8
- * Приоритет источников. Без него «PR влит → deploy» через секунду перебивалось
9
- * бы «ход завершился → in-progress», и карточка прыгала бы туда-сюда.
10
- *
11
- * Меньше число — сильнее источник.
10
+ * Source priority.
11
+ * Lower number indicates higher priority source.
12
12
  */
13
13
  const PRIORITY = { gitea: 0, tool: 1, session: 2, manual: 3 }
14
14
 
15
- /** Pull request считается черновиком, пока с него не снят признак WIP. */
15
+ /** Pull request is draft while marked with WIP prefix or draft flag. */
16
16
  export function isDraftPull(pull) {
17
17
  if (pull?.draft === true) return true
18
18
  return /^\s*(wip|draft)\b[:\s-]/i.test(String(pull?.title ?? ''))
19
19
  }
20
20
 
21
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, если наблюдения молчат
22
+ * Target column derived from forge observations.
23
+ * Evaluated from latest lifecycle state to earliest.
24
+ * WIP PR intentionally does NOT move card to review.
25
+ * @param {object} obs observations
26
+ * @param {object} [obs.issue] issue from Gitea (`state`)
27
+ * @param {Array} [obs.pulls] pull requests related to task
28
+ * @param {boolean} [obs.branchExists] whether task branch exists
29
+ * @returns {string|undefined} column or undefined if observations silent
36
30
  */
37
31
  export function deriveColumn(obs) {
38
32
  const issueClosed = obs?.issue?.state === 'closed'
39
33
  const pulls = Array.isArray(obs?.pulls) ? obs.pulls : []
40
34
  const branchExists = obs?.branchExists === true
41
35
 
42
- // Задача завершена, только когда cleanup сделан: issue закрыт И ветки нет.
43
- // Пока ветка цела, работа не закончена, сколько бы issue ни был закрыт.
36
+ // Task is finished only when cleanup is done: issue closed AND branch deleted.
37
+ // Task is finished only when cleanup is done: issue closed AND branch deleted.
44
38
  if (issueClosed && !branchExists) return 'done'
45
39
  if (issueClosed) return 'cleanup'
46
40
 
@@ -51,12 +45,10 @@ export function deriveColumn(obs) {
51
45
  }
52
46
 
53
47
  /**
54
- * Выбрать перевод из нескольких предложений разных источников.
48
+ * Select candidate transition from multiple sources.
55
49
  *
56
- * Предложение, совпадающее с текущей колонкой, отбрасывается: перевод в ту же
57
- * колонку — не перевод, а лишняя строка в журнале.
58
50
  *
59
- * @param {string} current текущая колонка
51
+ * @param {string} current current column
60
52
  * @param {Array<{column: string, source: string, detail?: string}>} candidates
61
53
  * @returns {{column: string, source: string, detail: string}|undefined}
62
54
  */
@@ -70,14 +62,10 @@ export function resolveTransition(current, candidates) {
70
62
  }
71
63
 
72
64
  /**
73
- * Нужен ли человек по событию сессии.
65
+ * Whether human intervention is required.
74
66
  *
75
- * Колонки «Блокировано» в согласованном наборе нет, поэтому запрос разрешения и
76
- * вопрос агента не двигают карточку, а поднимают на ней признак ожидания.
77
- * Двигать в несуществующую колонку нельзя, а прятать «агент стоит и ждёт» —
78
- * значит терять главное, ради чего смотрят на доску.
79
67
  *
80
- * @returns {boolean|undefined} true — поднять, false — снять, undefined — событие не про это
68
+ * @returns {boolean|undefined} true - raise, false - clear, undefined - not applicable
81
69
  */
82
70
  export function waitingFromEvent(type) {
83
71
  switch (type) {
@@ -93,45 +81,41 @@ export function waitingFromEvent(type) {
93
81
  }
94
82
  }
95
83
 
96
- /** Имя ветки, по которому задача узнаёт свою: то, что наблюдалось, либо ничего. */
84
+ /** Branch name identified for task: observed branch or undefined. */
97
85
  export function branchOfTask(task, branches) {
98
86
  const known = typeof task?.branch === 'string' && task.branch !== '' ? task.branch : undefined
99
87
  const names = (branches ?? []).map((b) => (typeof b === 'string' ? b : b?.name)).filter(Boolean)
100
88
  if (known !== undefined) return names.includes(known) ? known : undefined
101
89
 
102
- // Ветку задачи заводит агент и называет как хочет, но номер issue в имени —
103
- // общее правило воркфлоу. Ищем по номеру, а не по угаданному шаблону.
104
- if (typeof task?.issueNumber !== 'number') return undefined
105
- const marker = String(task.issueNumber)
90
+ // Agent creates branch matching workflow conventions with issue number.
91
+ // Look up by issue number rather than fixed string template.
92
+ if (!Number.isSafeInteger(task?.issueNumber) || task.issueNumber <= 0) return undefined
93
+ const marker = escapeRegex(task.issueNumber)
106
94
  return names.find((n) => new RegExp(`(^|[^0-9])${marker}([^0-9]|$)`).test(n))
107
95
  }
108
96
 
109
- /** Pull request-ы, относящиеся к задаче: по ветке либо по упоминанию issue. */
97
+ /** Pull requests related to task: matched by branch or issue reference. */
110
98
  export function pullsOfTask(task, pulls, branch) {
111
- const number = typeof task?.issueNumber === 'number' ? task.issueNumber : undefined
99
+ const number = Number.isSafeInteger(task?.issueNumber) && task.issueNumber > 0 ? task.issueNumber : undefined
112
100
  return (pulls ?? []).filter((p) => {
113
101
  if (branch !== undefined && p?.head?.ref === branch) return true
114
102
  if (number === undefined) return false
115
103
  const haystack = `${p?.title ?? ''} ${p?.body ?? ''}`
116
- return new RegExp(`#${number}([^0-9]|$)`).test(haystack)
104
+ return new RegExp(`#${escapeRegex(number)}([^0-9]|$)`).test(haystack)
117
105
  })
118
106
  }
119
107
 
120
108
  /**
121
- * Кто победил, когда обе стороны изменились с последней сверки.
122
- *
123
- * Правило простое: побеждает более позднее изменение. Важно другое —
124
- * проигравшая сторона НЕ исчезает молча, а возвращается в поле `overridden`,
125
- * чтобы вызывающий записал её в журнал задачи. Синхронизация, которая тихо
126
- * отменяет правку человека, хуже синхронизации, которой нет: расхождение
127
- * остаётся, а узнать о нём неоткуда.
109
+ * Conflict resolution when both local and remote changed since last sync.
110
+ * Rule: latest timestamp wins, and overridden side is reported in `overridden`
111
+ * to record the conflict explicitly in task history.
128
112
  *
129
113
  * @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 время последней удачной сверки
114
+ * @param {string} state.localColumn board column
115
+ * @param {string} [state.remoteColumn] column derived from forge
116
+ * @param {number} state.localUpdatedAt local update timestamp
117
+ * @param {number} state.remoteUpdatedAt remote update timestamp
118
+ * @param {number} state.syncedAt last successful sync timestamp
135
119
  * @returns {{winner: 'local'|'remote'|'none', column?: string, overridden?: string}}
136
120
  */
137
121
  export function resolveConflict({ localColumn, remoteColumn, localUpdatedAt, remoteUpdatedAt, syncedAt }) {
@@ -140,22 +124,21 @@ export function resolveConflict({ localColumn, remoteColumn, localUpdatedAt, rem
140
124
 
141
125
  const since = Number(syncedAt) || 0
142
126
 
143
- // До первой сверки «изменилось с прошлого раза» не имеет смысла: прошлого
144
- // раза не было. Карточку только что завели или импортировали, защищать в ней
145
- // нечего, и первая сверка обязана принять положение дел, а не спорить с ним.
127
+ // Prior to first sync, 'changed since last time' has no prior baseline;
128
+ // accept remote state initially.
146
129
  const firstSync = since === 0
147
130
  const localChanged = !firstSync && (Number(localUpdatedAt) || 0) > since
148
131
  const remoteChanged = firstSync || (Number(remoteUpdatedAt) || 0) > since
149
132
 
150
133
  if (localChanged && remoteChanged) {
151
- // Обе стороны двигались. Побеждает более позднее, проигравшее называется.
134
+ // Both sides modified: latest wins, overridden is recorded.
152
135
  if ((Number(localUpdatedAt) || 0) > (Number(remoteUpdatedAt) || 0)) {
153
136
  return { winner: 'local', overridden: remoteColumn }
154
137
  }
155
138
  return { winner: 'remote', column: remoteColumn, overridden: localColumn }
156
139
  }
157
140
 
158
- // Двигалась только доска — Gitea не имеет права тянуть карточку назад.
141
+ // Only board moved: remote sync does not pull card backward.
159
142
  if (localChanged) return { winner: 'local' }
160
143
 
161
144
  return { winner: 'remote', column: remoteColumn }