@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/filters.js CHANGED
@@ -1,58 +1,53 @@
1
- // Отборы доски: репозиторий и пространства меток.
1
+ // Board filters: repository and label namespaces.
2
2
  //
3
- // Пространства не зашиты списком. Метки живут в Gitea и меняются без нашего
4
- // участия — набор уже менялся дважды за час, — поэтому пространства собираются
5
- // из того, что реально пришло на карточки. Зашитый список начал бы врать при
6
- // первой же правке разметки.
3
+ // Namespaces are dynamically collected from task cards rather than hardcoded,
4
+ // as Gitea labels may evolve independently without requiring code changes.
7
5
 
8
- /** Отбор по репозиторию стоит в одном ряду с метками, но метка ему не нужна. */
6
+ /** Repository filter sits alongside labels without requiring a label tag. */
9
7
  export const REPO = 'repo'
10
8
 
11
- /** Автор задачи — такое же измерение отбора, как метка, но живёт своим полем. */
9
+ /** Task author — filter dimension stored in its own field. */
12
10
  export const AUTHOR = 'author'
13
11
 
14
- /** Ответственный: кто взял задачу, а не кто её завёл. */
12
+ /** Assignee: who is working on the task, distinct from author. */
15
13
  export const ASSIGNEE = 'assignee'
16
14
 
17
- /** Выпуск, к которому задача отнесена в Gitea. */
15
+ /** Milestone assigned in Gitea. */
18
16
  export const MILESTONE = 'milestone'
19
17
 
20
- /** Приоритет — локальное свойство доски, своё измерение отбора. */
18
+ /** Priority — local board property and filter dimension. */
21
19
  export const PRIORITY = 'priority'
22
20
 
23
21
  /**
24
- * Значение для задач без автора.
22
+ * Value for tasks without an author.
25
23
  *
26
- * Свои задачи заводятся на доске и автора не имеют. Выбросить их из отбора
27
- * значило бы отвечать на вопрос «кто завёл» неполным списком: «никто» — тоже
28
- * ответ, и по нему тоже отбирают.
24
+ * Local board tasks have no author. Keeping an explicit placeholder ensures
25
+ * filtering covers all tasks rather than silently omitting authorless items.
29
26
  */
30
27
  export const NO_AUTHOR = '—'
31
28
 
32
29
  /**
33
- * Значение для задач без ответственного.
30
+ * Value for unassigned tasks.
34
31
  *
35
- * «Никто не взял» — самый нужный отбор на доске: именно эти задачи ищут, когда
36
- * спрашивают «что свободно».
32
+ * Filtering by unassigned is a primary board workflow for finding available work.
37
33
  */
38
34
  export const NOBODY = '—'
39
35
 
40
- /** Значение для задач вне выпусков: «когда-нибудь» — тоже ответ. */
36
+ /** Value for tasks not assigned to any milestone. */
41
37
  export const NO_MILESTONE = '—'
42
38
 
43
- /** Порядок приоритетов в отборе: срочное сверху. */
39
+ /** Priority ordering in filter UI: urgent at the top. */
44
40
  const PRIORITY_ORDER = ['critical', 'high', 'medium', 'low']
45
41
 
46
42
  /**
47
- * Порядок известных пространств.
43
+ * Preferred namespace ordering.
48
44
  *
49
- * Алфавит поставил бы `priority` перед `type`, а смотрят в первую очередь на
50
- * тип и срочность. Незнакомые пространства дописываются в конец по алфавиту:
51
- * заведут в Gitea новое — оно появится само, без правки кода.
45
+ * Prioritizes type and urgency over raw alphabetical order. Unknown namespaces
46
+ * are appended in alphabetical order.
52
47
  */
53
48
  export const KNOWN_ORDER = ['type', 'priority', 'status', 'scope', 'risk', 'signal', 'release']
54
49
 
