@goodandready/dsh-kanban 0.2.2 → 0.2.8
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 +24 -0
- package/{docs/README.ru.md → README.ru.md} +10 -0
- package/{docs/README.zh.md → README.zh.md} +21 -0
- package/lib/board-tool.js +151 -0
- package/lib/client.js +1020 -763
- package/lib/commands.js +6 -11
- package/lib/config.js +31 -29
- package/lib/events.js +2 -2
- package/lib/filters.js +2 -2
- package/lib/gitea.js +15 -0
- package/lib/index.js +171 -142
- package/lib/power.js +1 -1
- package/lib/progress-dump.js +3 -3
- package/lib/routes.js +403 -5
- package/lib/store.js +59 -9
- package/lib/sync.js +26 -3
- package/lib/task-service.js +56 -0
- package/lib/updater.js +338 -0
- package/lib/worktree.js +1 -1
- package/package.json +7 -3
- 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/power.js
CHANGED
package/lib/progress-dump.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// dsh-kanban: фиксация и безопасная передача среза прогресса задачи (PROGRESSDUMP).
|
|
2
2
|
// Парсит блок <<<PROGRESSDUMP ... >>>PROGRESSDUMP, маскирует секреты и генерирует вводный бриф.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const BEGIN_MARKER = '<<<PROGRESSDUMP'
|
|
5
|
+
const END_MARKER = '>>>PROGRESSDUMP'
|
|
6
|
+
const FIELD_BYTE_LIMIT = 8 * 1024
|
|
7
7
|
export const REDACTED = '[REDACTED]'
|
|
8
8
|
|
|
9
9
|
const SENSITIVE_PATTERNS = [
|
package/lib/routes.js
CHANGED
|
@@ -19,12 +19,54 @@ import { computeNextRun } from './cron.js'
|
|
|
19
19
|
/** Больше этого тело запроса не читаем: без предела один запрос кладёт хост. */
|
|
20
20
|
export const MAX_BODY_BYTES = 256 * 1024
|
|
21
21
|
|
|
22
|
+
function isLoopbackAddress(addr) {
|
|
23
|
+
if (!addr) return true
|
|
24
|
+
return addr === '127.0.0.1' ||
|
|
25
|
+
addr === '::1' ||
|
|
26
|
+
addr === '::ffff:127.0.0.1' ||
|
|
27
|
+
addr === 'localhost'
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
/**
|
|
23
31
|
* Запрос с чужого сайта. Изменяющие маршруты правят локальное состояние, и
|
|
24
|
-
* пускать в них кросс-сайтовый запрос
|
|
32
|
+
* пускать в них кросс-сайтовый запрос нельзя (#261).
|
|
33
|
+
* Защита требует:
|
|
34
|
+
* 1. Наличие безопасного sec-fetch-site ('same-origin', 'same-site', 'none').
|
|
35
|
+
* 2. Совпадение origin и host (если origin передан).
|
|
36
|
+
* 3. Loopback-источник (remoteAddress: 127.0.0.1, ::1, ::ffff:127.0.0.1).
|
|
25
37
|
*/
|
|
26
38
|
export function isTrustedRequest(req) {
|
|
27
|
-
|
|
39
|
+
if (!req || !req.headers) return false
|
|
40
|
+
|
|
41
|
+
// 1. Проверка sec-fetch-site (отсутствие или cross-site запрещены)
|
|
42
|
+
const site = req.headers['sec-fetch-site']
|
|
43
|
+
if (!site || site === 'cross-site') {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
if (site !== 'same-origin' && site !== 'same-site' && site !== 'none') {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. Сверка origin с host
|
|
51
|
+
const origin = req.headers.origin
|
|
52
|
+
if (origin !== undefined) {
|
|
53
|
+
if (origin === 'null') return false
|
|
54
|
+
try {
|
|
55
|
+
const parsed = new URL(origin)
|
|
56
|
+
const host = req.headers.host || ''
|
|
57
|
+
if (parsed.host && parsed.host !== host) return false
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Проверка loopback remoteAddress
|
|
64
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress
|
|
65
|
+
if (remote && !isLoopbackAddress(remote)) {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return true
|
|
28
70
|
}
|
|
29
71
|
|
|
30
72
|
/** Разобрать `/dsh-kanban/task/<id>[/<action>]`; иначе undefined. */
|
|
@@ -115,7 +157,13 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
|
|
|
115
157
|
bySession.get(id).push(task)
|
|
116
158
|
}
|
|
117
159
|
const tasks = orderForBoard(
|
|
118
|
-
raw.map((task) =>
|
|
160
|
+
raw.map((task) => {
|
|
161
|
+
const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
|
|
162
|
+
if (task.isEpic) {
|
|
163
|
+
enriched.subtasksCount = store.countSubtasks(task.id)
|
|
164
|
+
}
|
|
165
|
+
return enriched
|
|
166
|
+
}),
|
|
119
167
|
columnsOf(store.boardKind(board)),
|
|
120
168
|
)
|
|
121
169
|
const kind = store.boardKind(board)
|
|
@@ -226,7 +274,7 @@ export function confirmPermission({ store, id }) {
|
|
|
226
274
|
}
|
|
227
275
|
|
|
228
276
|
/** Известные приоритеты. Пустой — «без приоритета». */
|
|
229
|
-
|
|
277
|
+
const PRIORITIES = ['', 'high', 'medium', 'low']
|
|
230
278
|
|
|
231
279
|
/**
|
|
232
280
|
* Дописать заметку в тело задачи.
|
|
@@ -386,7 +434,7 @@ export function importBoard({ store, input }) {
|
|
|
386
434
|
if (typeof store.backupDatabase === 'function') {
|
|
387
435
|
try {
|
|
388
436
|
backupMeta = store.backupDatabase()
|
|
389
|
-
} catch {}
|
|
437
|
+
} catch { /* backup best effort */ }
|
|
390
438
|
}
|
|
391
439
|
if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
|
|
392
440
|
if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
|
|
@@ -495,3 +543,353 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
495
543
|
// Префикс UTF-8 BOM для Microsoft Excel
|
|
496
544
|
return '\uFEFF' + rows.join('\r\n')
|
|
497
545
|
}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Очистить воркдерево задачи.
|
|
550
|
+
*/
|
|
551
|
+
export async function handleCleanupWorktree({ store, task, workspaceRegistry, force, cleanupTaskWorktree }) {
|
|
552
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
553
|
+
const out = await cleanupTaskWorktree({
|
|
554
|
+
store,
|
|
555
|
+
task,
|
|
556
|
+
workspaceRegistry,
|
|
557
|
+
force: force === true,
|
|
558
|
+
})
|
|
559
|
+
return { status: 200, body: out }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Получить diff рабочей копии задачи.
|
|
564
|
+
*/
|
|
565
|
+
export async function handleTaskDiff({ task, getTaskDiff }) {
|
|
566
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
567
|
+
const diffResult = await getTaskDiff({ worktreePath: task.worktree })
|
|
568
|
+
return { status: 200, body: diffResult }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Получить список коммитов задачи.
|
|
573
|
+
*/
|
|
574
|
+
export async function handleTaskCommits({ task, getTaskCommits }) {
|
|
575
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
576
|
+
const commits = await getTaskCommits({ worktreePath: task.worktree })
|
|
577
|
+
return { status: 200, body: { commits } }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Слить воркдерево задачи и перевести в done.
|
|
582
|
+
*/
|
|
583
|
+
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
|
|
584
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
585
|
+
const mergeResult = await mergeTaskWorktree({
|
|
586
|
+
task,
|
|
587
|
+
targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
|
|
588
|
+
workspaceRegistry,
|
|
589
|
+
})
|
|
590
|
+
if (!mergeResult.success) {
|
|
591
|
+
return {
|
|
592
|
+
status: 400,
|
|
593
|
+
body: { error: mergeResult.error, detail: mergeResult.detail, files: mergeResult.files },
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const updated = store.acceptTask(task.id)
|
|
597
|
+
store.addTransition({
|
|
598
|
+
taskId: task.id,
|
|
599
|
+
fromCol: task.column,
|
|
600
|
+
toCol: 'done',
|
|
601
|
+
source: 'manual',
|
|
602
|
+
detail: `принята и смержена ветка ${task.branch} в ${mergeResult.targetBranch}`,
|
|
603
|
+
})
|
|
604
|
+
return { status: 200, body: { task: updated, merged: true, targetBranch: mergeResult.targetBranch } }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Проверить статус воркдерева задачи (ветка, dirty, воркспейс).
|
|
609
|
+
*/
|
|
610
|
+
export async function handleTaskWorktreeStatus({ task, checkWorktreeDirty }) {
|
|
611
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
612
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
|
|
613
|
+
return {
|
|
614
|
+
status: 200,
|
|
615
|
+
body: {
|
|
616
|
+
worktree: task.worktree || '',
|
|
617
|
+
branch: task.branch || '',
|
|
618
|
+
workspaceId: task.workspaceId || '',
|
|
619
|
+
dirty: dirtyCheck.dirty,
|
|
620
|
+
files: dirtyCheck.files,
|
|
621
|
+
},
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Обновить DoD чеклист задачи.
|
|
627
|
+
*/
|
|
628
|
+
export function handleTaskChecklist({ store, task, input }) {
|
|
629
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
630
|
+
const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
|
|
631
|
+
const updated = store.updateChecklist(task.id, list)
|
|
632
|
+
return { status: 200, body: { task: updated } }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Установить отчёт выполнения задачи.
|
|
637
|
+
*/
|
|
638
|
+
export function handleTaskReport({ store, task, input }) {
|
|
639
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
640
|
+
const updated = store.setReport(task.id, input)
|
|
641
|
+
return { status: 200, body: { task: updated } }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Получить комментарии задачи.
|
|
646
|
+
*/
|
|
647
|
+
export function handleGetTaskComments({ task }) {
|
|
648
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
649
|
+
return { status: 200, body: { comments: task.comments || [] } }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Добавить комментарий к задаче.
|
|
654
|
+
*/
|
|
655
|
+
export function handleAddTaskComment({ store, task, input }) {
|
|
656
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
657
|
+
const author = input?.author || 'user'
|
|
658
|
+
const role = input?.role || 'user'
|
|
659
|
+
const text = input?.text || ''
|
|
660
|
+
const updated = store.addComment(task.id, { author, role, text })
|
|
661
|
+
return { status: 200, body: { task: updated } }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Принять задачу на ревью (перевод в done).
|
|
666
|
+
*/
|
|
667
|
+
export function handleAcceptTask({ store, task }) {
|
|
668
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
669
|
+
const updated = store.acceptTask(task.id)
|
|
670
|
+
return { status: 200, body: { task: updated } }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Отклонить задачу на ревью (перевод обратно в in_progress).
|
|
675
|
+
*/
|
|
676
|
+
export function handleRejectTask({ store, task, input }) {
|
|
677
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
678
|
+
const reason = String(input?.reason || '').trim()
|
|
679
|
+
if (!reason) return { status: 400, body: { error: 'reason-required' } }
|
|
680
|
+
const updated = store.rejectTask(task.id, reason)
|
|
681
|
+
return { status: 200, body: { task: updated } }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Сохранить дамп прогресса задачи.
|
|
686
|
+
*/
|
|
687
|
+
export function handleProgressDump({ store, task, input, formatProgressDump }) {
|
|
688
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
689
|
+
const formatted = typeof input === 'string' ? input : formatProgressDump(input)
|
|
690
|
+
const updated = store.updateTask(task.id, { progressDump: formatted })
|
|
691
|
+
return { status: 200, body: { task: updated } }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Decompose a task into subtasks and make the parent an Epic.
|
|
696
|
+
*/
|
|
697
|
+
export async function handleTriageTask({ store, gitea, task, input }) {
|
|
698
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
699
|
+
const subtasksInput = Array.isArray(input?.subtasks) ? input.subtasks : []
|
|
700
|
+
if (subtasksInput.length === 0) {
|
|
701
|
+
return { status: 400, body: { error: 'subtasks-required' } }
|
|
702
|
+
}
|
|
703
|
+
const createGitea = input?.createGiteaIssues === true && Boolean(task.owner && task.repo && gitea)
|
|
704
|
+
|
|
705
|
+
// Convert parent into Epic
|
|
706
|
+
store.updateTask(task.id, { isEpic: true })
|
|
707
|
+
|
|
708
|
+
const created = []
|
|
709
|
+
for (const item of subtasksInput) {
|
|
710
|
+
const title = String(item?.title ?? '').trim()
|
|
711
|
+
if (!title) continue
|
|
712
|
+
const body = String(item?.body ?? '').trim()
|
|
713
|
+
let issueNumber = null
|
|
714
|
+
let issueUrl = null
|
|
715
|
+
|
|
716
|
+
if (createGitea) {
|
|
717
|
+
try {
|
|
718
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
719
|
+
const giteaIssue = await gitea.createIssue({
|
|
720
|
+
owner: task.owner,
|
|
721
|
+
repo: task.repo,
|
|
722
|
+
title,
|
|
723
|
+
body: issueBody,
|
|
724
|
+
})
|
|
725
|
+
if (giteaIssue?.number) {
|
|
726
|
+
issueNumber = giteaIssue.number
|
|
727
|
+
issueUrl = giteaIssue.html_url || null
|
|
728
|
+
}
|
|
729
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const sub = store.createTask({
|
|
733
|
+
board: task.board || 'main',
|
|
734
|
+
column: 'backlog',
|
|
735
|
+
title,
|
|
736
|
+
body,
|
|
737
|
+
parentId: task.id,
|
|
738
|
+
owner: task.owner || null,
|
|
739
|
+
repo: task.repo || null,
|
|
740
|
+
issueNumber,
|
|
741
|
+
issueUrl,
|
|
742
|
+
labels: Array.isArray(item?.labels) ? item.labels : [],
|
|
743
|
+
workspaceId: task.workspaceId || '',
|
|
744
|
+
})
|
|
745
|
+
created.push(sub)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
store.addTransition({
|
|
749
|
+
taskId: task.id,
|
|
750
|
+
fromCol: task.column,
|
|
751
|
+
toCol: task.column,
|
|
752
|
+
source: 'manual',
|
|
753
|
+
detail: `decomposed into ${created.length} subtasks (epic triage)`,
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
status: 200,
|
|
758
|
+
body: {
|
|
759
|
+
epic: store.getTask(task.id),
|
|
760
|
+
subtasks: store.listSubtasks(task.id),
|
|
761
|
+
counts: store.countSubtasks(task.id),
|
|
762
|
+
createdCount: created.length,
|
|
763
|
+
},
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Get subtasks of a task.
|
|
769
|
+
*/
|
|
770
|
+
export function handleGetSubtasks({ store, task }) {
|
|
771
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
772
|
+
return {
|
|
773
|
+
status: 200,
|
|
774
|
+
body: {
|
|
775
|
+
subtasks: store.listSubtasks(task.id),
|
|
776
|
+
counts: store.countSubtasks(task.id),
|
|
777
|
+
},
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Create a single subtask linked to a parent task.
|
|
783
|
+
*/
|
|
784
|
+
export async function handleCreateSubtask({ store, gitea, task, input }) {
|
|
785
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
786
|
+
const title = String(input?.title ?? '').trim()
|
|
787
|
+
if (!title) return { status: 400, body: { error: 'title-required' } }
|
|
788
|
+
const body = String(input?.body ?? '').trim()
|
|
789
|
+
let issueNumber = null
|
|
790
|
+
let issueUrl = null
|
|
791
|
+
|
|
792
|
+
if (input?.createGiteaIssue === true && task.owner && task.repo && gitea) {
|
|
793
|
+
try {
|
|
794
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
795
|
+
const giteaIssue = await gitea.createIssue({
|
|
796
|
+
owner: task.owner,
|
|
797
|
+
repo: task.repo,
|
|
798
|
+
title,
|
|
799
|
+
body: issueBody,
|
|
800
|
+
})
|
|
801
|
+
if (giteaIssue?.number) {
|
|
802
|
+
issueNumber = giteaIssue.number
|
|
803
|
+
issueUrl = giteaIssue.html_url || null
|
|
804
|
+
}
|
|
805
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
store.updateTask(task.id, { isEpic: true })
|
|
809
|
+
const sub = store.createTask({
|
|
810
|
+
board: task.board || 'main',
|
|
811
|
+
column: input?.column || 'backlog',
|
|
812
|
+
title,
|
|
813
|
+
body,
|
|
814
|
+
parentId: task.id,
|
|
815
|
+
owner: task.owner || null,
|
|
816
|
+
repo: task.repo || null,
|
|
817
|
+
issueNumber,
|
|
818
|
+
issueUrl,
|
|
819
|
+
labels: Array.isArray(input?.labels) ? input.labels : [],
|
|
820
|
+
workspaceId: task.workspaceId || '',
|
|
821
|
+
})
|
|
822
|
+
return { status: 200, body: { subtask: sub, counts: store.countSubtasks(task.id) } }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Toggle a checklist item atomically by index or text.
|
|
827
|
+
*/
|
|
828
|
+
export function handleToggleChecklistItem({ store, task, input }) {
|
|
829
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
830
|
+
const current = Array.isArray(task.checklist) ? [...task.checklist] : []
|
|
831
|
+
let targetIndex = -1
|
|
832
|
+
|
|
833
|
+
if (typeof input?.index === 'number' && input.index >= 0 && input.index < current.length) {
|
|
834
|
+
targetIndex = input.index
|
|
835
|
+
} else if (typeof input?.text === 'string' && input.text.trim()) {
|
|
836
|
+
const q = input.text.trim().toLowerCase()
|
|
837
|
+
targetIndex = current.findIndex((item) => {
|
|
838
|
+
const t = typeof item === 'string' ? item : item?.text
|
|
839
|
+
return typeof t === 'string' && t.toLowerCase().includes(q)
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
if (targetIndex === -1) {
|
|
844
|
+
return { status: 404, body: { error: 'checklist-item-not-found' } }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const item = current[targetIndex]
|
|
848
|
+
let updatedItem
|
|
849
|
+
if (typeof item === 'string') {
|
|
850
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : true
|
|
851
|
+
updatedItem = { text: item, done: checked }
|
|
852
|
+
} else {
|
|
853
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : !item.done
|
|
854
|
+
updatedItem = { ...item, done: checked }
|
|
855
|
+
}
|
|
856
|
+
current[targetIndex] = updatedItem
|
|
857
|
+
const updated = store.updateChecklist(task.id, current)
|
|
858
|
+
return { status: 200, body: { task: updated, item: updatedItem, index: targetIndex } }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Create a Gitea Pull Request for a task.
|
|
863
|
+
*/
|
|
864
|
+
export async function handleCreateTaskPr({ store, gitea, task, input }) {
|
|
865
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
866
|
+
if (!task.owner || !task.repo) {
|
|
867
|
+
return { status: 400, body: { error: 'task-no-repo' } }
|
|
868
|
+
}
|
|
869
|
+
const head = String(input?.head || task.branch || '').trim()
|
|
870
|
+
if (!head) return { status: 400, body: { error: 'branch-required' } }
|
|
871
|
+
const base = String(input?.base || 'main').trim()
|
|
872
|
+
const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
|
|
873
|
+
const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
|
|
874
|
+
|
|
875
|
+
try {
|
|
876
|
+
const pr = await gitea.createPullRequest({
|
|
877
|
+
owner: task.owner,
|
|
878
|
+
repo: task.repo,
|
|
879
|
+
title,
|
|
880
|
+
body,
|
|
881
|
+
head,
|
|
882
|
+
base,
|
|
883
|
+
})
|
|
884
|
+
store.addTransition({
|
|
885
|
+
taskId: task.id,
|
|
886
|
+
fromCol: task.column,
|
|
887
|
+
toCol: task.column,
|
|
888
|
+
source: 'manual',
|
|
889
|
+
detail: `Created PR #${pr?.number || ''}: ${head} -> ${base}`,
|
|
890
|
+
})
|
|
891
|
+
return { status: 200, body: { pr, prUrl: pr?.html_url } }
|
|
892
|
+
} catch (err) {
|
|
893
|
+
return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
|
|
894
|
+
}
|
|
895
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
37
37
|
repo TEXT,
|
|
38
38
|
issueNumber INTEGER,
|
|
39
39
|
issueUrl TEXT,
|
|
40
|
+
externalRef TEXT,
|
|
40
41
|
labels TEXT NOT NULL DEFAULT '[]',
|
|
41
42
|
branch TEXT,
|
|
42
43
|
worktree TEXT,
|
|
@@ -70,6 +71,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
70
71
|
comments TEXT NOT NULL DEFAULT '[]',
|
|
71
72
|
rejectReason TEXT NOT NULL DEFAULT '',
|
|
72
73
|
rejectedAt INTEGER NOT NULL DEFAULT 0,
|
|
74
|
+
parentId TEXT NOT NULL DEFAULT '',
|
|
75
|
+
isEpic INTEGER NOT NULL DEFAULT 0,
|
|
73
76
|
updatedAt INTEGER NOT NULL
|
|
74
77
|
);
|
|
75
78
|
CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
|
|
@@ -95,10 +98,11 @@ CREATE TABLE IF NOT EXISTS dismissed (
|
|
|
95
98
|
|
|
96
99
|
/** Поля задачи, которые разрешено править через updateTask. */
|
|
97
100
|
const WRITABLE = [
|
|
98
|
-
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'labels',
|
|
101
|
+
'title', 'body', 'owner', 'repo', 'issueNumber', 'issueUrl', 'externalRef', 'labels',
|
|
99
102
|
'branch', 'worktree', 'workspaceId', 'sessionId', 'model', 'provider', 'waiting', 'syncedAt', 'plan',
|
|
100
103
|
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
101
|
-
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
|
|
104
|
+
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
|
|
105
|
+
'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
|
|
102
106
|
]
|
|
103
107
|
|
|
104
108
|
/** Источники перехода; чужое значение не пишем, чтобы журнал оставался читаемым. */
|
|
@@ -106,7 +110,7 @@ const SOURCES = new Set(['manual', 'session', 'gitea', 'tool'])
|
|
|
106
110
|
|
|
107
111
|
function rowToTask(row) {
|
|
108
112
|
if (row === undefined) return undefined
|
|
109
|
-
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, ...rest } = row
|
|
113
|
+
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, ...rest } = row
|
|
110
114
|
let parsedChecklist = []
|
|
111
115
|
try {
|
|
112
116
|
const value = JSON.parse(checklist ?? '[]')
|
|
@@ -150,6 +154,8 @@ function rowToTask(row) {
|
|
|
150
154
|
rejectReason: rejectReason ?? '',
|
|
151
155
|
rejectedAt: Number(rejectedAt) || 0,
|
|
152
156
|
reasoningEffort: rest.reasoningEffort ?? '',
|
|
157
|
+
isEpic: isEpic === 1,
|
|
158
|
+
parentId: parentId ?? '',
|
|
153
159
|
}
|
|
154
160
|
}
|
|
155
161
|
|
|
@@ -163,6 +169,8 @@ export function openStore({ dir }) {
|
|
|
163
169
|
const db = new DatabaseSync(join(dir, 'kanban.db'))
|
|
164
170
|
db.exec('PRAGMA journal_mode = WAL')
|
|
165
171
|
db.exec('PRAGMA foreign_keys = ON')
|
|
172
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
173
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
166
174
|
db.exec(SCHEMA)
|
|
167
175
|
// База могла быть создана прежней версией. Добавляем недостающие столбцы
|
|
168
176
|
// поштучно: ALTER на существующий столбец бросает, и это штатный ответ
|
|
@@ -194,6 +202,9 @@ export function openStore({ dir }) {
|
|
|
194
202
|
['comments', "TEXT NOT NULL DEFAULT '[]'"],
|
|
195
203
|
['rejectReason', "TEXT NOT NULL DEFAULT ''"],
|
|
196
204
|
['rejectedAt', "INTEGER NOT NULL DEFAULT 0"],
|
|
205
|
+
['parentId', "TEXT NOT NULL DEFAULT ''"],
|
|
206
|
+
['isEpic', "INTEGER NOT NULL DEFAULT 0"],
|
|
207
|
+
['externalRef', "TEXT"],
|
|
197
208
|
]) {
|
|
198
209
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* столбец уже есть */ }
|
|
199
210
|
}
|
|
@@ -202,6 +213,16 @@ export function openStore({ dir }) {
|
|
|
202
213
|
// заведения: это честнее нуля, который читался бы как «в 1970 году».
|
|
203
214
|
db.exec('UPDATE tasks SET columnAt = createdAt WHERE columnAt = 0')
|
|
204
215
|
|
|
216
|
+
// Индексы для ускорения выборок активных задач, поиска по Gitea issue и cron-задач
|
|
217
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_board_active ON tasks(board, archivedAt, col, position)')
|
|
218
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_gitea_issue ON tasks(owner, repo, issueNumber)')
|
|
219
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_external_ref ON tasks(externalRef)')
|
|
220
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_cron_next ON tasks(archivedAt, nextRunAt)')
|
|
221
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_queued ON tasks(archivedAt, queuedAt)')
|
|
222
|
+
db.exec('CREATE INDEX IF NOT EXISTS tasks_parent_id ON tasks(parentId)')
|
|
223
|
+
db.exec('PRAGMA synchronous = NORMAL')
|
|
224
|
+
try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
|
|
225
|
+
|
|
205
226
|
const q = {
|
|
206
227
|
boards: db.prepare('SELECT * FROM boards ORDER BY position'),
|
|
207
228
|
boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
|
|
@@ -217,6 +238,7 @@ export function openStore({ dir }) {
|
|
|
217
238
|
dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
|
|
218
239
|
isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
219
240
|
byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
241
|
+
byExternalRef: db.prepare('SELECT * FROM tasks WHERE externalRef = ? ORDER BY createdAt ASC LIMIT 1'),
|
|
220
242
|
tasksBySession: db.prepare('SELECT * FROM tasks WHERE sessionId = ? LIMIT 1'),
|
|
221
243
|
lastInColumn: db.prepare('SELECT position FROM tasks WHERE board = ? AND col = ? ORDER BY position DESC LIMIT 1'),
|
|
222
244
|
deleteTask: db.prepare('DELETE FROM tasks WHERE id = ?'),
|
|
@@ -227,6 +249,9 @@ export function openStore({ dir }) {
|
|
|
227
249
|
"SELECT * FROM tasks WHERE issueNumber IS NOT NULL AND col NOT IN ('done') ORDER BY updatedAt DESC"),
|
|
228
250
|
scheduledDue: db.prepare(
|
|
229
251
|
"SELECT * FROM tasks WHERE cron != '' AND nextRunAt > 0 AND nextRunAt <= ? AND archivedAt = 0 ORDER BY nextRunAt ASC"),
|
|
252
|
+
subtasksOf: db.prepare('SELECT * FROM tasks WHERE parentId = ? AND archivedAt = 0 ORDER BY createdAt ASC, position ASC'),
|
|
253
|
+
countSubtasks: db.prepare(
|
|
254
|
+
"SELECT COUNT(*) AS total, SUM(CASE WHEN col = 'done' THEN 1 ELSE 0 END) AS completed FROM tasks WHERE parentId = ? AND archivedAt = 0"),
|
|
230
255
|
}
|
|
231
256
|
|
|
232
257
|
function ensureBoard(id, title, kind) {
|
|
@@ -279,10 +304,10 @@ export function openStore({ dir }) {
|
|
|
279
304
|
.sort((a, b) => b.time - a.time)
|
|
280
305
|
if (files.length > 10) {
|
|
281
306
|
for (const old of files.slice(10)) {
|
|
282
|
-
try { unlinkSync(old.path) } catch {}
|
|
307
|
+
try { unlinkSync(old.path) } catch { /* unlink old backup best-effort */ }
|
|
283
308
|
}
|
|
284
309
|
}
|
|
285
|
-
} catch {}
|
|
310
|
+
} catch { /* backup rotation best-effort */ }
|
|
286
311
|
return { backup: backupName, path: dest }
|
|
287
312
|
},
|
|
288
313
|
|
|
@@ -354,16 +379,16 @@ export function openStore({ dir }) {
|
|
|
354
379
|
const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
|
|
355
380
|
const position = positionFor(board, column, input.beforeId, input.afterId)
|
|
356
381
|
db.prepare(`INSERT INTO tasks
|
|
357
|
-
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl,
|
|
382
|
+
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
|
|
358
383
|
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
359
384
|
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
360
385
|
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
|
|
361
|
-
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort)
|
|
362
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
386
|
+
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic)
|
|
387
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
363
388
|
id, board, column, position,
|
|
364
389
|
input.title ?? '', input.body ?? '',
|
|
365
390
|
input.owner ?? null, input.repo ?? null,
|
|
366
|
-
input.issueNumber ?? null, input.issueUrl ?? null,
|
|
391
|
+
input.issueNumber ?? null, input.issueUrl ?? null, input.externalRef ?? null,
|
|
367
392
|
JSON.stringify(input.labels ?? []),
|
|
368
393
|
input.branch ?? null, input.worktree ?? null, input.workspaceId ?? '',
|
|
369
394
|
input.sessionId ?? null, input.model ?? null, input.provider ?? null,
|
|
@@ -389,6 +414,8 @@ export function openStore({ dir }) {
|
|
|
389
414
|
input.rejectReason ?? '',
|
|
390
415
|
Number(input.rejectedAt) || 0,
|
|
391
416
|
typeof input.reasoningEffort === 'string' ? input.reasoningEffort : '',
|
|
417
|
+
input.parentId ?? '',
|
|
418
|
+
input.isEpic ? 1 : 0,
|
|
392
419
|
)
|
|
393
420
|
return rowToTask(q.taskById.get(id))
|
|
394
421
|
},
|
|
@@ -411,6 +438,8 @@ export function openStore({ dir }) {
|
|
|
411
438
|
else if (key === 'rejectReason') values.push(String(patch[key] ?? ''))
|
|
412
439
|
else if (key === 'waiting') values.push(patch[key] ? 1 : 0)
|
|
413
440
|
else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
|
|
441
|
+
else if (key === 'isEpic') values.push(patch[key] ? 1 : 0)
|
|
442
|
+
else if (key === 'parentId') values.push(String(patch[key] ?? ''))
|
|
414
443
|
else values.push(patch[key] ?? null)
|
|
415
444
|
}
|
|
416
445
|
if (fields.length > 0) {
|
|
@@ -546,6 +575,27 @@ export function openStore({ dir }) {
|
|
|
546
575
|
return rowToTask(q.byIssue.get(owner, repo, issueNumber))
|
|
547
576
|
},
|
|
548
577
|
|
|
578
|
+
findTaskByExternalRef(externalRef) {
|
|
579
|
+
const key = typeof externalRef === 'string' ? externalRef.trim() : ''
|
|
580
|
+
if (!key) return undefined
|
|
581
|
+
return rowToTask(q.byExternalRef.get(key))
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
listSubtasks(parentId) {
|
|
585
|
+
if (!parentId) return []
|
|
586
|
+
return q.subtasksOf.all(parentId).map(rowToTask)
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
countSubtasks(parentId) {
|
|
590
|
+
if (!parentId) return { total: 0, completed: 0 }
|
|
591
|
+
const res = q.countSubtasks.get(parentId)
|
|
592
|
+
return { total: res?.total ?? 0, completed: res?.completed ?? 0 }
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
setParent(id, parentId) {
|
|
596
|
+
return this.updateTask(id, { parentId: parentId ?? '' })
|
|
597
|
+
},
|
|
598
|
+
|
|
549
599
|
/** Задачи в архиве: свой список, а не колонка. */
|
|
550
600
|
listArchived() {
|
|
551
601
|
return q.archived.all().map(rowToTask)
|