@goodandready/dsh-kanban 0.1.19 → 0.1.21

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 CHANGED
@@ -116,6 +116,45 @@ Unlike passive task boards, `dsh-kanban` actively drives the agents:
116
116
 
117
117
  ---
118
118
 
119
+ ### 5. Card Detail: Labels, Priority and Deadlines
120
+
121
+ Cards carry more than a title. Labels and priority are editable right in the card
122
+ window — for a Gitea-backed task they travel back to the issue, for a board-only
123
+ task they live locally. Priority drives an "urgent first" ordering, so the column
124
+ answers "what now" without reading every card.
125
+
126
+ A due date can be set on any card. Overdue cards are highlighted, and a switch in
127
+ the board header narrows the board to them alone — the answer to "what is already
128
+ late" is one click, not a scan.
129
+
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
+ Assignment made in Gitea arrives on the next sync — in both directions,
145
+ including removal. Filtering by "nobody" answers the question the board is
146
+ opened for most often: what is free to pick up.
147
+
148
+ ### 7. Board Snapshot: Export and Import
149
+
150
+ `GET /dsh-kanban/snapshot` returns the whole board as JSON; `POST` to the same
151
+ route restores it. Import is idempotent: a task whose id is already on the board
152
+ is skipped rather than duplicated, so re-running an import is safe.
153
+
154
+ The snapshot is one resource with two verbs on purpose. An earlier build gave the
155
+ upload the path `/dsh-kanban/import`, which the Gitea issue import already owned —
156
+ two handlers on one path mean one of them silently never answers.
157
+
119
158
  ## 📦 Installation
120
159
 
121
160
  Install via DeepSeek Harness CLI:
