@goodandready/dsh-kanban 0.1.21 → 0.1.23
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 +53 -0
- package/lib/client.js +166 -7
- package/lib/filters.js +14 -0
- package/lib/import.js +15 -0
- package/lib/index.js +46 -7
- package/lib/launcher.js +45 -2
- package/lib/metrics.js +127 -0
- package/lib/routes.js +43 -0
- package/lib/store.js +11 -3
- package/lib/sync.js +5 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -141,6 +141,15 @@ back to the issue through the outbound queue, so the board keeps working while
|
|
|
141
141
|
Gitea is down; dropping sends an empty assignee list, because Gitea reads a
|
|
142
142
|
missing field as "leave it alone".
|
|
143
143
|
|
|
144
|
+
Three shortcuts follow from ownership. The column groups **by assignee** as
|
|
145
|
+
well as by project, with "nobody took it" always first — that group is what the
|
|
146
|
+
layout is opened for. A **"Mine: N"** button in the header narrows the board to
|
|
147
|
+
your own tasks in one click; it is drawn only when Gitea told the board who you
|
|
148
|
+
are and you actually have tasks, because a lying counter is worse than none.
|
|
149
|
+
And **starting work takes the task**: a free task becomes yours on launch, alone
|
|
150
|
+
or as part of a batch, while a task already assigned to someone else is left
|
|
151
|
+
alone.
|
|
152
|
+
|
|
144
153
|
Assignment made in Gitea arrives on the next sync — in both directions,
|
|
145
154
|
including removal. Filtering by "nobody" answers the question the board is
|
|
146
155
|
opened for most often: what is free to pick up.
|
|
@@ -207,6 +216,50 @@ npm test
|
|
|
207
216
|
|
|
208
217
|
---
|
|
209
218
|
|
|
219
|
+
### 8. Metrics: Where the Work Stands
|
|
220
|
+
|
|
221
|
+
The transition log has been filling up since day one, but it could only be read
|
|
222
|
+
one task at a time. The **Metrics** screen adds it up: how long tasks sit in each
|
|
223
|
+
column (median first — one task forgotten for half a year drags the average
|
|
224
|
+
until it describes nothing), how many reached Done in the last week and month,
|
|
225
|
+
and which tasks have been sitting still longer than the threshold.
|
|
226
|
+
|
|
227
|
+
Sitting time counts from entering the column, not from creation: a task can be
|
|
228
|
+
old and still move every day. Nothing is written — a metric that writes stops
|
|
229
|
+
being an observation and becomes another source of truth to disagree with.
|
|
230
|
+
|
|
231
|
+
### 9. Milestones and Reviving a Dead Session
|
|
232
|
+
|
|
233
|
+
The milestone travels with the issue and becomes a filter dimension of its own,
|
|
234
|
+
so "what is left before 0.2.0" is answered on the board rather than in Gitea.
|
|
235
|
+
Removing a milestone is news too, so an emptied field is synced like any other
|
|
236
|
+
change. Setting a milestone from the board is deliberately out of scope for now:
|
|
237
|
+
reading first, writing later, or we get a second place where it is edited.
|
|
238
|
+
|
|
239
|
+
A task whose session died — the harness restarted, the agent is gone — shows
|
|
240
|
+
"stopped". Its card now offers **resume work**: the old session is tried first,
|
|
241
|
+
and only if it cannot be revived does a new one start. The board says which of
|
|
242
|
+
the two happened, because "continued" and "started over" differ by whether the
|
|
243
|
+
conversation still exists.
|
|
244
|
+
|
|
245
|
+
## 🔌 Core Compatibility Check
|
|
246
|
+
|
|
247
|
+
A named import of an export the installed core no longer has is a `SyntaxError`
|
|
248
|
+
at parse time: the whole plugin tree fails to load and the harness restarts in a
|
|
249
|
+
loop. That happened twice in one week, and both times the owner found out before
|
|
250
|
+
the tests did.
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
npm run compat # uses $DSH_HOME, or ~/.dsh/profiles/web
|
|
254
|
+
npm run compat -- /path/to/profile
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The check reads every `import { … } from '@deepseek-ai/…'` in `lib/`, resolves
|
|
258
|
+
each package the way the harness resolves it — from the profile directory — and
|
|
259
|
+
compares the requested names against the actual exports. It is deliberately not
|
|
260
|
+
part of `npm test`: the test suite runs without a core and without a profile.
|
|
261
|
+
Run it on the machine where the harness lives, before publishing.
|
|
262
|
+
|
|
210
263
|
## 📄 License
|
|
211
264
|
|
|
212
265
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/lib/client.js
CHANGED
|
@@ -149,6 +149,27 @@ window.__ModuleLoader__.load({
|
|
|
149
149
|
return out
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Разложить колонку по ответственным.
|
|
154
|
+
*
|
|
155
|
+
* Группа «никто» идёт первой: свободные задачи — то, ради чего в эту
|
|
156
|
+
* раскладку и заглядывают. Дальше по алфавиту, чтобы порядок не плясал от
|
|
157
|
+
* того, кто первым попался в списке.
|
|
158
|
+
*
|
|
159
|
+
* @returns {Array<{who: string, tasks: object[]}>}
|
|
160
|
+
*/
|
|
161
|
+
function groupByAssignee(tasks) {
|
|
162
|
+
const by = new Map()
|
|
163
|
+
for (const task of tasks || []) {
|
|
164
|
+
const who = typeof task.assignee === 'string' && task.assignee !== '' ? task.assignee : ''
|
|
165
|
+
if (!by.has(who)) by.set(who, [])
|
|
166
|
+
by.get(who).push(task)
|
|
167
|
+
}
|
|
168
|
+
return [...by.entries()]
|
|
169
|
+
.sort((a, b) => (a[0] === '' ? -1 : b[0] === '' ? 1 : a[0].localeCompare(b[0])))
|
|
170
|
+
.map(([who, list]) => ({ who, tasks: list }))
|
|
171
|
+
}
|
|
172
|
+
|
|
152
173
|
function cardRef(task) {
|
|
153
174
|
if (!task || !task.repo) return ''
|
|
154
175
|
if (typeof task.issueNumber !== 'number') return task.repo
|
|
@@ -599,7 +620,7 @@ window.__ModuleLoader__.load({
|
|
|
599
620
|
}
|
|
600
621
|
|
|
601
622
|
const helpers = {
|
|
602
|
-
neighboursFor, tasksOf, sortTasks, packInfo, applyToEach, recallLaunch, rememberLaunch, stillOffered,
|
|
623
|
+
neighboursFor, tasksOf, sortTasks, packInfo, groupByAssignee, applyToEach, recallLaunch, rememberLaunch, stillOffered,
|
|
603
624
|
cardRef, taskRef, cardStatus, facetLabel, boardTitle,
|
|
604
625
|
columnTitle, planLine, stateLabel, isCollapsed,
|
|
605
626
|
agoText, exactAt, shortDate, syncLine, readableOn, tagStyle, priorityColor, renderBlocks, renderSpans, matchesFilters, anySelected, toggleValue, normalizeBoard, errorKey,
|
|
@@ -929,6 +950,14 @@ window.__ModuleLoader__.load({
|
|
|
929
950
|
'error.repo-not-created': 'The repository was not created; nothing else was done.',
|
|
930
951
|
'error.issue-not-created': 'The issue was not created. If the repository was new, it stayed — nothing was deleted.',
|
|
931
952
|
'board.archive': 'Archive',
|
|
953
|
+
'board.metrics': 'Metrics',
|
|
954
|
+
'metrics.hint': '{n} tasks on the board · done in a week: {week} · in a month: {month}',
|
|
955
|
+
'metrics.columns': 'Time spent in a column',
|
|
956
|
+
'metrics.median': 'median {time}',
|
|
957
|
+
'metrics.mean': 'average {time}',
|
|
958
|
+
'metrics.stale': 'Sitting still longer than {days} days',
|
|
959
|
+
'metrics.noStale': 'Nothing has been sitting that long.',
|
|
960
|
+
'metrics.empty': 'No transitions yet — nothing to measure.',
|
|
932
961
|
'board.export': 'Download',
|
|
933
962
|
'board.import': 'Import',
|
|
934
963
|
'board.exportHint': 'Download the whole board as JSON',
|
|
@@ -948,6 +977,11 @@ window.__ModuleLoader__.load({
|
|
|
948
977
|
'facet.repo': 'Repository',
|
|
949
978
|
'facet.author': 'Author',
|
|
950
979
|
'facet.assignee': 'Assignee',
|
|
980
|
+
'board.mine': 'Mine: {n}',
|
|
981
|
+
'board.mineHint': 'Tasks assigned to {who}',
|
|
982
|
+
'group.repo': 'By project',
|
|
983
|
+
'group.assignee': 'By assignee',
|
|
984
|
+
'group.nobody': 'nobody took it',
|
|
951
985
|
'card.assigneeHint': 'Who took the task',
|
|
952
986
|
'panel.assignee': 'taken by {who}',
|
|
953
987
|
'panel.assignMe': 'Take it',
|
|
@@ -958,6 +992,9 @@ window.__ModuleLoader__.load({
|
|
|
958
992
|
'board.pickMove': 'Move to…',
|
|
959
993
|
'board.someFailed': 'Done for {done} of {total}; the rest refused.',
|
|
960
994
|
'card.stop': 'stop',
|
|
995
|
+
'card.revive': 'resume work',
|
|
996
|
+
'revive.same': 'The old session came back — the chat is where you left it.',
|
|
997
|
+
'revive.fresh': 'The old session was gone, so a new one started; the previous chat is not there any more.',
|
|
961
998
|
'card.pack': 'batch {at} of {total}',
|
|
962
999
|
'card.packHint': 'Started together, one session; the agent takes them in this order.',
|
|
963
1000
|
'stop.idle': 'The agent was not running — there was nothing to stop.',
|
|
@@ -1180,6 +1217,14 @@ window.__ModuleLoader__.load({
|
|
|
1180
1217
|
'error.repo-not-created': '仓库没有创建,其他动作也未执行。',
|
|
1181
1218
|
'error.issue-not-created': 'issue 没有创建。如果仓库是新建的,它仍然保留——没有删除任何东西。',
|
|
1182
1219
|
'board.archive': '归档',
|
|
1220
|
+
'board.metrics': '指标',
|
|
1221
|
+
'metrics.hint': '看板上有 {n} 个任务 · 一周完成 {week} 个 · 一个月完成 {month} 个',
|
|
1222
|
+
'metrics.columns': '在每一列停留的时间',
|
|
1223
|
+
'metrics.median': '中位数 {time}',
|
|
1224
|
+
'metrics.mean': '平均 {time}',
|
|
1225
|
+
'metrics.stale': '停滞超过 {days} 天',
|
|
1226
|
+
'metrics.noStale': '没有停滞这么久的任务。',
|
|
1227
|
+
'metrics.empty': '还没有流转记录,无从统计。',
|
|
1183
1228
|
'board.export': '下载',
|
|
1184
1229
|
'board.import': '导入',
|
|
1185
1230
|
'board.exportHint': '将整个看板下载为 JSON',
|
|
@@ -1199,6 +1244,11 @@ window.__ModuleLoader__.load({
|
|
|
1199
1244
|
'facet.repo': '仓库',
|
|
1200
1245
|
'facet.author': '创建者',
|
|
1201
1246
|
'facet.assignee': '负责人',
|
|
1247
|
+
'board.mine': '我的:{n}',
|
|
1248
|
+
'board.mineHint': '指派给 {who} 的任务',
|
|
1249
|
+
'group.repo': '按项目',
|
|
1250
|
+
'group.assignee': '按负责人',
|
|
1251
|
+
'group.nobody': '无人接手',
|
|
1202
1252
|
'card.assigneeHint': '谁接下了这个任务',
|
|
1203
1253
|
'panel.assignee': '由 {who} 接手',
|
|
1204
1254
|
'panel.assignMe': '我来接',
|
|
@@ -1209,6 +1259,9 @@ window.__ModuleLoader__.load({
|
|
|
1209
1259
|
'board.pickMove': '移动到…',
|
|
1210
1260
|
'board.someFailed': '{total} 个中完成了 {done} 个,其余被拒绝。',
|
|
1211
1261
|
'card.stop': '停止',
|
|
1262
|
+
'card.revive': '继续工作',
|
|
1263
|
+
'revive.same': '原会话已恢复,对话还在原处。',
|
|
1264
|
+
'revive.fresh': '原会话已不在,因此新开了一个;先前的对话不在其中。',
|
|
1212
1265
|
'card.pack': '批次 {at}/{total}',
|
|
1213
1266
|
'card.packHint': '一起启动,同一个会话;Agent 按这个顺序接手。',
|
|
1214
1267
|
'stop.idle': 'Agent 本来就没有在跑,没有什么可停。',
|
|
@@ -1589,6 +1642,12 @@ window.__ModuleLoader__.load({
|
|
|
1589
1642
|
// работу», разница лишь в том, кому и когда.
|
|
1590
1643
|
// Останавливать нечего у того, кто не идёт: пункт, который
|
|
1591
1644
|
// ничего не делает, хуже отсутствующего.
|
|
1645
|
+
task.state === 'stopped'
|
|
1646
|
+
? React.createElement('button', {
|
|
1647
|
+
type: 'button', className: 'dkb-facetRow', key: 'revive',
|
|
1648
|
+
onClick: (e) => { e.stopPropagation(); props.onMenu(''); props.onRevive(task) },
|
|
1649
|
+
}, t('card.revive'))
|
|
1650
|
+
: null,
|
|
1592
1651
|
task.state === 'running' || task.state === 'waiting'
|
|
1593
1652
|
? React.createElement('button', {
|
|
1594
1653
|
type: 'button', className: 'dkb-facetRow', key: 'stop',
|
|
@@ -1735,12 +1794,20 @@ window.__ModuleLoader__.load({
|
|
|
1735
1794
|
// выбор переживает и открытие окна, и обновление доски.
|
|
1736
1795
|
const [picked, setPicked] = React.useState({})
|
|
1737
1796
|
const [archive, setArchive] = React.useState(null)
|
|
1797
|
+
const [metrics, setMetrics] = React.useState(null)
|
|
1738
1798
|
// «Только ждущие» держим отдельно от отборов: это не значение измерения,
|
|
1739
1799
|
// а взгляд «покажи, где стоит работа», и складывается он с любым отбором.
|
|
1740
1800
|
const [onlyWaiting, setOnlyWaiting] = React.useState(false)
|
|
1741
1801
|
const [onlyOverdue, setOnlyOverdue] = React.useState(false)
|
|
1742
1802
|
const [hoverSession, setHoverSession] = React.useState('')
|
|
1743
1803
|
const [order, setOrder] = React.useState('manual')
|
|
1804
|
+
// «По проектам» — как приходит с сервера; «по ответственному» — своя
|
|
1805
|
+
// раскладка поверх того же порядка.
|
|
1806
|
+
const [grouping, setGrouping] = React.useState('repo')
|
|
1807
|
+
// Кто «я»: спрашивается один раз у сервера. Пусто — Gitea не ответил, и
|
|
1808
|
+
// кнопку «Мои» рисовать нельзя: соврать числом хуже, чем не показать.
|
|
1809
|
+
const [me, setMe] = React.useState('')
|
|
1810
|
+
const [onlyMine, setOnlyMine] = React.useState(false)
|
|
1744
1811
|
// Чем запускать: профиль агента и уровень доступа. Обе службы у ядра
|
|
1745
1812
|
// необязательны, поэтому пустой список — не поломка, а «выбирать не из
|
|
1746
1813
|
// чего»: поле тогда не рисуется вовсе.
|
|
@@ -1830,6 +1897,9 @@ window.__ModuleLoader__.load({
|
|
|
1830
1897
|
|
|
1831
1898
|
React.useEffect(() => {
|
|
1832
1899
|
let alive = true
|
|
1900
|
+
api('/whoami')
|
|
1901
|
+
.then((out) => { if (alive && out) setMe(typeof out.login === 'string' ? out.login : '') })
|
|
1902
|
+
.catch(() => {})
|
|
1833
1903
|
api('/presets')
|
|
1834
1904
|
.then((out) => {
|
|
1835
1905
|
if (!alive || !out) return
|
|
@@ -2011,6 +2081,26 @@ window.__ModuleLoader__.load({
|
|
|
2011
2081
|
onClick: () => setOnlyOverdue((cur) => !cur),
|
|
2012
2082
|
}, t('board.overdueCount', { n: overdue }))
|
|
2013
2083
|
})(),
|
|
2084
|
+
(() => {
|
|
2085
|
+
// Ноль своих задач — кнопки нет: пустой счётчик в шапке перестают
|
|
2086
|
+
// замечать вместе с непустым.
|
|
2087
|
+
if (me === '') return null
|
|
2088
|
+
const mine = state.tasks.filter((x) => x.assignee === me).length
|
|
2089
|
+
if (mine === 0) return null
|
|
2090
|
+
return React.createElement('button', {
|
|
2091
|
+
type: 'button',
|
|
2092
|
+
className: 'dkb-waitCount' + (onlyMine ? ' dkb-save' : ''),
|
|
2093
|
+
title: t('board.mineHint', { who: me }),
|
|
2094
|
+
onClick: () => setOnlyMine((cur) => !cur),
|
|
2095
|
+
}, t('board.mine', { n: mine }))
|
|
2096
|
+
})(),
|
|
2097
|
+
React.createElement('select', {
|
|
2098
|
+
className: 'dkb-input', style: { width: 'auto' }, value: grouping,
|
|
2099
|
+
onChange: (e) => setGrouping(e.target.value),
|
|
2100
|
+
},
|
|
2101
|
+
React.createElement('option', { value: 'repo' }, t('group.repo')),
|
|
2102
|
+
React.createElement('option', { value: 'assignee' }, t('group.assignee')),
|
|
2103
|
+
),
|
|
2014
2104
|
React.createElement('select', {
|
|
2015
2105
|
className: 'dkb-input', style: { width: 'auto' }, value: order,
|
|
2016
2106
|
onChange: (e) => setOrder(e.target.value),
|
|
@@ -2019,11 +2109,22 @@ window.__ModuleLoader__.load({
|
|
|
2019
2109
|
React.createElement('option', { value: 'old' }, t('order.old')),
|
|
2020
2110
|
React.createElement('option', { value: 'new' }, t('order.new')),
|
|
2021
2111
|
),
|
|
2112
|
+
React.createElement('button', {
|
|
2113
|
+
type: 'button', className: metrics ? 'dkb-save' : 'dkb-discard',
|
|
2114
|
+
onClick: async () => {
|
|
2115
|
+
if (metrics) { setMetrics(null); return }
|
|
2116
|
+
try {
|
|
2117
|
+
setArchive(null)
|
|
2118
|
+
setMetrics(await api('/metrics?board=' + encodeURIComponent(board)))
|
|
2119
|
+
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
2120
|
+
},
|
|
2121
|
+
}, t('board.metrics')),
|
|
2022
2122
|
React.createElement('button', {
|
|
2023
2123
|
type: 'button', className: archive ? 'dkb-save' : 'dkb-discard',
|
|
2024
2124
|
onClick: async () => {
|
|
2025
2125
|
if (archive) { setArchive(null); return }
|
|
2026
2126
|
try {
|
|
2127
|
+
setMetrics(null)
|
|
2027
2128
|
const out = await api('/archive')
|
|
2028
2129
|
setArchive(Array.isArray(out.tasks) ? out.tasks : [])
|
|
2029
2130
|
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
@@ -2204,9 +2305,18 @@ window.__ModuleLoader__.load({
|
|
|
2204
2305
|
const items = sortTasks(
|
|
2205
2306
|
all.filter((task) => matchesQuery(task, query))
|
|
2206
2307
|
.filter((task) => !onlyWaiting || task.state === 'waiting')
|
|
2207
|
-
.filter((task) => !onlyOverdue || task.overdue === true)
|
|
2308
|
+
.filter((task) => !onlyOverdue || task.overdue === true)
|
|
2309
|
+
.filter((task) => !onlyMine || task.assignee === me),
|
|
2208
2310
|
order,
|
|
2209
2311
|
)
|
|
2312
|
+
// Ключ группы решает переключатель: заголовки, сворачивание и
|
|
2313
|
+
// счётчики остаются те же, меняется только по чему делим.
|
|
2314
|
+
const groupKeyOf = (task) => (grouping === 'assignee'
|
|
2315
|
+
? (task.assignee || '')
|
|
2316
|
+
: (task.repo || ''))
|
|
2317
|
+
const ordered = grouping === 'assignee'
|
|
2318
|
+
? groupByAssignee(items).flatMap((one) => one.tasks)
|
|
2319
|
+
: items
|
|
2210
2320
|
const limit = col.limit
|
|
2211
2321
|
const overLimit = limit !== undefined && limit !== null && all.length > limit
|
|
2212
2322
|
const slot = (index) => React.createElement('div', {
|
|
@@ -2226,11 +2336,11 @@ window.__ModuleLoader__.load({
|
|
|
2226
2336
|
// разложенными, поэтому группы идут подряд и разрываться не могут.
|
|
2227
2337
|
let group = null
|
|
2228
2338
|
const sizes = new Map()
|
|
2229
|
-
for (const x of
|
|
2339
|
+
for (const x of ordered) sizes.set(groupKeyOf(x), (sizes.get(groupKeyOf(x)) || 0) + 1)
|
|
2230
2340
|
const manyGroups = sizes.size > 1
|
|
2231
2341
|
|
|
2232
|
-
|
|
2233
|
-
const repo = task
|
|
2342
|
+
ordered.forEach((task, i) => {
|
|
2343
|
+
const repo = groupKeyOf(task)
|
|
2234
2344
|
if (manyGroups && repo !== group) {
|
|
2235
2345
|
group = repo
|
|
2236
2346
|
const shut = groups[col.id + '/' + repo] === true
|
|
@@ -2239,7 +2349,8 @@ window.__ModuleLoader__.load({
|
|
|
2239
2349
|
'aria-expanded': !shut,
|
|
2240
2350
|
onClick: () => setGroups((cur) => Object.assign({}, cur, { [col.id + '/' + repo]: !shut })),
|
|
2241
2351
|
},
|
|
2242
|
-
React.createElement('span', null,
|
|
2352
|
+
React.createElement('span', null,
|
|
2353
|
+
repo || t(grouping === 'assignee' ? 'group.nobody' : 'board.noRepo')),
|
|
2243
2354
|
// Счётчик виден и у свёрнутой: группа с работой, выглядящая
|
|
2244
2355
|
// пустой, — то же зло, что и свёрнутая колонка без числа.
|
|
2245
2356
|
React.createElement('span', { className: 'dkb-groupCount' }, String(sizes.get(repo))),
|
|
@@ -2261,6 +2372,18 @@ window.__ModuleLoader__.load({
|
|
|
2261
2372
|
pack: packs[task.id],
|
|
2262
2373
|
sameSession: hoverSession !== '' && task.sessionId === hoverSession,
|
|
2263
2374
|
onHover: setHoverSession,
|
|
2375
|
+
onRevive: async (x) => {
|
|
2376
|
+
// Переиспользуем обычный запуск: он сам пробует возобновить
|
|
2377
|
+
// прежнюю сессию и только потом заводит новую. Здесь важно
|
|
2378
|
+
// сказать, что именно вышло.
|
|
2379
|
+
try {
|
|
2380
|
+
const out = await api('/task/' + encodeURIComponent(x.id) + '/start', {
|
|
2381
|
+
method: 'POST', body: JSON.stringify({ revive: true }),
|
|
2382
|
+
})
|
|
2383
|
+
setError(t(out && out.mode === 'created' ? 'revive.fresh' : 'revive.same'))
|
|
2384
|
+
await load(board)
|
|
2385
|
+
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
2386
|
+
},
|
|
2264
2387
|
onStop: async (x) => {
|
|
2265
2388
|
try {
|
|
2266
2389
|
const out = await api('/task/' + encodeURIComponent(x.id) + '/stop', { method: 'POST', body: '{}' })
|
|
@@ -3002,10 +3125,46 @@ window.__ModuleLoader__.load({
|
|
|
3002
3125
|
)
|
|
3003
3126
|
: null
|
|
3004
3127
|
|
|
3128
|
+
// Метрика — это взгляд на журнал, а не новые данные: экран ничего не
|
|
3129
|
+
// меняет, поэтому и кнопок действий на нём нет.
|
|
3130
|
+
const metricsEl = metrics
|
|
3131
|
+
? React.createElement('div', { className: 'dkb-archive' },
|
|
3132
|
+
React.createElement('p', { className: 'dkb-hint' },
|
|
3133
|
+
t('metrics.hint', { n: metrics.tasks, week: metrics.done.week, month: metrics.done.month })),
|
|
3134
|
+
React.createElement('div', { className: 'dkb-group' }, t('metrics.columns')),
|
|
3135
|
+
metrics.columns.length === 0
|
|
3136
|
+
? React.createElement('p', { className: 'dkb-note' }, t('metrics.empty'))
|
|
3137
|
+
: React.createElement('ul', { className: 'dkb-archiveList' },
|
|
3138
|
+
metrics.columns.map((row) => React.createElement('li', {
|
|
3139
|
+
className: 'dkb-archiveRow', key: row.column,
|
|
3140
|
+
},
|
|
3141
|
+
React.createElement('span', { className: 'dkb-archiveTitle' }, columnTitle({ id: row.column }, t)),
|
|
3142
|
+
React.createElement('span', { className: 'dkb-taskRef' },
|
|
3143
|
+
t('metrics.median', { time: agoText(0, -row.median, t) })),
|
|
3144
|
+
React.createElement('span', { className: 'dkb-taskRef' },
|
|
3145
|
+
t('metrics.mean', { time: agoText(0, -row.mean, t) })),
|
|
3146
|
+
React.createElement('span', { className: 'dkb-count' }, String(row.tasks)),
|
|
3147
|
+
))),
|
|
3148
|
+
React.createElement('div', { className: 'dkb-group' },
|
|
3149
|
+
t('metrics.stale', { days: metrics.staleDays })),
|
|
3150
|
+
metrics.stale.length === 0
|
|
3151
|
+
? React.createElement('p', { className: 'dkb-note' }, t('metrics.noStale'))
|
|
3152
|
+
: React.createElement('ul', { className: 'dkb-archiveList' },
|
|
3153
|
+
metrics.stale.map((row) => React.createElement('li', {
|
|
3154
|
+
className: 'dkb-archiveRow', key: row.id,
|
|
3155
|
+
},
|
|
3156
|
+
React.createElement('span', { className: 'dkb-archiveTitle' }, row.title),
|
|
3157
|
+
React.createElement('span', { className: 'dkb-taskRef' }, columnTitle({ id: row.column }, t)),
|
|
3158
|
+
React.createElement('span', { className: 'dkb-taskRef', title: exactAt(row.since) },
|
|
3159
|
+
agoText(state.now, row.since, t)),
|
|
3160
|
+
))),
|
|
3161
|
+
)
|
|
3162
|
+
: null
|
|
3163
|
+
|
|
3005
3164
|
return React.createElement('div', { className: 'dkb-screen' },
|
|
3006
3165
|
style, bar,
|
|
3007
3166
|
error ? React.createElement('p', { className: 'dkb-failed' }, error) : null,
|
|
3008
|
-
archive ? archiveEl : columns,
|
|
3167
|
+
metrics ? metricsEl : archive ? archiveEl : columns,
|
|
3009
3168
|
panel, dialogEl, moveDialogEl, batchEl, queueEl,
|
|
3010
3169
|
)
|
|
3011
3170
|
}
|
package/lib/filters.js
CHANGED
|
@@ -14,6 +14,9 @@ export const AUTHOR = 'author'
|
|
|
14
14
|
/** Ответственный: кто взял задачу, а не кто её завёл. */
|
|
15
15
|
export const ASSIGNEE = 'assignee'
|
|
16
16
|
|
|
17
|
+
/** Выпуск, к которому задача отнесена в Gitea. */
|
|
18
|
+
export const MILESTONE = 'milestone'
|
|
19
|
+
|
|
17
20
|
/** Приоритет — локальное свойство доски, своё измерение отбора. */
|
|
18
21
|
export const PRIORITY = 'priority'
|
|
19
22
|
|
|
@@ -34,6 +37,9 @@ export const NO_AUTHOR = '—'
|
|
|
34
37
|
*/
|
|
35
38
|
export const NOBODY = '—'
|
|
36
39
|
|
|
40
|
+
/** Значение для задач вне выпусков: «когда-нибудь» — тоже ответ. */
|
|
41
|
+
export const NO_MILESTONE = '—'
|
|
42
|
+
|
|
37
43
|
/** Порядок приоритетов в отборе: срочное сверху. */
|
|
38
44
|
export const PRIORITY_ORDER = ['critical', 'high', 'medium', 'low']
|
|
39
45
|
|
|
@@ -79,6 +85,7 @@ export function facetsOf(tasks) {
|
|
|
79
85
|
for (const task of tasks ?? []) {
|
|
80
86
|
add(REPO, typeof task?.repo === 'string' ? task.repo : '')
|
|
81
87
|
add(ASSIGNEE, typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY)
|
|
88
|
+
add(MILESTONE, typeof task?.milestone === 'string' && task.milestone !== '' ? task.milestone : NO_MILESTONE)
|
|
82
89
|
add(AUTHOR, typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR)
|
|
83
90
|
// Приоритет живёт и полем (свои карточки), и меткой `priority/*` (задачи
|
|
84
91
|
// из Gitea). Оба попадают в одно измерение — отбор по срочности не должен
|
|
@@ -106,6 +113,9 @@ function rank(ns) {
|
|
|
106
113
|
// Ответственный идёт раньше автора: «кто делает» спрашивают чаще, чем «кто
|
|
107
114
|
// завёл».
|
|
108
115
|
if (ns === ASSIGNEE) return -2
|
|
116
|
+
// Выпуск идёт сразу за ответственным: «что осталось до релиза» спрашивают
|
|
117
|
+
// тогда же, когда «кто делает».
|
|
118
|
+
if (ns === MILESTONE) return -1.5
|
|
109
119
|
if (ns === AUTHOR) return -1
|
|
110
120
|
const at = KNOWN_ORDER.indexOf(ns)
|
|
111
121
|
return at === -1 ? KNOWN_ORDER.length : at
|
|
@@ -148,6 +158,10 @@ function hasAny(task, ns, wanted) {
|
|
|
148
158
|
const who = typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY
|
|
149
159
|
return wanted.includes(who)
|
|
150
160
|
}
|
|
161
|
+
if (ns === MILESTONE) {
|
|
162
|
+
const to = typeof task?.milestone === 'string' && task.milestone !== '' ? task.milestone : NO_MILESTONE
|
|
163
|
+
return wanted.includes(to)
|
|
164
|
+
}
|
|
151
165
|
if (ns === AUTHOR) {
|
|
152
166
|
const author = typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR
|
|
153
167
|
return wanted.includes(author)
|
package/lib/import.js
CHANGED
|
@@ -49,6 +49,17 @@ export function issueAssignee(issue) {
|
|
|
49
49
|
return first === undefined ? '' : first.login
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* К какому выпуску относится issue.
|
|
54
|
+
*
|
|
55
|
+
* Релизы ведут майлстоунами Gitea, а доска о них не знала: «что осталось до
|
|
56
|
+
* 0.2.0» приходилось смотреть в другом месте, хотя карточки лежат здесь.
|
|
57
|
+
*/
|
|
58
|
+
export function issueMilestone(issue) {
|
|
59
|
+
const title = issue?.milestone?.title
|
|
60
|
+
return typeof title === 'string' ? title : ''
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
/**
|
|
53
64
|
* Поля задачи из issue.
|
|
54
65
|
*
|
|
@@ -77,6 +88,7 @@ export function issueToTask(issue, { board = 'main', column = 'backlog', owner,
|
|
|
77
88
|
createdAt: issueBornAt(issue),
|
|
78
89
|
author: issueAuthor(issue),
|
|
79
90
|
assignee: issueAssignee(issue),
|
|
91
|
+
milestone: issueMilestone(issue),
|
|
80
92
|
}
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -126,6 +138,9 @@ export function refreshPatch(task, issue) {
|
|
|
126
138
|
if (issue?.assignee !== undefined || Array.isArray(issue?.assignees)) {
|
|
127
139
|
patch.assignee = issueAssignee(issue)
|
|
128
140
|
}
|
|
141
|
+
// Майлстоун снимают так же часто, как ставят, поэтому пустое значение —
|
|
142
|
+
// тоже новость, а не отсутствие новостей.
|
|
143
|
+
if ('milestone' in (issue ?? {})) patch.milestone = issueMilestone(issue)
|
|
129
144
|
return patch
|
|
130
145
|
}
|
|
131
146
|
|
package/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
MAX_BODY_BYTES, isTrustedRequest, parseTaskPath,
|
|
23
23
|
buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
|
|
24
24
|
taskBody,
|
|
25
|
-
taskLog, taskBySession, exportBoard, importBoard, assignTask,
|
|
25
|
+
taskLog, taskBySession, exportBoard, importBoard, assignTask, boardMetrics,
|
|
26
26
|
} from './routes.js'
|
|
27
27
|
import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
|
|
28
28
|
import {
|
|
@@ -387,6 +387,7 @@ export function apply(ctx, config) {
|
|
|
387
387
|
agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
|
|
388
388
|
permission: typeof input.permission === 'string' ? input.permission : undefined,
|
|
389
389
|
permissions: ctx.get('permissionPresets'),
|
|
390
|
+
claimAs: await whoAmI(),
|
|
390
391
|
}))
|
|
391
392
|
} catch (error) {
|
|
392
393
|
writeJson(res, error?.status ?? 500, { error: 'batch-failed' })
|
|
@@ -525,6 +526,47 @@ export function apply(ctx, config) {
|
|
|
525
526
|
// самый путь, на котором с первого дня живёт импорт issue из Gitea. Два
|
|
526
527
|
// обработчика на одном пути означают, что один из них не отвечает вовсе, и
|
|
527
528
|
// какой именно — решает порядок регистрации, а не мы.
|
|
529
|
+
/**
|
|
530
|
+
* Кто мы для Gitea, с памятью на время работы.
|
|
531
|
+
*
|
|
532
|
+
* Логин нужен и «взять себе», и запуску работы. Спрашивать его у Gitea
|
|
533
|
+
* каждый раз значит добавить в путь запуска ещё одну сеть, которая может не
|
|
534
|
+
* ответить; поэтому спрашиваем один раз и помним. Не ответила — работаем без
|
|
535
|
+
* назначения, а не отказываем в запуске.
|
|
536
|
+
*/
|
|
537
|
+
let knownLogin
|
|
538
|
+
const whoAmI = async () => {
|
|
539
|
+
if (knownLogin !== undefined) return knownLogin
|
|
540
|
+
try {
|
|
541
|
+
knownLogin = await gitea.me()
|
|
542
|
+
} catch (error) {
|
|
543
|
+
knownLogin = ''
|
|
544
|
+
}
|
|
545
|
+
return knownLogin
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
ctx.effect(() => ctx.webServer.register({
|
|
549
|
+
kind: 'exact',
|
|
550
|
+
path: '/dsh-kanban/metrics',
|
|
551
|
+
handler: (req, res) => {
|
|
552
|
+
const url = urlOf(req)
|
|
553
|
+
const days = Number(url.searchParams.get('staleDays'))
|
|
554
|
+
reply(res, boardMetrics({
|
|
555
|
+
store,
|
|
556
|
+
board: url.searchParams.get('board') || 'main',
|
|
557
|
+
staleDays: Number.isFinite(days) && days > 0 ? days : 14,
|
|
558
|
+
}))
|
|
559
|
+
},
|
|
560
|
+
}), 'dsh-kanban: метрики доски')
|
|
561
|
+
|
|
562
|
+
ctx.effect(() => ctx.webServer.register({
|
|
563
|
+
kind: 'exact',
|
|
564
|
+
path: '/dsh-kanban/whoami',
|
|
565
|
+
// Пустой логин — законный ответ: Gitea может быть не настроен или не
|
|
566
|
+
// отвечать. Браузер по нему просто не рисует «Мои», а не показывает ноль.
|
|
567
|
+
handler: async (req, res) => { reply(res, { login: await whoAmI() }) },
|
|
568
|
+
}), 'dsh-kanban: кто мы для Gitea')
|
|
569
|
+
|
|
528
570
|
ctx.effect(() => ctx.webServer.register({
|
|
529
571
|
kind: 'exact',
|
|
530
572
|
path: '/dsh-kanban/snapshot',
|
|
@@ -619,6 +661,7 @@ export function apply(ctx, config) {
|
|
|
619
661
|
agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
|
|
620
662
|
permission: typeof input.permission === 'string' ? input.permission : undefined,
|
|
621
663
|
permissions: ctx.get('permissionPresets'),
|
|
664
|
+
claimAs: await whoAmI(),
|
|
622
665
|
}))
|
|
623
666
|
} catch (error) {
|
|
624
667
|
ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
|
|
@@ -681,12 +724,8 @@ export function apply(ctx, config) {
|
|
|
681
724
|
// токеном ходит доска: своего понятия пользователя у харнесса нет.
|
|
682
725
|
let login = typeof input.login === 'string' ? input.login.trim() : ''
|
|
683
726
|
if (login === 'me') {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
} catch (error) {
|
|
687
|
-
writeJson(res, 502, { error: 'gitea-unreachable' })
|
|
688
|
-
return
|
|
689
|
-
}
|
|
727
|
+
login = await whoAmI()
|
|
728
|
+
if (login === '') { writeJson(res, 502, { error: 'gitea-unreachable' }); return }
|
|
690
729
|
}
|
|
691
730
|
const out = assignTask({ store, id, login })
|
|
692
731
|
if (out.task !== undefined && config.pushToGitea !== false) {
|
package/lib/launcher.js
CHANGED
|
@@ -199,10 +199,47 @@ export async function obtainAgent({
|
|
|
199
199
|
* @param {string} [text] сообщение человека; пусто — встроенная заготовка
|
|
200
200
|
* @returns {{sessionId: string, mode: string, task: object}}
|
|
201
201
|
*/
|
|
202
|
+
/**
|
|
203
|
+
* Что случилось при попытке продолжить работу.
|
|
204
|
+
*
|
|
205
|
+
* «Продолжил» и «начал заново» — разные вещи, и человек обязан видеть, какая
|
|
206
|
+
* из них произошла: в первом случае переписка на месте, во втором её больше
|
|
207
|
+
* нет.
|
|
208
|
+
*
|
|
209
|
+
* @returns {'opened'|'resumed'|'created'} как в obtainAgent
|
|
210
|
+
*/
|
|
211
|
+
export function revivalKind(mode) {
|
|
212
|
+
return mode === 'opened' || mode === 'resumed' ? mode : 'created'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Взять задачу на себя при запуске.
|
|
217
|
+
*
|
|
218
|
+
* Запустить работу и оставить задачу ничьей — значит показать её свободной,
|
|
219
|
+
* когда над ней уже работают. Чужое назначение не трогаем: задачу могли отдать
|
|
220
|
+
* другому осознанно.
|
|
221
|
+
*
|
|
222
|
+
* @returns {string} логин, если назначение случилось; иначе пустая строка
|
|
223
|
+
*/
|
|
224
|
+
export function claimOnStart({ store, task, login }) {
|
|
225
|
+
const who = typeof login === 'string' ? login.trim() : ''
|
|
226
|
+
if (who === '') return ''
|
|
227
|
+
if (typeof task?.assignee === 'string' && task.assignee !== '') return ''
|
|
228
|
+
store.updateTask(task.id, { assignee: who })
|
|
229
|
+
store.addTransition({
|
|
230
|
+
taskId: task.id,
|
|
231
|
+
fromCol: task.column,
|
|
232
|
+
toCol: task.column,
|
|
233
|
+
source: 'manual',
|
|
234
|
+
detail: `ответственный: ${who} (взял при запуске)`,
|
|
235
|
+
})
|
|
236
|
+
return who
|
|
237
|
+
}
|
|
238
|
+
|
|
202
239
|
export async function runTask({
|
|
203
240
|
agents, store, task, config, provider, model,
|
|
204
241
|
mintSessionId, createMessage, cwdOf, logger, text,
|
|
205
|
-
agentPreset, permission, permissions,
|
|
242
|
+
agentPreset, permission, permissions, claimAs,
|
|
206
243
|
}) {
|
|
207
244
|
const taken = await obtainAgent({
|
|
208
245
|
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
@@ -220,6 +257,9 @@ export async function runTask({
|
|
|
220
257
|
const before = store.getTask(task.id)
|
|
221
258
|
const patch = { sessionId: taken.sessionId, provider, model }
|
|
222
259
|
store.updateTask(task.id, patch)
|
|
260
|
+
// Запустил — значит отвечаешь: свободная задача становится твоей, чужая
|
|
261
|
+
// остаётся чужой.
|
|
262
|
+
claimOnStart({ store, task: before, login: claimAs })
|
|
223
263
|
|
|
224
264
|
// Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
|
|
225
265
|
// поймёт, куда делась переписка.
|
|
@@ -297,7 +337,7 @@ export function buildQueuedMessage(task, config, at, total) {
|
|
|
297
337
|
export async function runBatch({
|
|
298
338
|
agents, store, tasks, config, provider, model,
|
|
299
339
|
mintSessionId, createMessage, cwdOf, logger, text, now = Date.now(),
|
|
300
|
-
agentPreset, permission, permissions,
|
|
340
|
+
agentPreset, permission, permissions, claimAs,
|
|
301
341
|
}) {
|
|
302
342
|
const list = (tasks ?? []).filter((task) => task !== undefined)
|
|
303
343
|
if (list.length === 0) return { error: 'nothing-picked', status: 400 }
|
|
@@ -329,6 +369,9 @@ export async function runBatch({
|
|
|
329
369
|
// даже когда всё случилось в одну миллисекунду.
|
|
330
370
|
const patch = { sessionId: taken.sessionId, provider, model, queuedAt: at === 0 ? 0 : now + at }
|
|
331
371
|
store.updateTask(task.id, patch)
|
|
372
|
+
// Пачку берёт на себя тот, кто её запустил: пять карточек в работе без
|
|
373
|
+
// ответственного выглядят свободными.
|
|
374
|
+
claimOnStart({ store, task: before, login: claimAs })
|
|
332
375
|
if (at === 0 && before.column === 'backlog') {
|
|
333
376
|
store.moveTask(task.id, { column: 'in-progress' })
|
|
334
377
|
store.addTransition({
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Метрики доски: взгляд на журнал переходов, а не новые данные.
|
|
2
|
+
//
|
|
3
|
+
// Журнал копится с первого дня, но прочитать его можно было только по одной
|
|
4
|
+
// задаче. Вопросы «где затык», «сколько живёт задача» и «что лежит месяц» имеют
|
|
5
|
+
// ответ в тех же записях — их надо только сложить.
|
|
6
|
+
//
|
|
7
|
+
// Ничего не записываем: метрика, которая пишет, перестаёт быть наблюдением и
|
|
8
|
+
// становится ещё одним источником правды, расходящимся с журналом.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Сколько времени задача пробыла в каждой колонке.
|
|
12
|
+
*
|
|
13
|
+
* Переход закрывает предыдущий отрезок и открывает следующий. Последний
|
|
14
|
+
* отрезок закрывается «сейчас»: задача всё ещё лежит там, и это время идёт.
|
|
15
|
+
*
|
|
16
|
+
* @param {Array<{toCol: string, at: number}>} moves переходы задачи, старые первыми
|
|
17
|
+
* @param {number} bornAt когда задача заведена — начало первого отрезка
|
|
18
|
+
* @param {number} now
|
|
19
|
+
* @returns {Object<string, number>} колонка → миллисекунды
|
|
20
|
+
*/
|
|
21
|
+
export function timeInColumns(moves, bornAt, now) {
|
|
22
|
+
const rows = (moves ?? [])
|
|
23
|
+
.filter((m) => typeof m?.at === 'number' && typeof m?.toCol === 'string')
|
|
24
|
+
.slice()
|
|
25
|
+
.sort((a, b) => a.at - b.at)
|
|
26
|
+
|
|
27
|
+
const out = {}
|
|
28
|
+
// До первого перехода задача лежит там, где её завели. Колонку начала берём
|
|
29
|
+
// из первого перехода: `fromCol` и есть то, откуда она уехала.
|
|
30
|
+
let col = rows.length > 0 && typeof rows[0].fromCol === 'string' ? rows[0].fromCol : 'backlog'
|
|
31
|
+
let since = typeof bornAt === 'number' ? bornAt : (rows[0]?.at ?? now)
|
|
32
|
+
|
|
33
|
+
const add = (where, ms) => {
|
|
34
|
+
if (ms <= 0) return
|
|
35
|
+
out[where] = (out[where] ?? 0) + ms
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const move of rows) {
|
|
39
|
+
add(col, move.at - since)
|
|
40
|
+
col = move.toCol
|
|
41
|
+
since = move.at
|
|
42
|
+
}
|
|
43
|
+
add(col, now - since)
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Медиана: половина значений меньше, половина больше. */
|
|
48
|
+
export function median(values) {
|
|
49
|
+
const rows = (values ?? []).filter((x) => typeof x === 'number').slice().sort((a, b) => a - b)
|
|
50
|
+
if (rows.length === 0) return 0
|
|
51
|
+
const mid = Math.floor(rows.length / 2)
|
|
52
|
+
return rows.length % 2 === 1 ? rows[mid] : Math.round((rows[mid - 1] + rows[mid]) / 2)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Среднее; пустой набор даёт ноль, а не деление на ноль. */
|
|
56
|
+
export function mean(values) {
|
|
57
|
+
const rows = (values ?? []).filter((x) => typeof x === 'number')
|
|
58
|
+
if (rows.length === 0) return 0
|
|
59
|
+
return Math.round(rows.reduce((sum, x) => sum + x, 0) / rows.length)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Сводка по колонкам: сколько в среднем и по медиане в них лежат.
|
|
64
|
+
*
|
|
65
|
+
* Медиана важнее среднего: одна задача, забытая на полгода, сдвигает среднее
|
|
66
|
+
* так, что оно перестаёт описывать хоть что-нибудь.
|
|
67
|
+
*
|
|
68
|
+
* @param {Array<{task: object, moves: object[]}>} rows
|
|
69
|
+
* @returns {Array<{column: string, mean: number, median: number, tasks: number}>}
|
|
70
|
+
*/
|
|
71
|
+
export function columnStats(rows, now) {
|
|
72
|
+
const byColumn = new Map()
|
|
73
|
+
for (const row of rows ?? []) {
|
|
74
|
+
const spent = timeInColumns(row.moves, row.task?.createdAt, now)
|
|
75
|
+
for (const [column, ms] of Object.entries(spent)) {
|
|
76
|
+
if (!byColumn.has(column)) byColumn.set(column, [])
|
|
77
|
+
byColumn.get(column).push(ms)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...byColumn.entries()]
|
|
81
|
+
.map(([column, values]) => ({
|
|
82
|
+
column,
|
|
83
|
+
mean: mean(values),
|
|
84
|
+
median: median(values),
|
|
85
|
+
tasks: values.length,
|
|
86
|
+
}))
|
|
87
|
+
.sort((a, b) => b.median - a.median)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Задачи, залежавшиеся в своей колонке дольше порога.
|
|
92
|
+
*
|
|
93
|
+
* Считаем от входа в колонку, а не от заведения: задача может быть старой и
|
|
94
|
+
* при этом двигаться каждый день.
|
|
95
|
+
*
|
|
96
|
+
* @returns {Array<{id: string, title: string, column: string, since: number}>}
|
|
97
|
+
*/
|
|
98
|
+
export function stale(tasks, now, thresholdMs) {
|
|
99
|
+
if (!(thresholdMs > 0)) return []
|
|
100
|
+
return (tasks ?? [])
|
|
101
|
+
.filter((task) => {
|
|
102
|
+
const at = typeof task?.columnAt === 'number' && task.columnAt > 0 ? task.columnAt : task?.createdAt
|
|
103
|
+
return typeof at === 'number' && now - at >= thresholdMs
|
|
104
|
+
})
|
|
105
|
+
.map((task) => ({
|
|
106
|
+
id: task.id,
|
|
107
|
+
title: task.title,
|
|
108
|
+
column: task.column,
|
|
109
|
+
since: (typeof task.columnAt === 'number' && task.columnAt > 0 ? task.columnAt : task.createdAt),
|
|
110
|
+
}))
|
|
111
|
+
.sort((a, b) => a.since - b.since)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Сколько задач доехало до «Выполнено» за период.
|
|
116
|
+
*
|
|
117
|
+
* Считаем по переходам, а не по текущей колонке: задача, ушедшая в архив,
|
|
118
|
+
* всё равно была сделана.
|
|
119
|
+
*/
|
|
120
|
+
export function doneWithin(rows, from, to) {
|
|
121
|
+
let count = 0
|
|
122
|
+
for (const row of rows ?? []) {
|
|
123
|
+
const hit = (row.moves ?? []).some((m) => m?.toCol === 'done' && m.at >= from && m.at <= to)
|
|
124
|
+
if (hit) count += 1
|
|
125
|
+
}
|
|
126
|
+
return count
|
|
127
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from './config.js'
|
|
12
12
|
import { parsePlan, planItems, planProgress, taskState, queuePosition } from './plan.js'
|
|
13
|
+
import { columnStats, doneWithin, stale } from './metrics.js'
|
|
13
14
|
import { isStale } from './time.js'
|
|
14
15
|
import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from './filters.js'
|
|
15
16
|
import { parseBody } from './markdown.js'
|
|
@@ -274,6 +275,48 @@ export function deleteTask({ store, id }) {
|
|
|
274
275
|
* позвать `lib/markdown.js` не может. Заодно тело не едет в разметку строкой,
|
|
275
276
|
* и подставить туда чужое нечем.
|
|
276
277
|
*/
|
|
278
|
+
/**
|
|
279
|
+
* Метрики доски: где стоит работа и что залежалось.
|
|
280
|
+
*
|
|
281
|
+
* Считается по журналу переходов и ничего не записывает. Порог «залежалось»
|
|
282
|
+
* приходит запросом, а не настройкой: это вопрос, который задают по-разному в
|
|
283
|
+
* разные дни, а не свойство доски.
|
|
284
|
+
*
|
|
285
|
+
* @returns {{columns: object[], done: object, stale: object[], tasks: number}}
|
|
286
|
+
*/
|
|
287
|
+
export function boardMetrics({ store, board = 'main', now = Date.now(), staleDays = 14 }) {
|
|
288
|
+
const tasks = store.listTasks({ board })
|
|
289
|
+
const byTask = new Map()
|
|
290
|
+
for (const row of store.listAllTransitions()) {
|
|
291
|
+
if (!byTask.has(row.taskId)) byTask.set(row.taskId, [])
|
|
292
|
+
byTask.get(row.taskId).push(row)
|
|
293
|
+
}
|
|
294
|
+
const rows = tasks.map((task) => ({ task, moves: byTask.get(task.id) ?? [] }))
|
|
295
|
+
const day = 24 * 60 * 60 * 1000
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
tasks: tasks.length,
|
|
299
|
+
columns: columnStats(rows, now),
|
|
300
|
+
done: {
|
|
301
|
+
week: doneWithin(rows, now - 7 * day, now),
|
|
302
|
+
month: doneWithin(rows, now - 30 * day, now),
|
|
303
|
+
},
|
|
304
|
+
staleDays,
|
|
305
|
+
stale: stale(tasks, now, staleDays * day).slice(0, 50),
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Можно ли предлагать «продолжить работу».
|
|
311
|
+
*
|
|
312
|
+
* Только когда сессия у задачи записана, а живого агента за ней нет: у живой
|
|
313
|
+
* продолжать нечего, у задачи без сессии — нечего возобновлять.
|
|
314
|
+
*/
|
|
315
|
+
export function canRevive(task, live) {
|
|
316
|
+
const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
|
|
317
|
+
return has && (live === undefined || live === null)
|
|
318
|
+
}
|
|
319
|
+
|
|
277
320
|
export function taskBody({ store, id }) {
|
|
278
321
|
const task = store.getTask(id)
|
|
279
322
|
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
package/lib/store.js
CHANGED
|
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
51
51
|
labelColors TEXT NOT NULL DEFAULT '{}',
|
|
52
52
|
author TEXT NOT NULL DEFAULT '',
|
|
53
53
|
assignee TEXT NOT NULL DEFAULT '',
|
|
54
|
+
milestone TEXT NOT NULL DEFAULT '',
|
|
54
55
|
priority TEXT NOT NULL DEFAULT '',
|
|
55
56
|
dueAt INTEGER NOT NULL DEFAULT 0,
|
|
56
57
|
syncedAt INTEGER NOT NULL DEFAULT 0,
|
|
@@ -82,7 +83,7 @@ CREATE TABLE IF NOT EXISTS dismissed (
|
|
|
82
83
|
const WRITABLE = [
|
|
83
84
|
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
84
85
|
'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
85
|
-
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'priority', 'dueAt',
|
|
86
|
+
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
86
87
|
]
|
|
87
88
|
|
|
88
89
|
/** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
|
|
@@ -128,6 +129,7 @@ export function openStore({ dir }) {
|
|
|
128
129
|
['labelColors', "TEXT NOT NULL DEFAULT '{}'"],
|
|
129
130
|
['author', "TEXT NOT NULL DEFAULT ''"],
|
|
130
131
|
['assignee', "TEXT NOT NULL DEFAULT ''"],
|
|
132
|
+
['milestone', "TEXT NOT NULL DEFAULT ''"],
|
|
131
133
|
['priority', "TEXT NOT NULL DEFAULT ''"],
|
|
132
134
|
['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
133
135
|
]) {
|
|
@@ -259,8 +261,8 @@ export function openStore({ dir }) {
|
|
|
259
261
|
db.prepare(`INSERT INTO tasks
|
|
260
262
|
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
|
|
261
263
|
labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
|
|
262
|
-
labelColors, author, assignee, priority, dueAt, columnAt, createdAt, updatedAt)
|
|
263
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
264
|
+
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt)
|
|
265
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
264
266
|
id, board, column, position,
|
|
265
267
|
input.title ?? '', input.body ?? '',
|
|
266
268
|
input.owner ?? null, input.repo ?? null,
|
|
@@ -273,6 +275,7 @@ export function openStore({ dir }) {
|
|
|
273
275
|
JSON.stringify(input.labelColors ?? {}),
|
|
274
276
|
input.author ?? '',
|
|
275
277
|
input.assignee ?? '',
|
|
278
|
+
input.milestone ?? '',
|
|
276
279
|
input.priority ?? '',
|
|
277
280
|
Number(input.dueAt) || 0,
|
|
278
281
|
now, born, now,
|
|
@@ -372,6 +375,11 @@ export function openStore({ dir }) {
|
|
|
372
375
|
return q.transitionsOf.all(taskId)
|
|
373
376
|
},
|
|
374
377
|
|
|
378
|
+
/** Все переходы разом: метрике нужен журнал целиком, а не по одной задаче. */
|
|
379
|
+
listAllTransitions() {
|
|
380
|
+
return q.allTransitions.all()
|
|
381
|
+
},
|
|
382
|
+
|
|
375
383
|
/**
|
|
376
384
|
* Полный снимок доски для экспорта: все доски, все задачи (включая
|
|
377
385
|
* архивные) и журнал переходов. Импорт восстанавливает его как есть.
|
package/lib/sync.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { deriveColumn, resolveTransition, branchOfTask, pullsOfTask, resolveConflict } from './transitions.js'
|
|
8
8
|
import { watchedRepos, shouldTake, archiveBefore, isWatched } from './intake.js'
|
|
9
9
|
import { colorsOfIssue } from './filters.js'
|
|
10
|
-
import { issueToTask, issueBornAt, issueAuthor, issueAssignee } from './import.js'
|
|
10
|
+
import { issueToTask, issueBornAt, issueAuthor, issueAssignee, issueMilestone } from './import.js'
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Наблюдения по одному репозиторию.
|
|
@@ -100,6 +100,10 @@ function remoteFields(task, issue) {
|
|
|
100
100
|
const assignee = issueAssignee(issue)
|
|
101
101
|
if (assignee !== (task.assignee ?? '')) patch.assignee = assignee
|
|
102
102
|
}
|
|
103
|
+
if ('milestone' in (issue ?? {})) {
|
|
104
|
+
const milestone = issueMilestone(issue)
|
|
105
|
+
if (milestone !== (task.milestone ?? '')) patch.milestone = milestone
|
|
106
|
+
}
|
|
103
107
|
return patch
|
|
104
108
|
}
|
|
105
109
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"LICENSE"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node --test test/*.test.mjs"
|
|
21
|
+
"test": "node --test test/*.test.mjs",
|
|
22
|
+
"compat": "node tools/compat.mjs"
|
|
22
23
|
},
|
|
23
24
|
"keywords": [
|
|
24
25
|
"dsh",
|