@goodandready/dsh-kanban 0.1.18 → 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.
- package/README.md +21 -0
- package/lib/client.js +149 -1
- package/lib/filters.js +288 -243
- package/lib/index.js +34 -1
- package/lib/routes.js +331 -256
- package/lib/store.js +389 -366
- package/lib/transitions.js +162 -162
- package/package.json +1 -1
- /package/{README.ru.md → docs/README.ru.md} +0 -0
- /package/{README.zh.md → docs/README.zh.md} +0 -0
package/lib/transitions.js
CHANGED
|
@@ -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.
|
|
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",
|
|
File without changes
|
|
File without changes
|