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