@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/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, isTrustedRequest, parseTaskPath,
23
- buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
24
- taskBody,
25
- taskLog, taskBySession, exportBoard, importBoard, exportTasksCsv, assignTask, boardMetrics,
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 task = store.getTask(id)
953
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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
- reply(res, out)
998
+ writeJson(res, resObj.status, resObj.body)
961
999
  return
962
1000
  }
963
1001
  if (req.method === 'GET' && action === 'diff') {
964
- const task = store.getTask(id)
965
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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 task = store.getTask(id)
972
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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 task = store.getTask(id)
979
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
980
- const mergeResult = await mergeTaskWorktree({
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
- if (!mergeResult.success) {
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 task = store.getTask(id)
1002
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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
- if (req.method === 'POST' && action === 'checklist') {
1014
- const task = store.getTask(id)
1015
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
1016
- const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
1017
- const updated = store.updateChecklist(id, list)
1018
- reply(res, { task: updated })
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 task = store.getTask(id)
1023
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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 task = store.getTask(id)
1030
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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 task = store.getTask(id)
1036
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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 task = store.getTask(id)
1046
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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 task = store.getTask(id)
1053
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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.permissions,
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 task = store.getTask(id)
1099
- if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
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) => withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])),
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
+ }