package/lib/client.js CHANGED
@@ -682,6 +682,7 @@ window.__ModuleLoader__.load({
682
682
  '.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
683
  '.dkb-start{display:contents}' +
684
684
  '.dkb-pack{color:var(--dsw-alias-label-secondary)}' +
685
+ '.dkb-who{color:var(--dsw-alias-label-secondary)}' +
685
686
  '.dkb-sameSession{outline:1px solid var(--dsw-alias-label-secondary);outline-offset:1px}' +
686
687
  '.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
688
  '.dkb-archiveOpen{background:none;border:0;padding:0;text-align:left;cursor:pointer;font:inherit}' +
@@ -734,6 +735,8 @@ window.__ModuleLoader__.load({
734
735
  '.dkb-count[data-col=deploy]{background:var(--dsw-alias-state-business-primary)}' +
735
736
  '.dkb-count[data-col=done]{background:var(--dsw-alias-state-success-primary)}' +
736
737
  '.dkb-countOver{background:var(--dsw-alias-state-error-primary);color:#fff}' +
738
+ '.dkb-overdue{border-color:var(--dsw-alias-state-error-primary)}' +
739
+ '.dkb-dueOver{color:var(--dsw-alias-state-error-primary);font-weight:600}' +
737
740
  '.dkb-list{flex-direction:column;flex:1;gap:8px;min-height:0;padding:2px 8px 10px;display:flex;overflow-y:auto}' +
738
741
  '.dkb-empty{text-align:center;color:var(--dsw-alias-label-tertiary);padding:24px 8px;font-size:12px}' +
739
742
  '.dkb-taskCard{text-align:left;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;color:var(--dsw-alias-label-primary);border-radius:10px;flex-direction:column;gap:6px;padding:10px 12px;font-family:inherit;transition:box-shadow .12s,border-color .12s,transform .12s;display:flex}' +
@@ -894,6 +897,7 @@ window.__ModuleLoader__.load({
894
897
  'board.expand': 'Expand the column',
895
898
  'card.move': 'move',
896
899
  'card.toChat': 'to the chat',
900
+ 'card.due': 'due {date}',
897
901
  'card.pick': 'Select for a group start',
898
902
  'card.queue': 'to a queue...',
899
903
  'card.unqueue': 'take out of the queue',
@@ -925,6 +929,11 @@ window.__ModuleLoader__.load({
925
929
  'error.repo-not-created': 'The repository was not created; nothing else was done.',
926
930
  'error.issue-not-created': 'The issue was not created. If the repository was new, it stayed — nothing was deleted.',
927
931
  'board.archive': 'Archive',
932
+ 'board.export': 'Download',
933
+ 'board.import': 'Import',
934
+ 'board.exportHint': 'Download the whole board as JSON',
935
+ 'board.imported': 'Imported {n} tasks',
936
+ 'error.badImport': 'That file is not a valid board backup.',
928
937
  'panel.archive': 'To the archive',
929
938
  'archive.hint': 'Cards that spent their term in Done. Nothing is deleted: a restored card returns to the column it left.',
930
939
  'archive.empty': 'The archive is empty.',
@@ -938,6 +947,12 @@ window.__ModuleLoader__.load({
938
947
  'sync.neverSeen': 'no successful check since the harness started',
939
948
  'facet.repo': 'Repository',
940
949
  'facet.author': 'Author',
950
+ 'facet.assignee': 'Assignee',
951
+ 'card.assigneeHint': 'Who took the task',
952
+ 'panel.assignee': 'taken by {who}',
953
+ 'panel.assignMe': 'Take it',
954
+ 'panel.assignDrop': 'Drop it',
955
+ 'error.gitea-unreachable': 'Gitea did not answer, so there is nobody to take the task as.',
941
956
  'board.pickArchive': 'To the archive',
942
957
  'board.pickUnqueue': 'Out of the queue',
943
958
  'board.pickMove': 'Move to…',
@@ -1017,6 +1032,16 @@ window.__ModuleLoader__.load({
1017
1032
  'panel.startHint': 'A dedicated agent session opens for this task. Branch and worktree are created by the agent after its preflight, not here.',
1018
1033
  'panel.workDir': 'Working folder: {path}',
1019
1034
  'panel.workDirHint': 'The project root is not set in settings, so this folder is used.',
1035
+ 'panel.priority': 'Priority',
1036
+ 'panel.priorityNone': 'No priority',
1037
+ 'priority.high': 'High',
1038
+ 'priority.medium': 'Medium',
1039
+ 'priority.low': 'Low',
1040
+ 'panel.labels': 'Labels',
1041
+ 'panel.due': 'Due date',
1042
+ 'panel.saveEdit': 'Save',
1043
+ 'board.overdueCount': 'Overdue: {n}',
1044
+ 'board.overdueHint': 'Show only overdue tasks',
1020
1045
  'error.startFailed': 'Could not start the session.',
1021
1046
  'error.sessionNotOpened': 'The session started ({id}) but this build cannot switch to it. Find it in the session list.',
1022
1047
  'error.modelNotSelected': 'No model selected — pick one in Settings, or type it here.',
@@ -1123,6 +1148,7 @@ window.__ModuleLoader__.load({
1123
1148
  'board.expand': '展开该列',
1124
1149
  'card.move': '移动',
1125
1150
  'card.toChat': '进入对话',
1151
+ 'card.due': '截止 {date}',
1126
1152
  'card.pick': '选中以便批量启动',
1127
1153
  'card.queue': '加入队列…',
1128
1154
  'card.unqueue': '移出队列',
@@ -1154,6 +1180,11 @@ window.__ModuleLoader__.load({
1154
1180
  'error.repo-not-created': '仓库没有创建,其他动作也未执行。',
1155
1181
  'error.issue-not-created': 'issue 没有创建。如果仓库是新建的,它仍然保留——没有删除任何东西。',
1156
1182
  'board.archive': '归档',
1183
+ 'board.export': '下载',
1184
+ 'board.import': '导入',
1185
+ 'board.exportHint': '将整个看板下载为 JSON',
1186
+ 'board.imported': '已导入 {n} 个任务',
1187
+ 'error.badImport': '该文件不是有效的看板备份。',
1157
1188
  'panel.archive': '移入归档',
1158
1189
  'archive.hint': '在「已完成」里待够时间的卡片。什么都没有删除:恢复的卡片会回到它离开的那一列。',
1159
1190
  'archive.empty': '归档是空的。',
@@ -1167,6 +1198,12 @@ window.__ModuleLoader__.load({
1167
1198
  'sync.neverSeen': '自 harness 启动以来没有一次成功的核对',
1168
1199
  'facet.repo': '仓库',
1169
1200
  'facet.author': '创建者',
1201
+ 'facet.assignee': '负责人',
1202
+ 'card.assigneeHint': '谁接下了这个任务',
1203
+ 'panel.assignee': '由 {who} 接手',
1204
+ 'panel.assignMe': '我来接',
1205
+ 'panel.assignDrop': '放手',
1206
+ 'error.gitea-unreachable': 'Gitea 没有响应,无从得知以谁的身份接手。',
1170
1207
  'board.pickArchive': '移入归档',
1171
1208
  'board.pickUnqueue': '移出队列',
1172
1209
  'board.pickMove': '移动到…',
@@ -1246,6 +1283,16 @@ window.__ModuleLoader__.load({
1246
1283
  'panel.startHint': '会为该任务打开一个专属的 Agent 会话。分支和 worktree 由 Agent 在自检之后创建,不在这里。',
1247
1284
  'panel.workDir': '工作目录:{path}',
1248
1285
  'panel.workDirHint': '设置中未指定项目根目录,因此使用此目录。',
1286
+ 'panel.priority': '优先级',
1287
+ 'panel.priorityNone': '无优先级',
1288
+ 'priority.high': '高',
1289
+ 'priority.medium': '中',
1290
+ 'priority.low': '低',
1291
+ 'panel.labels': '标签',
1292
+ 'panel.due': '截止日期',
1293
+ 'panel.saveEdit': '保存',
1294
+ 'board.overdueCount': '已逾期:{n}',
1295
+ 'board.overdueHint': '仅显示已逾期的任务',
1249
1296
  'error.startFailed': '会话没能启动。',
1250
1297
  'error.sessionNotOpened': '会话已启动({id}),但此版本无法切换过去。请到会话列表中查找。',
1251
1298
  'error.modelNotSelected': '没有选择模型——请在设置中选择,或在此处填写。',
@@ -1481,8 +1528,10 @@ window.__ModuleLoader__.load({
1481
1528
  const touched = task.sessionId ? agoText(now, task.updatedAt, t) : ''
1482
1529
  const menu = props.menuOpen
1483
1530
  const urgent = priorityColor(task)
1531
+ const overdue = task.overdue === true
1484
1532
  return React.createElement('div', {
1485
1533
  className: 'dkb-taskCard' + (urgent ? ' dkb-urgent' : '')
1534
+ + (overdue ? ' dkb-overdue' : '')
1486
1535
  + (props.sameSession ? ' dkb-sameSession' : ''),
1487
1536
  onMouseEnter: () => { if (props.onHover) props.onHover(task.sessionId || '') },
1488
1537
  onMouseLeave: () => { if (props.onHover) props.onHover('') },
@@ -1581,6 +1630,16 @@ window.__ModuleLoader__.load({
1581
1630
  born
1582
1631
  ? React.createElement('span', { title: exactAt(task.createdAt) }, born)
1583
1632
  : null,
1633
+ task.dueAt > 0
1634
+ ? React.createElement('span', {
1635
+ className: overdue ? 'dkb-dueOver' : '',
1636
+ title: exactAt(task.dueAt),
1637
+ }, t('card.due', { date: shortDate(task.dueAt) }))
1638
+ : null,
1639
+ task.assignee
1640
+ ? React.createElement('span', { className: 'dkb-who', title: t('card.assigneeHint') },
1641
+ '@' + task.assignee)
1642
+ : null,
1584
1643
  pack
1585
1644
  ? React.createElement('span', {
1586
1645
  className: 'dkb-pack', title: t('card.packHint'),
@@ -1644,6 +1703,12 @@ window.__ModuleLoader__.load({
1644
1703
  const [openTask, setOpenTask] = React.useState(null)
1645
1704
  const [log, setLog] = React.useState([])
1646
1705
  const [dialog, setDialog] = React.useState(null)
1706
+ // Черновик правки приоритета и меток в окне задачи. Живёт отдельно от
1707
+ // карточки: пока человек не нажал «Сохранить», доска не должна видеть
1708
+ // полувведённое.
1709
+ const [editPriority, setEditPriority] = React.useState('')
1710
+ const [editLabels, setEditLabels] = React.useState('')
1711
+ const [editDue, setEditDue] = React.useState('')
1647
1712
  const [overColumn, setOverColumn] = React.useState('')
1648
1713
  const [model, setModel] = React.useState('')
1649
1714
  const [provider, setProvider] = React.useState('')
@@ -1673,6 +1738,7 @@ window.__ModuleLoader__.load({
1673
1738
  // «Только ждущие» держим отдельно от отборов: это не значение измерения,
1674
1739
  // а взгляд «покажи, где стоит работа», и складывается он с любым отбором.
1675
1740
  const [onlyWaiting, setOnlyWaiting] = React.useState(false)
1741
+ const [onlyOverdue, setOnlyOverdue] = React.useState(false)
1676
1742
  const [hoverSession, setHoverSession] = React.useState('')
1677
1743
  const [order, setOrder] = React.useState('manual')
1678
1744
  // Чем запускать: профиль агента и уровень доступа. Обе службы у ядра
@@ -1840,6 +1906,11 @@ window.__ModuleLoader__.load({
1840
1906
  setOpenTask(task)
1841
1907
  setDraftText('')
1842
1908
  setBody(null)
1909
+ setEditPriority(typeof task?.priority === 'string' ? task.priority : '')
1910
+ setEditLabels(Array.isArray(task?.labels) ? task.labels.join(', ') : '')
1911
+ setEditDue(typeof task?.dueAt === 'number' && task.dueAt > 0
1912
+ ? new Date(task.dueAt).toISOString().slice(0, 10)
1913
+ : '')
1843
1914
  api('/task/' + encodeURIComponent(task.id) + '/body')
1844
1915
  .then((out) => setBody((out && out.blocks) || []))
1845
1916
  .catch(() => setBody([]))
@@ -1928,6 +1999,18 @@ window.__ModuleLoader__.load({
1928
1999
  onClick: () => setOnlyWaiting((cur) => !cur),
1929
2000
  }, t('board.waitCount', { n: waiting }))
1930
2001
  })(),
2002
+ (() => {
2003
+ // Просроченные — те, чей дедлайн уже прошёл. Счётчик появляется
2004
+ // только когда такие есть: ноль в шапке — мебель.
2005
+ const overdue = state.tasks.filter((x) => x.overdue === true).length
2006
+ if (overdue === 0) return null
2007
+ return React.createElement('button', {
2008
+ type: 'button',
2009
+ className: 'dkb-waitCount' + (onlyOverdue ? ' dkb-save' : ''),
2010
+ title: t('board.overdueHint'),
2011
+ onClick: () => setOnlyOverdue((cur) => !cur),
2012
+ }, t('board.overdueCount', { n: overdue }))
2013
+ })(),
1931
2014
  React.createElement('select', {
1932
2015
  className: 'dkb-input', style: { width: 'auto' }, value: order,
1933
2016
  onChange: (e) => setOrder(e.target.value),
@@ -1946,6 +2029,39 @@ window.__ModuleLoader__.load({
1946
2029
  } catch (e) { setError(t(e.key || 'error.unknown')) }
1947
2030
  },
1948
2031
  }, t('board.archive')),
2032
+ // Экспорт скачивает полный снимок доски; импорт восстанавливает его.
2033
+ React.createElement('button', {
2034
+ type: 'button', className: 'dkb-discard',
2035
+ title: t('board.exportHint'),
2036
+ onClick: async () => {
2037
+ try {
2038
+ const out = await api('/snapshot')
2039
+ const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' })
2040
+ const url = URL.createObjectURL(blob)
2041
+ const a = document.createElement('a')
2042
+ a.href = url
2043
+ a.download = 'kanban-' + new Date().toISOString().slice(0, 10) + '.json'
2044
+ a.click()
2045
+ URL.revokeObjectURL(url)
2046
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2047
+ },
2048
+ }, t('board.export')),
2049
+ React.createElement('label', { className: 'dkb-discard dkb-import' },
2050
+ t('board.import'),
2051
+ React.createElement('input', {
2052
+ type: 'file', accept: 'application/json,.json', style: { display: 'none' },
2053
+ onChange: async (e) => {
2054
+ const file = e.target.files && e.target.files[0]
2055
+ e.target.value = ''
2056
+ if (!file) return
2057
+ try {
2058
+ const input = JSON.parse(await file.text())
2059
+ const out = await api('/snapshot', { method: 'POST', body: JSON.stringify(input) })
2060
+ setError(out.error ? t('error.' + out.error) : t('board.imported', { n: out.imported }))
2061
+ await load(board)
2062
+ } catch { setError(t('error.badImport')) }
2063
+ },
2064
+ })),
1949
2065
  // «Показано 12 из 81» появляется, только когда что-то отобрано или
1950
2066
  // введён поиск: на нетронутой доске эта строка была бы шумом.
1951
2067
  anySelected(filters) || query.trim() !== ''
@@ -2087,7 +2203,8 @@ window.__ModuleLoader__.load({
2087
2203
  const all = tasksOf(state.tasks, col.id).filter((task) => matchesFilters(task, filters))
2088
2204
  const items = sortTasks(
2089
2205
  all.filter((task) => matchesQuery(task, query))
2090
- .filter((task) => !onlyWaiting || task.state === 'waiting'),
2206
+ .filter((task) => !onlyWaiting || task.state === 'waiting')
2207
+ .filter((task) => !onlyOverdue || task.overdue === true),
2091
2208
  order,
2092
2209
  )
2093
2210
  const limit = col.limit
@@ -2255,6 +2372,7 @@ window.__ModuleLoader__.load({
2255
2372
  React.createElement('div', { className: 'dkb-title' }, openTask.title),
2256
2373
  React.createElement('div', { className: 'dkb-note' },
2257
2374
  t('panel.created', { ago: agoText(state.now, openTask.createdAt, t) })
2375
+ + (openTask.assignee ? ' · ' + t('panel.assignee', { who: openTask.assignee }) : '')
2258
2376
  // Автор стоит рядом с датой заведения: это одно событие — кто и
2259
2377
  // когда завёл, — и разносить его по разным углам незачем.
2260
2378
  + (openTask.author ? ' · ' + t('panel.author', { who: openTask.author }) : ''),
@@ -2272,6 +2390,70 @@ window.__ModuleLoader__.load({
2272
2390
  : body.length === 0
2273
2391
  ? null
2274
2392
  : React.createElement('div', { className: 'dkb-mdBody' }, renderBlocks(body)),
2393
+ // Приоритет и метки — локальное свойство доски: правятся здесь, а не
2394
+ // в Gitea. У задач из issue метки приезжают сверкой, но приоритет
2395
+ // всегда свой.
2396
+ React.createElement('div', { className: 'dkb-edit' },
2397
+ React.createElement('div', { className: 'dkb-field' },
2398
+ React.createElement('span', { className: 'dkb-label' }, t('panel.priority')),
2399
+ React.createElement('select', {
2400
+ className: 'dkb-input', value: editPriority,
2401
+ onChange: (e) => setEditPriority(e.target.value),
2402
+ },
2403
+ React.createElement('option', { value: '' }, t('panel.priorityNone')),
2404
+ ['high', 'medium', 'low'].map((p) => React.createElement('option', { value: p, key: p }, t('priority.' + p)))),
2405
+ ),
2406
+ React.createElement('div', { className: 'dkb-field' },
2407
+ React.createElement('span', { className: 'dkb-label' }, t('panel.labels')),
2408
+ React.createElement('input', {
2409
+ className: 'dkb-input', value: editLabels,
2410
+ placeholder: t('dialog.labelsHint'),
2411
+ onChange: (e) => setEditLabels(e.target.value),
2412
+ }),
2413
+ ),
2414
+ React.createElement('div', { className: 'dkb-field' },
2415
+ React.createElement('span', { className: 'dkb-label' }, t('panel.due')),
2416
+ React.createElement('input', {
2417
+ type: 'date', className: 'dkb-input', value: editDue,
2418
+ onChange: (e) => setEditDue(e.target.value),
2419
+ }),
2420
+ ),
2421
+ React.createElement('button', {
2422
+ type: 'button', className: 'dkb-save', style: { alignSelf: 'flex-start' },
2423
+ onClick: async () => {
2424
+ try {
2425
+ const labels = editLabels.split(',').map((s) => s.trim()).filter(Boolean)
2426
+ // Пустая дата снимает дедлайн (0); заполненная — полночь
2427
+ // этого дня по местному времени.
2428
+ const dueAt = editDue === ''
2429
+ ? 0
2430
+ : new Date(editDue + 'T00:00:00').getTime()
2431
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/update', {
2432
+ method: 'POST',
2433
+ body: JSON.stringify({ priority: editPriority, labels, dueAt }),
2434
+ })
2435
+ setOpenTask(out.task)
2436
+ await load(board)
2437
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2438
+ },
2439
+ }, t('panel.saveEdit')),
2440
+ ),
2441
+ // Взять и отдать — одна кнопка в двух состояниях: держать рядом
2442
+ // «взять» и «отказаться» значит предлагать бессмысленное действие.
2443
+ React.createElement('button', {
2444
+ type: 'button', className: 'dkb-discard', style: { alignSelf: 'flex-start' },
2445
+ onClick: async () => {
2446
+ const mine = openTask.assignee !== '' && openTask.assignee !== undefined
2447
+ try {
2448
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/assign', {
2449
+ method: 'POST',
2450
+ body: JSON.stringify({ login: mine ? '' : 'me' }),
2451
+ })
2452
+ if (out && out.task) setOpenTask(out.task)
2453
+ await load(board)
2454
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2455
+ },
2456
+ }, openTask.assignee ? t('panel.assignDrop') : t('panel.assignMe')),
2275
2457
  openTask.sessionId
2276
2458
  ? React.createElement('button', {
2277
2459
  type: 'button', className: 'dkb-discard', style: { alignSelf: 'flex-start' },
package/lib/filters.js CHANGED
@@ -11,6 +11,12 @@ export const REPO = 'repo'
11
11
  /** Автор задачи — такое же измерение отбора, как метка, но живёт своим полем. */
12
12
  export const AUTHOR = 'author'
13
13
 
14
+ /** Ответственный: кто взял задачу, а не кто её завёл. */
15
+ export const ASSIGNEE = 'assignee'
16
+
17
+ /** Приоритет — локальное свойство доски, своё измерение отбора. */
18
+ export const PRIORITY = 'priority'
19
+
14
20
  /**
15
21
  * Значение для задач без автора.
16
22
  *
@@ -20,6 +26,17 @@ export const AUTHOR = 'author'
20
26
  */
21
27
  export const NO_AUTHOR = '—'
22
28
 
29
+ /**
30
+ * Значение для задач без ответственного.
31
+ *
32
+ * «Никто не взял» — самый нужный отбор на доске: именно эти задачи ищут, когда
33
+ * спрашивают «что свободно».
34
+ */
35
+ export const NOBODY = '—'
36
+
37
+ /** Порядок приоритетов в отборе: срочное сверху. */
38
+ export const PRIORITY_ORDER = ['critical', 'high', 'medium', 'low']
39
+
23
40
  /**
24
41
  * Порядок известных пространств.
25
42
  *
@@ -61,7 +78,12 @@ export function facetsOf(tasks) {
61
78
 
62
79
  for (const task of tasks ?? []) {
63
80
  add(REPO, typeof task?.repo === 'string' ? task.repo : '')
81
+ add(ASSIGNEE, typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY)
64
82
  add(AUTHOR, typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR)
83
+ // Приоритет живёт и полем (свои карточки), и меткой `priority/*` (задачи
84
+ // из Gitea). Оба попадают в одно измерение — отбор по срочности не должен
85
+ // зависеть от того, откуда пришло значение.
86
+ add(PRIORITY, typeof task?.priority === 'string' ? task.priority : '')
65
87
  for (const name of task?.labels ?? []) {
66
88
  const parsed = splitLabel(name)
67
89
  if (parsed !== undefined) add(parsed.ns, parsed.value)
@@ -73,19 +95,34 @@ export function facetsOf(tasks) {
73
95
  ns,
74
96
  values: [...values.entries()]
75
97
  .map(([value, count]) => ({ value, count }))
76
- .sort((a, b) => a.value.localeCompare(b.value)),
98
+ .sort((a, b) => valueRank(ns, a.value) - valueRank(ns, b.value) || a.value.localeCompare(b.value)),
77
99
  }))
78
100
  .sort((a, b) => rank(a.ns) - rank(b.ns) || a.ns.localeCompare(b.ns))
79
101
  }
80
102
 
81
103
  /** Репозиторий первым, затем известные пространства, затем всё прочее. */
82
104
  function rank(ns) {
83
- if (ns === REPO) return -2
105
+ if (ns === REPO) return -3
106
+ // Ответственный идёт раньше автора: «кто делает» спрашивают чаще, чем «кто
107
+ // завёл».
108
+ if (ns === ASSIGNEE) return -2
84
109
  if (ns === AUTHOR) return -1
85
110
  const at = KNOWN_ORDER.indexOf(ns)
86
111
  return at === -1 ? KNOWN_ORDER.length : at
87
112
  }
88
113
 
114
+ /**
115
+ * Порядок значений внутри пространства.
116
+ *
117
+ * Приоритет — «срочное сначала», а не алфавит: `critical` сперва, `low`
118
+ * в самом низу. Для остальных — обычное сравнение строк.
119
+ */
120
+ function valueRank(ns, value) {
121
+ if (ns !== PRIORITY) return 0
122
+ const at = PRIORITY_ORDER.indexOf(value)
123
+ return at === -1 ? PRIORITY_ORDER.length : at
124
+ }
125
+
89
126
  /**
90
127
  * Подходит ли задача под выбранное.
91
128
  *
@@ -107,10 +144,24 @@ export function matchesFilters(task, selected) {
107
144
 
108
145
  function hasAny(task, ns, wanted) {
109
146
  if (ns === REPO) return wanted.includes(task?.repo ?? '')
147
+ if (ns === ASSIGNEE) {
148
+ const who = typeof task?.assignee === 'string' && task.assignee !== '' ? task.assignee : NOBODY
149
+ return wanted.includes(who)
150
+ }
110
151
  if (ns === AUTHOR) {
111
152
  const author = typeof task?.author === 'string' && task.author !== '' ? task.author : NO_AUTHOR
112
153
  return wanted.includes(author)
113
154
  }
155
+ if (ns === PRIORITY) {
156
+ // Приоритет и полем, и меткой `priority/*` — отбор по срочности не должен
157
+ // зависеть от источника значения.
158
+ if (wanted.includes(typeof task?.priority === 'string' ? task.priority : '')) return true
159
+ for (const name of task?.labels ?? []) {
160
+ const parsed = splitLabel(name)
161
+ if (parsed !== undefined && parsed.ns === PRIORITY && wanted.includes(parsed.value)) return true
162
+ }
163
+ return false
164
+ }
114
165
  for (const name of task?.labels ?? []) {
115
166
  const parsed = splitLabel(name)
116
167
  if (parsed !== undefined && parsed.ns === ns && wanted.includes(parsed.value)) return true
@@ -213,11 +264,24 @@ export function liveRank(task) {
213
264
  return 3
214
265
  }
215
266
 
216
- /** Поднять идущее наверх, не теряя прежнего порядка. */
267
+ /** Вес приоритета при подъёме: срочное впереди тихого. */
268
+ export function priorityWeight(priority) {
269
+ const at = PRIORITY_ORDER.indexOf(priority)
270
+ return at === -1 ? PRIORITY_ORDER.length : at
271
+ }
272
+
273
+ /**
274
+ * Поднять идущее наверх и приоритетное следом, не теряя прежнего порядка.
275
+ *
276
+ * Две независимые оси: сначала жизнь (ждёт/идёт/стоит), затем срочность
277
+ * (critical → low). Ручной порядок внутри ступени сохраняется.
278
+ */
217
279
  export function sortByLife(tasks) {
218
280
  return (tasks ?? [])
219
281
  .map((task, at) => ({ task, at }))
220
- .sort((a, b) => liveRank(a.task) - liveRank(b.task) || a.at - b.at)
282
+ .sort((a, b) => liveRank(a.task) - liveRank(b.task)
283
+ || priorityWeight(a.task?.priority) - priorityWeight(b.task?.priority)
284
+ || a.at - b.at)
221
285
  .map((x) => x.task)
222
286
  }
223
287
 
package/lib/gitea.js CHANGED
@@ -222,6 +222,34 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
222
222
  return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { state: 'closed' })
223
223
  },
224
224
 
225
+ /**
226
+ * Назначить ответственного за issue.
227
+ *
228
+ * Пустой список снимает назначение: Gitea понимает это как «никто», и
229
+ * доска обязана уметь то же самое — иначе снять с себя задачу можно было
230
+ * бы только руками в Gitea.
231
+ */
232
+ async setAssignees({ owner, repo, index, logins }) {
233
+ const n = safeSegment(index)
234
+ if (n === undefined) throw Object.assign(new Error('недопустимый номер issue'), { code: 'bad-segment' })
235
+ const list = (Array.isArray(logins) ? logins : [])
236
+ .filter((one) => typeof one === 'string' && one !== '')
237
+ return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { assignees: list })
238
+ },
239
+
240
+ /**
241
+ * Кто мы для Gitea.
242
+ *
243
+ * «Взять себе» на доске значит «назначить того, чьим токеном доска ходит»:
244
+ * своего понятия пользователя у харнесса нет, и выдумывать его вместо
245
+ * ответа сервера нельзя.
246
+ */
247
+ async me() {
248
+ const row = await request('GET', '/user')
249
+ const login = row?.login
250
+ return typeof login === 'string' ? login : ''
251
+ },
252
+
225
253
  /** Организации, доступные токену. Нужны, чтобы не спрашивать владельца лишний раз. */
226
254
  async listOrgs() {
227
255
  const rows = await request('GET', '/user/orgs?limit=50')
package/lib/import.js CHANGED
@@ -33,6 +33,22 @@ export function issueAuthor(issue) {
33
33
  return typeof login === 'string' ? login : ''
34
34
  }
35
35
 
36
+ /**
37
+ * Кто отвечает за issue.
38
+ *
39
+ * Gitea отдаёт назначенного двумя способами: одиночным `assignee` и списком
40
+ * `assignees`. Берём первого из списка, когда одиночного нет: доска показывает
41
+ * ОДНОГО ответственного, потому что «отвечают все» означает «не отвечает
42
+ * никто».
43
+ */
44
+ export function issueAssignee(issue) {
45
+ const one = issue?.assignee?.login
46
+ if (typeof one === 'string' && one !== '') return one
47
+ const many = Array.isArray(issue?.assignees) ? issue.assignees : []
48
+ const first = many.find((x) => typeof x?.login === 'string' && x.login !== '')
49
+ return first === undefined ? '' : first.login
50
+ }
51
+
36
52
  /**
37
53
  * Поля задачи из issue.
38
54
  *
@@ -60,6 +76,7 @@ export function issueToTask(issue, { board = 'main', column = 'backlog', owner,
60
76
  issueUrl: typeof issue?.html_url === 'string' ? issue.html_url : undefined,
61
77
  createdAt: issueBornAt(issue),
62
78
  author: issueAuthor(issue),
79
+ assignee: issueAssignee(issue),
63
80
  }
64
81
  }
65
82
 
@@ -103,6 +120,12 @@ export function refreshPatch(task, issue) {
103
120
  if (born !== undefined) patch.createdAt = born
104
121
  const author = issueAuthor(issue)
105
122
  if (author !== '') patch.author = author
123
+ // Снятие ответственного — тоже факт, поэтому пустое значение пишется наравне
124
+ // с именем: иначе доска показывала бы вчерашнего владельца задачи, с которой
125
+ // его уже сняли.
126
+ if (issue?.assignee !== undefined || Array.isArray(issue?.assignees)) {
127
+ patch.assignee = issueAssignee(issue)
128
+ }
106
129
  return patch
107
130
  }
108
131
 
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,
25
+ taskLog, taskBySession, exportBoard, importBoard, assignTask,
26
26
  } from './routes.js'
27
27
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
28
28
  import {
@@ -36,7 +36,7 @@ import { isWatched } from './intake.js'
36
36
  import { handleSessionEvent } from './lifecycle.js'
37
37
  import { boardMoveDefinition, boardPlanDefinition } from './board-tool.js'
38
38
  import { verifySignature, parseEvent } from './webhook.js'
39
- import { planOutbound, createOutbox } from './outbound.js'
39
+ import { planOutbound, planAssign, createOutbox } from './outbound.js'
40
40
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
41
41
 
42
42
  export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
@@ -519,6 +519,28 @@ export function apply(ctx, config) {
519
519
  },
520
520
  }), 'dsh-kanban: маршрут доски')
521
521
 
522
+ // Снимок доски — ОДИН ресурс с двумя действиями, а не две ручки.
523
+ //
524
+ // Прежде выгрузка звалась `/export`, а загрузка занимала `/import` — тот
525
+ // самый путь, на котором с первого дня живёт импорт issue из Gitea. Два
526
+ // обработчика на одном пути означают, что один из них не отвечает вовсе, и
527
+ // какой именно — решает порядок регистрации, а не мы.
528
+ ctx.effect(() => ctx.webServer.register({
529
+ kind: 'exact',
530
+ path: '/dsh-kanban/snapshot',
531
+ handler: async (req, res) => {
532
+ if (req.method === 'GET') { reply(res, exportBoard({ store })); return }
533
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
534
+ if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
535
+ try {
536
+ const input = await readBody(req)
537
+ reply(res, importBoard({ store, input }))
538
+ } catch {
539
+ writeJson(res, 400, { error: 'bad-request' })
540
+ }
541
+ },
542
+ }), 'dsh-kanban: снимок доски')
543
+
522
544
  ctx.effect(() => ctx.webServer.register({
523
545
  // Префикс регистрируется БЕЗ завершающего слэша: со слэшем сопоставление
524
546
  // сводится к равенству пути, и всё, что глубже, уходит в статику.
@@ -556,6 +578,17 @@ export function apply(ctx, config) {
556
578
  }
557
579
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
558
580
  if (req.method === 'DELETE' && action === undefined) { reply(res, deleteTask({ store, id })); return }
581
+ // Правка полей карточки: title, body, метки, приоритет. Приоритет —
582
+ // локальное свойство доски, в Gitea не пишется (updateTask это знает).
583
+ if (req.method === 'POST' && action === 'update') {
584
+ try {
585
+ const input = await readBody(req)
586
+ reply(res, updateTask({ store, id, input }))
587
+ } catch {
588
+ writeJson(res, 400, { error: 'bad-request' })
589
+ }
590
+ return
591
+ }
559
592
  if (req.method === 'POST' && action === 'refresh') {
560
593
  reply(res, await refreshTask({ gitea, store, id }))
561
594
  return
@@ -641,6 +674,29 @@ export function apply(ctx, config) {
641
674
  reply(res, out)
642
675
  return
643
676
  }
677
+ if (req.method === 'POST' && action === 'assign') {
678
+ const was = store.getTask(id)
679
+ if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
680
+ // Пустой логин снимает назначение; слово «me» просит того, чьим
681
+ // токеном ходит доска: своего понятия пользователя у харнесса нет.
682
+ let login = typeof input.login === 'string' ? input.login.trim() : ''
683
+ if (login === 'me') {
684
+ try {
685
+ login = await gitea.me()
686
+ } catch (error) {
687
+ writeJson(res, 502, { error: 'gitea-unreachable' })
688
+ return
689
+ }
690
+ }
691
+ const out = assignTask({ store, id, login })
692
+ if (out.task !== undefined && config.pushToGitea !== false) {
693
+ // В Gitea уходит очередью: доска не ждёт сеть и работает, когда
694
+ // Gitea лежит.
695
+ outbox.push(id, planAssign(out.task, login))
696
+ }
697
+ reply(res, out)
698
+ return
699
+ }
644
700
  if (req.method === 'POST' && action === 'archive') {
645
701
  reply(res, setArchived({ store, id, archived: true }))
646
702
  return
package/lib/outbound.js CHANGED
@@ -13,6 +13,22 @@
13
13
  // из состояния issue, ветки и pull request, а метка лишь пересказывала в Gitea
14
14
  // то, что там уже написано. Взамен доска не трогает чужую разметку вовсе.
15
15
 
16
+ /**
17
+ * Что отправить в Gitea при смене ответственного.
18
+ *
19
+ * Своей задачи (без issue) это не касается: назначать там некого и негде.
20
+ */
21
+ export function planAssign(task, login) {
22
+ if (typeof task?.issueNumber !== 'number') return []
23
+ return [{
24
+ kind: 'assign',
25
+ owner: task.owner,
26
+ repo: task.repo,
27
+ index: task.issueNumber,
28
+ login: typeof login === 'string' ? login : '',
29
+ }]
30
+ }
31
+
16
32
  export function planOutbound(task, column, config) {
17
33
  if (typeof task?.issueNumber !== 'number') return []
18
34
  // Закрытие issue — единственное, что доска сообщает Gitea: это факт, а не
@@ -58,6 +74,12 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
58
74
  if (item.op.kind === 'close') {
59
75
  await gitea.closeIssue({ owner: item.op.owner, repo: item.op.repo, index: item.op.index })
60
76
  }
77
+ if (item.op.kind === 'assign') {
78
+ await gitea.setAssignees({
79
+ owner: item.op.owner, repo: item.op.repo, index: item.op.index,
80
+ logins: item.op.login === '' ? [] : [item.op.login],
81
+ })
82
+ }
61
83
  sent += 1
62
84
  } catch (error) {
63
85
  item.attempts += 1
package/lib/routes.js CHANGED
@@ -67,6 +67,9 @@ function withPlan(task, liveOf, config, now, sameSession) {
67
67
  sessionId: task.sessionId,
68
68
  afterMinutes: config?.staleAfterMin,
69
69
  }),
70
+ // Просрочено — дедлайн есть и он раньше «сейчас». Считается на хосте,
71
+ // чтобы браузер не зависел от своих часов.
72
+ overdue: typeof task.dueAt === 'number' && task.dueAt > 0 && task.dueAt < now,
70
73
  }
71
74
  }
72
75
 
@@ -184,9 +187,21 @@ export function updateTask({ store, id, input }) {
184
187
  if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
185
188
  if (typeof input?.body === 'string') patch.body = input.body
186
189
  if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
190
+ // Приоритет — локальное свойство доски: в Gitea назад не пишется. Чужие
191
+ // значения отбрасываются: одно испорченное поле не должно ронять правку
192
+ // остальных.
193
+ if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
194
+ // Дедлайн — тоже локальное свойство. Ноль снимает, положительное число —
195
+ // ставит; отрицательное и мусор отбрасываются.
196
+ if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
197
+ patch.dueAt = Math.floor(input.dueAt)
198
+ }
187
199
  return { task: store.updateTask(id, patch) }
188
200
  }
189
201
 
202
+ /** Известные приоритеты. Пустой — «без приоритета». */
203
+ export const PRIORITIES = ['', 'high', 'medium', 'low']
204
+
190
205
  /**
191
206
  * Дописать заметку в тело задачи.
192
207
  *
@@ -222,6 +237,30 @@ export function listArchive({ store }) {
222
237
  return { tasks: store.listArchived() }
223
238
  }
224
239
 
240
+ /**
241
+ * Назначить ответственного или снять назначение.
242
+ *
243
+ * Пустой логин — это снятие, а не отказ: «никто» такое же законное состояние
244
+ * задачи, как и любой исполнитель.
245
+ *
246
+ * @returns {{task: object}|{error: string, status: number}}
247
+ */
248
+ export function assignTask({ store, id, login }) {
249
+ const task = store.getTask(id)
250
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
251
+ const who = typeof login === 'string' ? login.trim() : ''
252
+ if (who === (task.assignee ?? '')) return { task }
253
+ store.updateTask(id, { assignee: who })
254
+ store.addTransition({
255
+ taskId: id,
256
+ fromCol: task.column,
257
+ toCol: task.column,
258
+ source: 'manual',
259
+ detail: who === '' ? 'ответственный снят' : `ответственный: ${who}`,
260
+ })
261
+ return { task: store.getTask(id) }
262
+ }
263
+
225
264
  export function deleteTask({ store, id }) {
226
265
  if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
227
266
  store.deleteTask(id)
@@ -254,3 +293,63 @@ export function taskBySession({ store, sessionId }) {
254
293
  // `deploy` задаче с простой доски значит звать в колонку, которой нет.
255
294
  return { task, columns: columnsOf(store.boardKind(task.board)) }
256
295
  }
296
+
297
+ /**
298
+ * Полный снимок доски для бэкапа/переноса.
299
+ *
300
+ * Отдаём всё, что есть в хранилище: доски, задачи (включая архивные) и журнал
301
+ * переходов. Импорт восстанавливает это как есть.
302
+ */
303
+ export function exportBoard({ store }) {
304
+ return store.exportAll()
305
+ }
306
+
307
+ /**
308
+ * Восстановить доску из снимка.
309
+ *
310
+ * Идемпотентно: существующая задача с тем же id не перезаписывается, а
311
+ * пропускается — повторный импорт не плодит дубли. Доски создаются, если их
312
+ * ещё нет.
313
+ *
314
+ * @returns {{imported: number, skipped: number, error?: string}}
315
+ */
316
+ export function importBoard({ store, input }) {
317
+ if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
318
+ if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
319
+ if (!Array.isArray(input.tasks)) return { error: 'bad-import', status: 400 }
320
+
321
+ let imported = 0
322
+ let skipped = 0
323
+
324
+ for (const board of Array.isArray(input.boards) ? input.boards : []) {
325
+ if (typeof board?.id !== 'string' || board.id === '') continue
326
+ store.createBoard({ id: board.id, title: board.title, kind: board.kind })
327
+ }
328
+
329
+ for (const task of input.tasks) {
330
+ if (typeof task?.id !== 'string' || task.id === '') continue
331
+ if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
332
+ store.createTask({
333
+ id: task.id,
334
+ board: task.board ?? 'main',
335
+ column: task.column ?? 'backlog',
336
+ title: task.title ?? '',
337
+ body: task.body ?? '',
338
+ owner: task.owner, repo: task.repo,
339
+ issueNumber: task.issueNumber, issueUrl: task.issueUrl,
340
+ labels: Array.isArray(task.labels) ? task.labels : [],
341
+ branch: task.branch, worktree: task.worktree,
342
+ sessionId: task.sessionId, model: task.model, provider: task.provider,
343
+ waiting: task.waiting === true,
344
+ plan: task.plan ?? '',
345
+ labelColors: task.labelColors ?? {},
346
+ author: task.author ?? '',
347
+ priority: task.priority ?? '',
348
+ dueAt: typeof task.dueAt === 'number' ? task.dueAt : 0,
349
+ createdAt: task.createdAt,
350
+ })
351
+ imported += 1
352
+ }
353
+
354
+ return { imported, skipped }
355
+ }
package/lib/store.js CHANGED
@@ -50,6 +50,9 @@ CREATE TABLE IF NOT EXISTS tasks (
50
50
  queuedAt INTEGER NOT NULL DEFAULT 0,
51
51
  labelColors TEXT NOT NULL DEFAULT '{}',
52
52
  author TEXT NOT NULL DEFAULT '',
53
+ assignee TEXT NOT NULL DEFAULT '',
54
+ priority TEXT NOT NULL DEFAULT '',
55
+ dueAt INTEGER NOT NULL DEFAULT 0,
53
56
  syncedAt INTEGER NOT NULL DEFAULT 0,
54
57
  createdAt INTEGER NOT NULL,
55
58
  updatedAt INTEGER NOT NULL
@@ -79,7 +82,7 @@ CREATE TABLE IF NOT EXISTS dismissed (
79
82
  const WRITABLE = [
80
83
  'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
81
84
  'branch', 'worktree', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
82
- 'labelColors', 'queuedAt', 'createdAt', 'author',
85
+ 'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'priority', 'dueAt',
83
86
  ]
84
87
 
85
88
  /** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
@@ -124,6 +127,9 @@ export function openStore({ dir }) {
124
127
  ['queuedAt', 'INTEGER NOT NULL DEFAULT 0'],
125
128
  ['labelColors', "TEXT NOT NULL DEFAULT '{}'"],
126
129
  ['author', "TEXT NOT NULL DEFAULT ''"],
130
+ ['assignee', "TEXT NOT NULL DEFAULT ''"],
131
+ ['priority', "TEXT NOT NULL DEFAULT ''"],
132
+ ['dueAt', 'INTEGER NOT NULL DEFAULT 0'],
127
133
  ]) {
128
134
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
129
135
  }
@@ -138,6 +144,8 @@ export function openStore({ dir }) {
138
144
  insertBoard: db.prepare('INSERT INTO boards (id, title, kind, position, createdAt) VALUES (?, ?, ?, ?, ?)'),
139
145
  lastBoard: db.prepare('SELECT position FROM boards ORDER BY position DESC LIMIT 1'),
140
146
  taskById: db.prepare('SELECT * FROM tasks WHERE id = ?'),
147
+ allTasks: db.prepare('SELECT * FROM tasks ORDER BY board, col, position'),
148
+ allTransitions: db.prepare('SELECT * FROM transitions ORDER BY id'),
141
149
  tasksByBoard: db.prepare('SELECT * FROM tasks WHERE board = ? AND archivedAt = 0 ORDER BY col, position'),
142
150
  archived: db.prepare('SELECT * FROM tasks WHERE archivedAt > 0 ORDER BY archivedAt DESC'),
143
151
  archivable: db.prepare(
@@ -244,13 +252,15 @@ export function openStore({ dir }) {
244
252
  const born = Number(input.createdAt) || now
245
253
  const board = input.board ?? 'main'
246
254
  const column = input.column ?? 'backlog'
247
- const id = randomUUID()
255
+ // Импорт восстанавливает задачу с её прежним id; обычное заведение
256
+ // генерирует новый.
257
+ const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
248
258
  const position = positionFor(board, column, input.beforeId, input.afterId)
249
259
  db.prepare(`INSERT INTO tasks
250
260
  (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
251
261
  labels, branch, worktree, sessionId, model, provider, waiting, syncedAt,
252
- labelColors, author, columnAt, createdAt, updatedAt)
253
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
262
+ labelColors, author, assignee, priority, dueAt, columnAt, createdAt, updatedAt)
263
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
254
264
  id, board, column, position,
255
265
  input.title ?? '', input.body ?? '',
256
266
  input.owner ?? null, input.repo ?? null,
@@ -262,6 +272,9 @@ export function openStore({ dir }) {
262
272
  Number(input.syncedAt) || 0,
263
273
  JSON.stringify(input.labelColors ?? {}),
264
274
  input.author ?? '',
275
+ input.assignee ?? '',
276
+ input.priority ?? '',
277
+ Number(input.dueAt) || 0,
265
278
  now, born, now,
266
279
  )
267
280
  return rowToTask(q.taskById.get(id))
@@ -359,6 +372,19 @@ export function openStore({ dir }) {
359
372
  return q.transitionsOf.all(taskId)
360
373
  },
361
374
 
375
+ /**
376
+ * Полный снимок доски для экспорта: все доски, все задачи (включая
377
+ * архивные) и журнал переходов. Импорт восстанавливает его как есть.
378
+ */
379
+ exportAll() {
380
+ return {
381
+ version: 1,
382
+ boards: q.boards.all(),
383
+ tasks: q.allTasks.all().map(rowToTask),
384
+ transitions: q.allTransitions.all(),
385
+ }
386
+ },
387
+
362
388
  close() {
363
389
  db.close()
364
390
  },
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 } from './import.js'
10
+ import { issueToTask, issueBornAt, issueAuthor, issueAssignee } from './import.js'
11
11
 
12
12
  /**
13
13
  * Наблюдения по одному репозиторию.
@@ -94,6 +94,12 @@ function remoteFields(task, issue) {
94
94
  if (born !== undefined && born !== task.createdAt) patch.createdAt = born
95
95
  const author = issueAuthor(issue)
96
96
  if (author !== '' && author !== task.author) patch.author = author
97
+ // Ответственного сверка подхватывает В ОБЕ стороны: назначили в Gitea —
98
+ // видно на доске, сняли — тоже видно.
99
+ if (issue?.assignee !== undefined || Array.isArray(issue?.assignees)) {
100
+ const assignee = issueAssignee(issue)
101
+ if (assignee !== (task.assignee ?? '')) patch.assignee = assignee
102
+ }
97
103
  return patch
98
104
  }
99
105
 
@@ -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.21",
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",