@goodandready/dsh-kanban 0.2.21 → 0.2.22
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/board-tool.js +8 -5
- package/lib/fractional-indexing.js +582 -0
- package/lib/git-provider.js +11 -2
- package/lib/github.js +10 -0
- package/lib/import.js +11 -10
- package/lib/index.js +33 -8
- package/lib/launcher.js +160 -53
- package/lib/order.js +20 -50
- package/lib/outbound.js +26 -10
- package/lib/progress-dump.js +16 -11
- package/lib/routes.js +41 -25
- package/lib/store.js +156 -6
- package/lib/sync.js +6 -5
- package/lib/workflow-bridge.js +44 -18
- package/lib/worktree.js +21 -10
- package/package.json +1 -1
package/lib/progress-dump.js
CHANGED
|
@@ -62,15 +62,22 @@ export function parseProgressDump(text) {
|
|
|
62
62
|
const line = rawLine.trim()
|
|
63
63
|
const lower = line.toLowerCase()
|
|
64
64
|
|
|
65
|
-
if (lower.startsWith('goal:') || lower.startsWith('
|
|
65
|
+
if (lower.startsWith('goal:') || lower.startsWith('цель:')) {
|
|
66
66
|
currentKey = 'goal'
|
|
67
67
|
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
68
68
|
if (val) sections.goal.push(val)
|
|
69
|
-
} else if (lower.startsWith('progress:') || lower.startsWith('
|
|
69
|
+
} else if (lower.startsWith('progress:') || lower.startsWith('done:') || lower.startsWith('прогресс:')) {
|
|
70
70
|
currentKey = 'progress'
|
|
71
71
|
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
72
72
|
if (val) sections.progress.push(val)
|
|
73
|
-
} else if (
|
|
73
|
+
} else if (
|
|
74
|
+
lower.startsWith('next:') ||
|
|
75
|
+
lower.startsWith('next steps:') ||
|
|
76
|
+
lower.startsWith('next step:') ||
|
|
77
|
+
lower.startsWith('следующие шаги:') ||
|
|
78
|
+
lower.startsWith('шаги:') ||
|
|
79
|
+
lower.startsWith('дальше:')
|
|
80
|
+
) {
|
|
74
81
|
currentKey = 'next'
|
|
75
82
|
const val = line.slice(line.indexOf(':') + 1).trim()
|
|
76
83
|
if (val) sections.next.push(val)
|
|
@@ -98,16 +105,14 @@ export function parseProgressDump(text) {
|
|
|
98
105
|
*/
|
|
99
106
|
export function formatProgressDump(dump) {
|
|
100
107
|
if (!dump) return ''
|
|
108
|
+
const g = dump.goal || ''
|
|
109
|
+
const p = dump.progress || ''
|
|
110
|
+
const n = dump.next || ''
|
|
101
111
|
return [
|
|
102
112
|
BEGIN_MARKER,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
'Progress:',
|
|
107
|
-
dump.progress || '—',
|
|
108
|
-
'',
|
|
109
|
-
'Next steps:',
|
|
110
|
-
dump.next || '—',
|
|
113
|
+
`Goal: ${g}`,
|
|
114
|
+
`Progress: ${p}`,
|
|
115
|
+
`Next steps: ${n}`,
|
|
111
116
|
END_MARKER,
|
|
112
117
|
].join('\n')
|
|
113
118
|
}
|
package/lib/routes.js
CHANGED
|
@@ -235,15 +235,30 @@ export function updateTask({ store, id, input }) {
|
|
|
235
235
|
patch.permission = input.permission
|
|
236
236
|
if (input.permission !== current.permission) {
|
|
237
237
|
patch.permissionConfirmedAt = 0
|
|
238
|
+
patch.permissionConfirmedTier = ''
|
|
238
239
|
}
|
|
239
240
|
}
|
|
241
|
+
if (typeof input?.permissionConfirmedTier === 'string') {
|
|
242
|
+
patch.permissionConfirmedTier = input.permissionConfirmedTier
|
|
243
|
+
}
|
|
244
|
+
if (typeof input?.reasoningEffort === 'string') {
|
|
245
|
+
const val = input.reasoningEffort.trim().toLowerCase()
|
|
246
|
+
if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
|
|
247
|
+
patch.reasoningEffort = val
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (typeof input?.forge === 'string') {
|
|
251
|
+
const val = input.forge.trim().toLowerCase()
|
|
252
|
+
if (val === 'github' || val === 'gitea') patch.forge = val
|
|
253
|
+
}
|
|
240
254
|
return { task: store.updateTask(id, patch) }
|
|
241
255
|
}
|
|
242
256
|
|
|
243
|
-
export function confirmPermission({ store, id }) {
|
|
257
|
+
export function confirmPermission({ store, id, tier }) {
|
|
244
258
|
const task = store.getTask(id)
|
|
245
259
|
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
246
|
-
|
|
260
|
+
const effectiveTier = typeof tier === 'string' && tier ? tier : (task.permission || 'default')
|
|
261
|
+
return { task: store.updateTask(id, { permissionConfirmedAt: Date.now(), permissionConfirmedTier: effectiveTier }) }
|
|
247
262
|
}
|
|
248
263
|
|
|
249
264
|
/** Known priority values. Empty string indicates unprioritized. */
|
|
@@ -405,27 +420,22 @@ export function importBoard({ store, input }) {
|
|
|
405
420
|
for (const task of input.tasks) {
|
|
406
421
|
if (typeof task?.id !== 'string' || task.id === '') continue
|
|
407
422
|
if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
|
|
408
|
-
store.
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
body: task.body ?? '',
|
|
414
|
-
owner: task.owner, repo: task.repo,
|
|
415
|
-
issueNumber: task.issueNumber, issueUrl: task.issueUrl,
|
|
416
|
-
labels: Array.isArray(task.labels) ? task.labels : [],
|
|
417
|
-
branch: task.branch, worktree: task.worktree,
|
|
418
|
-
sessionId: task.sessionId, model: task.model, provider: task.provider,
|
|
419
|
-
waiting: task.waiting === true,
|
|
420
|
-
plan: task.plan ?? '',
|
|
421
|
-
labelColors: task.labelColors ?? {},
|
|
422
|
-
author: task.author ?? '',
|
|
423
|
-
priority: task.priority ?? '',
|
|
424
|
-
dueAt: typeof task.dueAt === 'number' ? task.dueAt : 0,
|
|
425
|
-
createdAt: task.createdAt,
|
|
426
|
-
})
|
|
423
|
+
if (typeof store.restoreTask === 'function') {
|
|
424
|
+
store.restoreTask(task)
|
|
425
|
+
} else {
|
|
426
|
+
store.createTask(task)
|
|
427
|
+
}
|
|
427
428
|
imported += 1
|
|
428
429
|
}
|
|
430
|
+
|
|
431
|
+
for (const trans of Array.isArray(input.transitions) ? input.transitions : []) {
|
|
432
|
+
if (typeof trans?.taskId !== 'string' || trans.taskId === '') continue
|
|
433
|
+
if (typeof store.restoreTransition === 'function') {
|
|
434
|
+
store.restoreTransition(trans)
|
|
435
|
+
} else if (typeof store.addTransition === 'function') {
|
|
436
|
+
store.addTransition(trans)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
429
439
|
}
|
|
430
440
|
|
|
431
441
|
if (typeof store.withTransaction === 'function') {
|
|
@@ -464,7 +474,12 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
464
474
|
|
|
465
475
|
function escapeCsv(val) {
|
|
466
476
|
if (val === null || val === undefined) return ''
|
|
467
|
-
|
|
477
|
+
let str = String(val)
|
|
478
|
+
// OWASP CSV Injection mitigation (#340)
|
|
479
|
+
const trimmed = str.replace(/^[\s\t\r\n]+/, '')
|
|
480
|
+
if (/^[=+\-@\t\r]/.test(str) || (trimmed.length > 0 && /^[=+\-@]/.test(trimmed))) {
|
|
481
|
+
str = `'${str}`
|
|
482
|
+
}
|
|
468
483
|
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
469
484
|
return `"${str.replace(/"/g, '""')}"`
|
|
470
485
|
}
|
|
@@ -809,10 +824,11 @@ export function handleToggleChecklistItem({ store, task, input }) {
|
|
|
809
824
|
let updatedItem
|
|
810
825
|
if (typeof item === 'string') {
|
|
811
826
|
const checked = input?.checked !== undefined ? Boolean(input.checked) : true
|
|
812
|
-
updatedItem = { text: item, done: checked }
|
|
827
|
+
updatedItem = { text: item, completed: checked, done: checked }
|
|
813
828
|
} else {
|
|
814
|
-
const
|
|
815
|
-
|
|
829
|
+
const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
|
|
830
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
|
|
831
|
+
updatedItem = { ...item, completed: checked, done: checked }
|
|
816
832
|
}
|
|
817
833
|
current[targetIndex] = updatedItem
|
|
818
834
|
const updated = store.updateChecklist(task.id, current)
|
package/lib/store.js
CHANGED
|
@@ -13,7 +13,7 @@ import { DatabaseSync } from 'node:sqlite'
|
|
|
13
13
|
import { randomUUID } from 'node:crypto'
|
|
14
14
|
import { mkdirSync, copyFileSync, readdirSync, unlinkSync, statSync, chmodSync, existsSync } from 'node:fs'
|
|
15
15
|
import { join } from 'node:path'
|
|
16
|
-
import { keyBetween } from './order.js'
|
|
16
|
+
import { keyBetween, generateNKeysBetween } from './order.js'
|
|
17
17
|
import { DEFAULT_BOARDS, normalizeKind } from './config.js'
|
|
18
18
|
|
|
19
19
|
const SCHEMA = `
|
|
@@ -103,7 +103,7 @@ const WRITABLE = [
|
|
|
103
103
|
'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
|
|
104
104
|
'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
|
|
105
105
|
'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
|
|
106
|
-
'workflowRunId', 'workflowStatus',
|
|
106
|
+
'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge',
|
|
107
107
|
]
|
|
108
108
|
|
|
109
109
|
/** Valid transition sources; unexpected values are rejected to preserve log integrity. */
|
|
@@ -242,6 +242,8 @@ export function openStore({ dir }) {
|
|
|
242
242
|
['provider', "TEXT NOT NULL DEFAULT 'gitea'"],
|
|
243
243
|
['workflowRunId', "TEXT NOT NULL DEFAULT ''"],
|
|
244
244
|
['workflowStatus', "TEXT NOT NULL DEFAULT ''"],
|
|
245
|
+
['forge', "TEXT NOT NULL DEFAULT ''"],
|
|
246
|
+
['permissionConfirmedTier', "TEXT NOT NULL DEFAULT ''"],
|
|
245
247
|
]) {
|
|
246
248
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
|
|
247
249
|
}
|
|
@@ -258,6 +260,35 @@ export function openStore({ dir }) {
|
|
|
258
260
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_parent_id ON tasks(parentId)')
|
|
259
261
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_updated ON tasks(updatedAt)')
|
|
260
262
|
db.exec('CREATE INDEX IF NOT EXISTS tasks_archivable ON tasks(archivedAt, col, columnAt)')
|
|
263
|
+
try { db.exec("ALTER TABLE dismissed ADD COLUMN forge TEXT NOT NULL DEFAULT ''") } catch { /* already exists */ }
|
|
264
|
+
db.exec("CREATE INDEX IF NOT EXISTS tasks_forge_issue ON tasks(forge, owner, repo, issueNumber)")
|
|
265
|
+
db.exec("UPDATE tasks SET forge = 'github' WHERE (forge = '' OR forge IS NULL) AND issueUrl LIKE '%github.com%'")
|
|
266
|
+
db.exec("UPDATE tasks SET forge = 'gitea' WHERE (forge = '' OR forge IS NULL) AND (issueUrl NOT LIKE '%github.com%' OR issueUrl IS NULL)")
|
|
267
|
+
db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, val TEXT)")
|
|
268
|
+
|
|
269
|
+
// Fractional indexing migration (#345): ensure all existing tasks have compact keys
|
|
270
|
+
const orderMigrated = db.prepare("SELECT val FROM meta WHERE key = 'fractional_indexing_migrated'").get()
|
|
271
|
+
if (!orderMigrated) {
|
|
272
|
+
try {
|
|
273
|
+
db.exec('BEGIN TRANSACTION')
|
|
274
|
+
const groups = db.prepare('SELECT DISTINCT board, col FROM tasks').all()
|
|
275
|
+
const getTasksInCol = db.prepare('SELECT id, position FROM tasks WHERE board = ? AND col = ? ORDER BY position ASC, createdAt ASC')
|
|
276
|
+
const updatePos = db.prepare('UPDATE tasks SET position = ? WHERE id = ?')
|
|
277
|
+
for (const group of groups) {
|
|
278
|
+
const rows = getTasksInCol.all(group.board, group.col)
|
|
279
|
+
if (rows.length > 0) {
|
|
280
|
+
const newKeys = generateNKeysBetween(null, null, rows.length)
|
|
281
|
+
for (let i = 0; i < rows.length; i++) {
|
|
282
|
+
updatePos.run(newKeys[i], rows[i].id)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, val) VALUES ('fractional_indexing_migrated', '1')").run()
|
|
287
|
+
db.exec('COMMIT')
|
|
288
|
+
} catch (e) {
|
|
289
|
+
try { db.exec('ROLLBACK') } catch { /* ignore */ }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
261
292
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
262
293
|
try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
|
|
263
294
|
|
|
@@ -274,6 +305,35 @@ export function openStore({ dir }) {
|
|
|
274
305
|
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
|
|
275
306
|
workflowRunId, workflowStatus)
|
|
276
307
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
308
|
+
restoreTask: db.prepare(`INSERT INTO tasks
|
|
309
|
+
(id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
|
|
310
|
+
labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
|
|
311
|
+
labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
|
|
312
|
+
cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, permissionConfirmedTier, runs,
|
|
313
|
+
checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
|
|
314
|
+
workflowRunId, workflowStatus, archivedAt, queuedAt, plan, forge)
|
|
315
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
316
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
317
|
+
board=excluded.board, col=excluded.col, position=excluded.position, title=excluded.title,
|
|
318
|
+
body=excluded.body, owner=excluded.owner, repo=excluded.repo, issueNumber=excluded.issueNumber,
|
|
319
|
+
issueUrl=excluded.issueUrl, externalRef=excluded.externalRef, labels=excluded.labels,
|
|
320
|
+
branch=excluded.branch, worktree=excluded.worktree, workspaceId=excluded.workspaceId,
|
|
321
|
+
sessionId=excluded.sessionId, model=excluded.model, provider=excluded.provider,
|
|
322
|
+
waiting=excluded.waiting, syncedAt=excluded.syncedAt, labelColors=excluded.labelColors,
|
|
323
|
+
author=excluded.author, assignee=excluded.assignee, milestone=excluded.milestone,
|
|
324
|
+
priority=excluded.priority, dueAt=excluded.dueAt, columnAt=excluded.columnAt,
|
|
325
|
+
createdAt=excluded.createdAt, updatedAt=excluded.updatedAt, cron=excluded.cron,
|
|
326
|
+
nextRunAt=excluded.nextRunAt, lastRunAt=excluded.lastRunAt, progressDump=excluded.progressDump,
|
|
327
|
+
permission=excluded.permission, permissionConfirmedAt=excluded.permissionConfirmedAt,
|
|
328
|
+
permissionConfirmedTier=excluded.permissionConfirmedTier, runs=excluded.runs,
|
|
329
|
+
checklist=excluded.checklist, report=excluded.report, comments=excluded.comments,
|
|
330
|
+
rejectReason=excluded.rejectReason, rejectedAt=excluded.rejectedAt,
|
|
331
|
+
reasoningEffort=excluded.reasoningEffort, parentId=excluded.parentId, isEpic=excluded.isEpic,
|
|
332
|
+
workflowRunId=excluded.workflowRunId, workflowStatus=excluded.workflowStatus,
|
|
333
|
+
archivedAt=excluded.archivedAt, queuedAt=excluded.queuedAt, plan=excluded.plan, forge=excluded.forge`),
|
|
334
|
+
restoreTransition: db.prepare(`INSERT INTO transitions
|
|
335
|
+
(taskId, fromCol, toCol, source, detail, at)
|
|
336
|
+
VALUES (?, ?, ?, ?, ?, ?)`),
|
|
277
337
|
boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
|
|
278
338
|
insertBoard: db.prepare('INSERT INTO boards (id, title, kind, position, createdAt) VALUES (?, ?, ?, ?, ?)'),
|
|
279
339
|
lastBoard: db.prepare('SELECT position FROM boards ORDER BY position DESC LIMIT 1'),
|
|
@@ -286,7 +346,9 @@ export function openStore({ dir }) {
|
|
|
286
346
|
"SELECT * FROM tasks WHERE archivedAt = 0 AND col = 'done' AND columnAt > 0 AND columnAt <= ?"),
|
|
287
347
|
dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
|
|
288
348
|
isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
349
|
+
isDismissedWithForge: db.prepare(`SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
|
|
289
350
|
byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
|
|
351
|
+
byIssueWithForge: db.prepare(`SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
|
|
290
352
|
byExternalRef: db.prepare('SELECT * FROM tasks WHERE externalRef = ? ORDER BY createdAt ASC LIMIT 1'),
|
|
291
353
|
tasksBySession: db.prepare('SELECT * FROM tasks WHERE sessionId = ? LIMIT 1'),
|
|
292
354
|
lastInColumn: db.prepare('SELECT position FROM tasks WHERE board = ? AND col = ? ORDER BY position DESC LIMIT 1'),
|
|
@@ -553,10 +615,91 @@ export function openStore({ dir }) {
|
|
|
553
615
|
updateChecklist(id, items) {
|
|
554
616
|
const task = rowToTask(q.taskById.get(id))
|
|
555
617
|
if (!task) return undefined
|
|
556
|
-
const list = Array.isArray(items) ? items : []
|
|
618
|
+
const list = (Array.isArray(items) ? items : []).map((item) => {
|
|
619
|
+
if (typeof item === 'string') return { text: item, completed: false, done: false }
|
|
620
|
+
const c = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
|
|
621
|
+
return { ...item, completed: c, done: c }
|
|
622
|
+
})
|
|
557
623
|
return this.updateTask(id, { checklist: list })
|
|
558
624
|
},
|
|
559
625
|
|
|
626
|
+
restoreTask(task) {
|
|
627
|
+
if (!task || !task.id) return undefined
|
|
628
|
+
const labels = JSON.stringify(Array.isArray(task.labels) ? task.labels : [])
|
|
629
|
+
const colors = JSON.stringify(task.labelColors && typeof task.labelColors === 'object' ? task.labelColors : {})
|
|
630
|
+
const runs = JSON.stringify(Array.isArray(task.runs) ? task.runs : [])
|
|
631
|
+
const checklist = JSON.stringify(Array.isArray(task.checklist) ? task.checklist : [])
|
|
632
|
+
const report = JSON.stringify(task.report && typeof task.report === 'object' ? task.report : {})
|
|
633
|
+
const comments = JSON.stringify(Array.isArray(task.comments) ? task.comments : [])
|
|
634
|
+
const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
|
|
635
|
+
q.restoreTask.run(
|
|
636
|
+
task.id,
|
|
637
|
+
task.board || 'main',
|
|
638
|
+
task.column || task.col || 'backlog',
|
|
639
|
+
task.position || keyBetween(undefined, undefined),
|
|
640
|
+
task.title || '',
|
|
641
|
+
task.body || '',
|
|
642
|
+
task.owner ?? null,
|
|
643
|
+
task.repo ?? null,
|
|
644
|
+
task.issueNumber ?? null,
|
|
645
|
+
task.issueUrl ?? null,
|
|
646
|
+
task.externalRef ?? null,
|
|
647
|
+
labels,
|
|
648
|
+
task.branch ?? null,
|
|
649
|
+
task.worktree ?? null,
|
|
650
|
+
task.workspaceId || '',
|
|
651
|
+
task.sessionId ?? null,
|
|
652
|
+
task.model ?? null,
|
|
653
|
+
task.provider ?? 'gitea',
|
|
654
|
+
task.waiting ? 1 : 0,
|
|
655
|
+
task.syncedAt || 0,
|
|
656
|
+
colors,
|
|
657
|
+
task.author || '',
|
|
658
|
+
task.assignee || '',
|
|
659
|
+
task.milestone || '',
|
|
660
|
+
task.priority || '',
|
|
661
|
+
task.dueAt || 0,
|
|
662
|
+
task.columnAt || task.createdAt || Date.now(),
|
|
663
|
+
task.createdAt || Date.now(),
|
|
664
|
+
task.updatedAt || Date.now(),
|
|
665
|
+
task.cron || '',
|
|
666
|
+
task.nextRunAt || 0,
|
|
667
|
+
task.lastRunAt || 0,
|
|
668
|
+
task.progressDump || '',
|
|
669
|
+
task.permission || '',
|
|
670
|
+
task.permissionConfirmedAt || 0,
|
|
671
|
+
task.permissionConfirmedTier || '',
|
|
672
|
+
runs,
|
|
673
|
+
checklist,
|
|
674
|
+
report,
|
|
675
|
+
comments,
|
|
676
|
+
task.rejectReason || '',
|
|
677
|
+
task.rejectedAt || 0,
|
|
678
|
+
task.reasoningEffort || '',
|
|
679
|
+
task.parentId || '',
|
|
680
|
+
task.isEpic ? 1 : 0,
|
|
681
|
+
task.workflowRunId || '',
|
|
682
|
+
task.workflowStatus || '',
|
|
683
|
+
task.archivedAt || 0,
|
|
684
|
+
task.queuedAt || 0,
|
|
685
|
+
task.plan || '',
|
|
686
|
+
forge,
|
|
687
|
+
)
|
|
688
|
+
return rowToTask(q.taskById.get(task.id))
|
|
689
|
+
},
|
|
690
|
+
|
|
691
|
+
restoreTransition(trans) {
|
|
692
|
+
if (!trans || !trans.taskId) return undefined
|
|
693
|
+
q.restoreTransition.run(
|
|
694
|
+
trans.taskId,
|
|
695
|
+
trans.fromCol ?? null,
|
|
696
|
+
trans.toCol || '',
|
|
697
|
+
trans.source || 'unknown',
|
|
698
|
+
trans.detail || '',
|
|
699
|
+
trans.at || Date.now(),
|
|
700
|
+
)
|
|
701
|
+
},
|
|
702
|
+
|
|
560
703
|
setReport(id, reportData) {
|
|
561
704
|
const task = rowToTask(q.taskById.get(id))
|
|
562
705
|
if (!task) return undefined
|
|
@@ -621,12 +764,18 @@ export function openStore({ dir }) {
|
|
|
621
764
|
},
|
|
622
765
|
|
|
623
766
|
/** Whether an issue was previously dismissed. */
|
|
624
|
-
isDismissed({ owner, repo, issueNumber }) {
|
|
767
|
+
isDismissed({ owner, repo, issueNumber, forge }) {
|
|
768
|
+
if (forge) {
|
|
769
|
+
return q.isDismissedWithForge.get(owner, repo, issueNumber, forge, forge) !== undefined
|
|
770
|
+
}
|
|
625
771
|
return q.isDismissed.get(owner, repo, issueNumber) !== undefined
|
|
626
772
|
},
|
|
627
773
|
|
|
628
774
|
/** Find task by issue to prevent duplicate import. */
|
|
629
|
-
findTaskByIssue({ owner, repo, issueNumber }) {
|
|
775
|
+
findTaskByIssue({ owner, repo, issueNumber, forge }) {
|
|
776
|
+
if (forge) {
|
|
777
|
+
return rowToTask(q.byIssueWithForge.get(owner, repo, issueNumber, forge, forge))
|
|
778
|
+
}
|
|
630
779
|
return rowToTask(q.byIssue.get(owner, repo, issueNumber))
|
|
631
780
|
},
|
|
632
781
|
|
|
@@ -667,8 +816,9 @@ export function openStore({ dir }) {
|
|
|
667
816
|
},
|
|
668
817
|
|
|
669
818
|
setArchived(id, archivedAt) {
|
|
819
|
+
const at = typeof archivedAt === 'boolean' ? (archivedAt ? Date.now() : 0) : (Number(archivedAt) || 0)
|
|
670
820
|
db.prepare('UPDATE tasks SET archivedAt = ?, updatedAt = ? WHERE id = ?')
|
|
671
|
-
.run(
|
|
821
|
+
.run(at, Date.now(), id)
|
|
672
822
|
return rowToTask(q.taskById.get(id))
|
|
673
823
|
},
|
|
674
824
|
|
package/lib/sync.js
CHANGED
|
@@ -39,7 +39,7 @@ export async function withBackoff(fn, { retries = 2, baseMs = 50, maxMs = 500 }
|
|
|
39
39
|
* Failures are not silently swallowed: incomplete observations (missing PRs or branches)
|
|
40
40
|
* would lead to erroneous column transitions. If reading fails, the repository is skipped.
|
|
41
41
|
*/
|
|
42
|
-
async function readRepo({ gitea, owner, repo }) {
|
|
42
|
+
async function readRepo({ gitea, owner, repo, forge }) {
|
|
43
43
|
const [pulls, branches] = await Promise.all([
|
|
44
44
|
gitea.listPulls({ owner, repo, state: 'all' }),
|
|
45
45
|
gitea.listBranches({ owner, repo }),
|
|
@@ -182,8 +182,9 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
|
|
|
182
182
|
const tasks = store.listWatchable().filter((t) => (only ? only(t) : true))
|
|
183
183
|
const byRepo = new Map()
|
|
184
184
|
for (const task of tasks) {
|
|
185
|
-
const
|
|
186
|
-
|
|
185
|
+
const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
|
|
186
|
+
const key = `${forge}:${task.owner}/${task.repo}`
|
|
187
|
+
if (!byRepo.has(key)) byRepo.set(key, { forge, owner: task.owner, repo: task.repo, tasks: [] })
|
|
187
188
|
byRepo.get(key).tasks.push(task)
|
|
188
189
|
}
|
|
189
190
|
|
|
@@ -200,7 +201,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
|
|
|
200
201
|
for (const group of byRepo.values()) {
|
|
201
202
|
let repoState
|
|
202
203
|
try {
|
|
203
|
-
repoState = await withBackoff(() => readRepo({ gitea, owner: group.owner, repo: group.repo }), { retries: 2, baseMs: 40 })
|
|
204
|
+
repoState = await withBackoff(() => readRepo({ gitea, owner: group.owner, repo: group.repo, forge: group.forge }), { retries: 2, baseMs: 40 })
|
|
204
205
|
} catch (error) {
|
|
205
206
|
failed += group.tasks.length
|
|
206
207
|
reposFailed += 1
|
|
@@ -212,7 +213,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
|
|
|
212
213
|
for (const task of group.tasks) {
|
|
213
214
|
checked += 1
|
|
214
215
|
try {
|
|
215
|
-
const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber })
|
|
216
|
+
const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber, provider: group.forge, issueUrl: task.issueUrl })
|
|
216
217
|
const observation = observeTask(task, repoState, issue)
|
|
217
218
|
const applied = applyObservation({
|
|
218
219
|
store, task, observation, issue, remoteUpdatedAt: issue?.updated_at,
|
package/lib/workflow-bridge.js
CHANGED
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
// - post summary to linked Gitea / GitHub issue.
|
|
12
12
|
// 5. Cancel active workflow on card request.
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
buildStartMessage,
|
|
16
|
+
obtainAgent,
|
|
17
|
+
verifyPermissionGate,
|
|
18
|
+
countActiveSessions,
|
|
19
|
+
reserveSessionSlot,
|
|
20
|
+
releaseSessionSlot,
|
|
21
|
+
} from './launcher.js'
|
|
15
22
|
import { applyMove } from './routes.js'
|
|
16
23
|
import { defaultGitRunner } from './worktree.js'
|
|
17
24
|
|
|
@@ -169,11 +176,11 @@ export function buildWorkflowMeta({ task, role }) {
|
|
|
169
176
|
*/
|
|
170
177
|
export function resolveTargetColumn(currentCol) {
|
|
171
178
|
const col = String(currentCol || '').toLowerCase()
|
|
172
|
-
if (col === 'backlog' || col === 'ready' || col === 'progress') {
|
|
179
|
+
if (col === 'backlog' || col === 'ready' || col === 'progress' || col === 'in-progress') {
|
|
173
180
|
return 'review'
|
|
174
181
|
}
|
|
175
182
|
if (col === 'review') {
|
|
176
|
-
return '
|
|
183
|
+
return 'review'
|
|
177
184
|
}
|
|
178
185
|
return col || 'review'
|
|
179
186
|
}
|
|
@@ -268,21 +275,40 @@ export class WorkflowBridge {
|
|
|
268
275
|
})
|
|
269
276
|
|
|
270
277
|
const config = this.getConfig()
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
278
|
+
verifyPermissionGate({ task, permission, config })
|
|
279
|
+
|
|
280
|
+
const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
|
|
281
|
+
if (maxConcurrent > 0 && this.agents && this.store) {
|
|
282
|
+
const activeCount = countActiveSessions({ store: this.store, agents: this.agents })
|
|
283
|
+
if (activeCount >= maxConcurrent) {
|
|
284
|
+
const err = new Error('concurrent-limit-reached')
|
|
285
|
+
err.key = 'concurrent-limit-reached'
|
|
286
|
+
err.status = 429
|
|
287
|
+
throw err
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
reserveSessionSlot(task.id)
|
|
292
|
+
let taken
|
|
293
|
+
try {
|
|
294
|
+
taken = await obtainAgent({
|
|
295
|
+
agents: this.agents,
|
|
296
|
+
task,
|
|
297
|
+
config,
|
|
298
|
+
provider,
|
|
299
|
+
model,
|
|
300
|
+
mintSessionId,
|
|
301
|
+
cwdOf,
|
|
302
|
+
logger: this.logger,
|
|
303
|
+
agentPreset: preset,
|
|
304
|
+
permission,
|
|
305
|
+
permissions,
|
|
306
|
+
gitRunner,
|
|
307
|
+
workspaceRegistry,
|
|
308
|
+
})
|
|
309
|
+
} finally {
|
|
310
|
+
releaseSessionSlot(task.id)
|
|
311
|
+
}
|
|
286
312
|
|
|
287
313
|
const promptText = typeof text === 'string' && text.trim() !== ''
|
|
288
314
|
? text.trim()
|
package/lib/worktree.js
CHANGED
|
@@ -30,10 +30,11 @@ export async function defaultGitRunner(args, cwd) {
|
|
|
30
30
|
maxBuffer: 10 * 1024 * 1024,
|
|
31
31
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
32
32
|
})
|
|
33
|
-
return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0 }
|
|
33
|
+
return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0, exitCode: 0 }
|
|
34
34
|
} catch (err) {
|
|
35
35
|
const error = new Error(`git ${args.join(' ')} failed: ${err.stderr || err.message}`)
|
|
36
36
|
error.code = err.code || 1
|
|
37
|
+
error.exitCode = err.code || 1
|
|
37
38
|
error.stdout = err.stdout ? String(err.stdout).trim() : ''
|
|
38
39
|
error.stderr = err.stderr ? String(err.stderr).trim() : ''
|
|
39
40
|
throw error
|
|
@@ -264,7 +265,7 @@ export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitR
|
|
|
264
265
|
.filter((line) => !isSubmoduleDrift(worktreePath, line))
|
|
265
266
|
return { dirty: files.length > 0, files }
|
|
266
267
|
} catch (error) {
|
|
267
|
-
return { dirty:
|
|
268
|
+
return { dirty: true, files: [], error: String(error?.message ?? error) }
|
|
268
269
|
}
|
|
269
270
|
}
|
|
270
271
|
|
|
@@ -289,6 +290,13 @@ export async function removeTaskWorktree({
|
|
|
289
290
|
}
|
|
290
291
|
|
|
291
292
|
const dirtyCheck = await checkWorktreeDirty({ worktreePath, gitRunner })
|
|
293
|
+
if (dirtyCheck.error) {
|
|
294
|
+
return {
|
|
295
|
+
removed: false,
|
|
296
|
+
error: 'worktree-status-error',
|
|
297
|
+
detail: dirtyCheck.error,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
292
300
|
if (dirtyCheck.dirty && !force) {
|
|
293
301
|
return {
|
|
294
302
|
removed: false,
|
|
@@ -431,21 +439,21 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
|
|
|
431
439
|
let diffOut = ''
|
|
432
440
|
if (base) {
|
|
433
441
|
const res = await gitRunner(['diff', `${base}...HEAD`], worktreePath)
|
|
434
|
-
if (res
|
|
442
|
+
if ((res?.exitCode ?? res?.code ?? 0) === 0) {
|
|
435
443
|
diffOut = res.stdout
|
|
436
444
|
}
|
|
437
445
|
}
|
|
438
446
|
|
|
439
447
|
// Include uncommitted changes (unstaged + staged)
|
|
440
448
|
const unstaged = await gitRunner(['diff', 'HEAD'], worktreePath)
|
|
441
|
-
if (unstaged
|
|
449
|
+
if ((unstaged?.exitCode ?? unstaged?.code ?? 0) === 0 && unstaged.stdout.trim() !== '') {
|
|
442
450
|
diffOut = diffOut ? `${diffOut}\n\n# Uncommitted Changes:\n${unstaged.stdout}` : unstaged.stdout
|
|
443
451
|
}
|
|
444
452
|
|
|
445
453
|
// Fallback to plain git diff if base...HEAD produced no output
|
|
446
454
|
if (!diffOut.trim()) {
|
|
447
455
|
const fallback = await gitRunner(['diff'], worktreePath)
|
|
448
|
-
if (fallback
|
|
456
|
+
if ((fallback?.exitCode ?? fallback?.code ?? 0) === 0) diffOut = fallback.stdout
|
|
449
457
|
}
|
|
450
458
|
|
|
451
459
|
// Count affected files
|
|
@@ -481,10 +489,10 @@ export async function getTaskCommits({ worktreePath, baseRef, gitRunner = defaul
|
|
|
481
489
|
|
|
482
490
|
const range = base ? `${base}..HEAD` : '-n 20'
|
|
483
491
|
const logRes = await gitRunner(['log', range, '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
|
|
484
|
-
if (logRes
|
|
492
|
+
if ((logRes?.exitCode ?? logRes?.code ?? 0) !== 0 || !logRes.stdout.trim()) {
|
|
485
493
|
// Fallback to last 10 commits
|
|
486
494
|
const fallback = await gitRunner(['log', '-n', '10', '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
|
|
487
|
-
if (fallback
|
|
495
|
+
if ((fallback?.exitCode ?? fallback?.code ?? 0) !== 0 || !fallback.stdout.trim()) return []
|
|
488
496
|
return fallback.stdout.trim().split('\n').map(parseCommitLine).filter(Boolean)
|
|
489
497
|
}
|
|
490
498
|
|
|
@@ -521,6 +529,9 @@ export async function mergeTaskWorktree({
|
|
|
521
529
|
|
|
522
530
|
// 1. Verify worktree cleanliness
|
|
523
531
|
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree, gitRunner })
|
|
532
|
+
if (dirtyCheck.error) {
|
|
533
|
+
return { success: false, merged: false, error: 'worktree-status-error', detail: dirtyCheck.error }
|
|
534
|
+
}
|
|
524
535
|
if (dirtyCheck.dirty) {
|
|
525
536
|
return { success: false, merged: false, error: 'worktree-dirty', files: dirtyCheck.files }
|
|
526
537
|
}
|
|
@@ -528,7 +539,7 @@ export async function mergeTaskWorktree({
|
|
|
528
539
|
// 2. Locate main repository root
|
|
529
540
|
let mainRepoDir = ''
|
|
530
541
|
const revParse = await gitRunner(['rev-parse', '--git-common-dir'], task.worktree)
|
|
531
|
-
if (revParse
|
|
542
|
+
if ((revParse?.exitCode ?? revParse?.code ?? 0) === 0 && revParse.stdout.trim()) {
|
|
532
543
|
const commonDir = revParse.stdout.trim()
|
|
533
544
|
mainRepoDir = commonDir.endsWith('/.git') ? commonDir.slice(0, -5) : commonDir
|
|
534
545
|
}
|
|
@@ -551,7 +562,7 @@ export async function mergeTaskWorktree({
|
|
|
551
562
|
const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
|
|
552
563
|
if (currentBranch.stdout.trim() !== target) {
|
|
553
564
|
const checkoutRes = await gitRunner(['checkout', '--', target], mainRepoDir)
|
|
554
|
-
if (checkoutRes
|
|
565
|
+
if ((checkoutRes?.exitCode ?? checkoutRes?.code ?? 0) !== 0) {
|
|
555
566
|
return { success: false, merged: false, error: 'checkout-target-failed', detail: checkoutRes.stderr }
|
|
556
567
|
}
|
|
557
568
|
}
|
|
@@ -559,7 +570,7 @@ export async function mergeTaskWorktree({
|
|
|
559
570
|
// 5. Execute git merge --no-ff
|
|
560
571
|
const mergeMsg = `Merge branch '${task.branch}' for task ${task.id}`
|
|
561
572
|
const mergeRes = await gitRunner(['merge', '--no-ff', task.branch, '-m', mergeMsg], mainRepoDir)
|
|
562
|
-
if (mergeRes
|
|
573
|
+
if ((mergeRes?.exitCode ?? mergeRes?.code ?? 0) !== 0) {
|
|
563
574
|
// Roll back merge on conflict
|
|
564
575
|
await gitRunner(['merge', '--abort'], mainRepoDir)
|
|
565
576
|
return { success: false, merged: false, error: 'merge-conflict', detail: mergeRes.stderr || mergeRes.stdout }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|