@goodandready/dsh-kanban 0.2.18 → 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/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/templates.js CHANGED
@@ -1,60 +1,60 @@
1
- // Шаблоны задач для быстрой постановки типовых работ (#220).
1
+ // Task templates for rapid standard work decomposition (#220, #304).
2
2
 
3
3
  export const TASK_TEMPLATES = [
4
4
  {
5
5
  id: 'feature',
6
- title: 'Новая функциональность (Feature)',
7
- promptTemplate: 'Реализовать новую функциональность:\n- Цель:\n- Требования:\n- Границы изменений:',
6
+ title: 'New Feature',
7
+ promptTemplate: 'Implement new feature:\n- Goal:\n- Requirements:\n- Scope of changes:',
8
8
  checklist: [
9
- { id: '1', text: 'Архитектурный план и дизайн-контракт согласованы', required: true, completed: false },
10
- { id: '2', text: 'Код написан с соблюдением стандартов проекта', required: true, completed: false },
11
- { id: '3', text: 'Написаны автоматические модульные/интеграционные тесты', required: true, completed: false },
12
- { id: '4', text: 'Документация обновлена (README, docs/)', required: true, completed: false },
9
+ { id: '1', text: 'Architecture plan and design contract agreed', required: true, completed: false },
10
+ { id: '2', text: 'Code adheres to project guidelines and standards', required: true, completed: false },
11
+ { id: '3', text: 'Unit and integration tests added and passing', required: true, completed: false },
12
+ { id: '4', text: 'Documentation updated (README, docs/)', required: true, completed: false },
13
13
  ],
14
14
  priority: 'medium',
15
15
  },
16
16
  {
17
17
  id: 'bugfix',
18
- title: 'Исправление ошибки (Bugfix)',
19
- promptTemplate: 'Локализовать и исправить дефект:\n- Шаги воспроизведения:\n- Ожидаемое поведение:\n- Фактическое поведение:',
18
+ title: 'Bug Fix',
19
+ promptTemplate: 'Localize and resolve defect:\n- Reproduction steps:\n- Expected behavior:\n- Actual behavior:',
20
20
  checklist: [
21
- { id: '1', text: 'Дефект воспроизведен изолированным тестом', required: true, completed: false },
22
- { id: '2', text: 'Причина устранена без побочных эффектов', required: true, completed: false },
23
- { id: '3', text: 'Регрессионный тест добавлен в test suite', required: true, completed: false },
21
+ { id: '1', text: 'Defect reproduced with isolated test case', required: true, completed: false },
22
+ { id: '2', text: 'Root cause fixed without regressions', required: true, completed: false },
23
+ { id: '3', text: 'Regression test added to test suite', required: true, completed: false },
24
24
  ],
25
25
  priority: 'high',
26
26
  },
27
27
  {
28
28
  id: 'refactor',
29
- title: 'Рефакторинг и оптимизация (Refactoring)',
30
- promptTemplate: 'Провести рефакторинг модуля без изменения внешнего поведения:\n- Целевой модуль:\n- Проблема архитектуры:\n- Критерии завершения:',
29
+ title: 'Refactoring & Optimization',
30
+ promptTemplate: 'Refactor module preserving external behavior:\n- Target module:\n- Architectural issue:\n- Completion criteria:',
31
31
  checklist: [
32
- { id: '1', text: 'Покрыто существующими тестами (100% green)', required: true, completed: false },
33
- { id: '2', text: 'Упрощена структура/устранен техдолг', required: true, completed: false },
34
- { id: '3', text: 'Производительность не деградировала', required: true, completed: false },
32
+ { id: '1', text: 'Covered by existing tests (100% green)', required: true, completed: false },
33
+ { id: '2', text: 'Structure simplified and technical debt eliminated', required: true, completed: false },
34
+ { id: '3', text: 'Performance verified (no degradation)', required: true, completed: false },
35
35
  ],
36
36
  priority: 'low',
37
37
  },
38
38
  {
39
39
  id: 'security',
40
- title: 'Аудит безопасности и зависимостей (Security)',
41
- promptTemplate: 'Проверить безопасность и уязвимости зависимостей:\n- Область проверки:\n- Ревизия зависимостей:',
40
+ title: 'Security & Dependency Audit',
41
+ promptTemplate: 'Verify security and dependency vulnerabilities:\n- Audit scope:\n- Dependency revision:',
42
42
  checklist: [
43
- { id: '1', text: 'Проверен supply-chain и лицензии зависимостей', required: true, completed: false },
44
- { id: '2', text: 'Устранены известные CVE', required: true, completed: false },
45
- { id: '3', text: 'Секреты и токены проверены на отсутствие утечек', required: true, completed: false },
43
+ { id: '1', text: 'Supply-chain and dependency licenses verified', required: true, completed: false },
44
+ { id: '2', text: 'Known CVEs mitigated or resolved', required: true, completed: false },
45
+ { id: '3', text: 'Secrets and credentials verified clean of leaks', required: true, completed: false },
46
46
  ],
47
47
  priority: 'high',
48
48
  },
49
49
  {
50
50
  id: 'release',
51
- title: 'Подготовка и выпуск релиза (Release Prep)',
52
- promptTemplate: 'Подготовить релиз пакета:\n- Версия:\n- Список изменений:\n- Проверки перед публикацией:',
51
+ title: 'Release Preparation',
52
+ promptTemplate: 'Prepare package release:\n- Version:\n- Changelog summary:\n- Pre-release verification:',
53
53
  checklist: [
54
- { id: '1', text: 'Все тесты репозитория пройдены (npm test)', required: true, completed: false },
55
- { id: '2', text: 'Совместимость с ядром подтверждена (npm run compat)', required: true, completed: false },
56
- { id: '3', text: 'Размер упакованного пакета проверен (< 256 КБ)', required: true, completed: false },
57
- { id: '4', text: 'Документация и changelog дополнены', required: true, completed: false },
54
+ { id: '1', text: 'All repository tests pass (npm test)', required: true, completed: false },
55
+ { id: '2', text: 'Core compatibility confirmed (npm run compat)', required: true, completed: false },
56
+ { id: '3', text: 'Package archive size verified (< 256 KiB)', required: true, completed: false },
57
+ { id: '4', text: 'Documentation and changelog updated', required: true, completed: false },
58
58
  ],
59
59
  priority: 'medium',
60
60
  },
@@ -62,4 +62,4 @@ export const TASK_TEMPLATES = [
62
62
 
63
63
  export function getTemplateById(id) {
64
64
  return TASK_TEMPLATES.find((t) => t.id === id)
65
- }
65
+ }
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)