@goodandready/dsh-kanban 0.1.19 → 0.1.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.
@@ -1,162 +1,162 @@
1
- // Автоперемещение карточек: вывод колонки из наблюдений и приоритет источников.
2
- //
3
- // Модуль чистый — ни сети, ни хранилища, ни харнесса. Наблюдения приходят
4
- // готовыми, решение возвращается значением. Поэтому вся логика переходов
5
- // проверяется тестами, а места, где она вызывается, остаются тонкими.
6
-
7
- /**
8
- * Приоритет источников. Без него «PR влит → deploy» через секунду перебивалось
9
- * бы «ход завершился → in-progress», и карточка прыгала бы туда-сюда.
10
- *
11
- * Меньше число — сильнее источник.
12
- */
13
- const PRIORITY = { gitea: 0, tool: 1, session: 2, manual: 3 }
14
-
15
- /** Pull request считается черновиком, пока с него не снят признак WIP. */
16
- export function isDraftPull(pull) {
17
- if (pull?.draft === true) return true
18
- return /^\s*(wip|draft)\b[:\s-]/i.test(String(pull?.title ?? ''))
19
- }
20
-
21
- /**
22
- * Колонка, которую диктуют наблюдения в Gitea.
23
- *
24
- * Порядок проверок идёт от самого позднего состояния к самому раннему: задача
25
- * не может «вернуться» в in-progress только потому, что ветка ещё цела, если
26
- * issue уже закрыт.
27
- *
28
- * WIP PR намеренно НЕ двигает карточку в review: он открывается рано и означает
29
- * «работа идёт», а не «смотрите».
30
- *
31
- * @param {object} obs наблюдения
32
- * @param {object} [obs.issue] issue из Gitea (`state`)
33
- * @param {Array} [obs.pulls] pull request-ы, связанные с задачей
34
- * @param {boolean} [obs.branchExists] существует ли ветка задачи
35
- * @returns {string|undefined} колонка либо undefined, если наблюдения молчат
36
- */
37
- export function deriveColumn(obs) {
38
- const issueClosed = obs?.issue?.state === 'closed'
39
- const pulls = Array.isArray(obs?.pulls) ? obs.pulls : []
40
- const branchExists = obs?.branchExists === true
41
-
42
- // Задача завершена, только когда cleanup сделан: issue закрыт И ветки нет.
43
- // Пока ветка цела, работа не закончена, сколько бы issue ни был закрыт.
44
- if (issueClosed && !branchExists) return 'done'
45
- if (issueClosed) return 'cleanup'
46
-
47
- if (pulls.some((p) => p?.merged === true || p?.merged_at)) return 'deploy'
48
- if (pulls.some((p) => p?.state === 'open' && !isDraftPull(p))) return 'review'
49
- if (branchExists) return 'in-progress'
50
- return undefined
51
- }
52
-
53
- /**
54
- * Выбрать перевод из нескольких предложений разных источников.
55
- *
56
- * Предложение, совпадающее с текущей колонкой, отбрасывается: перевод в ту же
57
- * колонку — не перевод, а лишняя строка в журнале.
58
- *
59
- * @param {string} current текущая колонка
60
- * @param {Array<{column: string, source: string, detail?: string}>} candidates
61
- * @returns {{column: string, source: string, detail: string}|undefined}
62
- */
63
- export function resolveTransition(current, candidates) {
64
- const usable = (candidates ?? [])
65
- .filter((c) => c && typeof c.column === 'string' && c.column !== '' && c.column !== current)
66
- .filter((c) => PRIORITY[c.source] !== undefined)
67
- if (usable.length === 0) return undefined
68
- const best = usable.reduce((a, b) => (PRIORITY[a.source] <= PRIORITY[b.source] ? a : b))
69
- return { column: best.column, source: best.source, detail: best.detail ?? '' }
70
- }
71
-
72
- /**
73
- * Нужен ли человек по событию сессии.
74
- *
75
- * Колонки «Блокировано» в согласованном наборе нет, поэтому запрос разрешения и
76
- * вопрос агента не двигают карточку, а поднимают на ней признак ожидания.
77
- * Двигать в несуществующую колонку нельзя, а прятать «агент стоит и ждёт» —
78
- * значит терять главное, ради чего смотрят на доску.
79
- *
80
- * @returns {boolean|undefined} true — поднять, false — снять, undefined — событие не про это
81
- */
82
- export function waitingFromEvent(type) {
83
- switch (type) {
84
- case 'approval/asked':
85
- case 'question/requested':
86
- return true
87
- case 'approval/decided':
88
- case 'turn/start':
89
- case 'user/message':
90
- return false
91
- default:
92
- return undefined
93
- }
94
- }
95
-
96
- /** Имя ветки, по которому задача узнаёт свою: то, что наблюдалось, либо ничего. */
97
- export function branchOfTask(task, branches) {
98
- const known = typeof task?.branch === 'string' && task.branch !== '' ? task.branch : undefined
99
- const names = (branches ?? []).map((b) => (typeof b === 'string' ? b : b?.name)).filter(Boolean)
100
- if (known !== undefined) return names.includes(known) ? known : undefined
101
-
102
- // Ветку задачи заводит агент и называет как хочет, но номер issue в имени —
103
- // общее правило воркфлоу. Ищем по номеру, а не по угаданному шаблону.
104
- if (typeof task?.issueNumber !== 'number') return undefined
105
- const marker = String(task.issueNumber)
106
- return names.find((n) => new RegExp(`(^|[^0-9])${marker}([^0-9]|$)`).test(n))
107
- }
108
-
109
- /** Pull request-ы, относящиеся к задаче: по ветке либо по упоминанию issue. */
110
- export function pullsOfTask(task, pulls, branch) {
111
- const number = typeof task?.issueNumber === 'number' ? task.issueNumber : undefined
112
- return (pulls ?? []).filter((p) => {
113
- if (branch !== undefined && p?.head?.ref === branch) return true
114
- if (number === undefined) return false
115
- const haystack = `${p?.title ?? ''} ${p?.body ?? ''}`
116
- return new RegExp(`#${number}([^0-9]|$)`).test(haystack)
117
- })
118
- }
119
-
120
- /**
121
- * Кто победил, когда обе стороны изменились с последней сверки.
122
- *
123
- * Правило простое: побеждает более позднее изменение. Важно другое —
124
- * проигравшая сторона НЕ исчезает молча, а возвращается в поле `overridden`,
125
- * чтобы вызывающий записал её в журнал задачи. Синхронизация, которая тихо
126
- * отменяет правку человека, хуже синхронизации, которой нет: расхождение
127
- * остаётся, а узнать о нём неоткуда.
128
- *
129
- * @param {object} state
130
- * @param {string} state.localColumn колонка на доске
131
- * @param {string} [state.remoteColumn] колонка, выведенная из Gitea
132
- * @param {number} state.localUpdatedAt когда правили на доске
133
- * @param {number} state.remoteUpdatedAt когда правили в Gitea
134
- * @param {number} state.syncedAt время последней удачной сверки
135
- * @returns {{winner: 'local'|'remote'|'none', column?: string, overridden?: string}}
136
- */
137
- export function resolveConflict({ localColumn, remoteColumn, localUpdatedAt, remoteUpdatedAt, syncedAt }) {
138
- if (remoteColumn === undefined || remoteColumn === null || remoteColumn === '') return { winner: 'local' }
139
- if (remoteColumn === localColumn) return { winner: 'none' }
140
-
141
- const since = Number(syncedAt) || 0
142
-
143
- // До первой сверки «изменилось с прошлого раза» не имеет смысла: прошлого
144
- // раза не было. Карточку только что завели или импортировали, защищать в ней
145
- // нечего, и первая сверка обязана принять положение дел, а не спорить с ним.
146
- const firstSync = since === 0
147
- const localChanged = !firstSync && (Number(localUpdatedAt) || 0) > since
148
- const remoteChanged = firstSync || (Number(remoteUpdatedAt) || 0) > since
149
-
150
- if (localChanged && remoteChanged) {
151
- // Обе стороны двигались. Побеждает более позднее, проигравшее называется.
152
- if ((Number(localUpdatedAt) || 0) > (Number(remoteUpdatedAt) || 0)) {
153
- return { winner: 'local', overridden: remoteColumn }
154
- }
155
- return { winner: 'remote', column: remoteColumn, overridden: localColumn }
156
- }
157
-
158
- // Двигалась только доска — Gitea не имеет права тянуть карточку назад.
159
- if (localChanged) return { winner: 'local' }
160
-
161
- return { winner: 'remote', column: remoteColumn }
162
- }
1
+ // Автоперемещение карточек: вывод колонки из наблюдений и приоритет источников.
2
+ //
3
+ // Модуль чистый — ни сети, ни хранилища, ни харнесса. Наблюдения приходят
4
+ // готовыми, решение возвращается значением. Поэтому вся логика переходов
5
+ // проверяется тестами, а места, где она вызывается, остаются тонкими.
6
+
7
+ /**
8
+ * Приоритет источников. Без него «PR влит → deploy» через секунду перебивалось
9
+ * бы «ход завершился → in-progress», и карточка прыгала бы туда-сюда.
10
+ *
11
+ * Меньше число — сильнее источник.
12
+ */
13
+ const PRIORITY = { gitea: 0, tool: 1, session: 2, manual: 3 }
14
+
15
+ /** Pull request считается черновиком, пока с него не снят признак WIP. */
16
+ export function isDraftPull(pull) {
17
+ if (pull?.draft === true) return true
18
+ return /^\s*(wip|draft)\b[:\s-]/i.test(String(pull?.title ?? ''))
19
+ }
20
+
21
+ /**
22
+ * Колонка, которую диктуют наблюдения в Gitea.
23
+ *
24
+ * Порядок проверок идёт от самого позднего состояния к самому раннему: задача
25
+ * не может «вернуться» в in-progress только потому, что ветка ещё цела, если
26
+ * issue уже закрыт.
27
+ *
28
+ * WIP PR намеренно НЕ двигает карточку в review: он открывается рано и означает
29
+ * «работа идёт», а не «смотрите».
30
+ *
31
+ * @param {object} obs наблюдения
32
+ * @param {object} [obs.issue] issue из Gitea (`state`)
33
+ * @param {Array} [obs.pulls] pull request-ы, связанные с задачей
34
+ * @param {boolean} [obs.branchExists] существует ли ветка задачи
35
+ * @returns {string|undefined} колонка либо undefined, если наблюдения молчат
36
+ */
37
+ export function deriveColumn(obs) {
38
+ const issueClosed = obs?.issue?.state === 'closed'
39
+ const pulls = Array.isArray(obs?.pulls) ? obs.pulls : []
40
+ const branchExists = obs?.branchExists === true
41
+
42
+ // Задача завершена, только когда cleanup сделан: issue закрыт И ветки нет.
43
+ // Пока ветка цела, работа не закончена, сколько бы issue ни был закрыт.
44
+ if (issueClosed && !branchExists) return 'done'
45
+ if (issueClosed) return 'cleanup'
46
+
47
+ if (pulls.some((p) => p?.merged === true || p?.merged_at)) return 'deploy'
48
+ if (pulls.some((p) => p?.state === 'open' && !isDraftPull(p))) return 'review'
49
+ if (branchExists) return 'in-progress'
50
+ return undefined
51
+ }
52
+
53
+ /**
54
+ * Выбрать перевод из нескольких предложений разных источников.
55
+ *
56
+ * Предложение, совпадающее с текущей колонкой, отбрасывается: перевод в ту же
57
+ * колонку — не перевод, а лишняя строка в журнале.
58
+ *
59
+ * @param {string} current текущая колонка
60
+ * @param {Array<{column: string, source: string, detail?: string}>} candidates
61
+ * @returns {{column: string, source: string, detail: string}|undefined}
62
+ */
63
+ export function resolveTransition(current, candidates) {
64
+ const usable = (candidates ?? [])
65
+ .filter((c) => c && typeof c.column === 'string' && c.column !== '' && c.column !== current)
66
+ .filter((c) => PRIORITY[c.source] !== undefined)
67
+ if (usable.length === 0) return undefined
68
+ const best = usable.reduce((a, b) => (PRIORITY[a.source] <= PRIORITY[b.source] ? a : b))
69
+ return { column: best.column, source: best.source, detail: best.detail ?? '' }
70
+ }
71
+
72
+ /**
73
+ * Нужен ли человек по событию сессии.
74
+ *
75
+ * Колонки «Блокировано» в согласованном наборе нет, поэтому запрос разрешения и
76
+ * вопрос агента не двигают карточку, а поднимают на ней признак ожидания.
77
+ * Двигать в несуществующую колонку нельзя, а прятать «агент стоит и ждёт» —
78
+ * значит терять главное, ради чего смотрят на доску.
79
+ *
80
+ * @returns {boolean|undefined} true — поднять, false — снять, undefined — событие не про это
81
+ */
82
+ export function waitingFromEvent(type) {
83
+ switch (type) {
84
+ case 'approval/asked':
85
+ case 'question/requested':
86
+ return true
87
+ case 'approval/decided':
88
+ case 'turn/start':
89
+ case 'user/message':
90
+ return false
91
+ default:
92
+ return undefined
93
+ }
94
+ }
95
+
96
+ /** Имя ветки, по которому задача узнаёт свою: то, что наблюдалось, либо ничего. */
97
+ export function branchOfTask(task, branches) {
98
+ const known = typeof task?.branch === 'string' && task.branch !== '' ? task.branch : undefined
99
+ const names = (branches ?? []).map((b) => (typeof b === 'string' ? b : b?.name)).filter(Boolean)
100
+ if (known !== undefined) return names.includes(known) ? known : undefined
101
+
102
+ // Ветку задачи заводит агент и называет как хочет, но номер issue в имени —
103
+ // общее правило воркфлоу. Ищем по номеру, а не по угаданному шаблону.
104
+ if (typeof task?.issueNumber !== 'number') return undefined
105
+ const marker = String(task.issueNumber)
106
+ return names.find((n) => new RegExp(`(^|[^0-9])${marker}([^0-9]|$)`).test(n))
107
+ }
108
+
109
+ /** Pull request-ы, относящиеся к задаче: по ветке либо по упоминанию issue. */
110
+ export function pullsOfTask(task, pulls, branch) {
111
+ const number = typeof task?.issueNumber === 'number' ? task.issueNumber : undefined
112
+ return (pulls ?? []).filter((p) => {
113
+ if (branch !== undefined && p?.head?.ref === branch) return true
114
+ if (number === undefined) return false
115
+ const haystack = `${p?.title ?? ''} ${p?.body ?? ''}`
116
+ return new RegExp(`#${number}([^0-9]|$)`).test(haystack)
117
+ })
118
+ }
119
+
120
+ /**
121
+ * Кто победил, когда обе стороны изменились с последней сверки.
122
+ *
123
+ * Правило простое: побеждает более позднее изменение. Важно другое —
124
+ * проигравшая сторона НЕ исчезает молча, а возвращается в поле `overridden`,
125
+ * чтобы вызывающий записал её в журнал задачи. Синхронизация, которая тихо
126
+ * отменяет правку человека, хуже синхронизации, которой нет: расхождение
127
+ * остаётся, а узнать о нём неоткуда.
128
+ *
129
+ * @param {object} state
130
+ * @param {string} state.localColumn колонка на доске
131
+ * @param {string} [state.remoteColumn] колонка, выведенная из Gitea
132
+ * @param {number} state.localUpdatedAt когда правили на доске
133
+ * @param {number} state.remoteUpdatedAt когда правили в Gitea
134
+ * @param {number} state.syncedAt время последней удачной сверки
135
+ * @returns {{winner: 'local'|'remote'|'none', column?: string, overridden?: string}}
136
+ */
137
+ export function resolveConflict({ localColumn, remoteColumn, localUpdatedAt, remoteUpdatedAt, syncedAt }) {
138
+ if (remoteColumn === undefined || remoteColumn === null || remoteColumn === '') return { winner: 'local' }
139
+ if (remoteColumn === localColumn) return { winner: 'none' }
140
+
141
+ const since = Number(syncedAt) || 0
142
+
143
+ // До первой сверки «изменилось с прошлого раза» не имеет смысла: прошлого
144
+ // раза не было. Карточку только что завели или импортировали, защищать в ней
145
+ // нечего, и первая сверка обязана принять положение дел, а не спорить с ним.
146
+ const firstSync = since === 0
147
+ const localChanged = !firstSync && (Number(localUpdatedAt) || 0) > since
148
+ const remoteChanged = firstSync || (Number(remoteUpdatedAt) || 0) > since
149
+
150
+ if (localChanged && remoteChanged) {
151
+ // Обе стороны двигались. Побеждает более позднее, проигравшее называется.
152
+ if ((Number(localUpdatedAt) || 0) > (Number(remoteUpdatedAt) || 0)) {
153
+ return { winner: 'local', overridden: remoteColumn }
154
+ }
155
+ return { winner: 'remote', column: remoteColumn, overridden: localColumn }
156
+ }
157
+
158
+ // Двигалась только доска — Gitea не имеет права тянуть карточку назад.
159
+ if (localChanged) return { winner: 'local' }
160
+
161
+ return { winner: 'remote', column: remoteColumn }
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",