@goodandready/dsh-kanban 0.2.2 → 0.2.7
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 +10 -0
- package/{docs/README.ru.md → README.ru.md} +10 -0
- package/{docs/README.zh.md → README.zh.md} +10 -0
- package/lib/board-tool.js +151 -0
- package/lib/client.js +862 -738
- package/lib/gitea.js +15 -0
- package/lib/index.js +109 -91
- package/lib/routes.js +357 -1
- package/lib/store.js +42 -4
- package/lib/sync.js +24 -1
- package/package.json +3 -2
- package/docs/design/DESIGN.md +0 -94
- package/docs/plans/2026-08-26-dsh-kanban-0.1.md +0 -169
- package/docs/plans/2026-09-07-quality-acceptance-gateway.md +0 -25
- package/docs/plans/2026-09-08-concurrency-safety.md +0 -8
- package/docs/plans/2026-09-08-diff-viewer-merge.md +0 -7
- package/docs/plans/2026-09-08-session-worktree-stability.md +0 -33
- package/docs/plans/2026-09-10-issue-238-settings-scope.md +0 -13
package/lib/gitea.js
CHANGED
|
@@ -287,6 +287,21 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
|
|
|
287
287
|
})
|
|
288
288
|
},
|
|
289
289
|
|
|
290
|
+
/** Создать Pull Request в репозитории. */
|
|
291
|
+
async createPullRequest({ owner, repo, title, body, head, base = 'main' }) {
|
|
292
|
+
const name = String(title ?? '').trim()
|
|
293
|
+
if (name === '') throw Object.assign(new Error('пустой заголовок'), { code: 'bad-title' })
|
|
294
|
+
const h = String(head ?? '').trim()
|
|
295
|
+
const b = String(base ?? 'main').trim()
|
|
296
|
+
if (!h) throw Object.assign(new Error('ветка head обязательна'), { code: 'bad-head' })
|
|
297
|
+
return request('POST', repoPath(owner, repo, '/pulls'), {
|
|
298
|
+
title: name,
|
|
299
|
+
body: String(body ?? ''),
|
|
300
|
+
head: h,
|
|
301
|
+
base: b,
|
|
302
|
+
})
|
|
303
|
+
},
|
|
304
|
+
|
|
290
305
|
/**
|
|
291
306
|
* Ветки репозитория: по ним видно, начата ли работа и сделан ли cleanup.
|
|
292
307
|
*
|
package/lib/index.js
CHANGED
|
@@ -9,21 +9,44 @@
|
|
|
9
9
|
// `routes.js`) и проверяется тестами без харнесса. Здесь остаётся обвязка
|
|
10
10
|
// cordis: настройки, хранилище и маршруты.
|
|
11
11
|
|
|
12
|
-
import { homedir } from 'node:os'
|
|
13
|
-
import { join } from 'node:path'
|
|
14
|
-
import { randomUUID } from 'node:crypto'
|
|
15
|
-
import z from '@deepseek-ai/schemastery'
|
|
16
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
17
|
-
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
18
|
-
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
19
|
-
import { CONFIG_DEFAULTS, CONFIG_HINTS, withDefaults, rootOf } from './config.js'
|
|
20
|
-
import { openStore } from './store.js'
|
|
21
12
|
import {
|
|
22
|
-
MAX_BODY_BYTES,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
MAX_BODY_BYTES,
|
|
14
|
+
isTrustedRequest,
|
|
15
|
+
parseTaskPath,
|
|
16
|
+
buildBoard,
|
|
17
|
+
applyMove,
|
|
18
|
+
createTask,
|
|
19
|
+
updateTask,
|
|
26
20
|
confirmPermission,
|
|
21
|
+
appendNote,
|
|
22
|
+
setArchived,
|
|
23
|
+
listArchive,
|
|
24
|
+
assignTask,
|
|
25
|
+
deleteTask,
|
|
26
|
+
boardMetrics,
|
|
27
|
+
taskBody,
|
|
28
|
+
taskLog,
|
|
29
|
+
taskBySession,
|
|
30
|
+
exportBoard,
|
|
31
|
+
importBoard,
|
|
32
|
+
exportTasksCsv,
|
|
33
|
+
handleCleanupWorktree,
|
|
34
|
+
handleTaskDiff,
|
|
35
|
+
handleTaskCommits,
|
|
36
|
+
handleTaskMerge,
|
|
37
|
+
handleTaskWorktreeStatus,
|
|
38
|
+
handleTaskChecklist,
|
|
39
|
+
handleTaskReport,
|
|
40
|
+
handleGetTaskComments,
|
|
41
|
+
handleAddTaskComment,
|
|
42
|
+
handleAcceptTask,
|
|
43
|
+
handleRejectTask,
|
|
44
|
+
handleProgressDump,
|
|
45
|
+
handleTriageTask,
|
|
46
|
+
handleGetSubtasks,
|
|
47
|
+
handleCreateSubtask,
|
|
48
|
+
handleToggleChecklistItem,
|
|
49
|
+
handleCreateTaskPr,
|
|
27
50
|
} from './routes.js'
|
|
28
51
|
import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
|
|
29
52
|
import {
|
|
@@ -44,6 +67,8 @@ import {
|
|
|
44
67
|
boardReportDefinition,
|
|
45
68
|
boardCommentsDefinition,
|
|
46
69
|
boardCommentAddDefinition,
|
|
70
|
+
boardDecomposeDefinition,
|
|
71
|
+
boardChecklistItemDefinition,
|
|
47
72
|
} from './board-tool.js'
|
|
48
73
|
import { verifySignature, parseEvent } from './webhook.js'
|
|
49
74
|
import { planOutbound, planAssign, createOutbox } from './outbound.js'
|
|
@@ -51,6 +76,19 @@ import { computeNextRun } from './cron.js'
|
|
|
51
76
|
import { PowerInhibitor } from './power.js'
|
|
52
77
|
import { formatProgressDump, parseProgressDump } from './progress-dump.js'
|
|
53
78
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
79
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
80
|
+
import z from '@deepseek-ai/schemastery'
|
|
81
|
+
import { join } from 'node:path'
|
|
82
|
+
import { homedir } from 'node:os'
|
|
83
|
+
import { openStore } from './store.js'
|
|
84
|
+
import {
|
|
85
|
+
COLUMN_ORDER,
|
|
86
|
+
wipLimitField,
|
|
87
|
+
withDefaults,
|
|
88
|
+
CONFIG_DEFAULTS,
|
|
89
|
+
CONFIG_HINTS,
|
|
90
|
+
rootOf,
|
|
91
|
+
} from './config.js'
|
|
54
92
|
import { eventHub } from './events.js'
|
|
55
93
|
import { TASK_TEMPLATES } from './templates.js'
|
|
56
94
|
|
|
@@ -688,6 +726,8 @@ export function apply(ctx, config) {
|
|
|
688
726
|
ctx.tools.register(defineTool(boardReportDefinition({ store }))),
|
|
689
727
|
ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
|
|
690
728
|
ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
|
|
729
|
+
ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea }))),
|
|
730
|
+
ctx.tools.register(defineTool(boardChecklistItemDefinition({ store }))),
|
|
691
731
|
]
|
|
692
732
|
return () => { for (const stop of off) stop?.() }
|
|
693
733
|
}
|
|
@@ -949,112 +989,93 @@ export function apply(ctx, config) {
|
|
|
949
989
|
return
|
|
950
990
|
}
|
|
951
991
|
if (req.method === 'POST' && action === 'cleanup-worktree') {
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
const out = await cleanupTaskWorktree({
|
|
955
|
-
store,
|
|
956
|
-
task,
|
|
992
|
+
const resObj = await handleCleanupWorktree({
|
|
993
|
+
store, task: store.getTask(id),
|
|
957
994
|
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
958
995
|
force: input?.force === true,
|
|
996
|
+
cleanupTaskWorktree,
|
|
959
997
|
})
|
|
960
|
-
|
|
998
|
+
writeJson(res, resObj.status, resObj.body)
|
|
961
999
|
return
|
|
962
1000
|
}
|
|
963
1001
|
if (req.method === 'GET' && action === 'diff') {
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
const diffResult = await getTaskDiff({ worktreePath: task.worktree })
|
|
967
|
-
reply(res, diffResult)
|
|
1002
|
+
const resObj = await handleTaskDiff({ task: store.getTask(id), getTaskDiff })
|
|
1003
|
+
writeJson(res, resObj.status, resObj.body)
|
|
968
1004
|
return
|
|
969
1005
|
}
|
|
970
1006
|
if (req.method === 'GET' && action === 'commits') {
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
const commits = await getTaskCommits({ worktreePath: task.worktree })
|
|
974
|
-
reply(res, { commits })
|
|
1007
|
+
const resObj = await handleTaskCommits({ task: store.getTask(id), getTaskCommits })
|
|
1008
|
+
writeJson(res, resObj.status, resObj.body)
|
|
975
1009
|
return
|
|
976
1010
|
}
|
|
977
1011
|
if (req.method === 'POST' && action === 'merge') {
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
task,
|
|
982
|
-
targetBranch: typeof input?.targetBranch === 'string' ? input.targetBranch.trim() : undefined,
|
|
1012
|
+
const resObj = await handleTaskMerge({
|
|
1013
|
+
store, task: store.getTask(id),
|
|
1014
|
+
targetBranch: input?.targetBranch,
|
|
983
1015
|
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
1016
|
+
mergeTaskWorktree,
|
|
984
1017
|
})
|
|
985
|
-
|
|
986
|
-
writeJson(res, 400, { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files })
|
|
987
|
-
return
|
|
988
|
-
}
|
|
989
|
-
const updated = store.acceptTask(id)
|
|
990
|
-
store.addTransition({
|
|
991
|
-
taskId: id,
|
|
992
|
-
fromCol: task.column,
|
|
993
|
-
toCol: 'done',
|
|
994
|
-
source: 'manual',
|
|
995
|
-
detail: `принята и смержена ветка ${task.branch} в ${mergeResult.targetBranch}`,
|
|
996
|
-
})
|
|
997
|
-
reply(res, { task: updated, merged: true, targetBranch: mergeResult.targetBranch })
|
|
1018
|
+
writeJson(res, resObj.status, resObj.body)
|
|
998
1019
|
return
|
|
999
1020
|
}
|
|
1000
1021
|
if (req.method === 'GET' && action === 'worktree-status') {
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
|
|
1004
|
-
reply(res, {
|
|
1005
|
-
worktree: task.worktree || '',
|
|
1006
|
-
branch: task.branch || '',
|
|
1007
|
-
workspaceId: task.workspaceId || '',
|
|
1008
|
-
dirty: dirtyCheck.dirty,
|
|
1009
|
-
files: dirtyCheck.files,
|
|
1010
|
-
})
|
|
1022
|
+
const resObj = await handleTaskWorktreeStatus({ task: store.getTask(id), checkWorktreeDirty })
|
|
1023
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1011
1024
|
return
|
|
1012
1025
|
}
|
|
1013
|
-
|
|
1014
|
-
const
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1026
|
+
if (req.method === 'GET' && action === 'subtasks') {
|
|
1027
|
+
const resObj = handleGetSubtasks({ store, task: store.getTask(id) })
|
|
1028
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1029
|
+
return
|
|
1030
|
+
}
|
|
1031
|
+
if (req.method === 'POST' && action === 'triage') {
|
|
1032
|
+
const resObj = await handleTriageTask({ store, gitea, task: store.getTask(id), input })
|
|
1033
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1034
|
+
return
|
|
1035
|
+
}
|
|
1036
|
+
if (req.method === 'POST' && (action === 'subtask' || action === 'subtasks')) {
|
|
1037
|
+
const resObj = await handleCreateSubtask({ store, gitea, task: store.getTask(id), input })
|
|
1038
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
if (req.method === 'POST' && action === 'checklist-toggle') {
|
|
1042
|
+
const resObj = handleToggleChecklistItem({ store, task: store.getTask(id), input })
|
|
1043
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1044
|
+
return
|
|
1045
|
+
}
|
|
1046
|
+
if (req.method === 'POST' && action === 'pr') {
|
|
1047
|
+
const resObj = await handleCreateTaskPr({ store, gitea, task: store.getTask(id), input })
|
|
1048
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1049
|
+
return
|
|
1050
|
+
}
|
|
1051
|
+
if (req.method === 'POST' && action === 'checklist') {
|
|
1052
|
+
const resObj = handleTaskChecklist({ store, task: store.getTask(id), input })
|
|
1053
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1019
1054
|
return
|
|
1020
1055
|
}
|
|
1021
1056
|
if (req.method === 'POST' && action === 'report') {
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
const updated = store.setReport(id, input)
|
|
1025
|
-
reply(res, { task: updated })
|
|
1057
|
+
const resObj = handleTaskReport({ store, task: store.getTask(id), input })
|
|
1058
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1026
1059
|
return
|
|
1027
1060
|
}
|
|
1028
1061
|
if (req.method === 'GET' && action === 'comments') {
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
reply(res, { comments: task.comments || [] })
|
|
1062
|
+
const resObj = handleGetTaskComments({ task: store.getTask(id) })
|
|
1063
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1032
1064
|
return
|
|
1033
1065
|
}
|
|
1034
1066
|
if (req.method === 'POST' && action === 'comments') {
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
const author = input?.author || 'user'
|
|
1038
|
-
const role = input?.role || 'user'
|
|
1039
|
-
const text = input?.text || ''
|
|
1040
|
-
const updated = store.addComment(id, { author, role, text })
|
|
1041
|
-
reply(res, { task: updated })
|
|
1067
|
+
const resObj = handleAddTaskComment({ store, task: store.getTask(id), input })
|
|
1068
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1042
1069
|
return
|
|
1043
1070
|
}
|
|
1044
1071
|
if (req.method === 'POST' && action === 'accept') {
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1047
|
-
const updated = store.acceptTask(id)
|
|
1048
|
-
reply(res, { task: updated })
|
|
1072
|
+
const resObj = handleAcceptTask({ store, task: store.getTask(id) })
|
|
1073
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1049
1074
|
return
|
|
1050
1075
|
}
|
|
1051
1076
|
if (req.method === 'POST' && action === 'reject') {
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
const reason = String(input?.reason || '').trim()
|
|
1055
|
-
if (!reason) { writeJson(res, 400, { error: 'reason-required' }); return }
|
|
1056
|
-
const updated = store.rejectTask(id, reason)
|
|
1057
|
-
reply(res, { task: updated })
|
|
1077
|
+
const resObj = handleRejectTask({ store, task: store.getTask(id), input })
|
|
1078
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1058
1079
|
return
|
|
1059
1080
|
}
|
|
1060
1081
|
if (req.method === 'POST' && action === 'resume') {
|
|
@@ -1080,7 +1101,7 @@ export function apply(ctx, config) {
|
|
|
1080
1101
|
text: input?.text,
|
|
1081
1102
|
agentPreset: input?.agentPreset || task.agentPreset,
|
|
1082
1103
|
permission: input?.permission || task.permission,
|
|
1083
|
-
permissions: ctx.
|
|
1104
|
+
permissions: ctx.get('permissionPresets'),
|
|
1084
1105
|
claimAs: req.headers['x-dsh-user'],
|
|
1085
1106
|
gitRunner,
|
|
1086
1107
|
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
@@ -1095,11 +1116,8 @@ export function apply(ctx, config) {
|
|
|
1095
1116
|
return
|
|
1096
1117
|
}
|
|
1097
1118
|
if (req.method === 'POST' && action === 'progress-dump') {
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
const formatted = typeof input === 'string' ? input : formatProgressDump(input)
|
|
1101
|
-
const updated = store.updateTask(id, { progressDump: formatted })
|
|
1102
|
-
reply(res, { task: updated })
|
|
1119
|
+
const resObj = handleProgressDump({ store, task: store.getTask(id), input, formatProgressDump })
|
|
1120
|
+
writeJson(res, resObj.status, resObj.body)
|
|
1103
1121
|
return
|
|
1104
1122
|
}
|
|
1105
1123
|
if (req.method === 'POST' && action === 'stop') {
|
package/lib/routes.js
CHANGED
|
@@ -115,7 +115,13 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
|
|
|
115
115
|
bySession.get(id).push(task)
|
|
116
116
|
}
|
|
117
117
|
const tasks = orderForBoard(
|
|
118
|
-
raw.map((task) =>
|
|
118
|
+
raw.map((task) => {
|
|
119
|
+
const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
|
|
120
|
+
if (task.isEpic) {
|
|
121
|
+
enriched.subtasksCount = store.countSubtasks(task.id)
|
|
122
|
+
}
|
|
123
|
+
return enriched
|
|
124
|
+
}),
|
|
119
125
|
columnsOf(store.boardKind(board)),
|
|
120
126
|
)
|
|
121
127
|
const kind = store.boardKind(board)
|
|
@@ -495,3 +501,353 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
495
501
|
// Префикс UTF-8 BOM для Microsoft Excel
|
|
496
502
|
return '\uFEFF' + rows.join('\r\n')
|
|
497
503
|
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Очистить воркдерево задачи.
|
|
508
|
+
*/
|
|
509
|
+
export async function handleCleanupWorktree({ store, task, workspaceRegistry, force, cleanupTaskWorktree }) {
|
|
510
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
511
|
+
const out = await cleanupTaskWorktree({
|
|
512
|
+
store,
|
|
513
|
+
task,
|
|
514
|
+
workspaceRegistry,
|
|
515
|
+
force: force === true,
|
|
516
|
+
})
|
|
517
|
+
return { status: 200, body: out }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Получить diff рабочей копии задачи.
|
|
522
|
+
*/
|
|
523
|
+
export async function handleTaskDiff({ task, getTaskDiff }) {
|
|
524
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
525
|
+
const diffResult = await getTaskDiff({ worktreePath: task.worktree })
|
|
526
|
+
return { status: 200, body: diffResult }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Получить список коммитов задачи.
|
|
531
|
+
*/
|
|
532
|
+
export async function handleTaskCommits({ task, getTaskCommits }) {
|
|
533
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
534
|
+
const commits = await getTaskCommits({ worktreePath: task.worktree })
|
|
535
|
+
return { status: 200, body: { commits } }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Слить воркдерево задачи и перевести в done.
|
|
540
|
+
*/
|
|
541
|
+
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
|
|
542
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
543
|
+
const mergeResult = await mergeTaskWorktree({
|
|
544
|
+
task,
|
|
545
|
+
targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
|
|
546
|
+
workspaceRegistry,
|
|
547
|
+
})
|
|
548
|
+
if (!mergeResult.success) {
|
|
549
|
+
return {
|
|
550
|
+
status: 400,
|
|
551
|
+
body: { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files },
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const updated = store.acceptTask(task.id)
|
|
555
|
+
store.addTransition({
|
|
556
|
+
taskId: task.id,
|
|
557
|
+
fromCol: task.column,
|
|
558
|
+
toCol: 'done',
|
|
559
|
+
source: 'manual',
|
|
560
|
+
detail: `принята и смержена ветка ${task.branch} в ${mergeResult.targetBranch}`,
|
|
561
|
+
})
|
|
562
|
+
return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Проверить статус воркдерева задачи (ветка, dirty, воркспейс).
|
|
567
|
+
*/
|
|
568
|
+
export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
|
|
569
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
570
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
|
|
571
|
+
return {
|
|
572
|
+
status: 200,
|
|
573
|
+
body: {
|
|
574
|
+
worktree: task.worktree || '',
|
|
575
|
+
branch: task.branch || '',
|
|
576
|
+
workspaceId: task.workspaceId || '',
|
|
577
|
+
dirty: dirtyCheck.dirty,
|
|
578
|
+
files: dirtyCheck.files,
|
|
579
|
+
},
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Обновить DoD чеклист задачи.
|
|
585
|
+
*/
|
|
586
|
+
export function handleTaskChecklist({ store, task, input }) {
|
|
587
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
588
|
+
const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
|
|
589
|
+
const updated = store.updateChecklist(task.id, list)
|
|
590
|
+
return { status: 200, body: { task: updated } }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Установить отчёт выполнения задачи.
|
|
595
|
+
*/
|
|
596
|
+
export function handleTaskReport({ store, task, input }) {
|
|
597
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
598
|
+
const updated = store.setReport(task.id, input)
|
|
599
|
+
return { status: 200, body: { task: updated } }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Получить комментарии задачи.
|
|
604
|
+
*/
|
|
605
|
+
export function handleGetTaskComments({ task }) {
|
|
606
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
607
|
+
return { status: 200, body: { comments: task.comments || [] } }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Добавить комментарий к задаче.
|
|
612
|
+
*/
|
|
613
|
+
export function handleAddTaskComment({ store, task, input }) {
|
|
614
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
615
|
+
const author = input?.author || 'user'
|
|
616
|
+
const role = input?.role || 'user'
|
|
617
|
+
const text = input?.text || ''
|
|
618
|
+
const updated = store.addComment(task.id, { author, role, text })
|
|
619
|
+
return { status: 200, body: { task: updated } }
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Принять задачу на ревью (перевод в done).
|
|
624
|
+
*/
|
|
625
|
+
export function handleAcceptTask({ store, task }) {
|
|
626
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
627
|
+
const updated = store.acceptTask(task.id)
|
|
628
|
+
return { status: 200, body: { task: updated } }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Отклонить задачу на ревью (перевод обратно в in_progress).
|
|
633
|
+
*/
|
|
634
|
+
export function handleRejectTask({ store, task, input }) {
|
|
635
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
636
|
+
const reason = String(input?.reason || '').trim()
|
|
637
|
+
if (!reason) return { status: 400, body: { error: 'reason-required' } }
|
|
638
|
+
const updated = store.rejectTask(task.id, reason)
|
|
639
|
+
return { status: 200, body: { task: updated } }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Сохранить дамп прогресса задачи.
|
|
644
|
+
*/
|
|
645
|
+
export function handleProgressDump({ store, task, input, formatProgressDump }) {
|
|
646
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
647
|
+
const formatted = typeof input === 'string' ? input : formatProgressDump(input)
|
|
648
|
+
const updated = store.updateTask(task.id, { progressDump: formatted })
|
|
649
|
+
return { status: 200, body: { task: updated } }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Decompose a task into subtasks and make the parent an Epic.
|
|
654
|
+
*/
|
|
655
|
+
export async function handleTriageTask({ store, gitea, task, input }) {
|
|
656
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
657
|
+
const subtasksInput = Array.isArray(input?.subtasks) ? input.subtasks : []
|
|
658
|
+
if (subtasksInput.length === 0) {
|
|
659
|
+
return { status: 400, body: { error: 'subtasks-required' } }
|
|
660
|
+
}
|
|
661
|
+
const createGitea = input?.createGiteaIssues === true && Boolean(task.owner && task.repo && gitea)
|
|
662
|
+
|
|
663
|
+
// Convert parent into Epic
|
|
664
|
+
store.updateTask(task.id, { isEpic: true })
|
|
665
|
+
|
|
666
|
+
const created = []
|
|
667
|
+
for (const item of subtasksInput) {
|
|
668
|
+
const title = String(item?.title ?? '').trim()
|
|
669
|
+
if (!title) continue
|
|
670
|
+
const body = String(item?.body ?? '').trim()
|
|
671
|
+
let issueNumber = null
|
|
672
|
+
let issueUrl = null
|
|
673
|
+
|
|
674
|
+
if (createGitea) {
|
|
675
|
+
try {
|
|
676
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
677
|
+
const giteaIssue = await gitea.createIssue({
|
|
678
|
+
owner: task.owner,
|
|
679
|
+
repo: task.repo,
|
|
680
|
+
title,
|
|
681
|
+
body: issueBody,
|
|
682
|
+
})
|
|
683
|
+
if (giteaIssue?.number) {
|
|
684
|
+
issueNumber = giteaIssue.number
|
|
685
|
+
issueUrl = giteaIssue.html_url || null
|
|
686
|
+
}
|
|
687
|
+
} catch {}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const sub = store.createTask({
|
|
691
|
+
board: task.board || 'main',
|
|
692
|
+
column: 'backlog',
|
|
693
|
+
title,
|
|
694
|
+
body,
|
|
695
|
+
parentId: task.id,
|
|
696
|
+
owner: task.owner || null,
|
|
697
|
+
repo: task.repo || null,
|
|
698
|
+
issueNumber,
|
|
699
|
+
issueUrl,
|
|
700
|
+
labels: Array.isArray(item?.labels) ? item.labels : [],
|
|
701
|
+
workspaceId: task.workspaceId || '',
|
|
702
|
+
})
|
|
703
|
+
created.push(sub)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
store.addTransition({
|
|
707
|
+
taskId: task.id,
|
|
708
|
+
fromCol: task.column,
|
|
709
|
+
toCol: task.column,
|
|
710
|
+
source: 'manual',
|
|
711
|
+
detail: `decomposed into ${created.length} subtasks (epic triage)`,
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
status: 200,
|
|
716
|
+
body: {
|
|
717
|
+
epic: store.getTask(task.id),
|
|
718
|
+
subtasks: store.listSubtasks(task.id),
|
|
719
|
+
counts: store.countSubtasks(task.id),
|
|
720
|
+
createdCount: created.length,
|
|
721
|
+
},
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Get subtasks of a task.
|
|
727
|
+
*/
|
|
728
|
+
export function handleGetSubtasks({ store, task }) {
|
|
729
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
730
|
+
return {
|
|
731
|
+
status: 200,
|
|
732
|
+
body: {
|
|
733
|
+
subtasks: store.listSubtasks(task.id),
|
|
734
|
+
counts: store.countSubtasks(task.id),
|
|
735
|
+
},
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Create a single subtask linked to a parent task.
|
|
741
|
+
*/
|
|
742
|
+
export async function handleCreateSubtask({ store, gitea, task, input }) {
|
|
743
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
744
|
+
const title = String(input?.title ?? '').trim()
|
|
745
|
+
if (!title) return { status: 400, body: { error: 'title-required' } }
|
|
746
|
+
const body = String(input?.body ?? '').trim()
|
|
747
|
+
let issueNumber = null
|
|
748
|
+
let issueUrl = null
|
|
749
|
+
|
|
750
|
+
if (input?.createGiteaIssue === true && task.owner && task.repo && gitea) {
|
|
751
|
+
try {
|
|
752
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
753
|
+
const giteaIssue = await gitea.createIssue({
|
|
754
|
+
owner: task.owner,
|
|
755
|
+
repo: task.repo,
|
|
756
|
+
title,
|
|
757
|
+
body: issueBody,
|
|
758
|
+
})
|
|
759
|
+
if (giteaIssue?.number) {
|
|
760
|
+
issueNumber = giteaIssue.number
|
|
761
|
+
issueUrl = giteaIssue.html_url || null
|
|
762
|
+
}
|
|
763
|
+
} catch {}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
store.updateTask(task.id, { isEpic: true })
|
|
767
|
+
const sub = store.createTask({
|
|
768
|
+
board: task.board || 'main',
|
|
769
|
+
column: input?.column || 'backlog',
|
|
770
|
+
title,
|
|
771
|
+
body,
|
|
772
|
+
parentId: task.id,
|
|
773
|
+
owner: task.owner || null,
|
|
774
|
+
repo: task.repo || null,
|
|
775
|
+
issueNumber,
|
|
776
|
+
issueUrl,
|
|
777
|
+
labels: Array.isArray(input?.labels) ? input.labels : [],
|
|
778
|
+
workspaceId: task.workspaceId || '',
|
|
779
|
+
})
|
|
780
|
+
return { status: 200, body: { subtask: sub, counts: store.countSubtasks(task.id) } }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Toggle a checklist item atomically by index or text.
|
|
785
|
+
*/
|
|
786
|
+
export function handleToggleChecklistItem({ store, task, input }) {
|
|
787
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
788
|
+
const current = Array.isArray(task.checklist) ? [...task.checklist] : []
|
|
789
|
+
let targetIndex = -1
|
|
790
|
+
|
|
791
|
+
if (typeof input?.index === 'number' && input.index >= 0 && input.index < current.length) {
|
|
792
|
+
targetIndex = input.index
|
|
793
|
+
} else if (typeof input?.text === 'string' && input.text.trim()) {
|
|
794
|
+
const q = input.text.trim().toLowerCase()
|
|
795
|
+
targetIndex = current.findIndex((item) => {
|
|
796
|
+
const t = typeof item === 'string' ? item : item?.text
|
|
797
|
+
return typeof t === 'string' && t.toLowerCase().includes(q)
|
|
798
|
+
})
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (targetIndex === -1) {
|
|
802
|
+
return { status: 404, body: { error: 'checklist-item-not-found' } }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const item = current[targetIndex]
|
|
806
|
+
let updatedItem
|
|
807
|
+
if (typeof item === 'string') {
|
|
808
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : true
|
|
809
|
+
updatedItem = { text: item, done: checked }
|
|
810
|
+
} else {
|
|
811
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : !item.done
|
|
812
|
+
updatedItem = { ...item, done: checked }
|
|
813
|
+
}
|
|
814
|
+
current[targetIndex] = updatedItem
|
|
815
|
+
const updated = store.updateChecklist(task.id, current)
|
|
816
|
+
return { status: 200, body: { task: updated, item: updatedItem, index: targetIndex } }
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Create a Gitea Pull Request for a task.
|
|
821
|
+
*/
|
|
822
|
+
export async function handleCreateTaskPr({ store, gitea, task, input }) {
|
|
823
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
824
|
+
if (!task.owner || !task.repo) {
|
|
825
|
+
return { status: 400, body: { error: 'task-no-repo' } }
|
|
826
|
+
}
|
|
827
|
+
const head = String(input?.head || task.branch || '').trim()
|
|
828
|
+
if (!head) return { status: 400, body: { error: 'branch-required' } }
|
|
829
|
+
const base = String(input?.base || 'main').trim()
|
|
830
|
+
const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
|
|
831
|
+
const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
const pr = await gitea.createPullRequest({
|
|
835
|
+
owner: task.owner,
|
|
836
|
+
repo: task.repo,
|
|
837
|
+
title,
|
|
838
|
+
body,
|
|
839
|
+
head,
|
|
840
|
+
base,
|
|
841
|
+
})
|
|
842
|
+
store.addTransition({
|
|
843
|
+
taskId: task.id,
|
|
844
|
+
fromCol: task.column,
|
|
845
|
+
toCol: task.column,
|
|
846
|
+
source: 'manual',
|
|
847
|
+
detail: `Created PR #${pr?.number || ''}: ${head} -> ${base}`,
|
|
848
|
+
})
|
|
849
|
+
return { status: 200, body: { pr, prUrl: pr?.html_url } }
|
|
850
|
+
} catch (err) {
|
|
851
|
+
return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
|
|
852
|
+
}
|
|
853
|
+
}
|