@goodandready/dsh-kanban 0.1.20 → 0.1.22
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 +46 -1
- package/lib/client.js +111 -6
- package/lib/filters.js +307 -288
- package/lib/gitea.js +28 -0
- package/lib/import.js +23 -0
- package/lib/index.js +50 -2
- package/lib/launcher.js +32 -2
- package/lib/outbound.js +22 -0
- package/lib/routes.js +355 -331
- package/lib/store.js +392 -389
- package/lib/sync.js +7 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -127,7 +127,34 @@ A due date can be set on any card. Overdue cards are highlighted, and a switch i
|
|
|
127
127
|
the board header narrows the board to them alone — the answer to "what is already
|
|
128
128
|
late" is one click, not a scan.
|
|
129
129
|
|
|
130
|
-
### 6.
|
|
130
|
+
### 6. Ownership: Who Took the Task
|
|
131
|
+
|
|
132
|
+
The board carries two different people per task and never confuses them: the
|
|
133
|
+
**author** who filed the issue and the **assignee** who took it. Both arrive
|
|
134
|
+
from Gitea, both are filter dimensions, and the assignee is shown on the card
|
|
135
|
+
itself — "who is doing this" is asked more often than "who wrote it".
|
|
136
|
+
|
|
137
|
+
A single button in the task window takes the task or drops it. Taking assigns
|
|
138
|
+
the account whose token the board uses: the harness has no user of its own, so
|
|
139
|
+
"me" is resolved by asking Gitea rather than by guessing. The change travels
|
|
140
|
+
back to the issue through the outbound queue, so the board keeps working while
|
|
141
|
+
Gitea is down; dropping sends an empty assignee list, because Gitea reads a
|
|
142
|
+
missing field as "leave it alone".
|
|
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
|
+
|
|
153
|
+
Assignment made in Gitea arrives on the next sync — in both directions,
|
|
154
|
+
including removal. Filtering by "nobody" answers the question the board is
|
|
155
|
+
opened for most often: what is free to pick up.
|
|
156
|
+
|
|
157
|
+
### 7. Board Snapshot: Export and Import
|
|
131
158
|
|
|
132
159
|
`GET /dsh-kanban/snapshot` returns the whole board as JSON; `POST` to the same
|
|
133
160
|
route restores it. Import is idempotent: a task whose id is already on the board
|
|
@@ -189,6 +216,24 @@ npm test
|
|
|
189
216
|
|
|
190
217
|
---
|
|
191
218
|
|
|
219
|
+
## 🔌 Core Compatibility Check
|
|
220
|
+
|
|
221
|
+
A named import of an export the installed core no longer has is a `SyntaxError`
|
|
222
|
+
at parse time: the whole plugin tree fails to load and the harness restarts in a
|
|
223
|
+
loop. That happened twice in one week, and both times the owner found out before
|
|
224
|
+
the tests did.
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
npm run compat # uses $DSH_HOME, or ~/.dsh/profiles/web
|
|
228
|
+
npm run compat -- /path/to/profile
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The check reads every `import { … } from '@deepseek-ai/…'` in `lib/`, resolves
|
|
232
|
+
each package the way the harness resolves it — from the profile directory — and
|
|
233
|
+
compares the requested names against the actual exports. It is deliberately not
|
|
234
|
+
part of `npm test`: the test suite runs without a core and without a profile.
|
|
235
|
+
Run it on the machine where the harness lives, before publishing.
|
|
236
|
+
|
|
192
237
|
## 📄 License
|
|
193
238
|
|
|
194
239
|
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,
|
|
@@ -682,6 +703,7 @@ window.__ModuleLoader__.load({
|
|
|
682
703
|
'.dkb-archiveRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;align-items:center;gap:12px;padding:8px 12px;display:flex}' +
|
|
683
704
|
'.dkb-start{display:contents}' +
|
|
684
705
|
'.dkb-pack{color:var(--dsw-alias-label-secondary)}' +
|
|
706
|
+
'.dkb-who{color:var(--dsw-alias-label-secondary)}' +
|
|
685
707
|
'.dkb-sameSession{outline:1px solid var(--dsw-alias-label-secondary);outline-offset:1px}' +
|
|
686
708
|
'.dkb-waitCount{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:0 10px;height:30px;cursor:pointer;color:var(--dsw-alias-label-primary)}' +
|
|
687
709
|
'.dkb-archiveOpen{background:none;border:0;padding:0;text-align:left;cursor:pointer;font:inherit}' +
|
|
@@ -946,6 +968,17 @@ window.__ModuleLoader__.load({
|
|
|
946
968
|
'sync.neverSeen': 'no successful check since the harness started',
|
|
947
969
|
'facet.repo': 'Repository',
|
|
948
970
|
'facet.author': 'Author',
|
|
971
|
+
'facet.assignee': 'Assignee',
|
|
972
|
+
'board.mine': 'Mine: {n}',
|
|
973
|
+
'board.mineHint': 'Tasks assigned to {who}',
|
|
974
|
+
'group.repo': 'By project',
|
|
975
|
+
'group.assignee': 'By assignee',
|
|
976
|
+
'group.nobody': 'nobody took it',
|
|
977
|
+
'card.assigneeHint': 'Who took the task',
|
|
978
|
+
'panel.assignee': 'taken by {who}',
|
|
979
|
+
'panel.assignMe': 'Take it',
|
|
980
|
+
'panel.assignDrop': 'Drop it',
|
|
981
|
+
'error.gitea-unreachable': 'Gitea did not answer, so there is nobody to take the task as.',
|
|
949
982
|
'board.pickArchive': 'To the archive',
|
|
950
983
|
'board.pickUnqueue': 'Out of the queue',
|
|
951
984
|
'board.pickMove': 'Move to…',
|
|
@@ -1191,6 +1224,17 @@ window.__ModuleLoader__.load({
|
|
|
1191
1224
|
'sync.neverSeen': '自 harness 启动以来没有一次成功的核对',
|
|
1192
1225
|
'facet.repo': '仓库',
|
|
1193
1226
|
'facet.author': '创建者',
|
|
1227
|
+
'facet.assignee': '负责人',
|
|
1228
|
+
'board.mine': '我的:{n}',
|
|
1229
|
+
'board.mineHint': '指派给 {who} 的任务',
|
|
1230
|
+
'group.repo': '按项目',
|
|
1231
|
+
'group.assignee': '按负责人',
|
|
1232
|
+
'group.nobody': '无人接手',
|
|
1233
|
+
'card.assigneeHint': '谁接下了这个任务',
|
|
1234
|
+
'panel.assignee': '由 {who} 接手',
|
|
1235
|
+
'panel.assignMe': '我来接',
|
|
1236
|
+
'panel.assignDrop': '放手',
|
|
1237
|
+
'error.gitea-unreachable': 'Gitea 没有响应,无从得知以谁的身份接手。',
|
|
1194
1238
|
'board.pickArchive': '移入归档',
|
|
1195
1239
|
'board.pickUnqueue': '移出队列',
|
|
1196
1240
|
'board.pickMove': '移动到…',
|
|
@@ -1623,6 +1667,10 @@ window.__ModuleLoader__.load({
|
|
|
1623
1667
|
title: exactAt(task.dueAt),
|
|
1624
1668
|
}, t('card.due', { date: shortDate(task.dueAt) }))
|
|
1625
1669
|
: null,
|
|
1670
|
+
task.assignee
|
|
1671
|
+
? React.createElement('span', { className: 'dkb-who', title: t('card.assigneeHint') },
|
|
1672
|
+
'@' + task.assignee)
|
|
1673
|
+
: null,
|
|
1626
1674
|
pack
|
|
1627
1675
|
? React.createElement('span', {
|
|
1628
1676
|
className: 'dkb-pack', title: t('card.packHint'),
|
|
@@ -1724,6 +1772,13 @@ window.__ModuleLoader__.load({
|
|
|
1724
1772
|
const [onlyOverdue, setOnlyOverdue] = React.useState(false)
|
|
1725
1773
|
const [hoverSession, setHoverSession] = React.useState('')
|
|
1726
1774
|
const [order, setOrder] = React.useState('manual')
|
|
1775
|
+
// «По проектам» — как приходит с сервера; «по ответственному» — своя
|
|
1776
|
+
// раскладка поверх того же порядка.
|
|
1777
|
+
const [grouping, setGrouping] = React.useState('repo')
|
|
1778
|
+
// Кто «я»: спрашивается один раз у сервера. Пусто — Gitea не ответил, и
|
|
1779
|
+
// кнопку «Мои» рисовать нельзя: соврать числом хуже, чем не показать.
|
|
1780
|
+
const [me, setMe] = React.useState('')
|
|
1781
|
+
const [onlyMine, setOnlyMine] = React.useState(false)
|
|
1727
1782
|
// Чем запускать: профиль агента и уровень доступа. Обе службы у ядра
|
|
1728
1783
|
// необязательны, поэтому пустой список — не поломка, а «выбирать не из
|
|
1729
1784
|
// чего»: поле тогда не рисуется вовсе.
|
|
@@ -1813,6 +1868,9 @@ window.__ModuleLoader__.load({
|
|
|
1813
1868
|
|
|
1814
1869
|
React.useEffect(() => {
|
|
1815
1870
|
let alive = true
|
|
1871
|
+
api('/whoami')
|
|
1872
|
+
.then((out) => { if (alive && out) setMe(typeof out.login === 'string' ? out.login : '') })
|
|
1873
|
+
.catch(() => {})
|
|
1816
1874
|
api('/presets')
|
|
1817
1875
|
.then((out) => {
|
|
1818
1876
|
if (!alive || !out) return
|
|
@@ -1994,6 +2052,26 @@ window.__ModuleLoader__.load({
|
|
|
1994
2052
|
onClick: () => setOnlyOverdue((cur) => !cur),
|
|
1995
2053
|
}, t('board.overdueCount', { n: overdue }))
|
|
1996
2054
|
})(),
|
|
2055
|
+
(() => {
|
|
2056
|
+
// Ноль своих задач — кнопки нет: пустой счётчик в шапке перестают
|
|
2057
|
+
// замечать вместе с непустым.
|
|
2058
|
+
if (me === '') return null
|
|
2059
|
+
const mine = state.tasks.filter((x) => x.assignee === me).length
|
|
2060
|
+
if (mine === 0) return null
|
|
2061
|
+
return React.createElement('button', {
|
|
2062
|
+
type: 'button',
|
|
2063
|
+
className: 'dkb-waitCount' + (onlyMine ? ' dkb-save' : ''),
|
|
2064
|
+
title: t('board.mineHint', { who: me }),
|
|
2065
|
+
onClick: () => setOnlyMine((cur) => !cur),
|
|
2066
|
+
}, t('board.mine', { n: mine }))
|
|
2067
|
+
})(),
|
|
2068
|
+
React.createElement('select', {
|
|
2069
|
+
className: 'dkb-input', style: { width: 'auto' }, value: grouping,
|
|
2070
|
+
onChange: (e) => setGrouping(e.target.value),
|
|
2071
|
+
},
|
|
2072
|
+
React.createElement('option', { value: 'repo' }, t('group.repo')),
|
|
2073
|
+
React.createElement('option', { value: 'assignee' }, t('group.assignee')),
|
|
2074
|
+
),
|
|
1997
2075
|
React.createElement('select', {
|
|
1998
2076
|
className: 'dkb-input', style: { width: 'auto' }, value: order,
|
|
1999
2077
|
onChange: (e) => setOrder(e.target.value),
|
|
@@ -2187,9 +2265,18 @@ window.__ModuleLoader__.load({
|
|
|
2187
2265
|
const items = sortTasks(
|
|
2188
2266
|
all.filter((task) => matchesQuery(task, query))
|
|
2189
2267
|
.filter((task) => !onlyWaiting || task.state === 'waiting')
|
|
2190
|
-
.filter((task) => !onlyOverdue || task.overdue === true)
|
|
2268
|
+
.filter((task) => !onlyOverdue || task.overdue === true)
|
|
2269
|
+
.filter((task) => !onlyMine || task.assignee === me),
|
|
2191
2270
|
order,
|
|
2192
2271
|
)
|
|
2272
|
+
// Ключ группы решает переключатель: заголовки, сворачивание и
|
|
2273
|
+
// счётчики остаются те же, меняется только по чему делим.
|
|
2274
|
+
const groupKeyOf = (task) => (grouping === 'assignee'
|
|
2275
|
+
? (task.assignee || '')
|
|
2276
|
+
: (task.repo || ''))
|
|
2277
|
+
const ordered = grouping === 'assignee'
|
|
2278
|
+
? groupByAssignee(items).flatMap((one) => one.tasks)
|
|
2279
|
+
: items
|
|
2193
2280
|
const limit = col.limit
|
|
2194
2281
|
const overLimit = limit !== undefined && limit !== null && all.length > limit
|
|
2195
2282
|
const slot = (index) => React.createElement('div', {
|
|
@@ -2209,11 +2296,11 @@ window.__ModuleLoader__.load({
|
|
|
2209
2296
|
// разложенными, поэтому группы идут подряд и разрываться не могут.
|
|
2210
2297
|
let group = null
|
|
2211
2298
|
const sizes = new Map()
|
|
2212
|
-
for (const x of
|
|
2299
|
+
for (const x of ordered) sizes.set(groupKeyOf(x), (sizes.get(groupKeyOf(x)) || 0) + 1)
|
|
2213
2300
|
const manyGroups = sizes.size > 1
|
|
2214
2301
|
|
|
2215
|
-
|
|
2216
|
-
const repo = task
|
|
2302
|
+
ordered.forEach((task, i) => {
|
|
2303
|
+
const repo = groupKeyOf(task)
|
|
2217
2304
|
if (manyGroups && repo !== group) {
|
|
2218
2305
|
group = repo
|
|
2219
2306
|
const shut = groups[col.id + '/' + repo] === true
|
|
@@ -2222,7 +2309,8 @@ window.__ModuleLoader__.load({
|
|
|
2222
2309
|
'aria-expanded': !shut,
|
|
2223
2310
|
onClick: () => setGroups((cur) => Object.assign({}, cur, { [col.id + '/' + repo]: !shut })),
|
|
2224
2311
|
},
|
|
2225
|
-
React.createElement('span', null,
|
|
2312
|
+
React.createElement('span', null,
|
|
2313
|
+
repo || t(grouping === 'assignee' ? 'group.nobody' : 'board.noRepo')),
|
|
2226
2314
|
// Счётчик виден и у свёрнутой: группа с работой, выглядящая
|
|
2227
2315
|
// пустой, — то же зло, что и свёрнутая колонка без числа.
|
|
2228
2316
|
React.createElement('span', { className: 'dkb-groupCount' }, String(sizes.get(repo))),
|
|
@@ -2355,6 +2443,7 @@ window.__ModuleLoader__.load({
|
|
|
2355
2443
|
React.createElement('div', { className: 'dkb-title' }, openTask.title),
|
|
2356
2444
|
React.createElement('div', { className: 'dkb-note' },
|
|
2357
2445
|
t('panel.created', { ago: agoText(state.now, openTask.createdAt, t) })
|
|
2446
|
+
+ (openTask.assignee ? ' · ' + t('panel.assignee', { who: openTask.assignee }) : '')
|
|
2358
2447
|
// Автор стоит рядом с датой заведения: это одно событие — кто и
|
|
2359
2448
|
// когда завёл, — и разносить его по разным углам незачем.
|
|
2360
2449
|
+ (openTask.author ? ' · ' + t('panel.author', { who: openTask.author }) : ''),
|
|
@@ -2420,6 +2509,22 @@ window.__ModuleLoader__.load({
|
|
|
2420
2509
|
},
|
|
2421
2510
|
}, t('panel.saveEdit')),
|
|
2422
2511
|
),
|
|
2512
|
+
// Взять и отдать — одна кнопка в двух состояниях: держать рядом
|
|
2513
|
+
// «взять» и «отказаться» значит предлагать бессмысленное действие.
|
|
2514
|
+
React.createElement('button', {
|
|
2515
|
+
type: 'button', className: 'dkb-discard', style: { alignSelf: 'flex-start' },
|
|
2516
|
+
onClick: async () => {
|
|
2517
|
+
const mine = openTask.assignee !== '' && openTask.assignee !== undefined
|
|
2518
|
+
try {
|
|
2519
|
+
const out = await api('/task/' + encodeURIComponent(openTask.id) + '/assign', {
|
|
2520
|
+
method: 'POST',
|
|
2521
|
+
body: JSON.stringify({ login: mine ? '' : 'me' }),
|
|
2522
|
+
})
|
|
2523
|
+
if (out && out.task) setOpenTask(out.task)
|
|
2524
|
+
await load(board)
|
|
2525
|
+
} catch (e) { setError(t(e.key || 'error.unknown')) }
|
|
2526
|
+
},
|
|
2527
|
+
}, openTask.assignee ? t('panel.assignDrop') : t('panel.assignMe')),
|
|
2423
2528
|
openTask.sessionId
|
|
2424
2529
|
? React.createElement('button', {
|
|
2425
2530
|
type: 'button', className: 'dkb-discard', style: { alignSelf: 'flex-start' },
|