55
- /** Разбор метки на пространство и значение; без `/` — пространства нет. */
50
+ /** Split label into namespace and value; returns undefined if no slash delimiter. */
56
51
  export function splitLabel(name) {
57
52
  const text = String(name ?? '')
58
53
  const at = text.indexOf('/')
@@ -61,15 +56,13 @@ export function splitLabel(name) {
61
56
  }
62
57
 
63
58
  /**
64
- * Из чего собрать отборы.
59
+ * Build available filter dimensions and value counts.
65
60
  *
66
- * Метки без пространства (`epic`, `hotfix`) своего отбора не получают: список
67
- * из двух значений ради двух меток — мебель. На карточках они видны и находятся
68
- * поиском.
61
+ * Bare labels without a namespace (e.g. epic, hotfix) do not generate separate
62
+ * filter groups; they remain visible on cards and searchable via text query.
69
63
  *
70
- * Считаем по ВСЕМ переданным задачам, а не по уже отобранным: счётчик рядом со
71
- * значением отвечает на вопрос «сколько там всего», а не «сколько осталось
72
- * после моего же выбора».
64
+ * Counts are calculated across all provided tasks rather than currently filtered
65
+ * tasks so the badge reflects the total pool size.
73
66
  *
74
67
  * @returns {Array<{ns: string, values: Array<{value: string, count: number}>}>}
75
68
  */
@@ -87,9 +80,7 @@ export function facetsOf(tasks) {
87
80
  add(ASSIGNEE, typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY)
88
81
  add(MILESTONE, typeof task?.milestone === 'string' && task.milestone !== '' ? task.milestone : NO_MILESTONE)
89
82
  add(AUTHOR, typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR)
90
- // Приоритет живёт и полем (свои карточки), и меткой `priority/*` (задачи
91
- // из Gitea). Оба попадают в одно измерение — отбор по срочности не должен
92
- // зависеть от того, откуда пришло значение.
83
+ // Priority lives both in task.priority and in priority/* label: merge into one dimension.
93
84
  add(PRIORITY, typeof task?.priority === 'string' ? task.priority : '')
94
85
  for (const name of task?.labels ?? []) {
95
86
  const parsed = splitLabel(name)
@@ -107,14 +98,12 @@ export function facetsOf(tasks) {
107
98
  .sort((a, b) => rank(a.ns) - rank(b.ns) || a.ns.localeCompare(b.ns))
108
99
  }
109
100
 
110
- /** Репозиторий первым, затем известные пространства, затем всё прочее. */
101
+ /** Repository first, followed by known namespaces, then alphabetical. */
111
102
  function rank(ns) {
112
103
  if (ns === REPO) return -3
113
- // Ответственный идёт раньше автора: «кто делает» спрашивают чаще, чем «кто
114
- // завёл».
104
+ // Assignee precedes author: users look for active owner more frequently than creator.
115
105
  if (ns === ASSIGNEE) return -2
116
- // Выпуск идёт сразу за ответственным: «что осталось до релиза» спрашивают
117
- // тогда же, когда «кто делает».
106
+ // Milestone immediately follows assignee.
118
107
  if (ns === MILESTONE) return -1.5
119
108
  if (ns === AUTHOR) return -1
120
109
  const at = KNOWN_ORDER.indexOf(ns)
@@ -122,10 +111,9 @@ function rank(ns) {
122
111
  }
123
112
 
124
113
  /**
125
- * Порядок значений внутри пространства.
114
+ * Value ordering within a namespace.
126
115
  *
127
- * Приоритет — «срочное сначала», а не алфавит: `critical` сперва, `low`
128
- * в самом низу. Для остальных — обычное сравнение строк.
116
+ * Priority uses urgency ordering (critical -> low); other namespaces use standard string comparison.
129
117
  */
130
118
  function valueRank(ns, value) {
131
119
  if (ns !== PRIORITY) return 0
@@ -134,14 +122,11 @@ function valueRank(ns, value) {
134
122
  }
135
123
 
136
124
  /**
137
- * Подходит ли задача под выбранное.
125
+ * Check whether a task satisfies the active filter selection.
138
126
  *
139
- * Между пространствами — И, внутри пространства — ИЛИ. «Срочные баги» это
140
- * `priority/high` И `type/bug`; а выбор `high` вместе с `critical` означает
141
- * «любая из двух», потому что срочность у задачи одна и «И» внутри
142
- * пространства всегда давало бы пусто.
127
+ * AND between namespaces, OR within each namespace.
143
128
  *
144
- * @param {object} selected карта пространство -> список выбранных значений
129
+ * @param {object} selected map of namespace -> array of selected values
145
130
  */
146
131
  export function matchesFilters(task, selected) {
147
132
  for (const [ns, values] of Object.entries(selected ?? {})) {
@@ -167,8 +152,7 @@ function hasAny(task, ns, wanted) {
167
152
  return wanted.includes(author)
168
153
  }
169
154
  if (ns === PRIORITY) {
170
- // Приоритет и полем, и меткой `priority/*` — отбор по срочности не должен
171
- // зависеть от источника значения.
155
+ // Check both field value and priority/* label.
172
156
  if (wanted.includes(typeof task?.priority === 'string' ? task.priority : '')) return true
173
157
  for (const name of task?.labels ?? []) {
174
158
  const parsed = splitLabel(name)
@@ -183,16 +167,15 @@ function hasAny(task, ns, wanted) {
183
167
  return false
184
168
  }
185
169
 
186
- /** Выбрано ли хоть что-нибудь: пустой набор отборов доску не сужает. */
170
+ /** True if any filter value is currently selected. */
187
171
  export function anySelected(selected) {
188
172
  return Object.values(selected ?? {}).some((values) => (values ?? []).length > 0)
189
173
  }
190
174
 
191
175
  /**
192
- * Переключить одно значение.
176
+ * Toggle a single filter value.
193
177
  *
194
- * Возвращает новую карту, а не правит прежнюю: состояние отборов живёт в
195
- * React, и правка на месте не вызвала бы перерисовку.
178
+ * Returns a new object map for React state immutability.
196
179
  */
197
180
  export function toggleValue(selected, ns, value) {
198
181
  const current = (selected ?? {})[ns] ?? []
@@ -205,19 +188,17 @@ export function toggleValue(selected, ns, value) {
205
188
  return out
206
189
  }
207
190
 
208
- /** Снять всё разом. */
191
+ /** Reset all active filters. */
209
192
  export function clearFilters() {
210
193
  return {}
211
194
  }
212
195
 
213
196
  /**
214
- * Метки задачи, разобранные по пространствам.
197
+ * Extract task labels grouped by namespace.
215
198
  *
216
- * Едет на карточке вместе с задачей: браузерная половина — отдельный бандл и
217
- * позвать этот модуль не может, а разбор, написанный там во второй раз,
218
- * однажды разойдётся с этим.
199
+ * Included on task payload so client bundle does not need to duplicate parsing logic.
219
200
  *
220
- * @returns {object} карта пространство -> список значений
201
+ * @returns {object} map of namespace -> array of values
221
202
  */
222
203
  export function facetsOfTask(task) {
223
204
  const out = {}
@@ -231,13 +212,12 @@ export function facetsOfTask(task) {
231
212
  }
232
213
 
233
214
  /**
234
- * Цвет метки из ответа Gitea.
215
+ * Validate hex color code from Gitea response.
235
216
  *
236
- * Приходит шестизначным кодом без решётки (`0e8a16`), но встречается и с ней.
237
- * Всё, что не похоже на шестнадцатеричный код, считаем отсутствием цвета:
238
- * значение попадает прямо в стиль, и пускать туда что попало нельзя.
217
+ * Accepts 6-character hex string with or without leading '#'.
218
+ * Rejects invalid strings to prevent arbitrary values in CSS styles.
239
219
  *
240
- * @returns {string|undefined} код без решётки
220
+ * @returns {string|undefined} lowercase 6-char hex code without '#'
241
221
  */
242
222
  export function labelColor(value) {
243
223
  const text = String(value ?? '').trim().replace(/^#/, '')
@@ -245,10 +225,9 @@ export function labelColor(value) {
245
225
  }
246
226
 
247
227
  /**
248
- * Карта имя метки -> цвет из списка меток issue.
228
+ * Map label name -> hex color from issue labels.
249
229
  *
250
- * Держим отдельно от имён: имена читают отборы, сверка, чип и поиск, и менять
251
- * их устройство ради оформления было бы дорого и ни к чему.
230
+ * Stored separately from label names to keep name parsing and matching clean.
252
231
  */
253
232
  export function colorsOfIssue(labels) {
254
233
  const out = {}
@@ -262,13 +241,10 @@ export function colorsOfIssue(labels) {
262
241
  }
263
242
 
264
243
  /**
265
- * Вес задачи при подъёме наверх колонки.
244
+ * Task rank for top-of-column sorting.
266
245
  *
267
- * Ждущие впереди идущих намеренно: идущая работа идёт сама, а ждущая стоит
268
- * из-за человека, и он должен увидеть её первой.
269
- *
270
- * Ручной порядок не переписывается — внутри каждой ступени он сохраняется, а
271
- * подъём работает поверх него.
246
+ * Waiting tasks take precedence over running tasks because waiting items require user action.
247
+ * Manual ordering within the same rank tier is preserved.
272
248
  */
273
249
  export function liveRank(task) {
274
250
  const state = task?.state
@@ -278,17 +254,18 @@ export function liveRank(task) {
278
254
  return 3
279
255
  }
280
256
 
281
- /** Вес приоритета при подъёме: срочное впереди тихого. */
257
+ /** Priority weight for rank sorting: urgent precedes normal. */
282
258
  function priorityWeight(priority) {
283
259
  const at = PRIORITY_ORDER.indexOf(priority)
284
260
  return at === -1 ? PRIORITY_ORDER.length : at
285
261
  }
286
262
 
287
263
  /**
288
- * Поднять идущее наверх и приоритетное следом, не теряя прежнего порядка.
264
+ * Sort tasks by active state and priority while preserving relative manual order.
289
265
  *
290
- * Две независимые оси: сначала жизнь (ждёт/идёт/стоит), затем срочность
291
- * (critical → low). Ручной порядок внутри ступени сохраняется.
266
+ * Primary sort: lifecycle activity (waiting -> running -> stopped -> idle).
267
+ * Secondary sort: urgency (critical -> low).
268
+ * Tertiary sort: original array position.
292
269
  */
293
270
  export function sortByLife(tasks) {
294
271
  return (tasks ?? [])
@@ -300,11 +277,10 @@ export function sortByLife(tasks) {
300
277
  }
301
278
 
302
279
  /**
303
- * Разложить задачи по проектам.
280
+ * Group tasks by repository.
304
281
  *
305
- * Крупные группы впереди: проект с двадцатью задачами не должен теряться под
306
- * однозадачными. При равенстве — по имени, чтобы порядок не плясал между
307
- * обновлениями.
282
+ * Larger groups are placed first to keep active projects visible.
283
+ * Tied group sizes are sorted alphabetically by repo name.
308
284
  *
309
285
  * @returns {Array<{repo: string, tasks: Array<object>}>}
310
286
  */
@@ -1,9 +1,9 @@
1
- // Композитный маршрутизатор Git-провайдеров (Gitea и GitHub).
1
+ // Composite router for Git providers (Gitea and GitHub).
2
2
  //
3
- // Предоставляет единый интерфейс к обеим системам:
4
- // - прозрачно направляет запросы задач в соответствующий провайдер (task.provider / issueUrl);
5
- // - в режиме 'auto' опрашивает настроенный провайдер или объединяет результаты поиска;
6
- // - позволяет доске бесшовно работать как только с Gitea, так и только с GitHub или с обоими сразу.
3
+ // Exposes unified interface across both platforms:
4
+ // - routes task queries to appropriate provider (task.provider / issueUrl);
5
+ // - in 'auto' mode queries configured provider or merges search results;
6
+ // - supports Gitea-only, GitHub-only, or simultaneous dual-forge operations.
7
7
 
8
8
  export function createGitProvider({ gitea, github, getConfig }) {
9
9
  async function resolveActiveProvider(hint) {
@@ -23,7 +23,7 @@ export function createGitProvider({ gitea, github, getConfig }) {
23
23
  if (mode === 'github') return github
24
24
  if (mode === 'gitea') return gitea
25
25
 
26
- // В режиме 'auto': проверяем доступность провайдеров
26
+ // In 'auto' mode: check provider availability
27
27
  const giteaReady = await gitea.isConfigured()
28
28
  const githubReady = await github.isConfigured()
29
29
  if (githubReady && !giteaReady) return github
@@ -40,10 +40,10 @@ export function createGitProvider({ gitea, github, getConfig }) {
40
40
  gitea,
41
41
  github,
42
42
 
43
- /** Определить провайдера для задачи или контекста. */
43
+ /** Resolve provider for task or context. */
44
44
  resolve: resolveActiveProvider,
45
45
 
46
- /** Настроен ли хотя бы один или конкретный провайдер. */
46
+ /** Whether any or specific provider is configured. */
47
47
  async isConfigured(providerName) {
48
48
  if (providerName === 'github') return github.isConfigured()
49
49
  if (providerName === 'gitea') return gitea.isConfigured()
@@ -52,81 +52,81 @@ export function createGitProvider({ gitea, github, getConfig }) {
52
52
  return github.isConfigured()
53
53
  },
54
54
 
55
- /** Логин текущего пользователя. */
55
+ /** Current user login. */
56
56
  async me(target) {
57
57
  const p = forTarget(target) || await resolveActiveProvider(target)
58
58
  return p.me()
59
59
  },
60
60
 
61
- /** Список issues. */
61
+ /** List issues. */
62
62
  async listIssues(options) {
63
63
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
64
64
  return p.listIssues(options)
65
65
  },
66
66
 
67
- /** Получить issue по номеру. */
67
+ /** Get issue by number. */
68
68
  async getIssue(options) {
69
69
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
70
70
  return p.getIssue(options)
71
71
  },
72
72
 
73
- /** Создать issue. */
73
+ /** Create issue. */
74
74
  async createIssue(options) {
75
75
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
76
76
  return p.createIssue(options)
77
77
  },
78
78
 
79
- /** Закрыть issue. */
79
+ /** Close issue. */
80
80
  async closeIssue(options) {
81
81
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
82
82
  return p.closeIssue(options)
83
83
  },
84
84
 
85
- /** Комментарий в issue. */
85
+ /** Comment on issue. */
86
86
  async comment(options) {
87
87
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
88
88
  return p.comment(options)
89
89
  },
90
90
 
91
- /** Назначить ответственных. */
91
+ /** Assign responsible users. */
92
92
  async setAssignees(options) {
93
93
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
94
94
  return p.setAssignees(options)
95
95
  },
96
96
 
97
- /** Список Pull Requests. */
97
+ /** List Pull Requests. */
98
98
  async listPulls(options) {
99
99
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
100
100
  return p.listPulls(options)
101
101
  },
102
102
 
103
- /** Создать Pull Request. */
103
+ /** Create Pull Request. */
104
104
  async createPullRequest(options) {
105
105
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
106
106
  return p.createPullRequest(options)
107
107
  },
108
108
 
109
- /** Список веток репозитория. */
109
+ /** List repository branches. */
110
110
  async listBranches(options) {
111
111
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
112
112
  return p.listBranches(options)
113
113
  },
114
114
 
115
- /** Список организаций. */
115
+ /** List organizations. */
116
116
  async listOrgs(target) {
117
117
  const p = forTarget(target) || await resolveActiveProvider(target)
118
118
  return p.listOrgs()
119
119
  },
120
120
 
121
- /** Репозитории организации. */
121
+ /** Organization repositories. */
122
122
  async listOrgRepos(options) {
123
123
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
124
124
  return p.listOrgRepos(options)
125
125
  },
126
126
 
127
127
  /**
128
- * Поиск репозиториев: ищет в целевом провайдере либо агрегирует результаты
129
- * из обоих, если включены оба.
128
+ * Search repositories: queries target provider or aggregates results
129
+ * from both if enabled.
130
130
  */
131
131
  async searchRepos(options) {
132
132
  const target = forTarget(options?.provider)
@@ -138,7 +138,7 @@ export function createGitProvider({ gitea, github, getConfig }) {
138
138
  if (mode === 'github') return github.searchRepos(options)
139
139
  if (mode === 'gitea') return gitea.searchRepos(options)
140
140
 
141
- // Если режим 'auto' / 'both', опрашиваем оба настроенных
141
+ // If 'auto' / 'both' mode, query both configured providers
142
142
  const [giteaConfigured, githubConfigured] = await Promise.all([
143
143
  gitea.isConfigured(),
144
144
  github.isConfigured(),