@goodandready/dsh-kanban 0.2.20 → 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/config.js +1 -1
- package/lib/events.js +16 -4
- package/lib/fractional-indexing.js +582 -0
- package/lib/git-provider.js +11 -2
- package/lib/gitea.js +8 -1
- package/lib/github.js +22 -6
- package/lib/import.js +11 -10
- package/lib/index.js +98 -16
- package/lib/launcher.js +164 -53
- package/lib/order.js +20 -50
- package/lib/outbound.js +29 -12
- package/lib/progress-dump.js +16 -11
- package/lib/routes.js +47 -25
- package/lib/store.js +165 -6
- package/lib/sync.js +6 -5
- package/lib/transitions.js +8 -4
- package/lib/workflow-bridge.js +61 -31
- package/lib/worktree.js +66 -12
- package/package.json +1 -1
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'),
|
|
@@ -541,13 +603,103 @@ export function openStore({ dir }) {
|
|
|
541
603
|
return this.updateTask(id, { comments })
|
|
542
604
|
},
|
|
543
605
|
|
|
606
|
+
addTaskComment(id, comment) {
|
|
607
|
+
if (!comment || typeof comment !== 'object') return undefined
|
|
608
|
+
return this.addComment(id, {
|
|
609
|
+
author: comment.author,
|
|
610
|
+
role: comment.role,
|
|
611
|
+
text: comment.text || comment.body || '',
|
|
612
|
+
})
|
|
613
|
+
},
|
|
614
|
+
|
|
544
615
|
updateChecklist(id, items) {
|
|
545
616
|
const task = rowToTask(q.taskById.get(id))
|
|
546
617
|
if (!task) return undefined
|
|
547
|
-
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
|
+
})
|
|
548
623
|
return this.updateTask(id, { checklist: list })
|
|
549
624
|
},
|
|
550
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
|
+
|
|
551
703
|
setReport(id, reportData) {
|
|
552
704
|
const task = rowToTask(q.taskById.get(id))
|
|
553
705
|
if (!task) return undefined
|
|
@@ -612,12 +764,18 @@ export function openStore({ dir }) {
|
|
|
612
764
|
},
|
|
613
765
|
|
|
614
766
|
/** Whether an issue was previously dismissed. */
|
|
615
|
-
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
|
+
}
|
|
616
771
|
return q.isDismissed.get(owner, repo, issueNumber) !== undefined
|
|
617
772
|
},
|
|
618
773
|
|
|
619
774
|
/** Find task by issue to prevent duplicate import. */
|
|
620
|
-
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
|
+
}
|
|
621
779
|
return rowToTask(q.byIssue.get(owner, repo, issueNumber))
|
|
622
780
|
},
|
|
623
781
|
|
|
@@ -658,8 +816,9 @@ export function openStore({ dir }) {
|
|
|
658
816
|
},
|
|
659
817
|
|
|
660
818
|
setArchived(id, archivedAt) {
|
|
819
|
+
const at = typeof archivedAt === 'boolean' ? (archivedAt ? Date.now() : 0) : (Number(archivedAt) || 0)
|
|
661
820
|
db.prepare('UPDATE tasks SET archivedAt = ?, updatedAt = ? WHERE id = ?')
|
|
662
|
-
.run(
|
|
821
|
+
.run(at, Date.now(), id)
|
|
663
822
|
return rowToTask(q.taskById.get(id))
|
|
664
823
|
},
|
|
665
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/transitions.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
function escapeRegex(str) {
|
|
2
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
3
|
+
}
|
|
4
|
+
|
|
1
5
|
// Card auto-movement: derive column from observations and source priority.
|
|
2
6
|
// Pure module without network, storage, or harness dependencies.
|
|
3
7
|
// Observations arrive as data, verdicts return as values.
|
|
@@ -85,19 +89,19 @@ export function branchOfTask(task, branches) {
|
|
|
85
89
|
|
|
86
90
|
// Agent creates branch matching workflow conventions with issue number.
|
|
87
91
|
// Look up by issue number rather than fixed string template.
|
|
88
|
-
if (
|
|
89
|
-
const marker =
|
|
92
|
+
if (!Number.isSafeInteger(task?.issueNumber) || task.issueNumber <= 0) return undefined
|
|
93
|
+
const marker = escapeRegex(task.issueNumber)
|
|
90
94
|
return names.find((n) => new RegExp(`(^|[^0-9])${marker}([^0-9]|$)`).test(n))
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
/** Pull requests related to task: matched by branch or issue reference. */
|
|
94
98
|
export function pullsOfTask(task, pulls, branch) {
|
|
95
|
-
const number =
|
|
99
|
+
const number = Number.isSafeInteger(task?.issueNumber) && task.issueNumber > 0 ? task.issueNumber : undefined
|
|
96
100
|
return (pulls ?? []).filter((p) => {
|
|
97
101
|
if (branch !== undefined && p?.head?.ref === branch) return true
|
|
98
102
|
if (number === undefined) return false
|
|
99
103
|
const haystack = `${p?.title ?? ''} ${p?.body ?? ''}`
|
|
100
|
-
return new RegExp(`#${number}([^0-9]|$)`).test(haystack)
|
|
104
|
+
return new RegExp(`#${escapeRegex(number)}([^0-9]|$)`).test(haystack)
|
|
101
105
|
})
|
|
102
106
|
}
|
|
103
107
|
|
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
|
|
|
@@ -120,18 +127,20 @@ export function resolvePreset({ role, availablePresets = [], defaultPreset = und
|
|
|
120
127
|
* @returns {string}
|
|
121
128
|
*/
|
|
122
129
|
export function buildWorkflowScript({ task, role }) {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const
|
|
130
|
+
const rawRole = String(role || 'backend')
|
|
131
|
+
const rawTaskId = String(task?.id || 'unknown')
|
|
132
|
+
const validIssue = Number.isSafeInteger(task?.issueNumber) && task.issueNumber > 0 ? task.issueNumber : undefined
|
|
133
|
+
const cardLabel = validIssue !== undefined ? `#${validIssue}` : rawTaskId
|
|
134
|
+
const logMsg = `Starting phase ${rawRole} for card ${cardLabel}`
|
|
126
135
|
|
|
127
136
|
return [
|
|
128
|
-
`phase(
|
|
129
|
-
`log(
|
|
137
|
+
`phase(${JSON.stringify(rawRole)});`,
|
|
138
|
+
`log(${JSON.stringify(logMsg)});`,
|
|
130
139
|
`const prompt = args.prompt;`,
|
|
131
|
-
`const result = await agent(prompt, { label:
|
|
140
|
+
`const result = await agent(prompt, { label: ${JSON.stringify(rawRole)}, phase: ${JSON.stringify(rawRole)} });`,
|
|
132
141
|
`return {`,
|
|
133
|
-
` role:
|
|
134
|
-
` taskId:
|
|
142
|
+
` role: ${JSON.stringify(rawRole)},`,
|
|
143
|
+
` taskId: ${JSON.stringify(rawTaskId)},`,
|
|
135
144
|
` summary: typeof result === 'string' ? result : (result ? JSON.stringify(result) : '')`,
|
|
136
145
|
`};`,
|
|
137
146
|
].join('\n')
|
|
@@ -167,11 +176,11 @@ export function buildWorkflowMeta({ task, role }) {
|
|
|
167
176
|
*/
|
|
168
177
|
export function resolveTargetColumn(currentCol) {
|
|
169
178
|
const col = String(currentCol || '').toLowerCase()
|
|
170
|
-
if (col === 'backlog' || col === 'ready' || col === 'progress') {
|
|
179
|
+
if (col === 'backlog' || col === 'ready' || col === 'progress' || col === 'in-progress') {
|
|
171
180
|
return 'review'
|
|
172
181
|
}
|
|
173
182
|
if (col === 'review') {
|
|
174
|
-
return '
|
|
183
|
+
return 'review'
|
|
175
184
|
}
|
|
176
185
|
return col || 'review'
|
|
177
186
|
}
|
|
@@ -266,21 +275,40 @@ export class WorkflowBridge {
|
|
|
266
275
|
})
|
|
267
276
|
|
|
268
277
|
const config = this.getConfig()
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
}
|
|
284
312
|
|
|
285
313
|
const promptText = typeof text === 'string' && text.trim() !== ''
|
|
286
314
|
? text.trim()
|
|
@@ -469,11 +497,13 @@ export class WorkflowBridge {
|
|
|
469
497
|
}
|
|
470
498
|
|
|
471
499
|
// Add comment to card
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
})
|
|
500
|
+
if (summary) {
|
|
501
|
+
const text = `🚀 **Workflow completed (${role}):**\n\n${summary}`
|
|
502
|
+
if (typeof this.store?.addComment === 'function') {
|
|
503
|
+
this.store.addComment(taskId, { author: 'workflow', role: 'workflow', text })
|
|
504
|
+
} else if (typeof this.store?.addTaskComment === 'function') {
|
|
505
|
+
this.store.addTaskComment(taskId, { author: 'workflow', role: 'workflow', text, body: text })
|
|
506
|
+
}
|
|
477
507
|
}
|
|
478
508
|
} else {
|
|
479
509
|
this.store.addTransition({
|
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
|
|
@@ -87,6 +88,22 @@ export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => pro
|
|
|
87
88
|
/**
|
|
88
89
|
* Resolve canonical worktree path for task with path traversal protection.
|
|
89
90
|
*/
|
|
91
|
+
export function isSafeBranchName(name) {
|
|
92
|
+
if (typeof name !== 'string') return false
|
|
93
|
+
const trimmed = name.trim()
|
|
94
|
+
if (trimmed === '' || trimmed.startsWith('-') || trimmed.startsWith('/') || trimmed.endsWith('/')) return false
|
|
95
|
+
if (trimmed.includes('..') || trimmed.includes('~') || trimmed.includes('^') || trimmed.includes(':') || trimmed.includes('?') || trimmed.includes('*') || trimmed.includes('[') || trimmed.includes('\\') || trimmed.includes('@{') || trimmed.endsWith('.lock')) return false
|
|
96
|
+
return /^[A-Za-z0-9_./-]+$/.test(trimmed)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isPathFenced(targetPath, rootPath) {
|
|
100
|
+
if (!targetPath || !rootPath) return false
|
|
101
|
+
const resolvedTarget = resolve(targetPath)
|
|
102
|
+
const resolvedRoot = resolve(rootPath)
|
|
103
|
+
const fence = resolvedRoot.endsWith(sep) || resolvedRoot.endsWith('/') ? resolvedRoot : resolvedRoot + sep
|
|
104
|
+
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(fence)
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
|
|
91
108
|
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
92
109
|
const repoKey = safeRepoKey(task)
|
|
@@ -248,7 +265,7 @@ export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitR
|
|
|
248
265
|
.filter((line) => !isSubmoduleDrift(worktreePath, line))
|
|
249
266
|
return { dirty: files.length > 0, files }
|
|
250
267
|
} catch (error) {
|
|
251
|
-
return { dirty:
|
|
268
|
+
return { dirty: true, files: [], error: String(error?.message ?? error) }
|
|
252
269
|
}
|
|
253
270
|
}
|
|
254
271
|
|
|
@@ -262,12 +279,24 @@ export async function removeTaskWorktree({
|
|
|
262
279
|
force = false,
|
|
263
280
|
deleteBranch = true,
|
|
264
281
|
gitRunner = defaultGitRunner,
|
|
282
|
+
rootPath,
|
|
265
283
|
}) {
|
|
266
284
|
if (!worktreePath || !existsSync(worktreePath)) {
|
|
267
285
|
return { removed: false, reason: 'not-found' }
|
|
268
286
|
}
|
|
269
287
|
|
|
288
|
+
if (rootPath && !isPathFenced(worktreePath, rootPath)) {
|
|
289
|
+
return { removed: false, error: 'unfenced-worktree-path' }
|
|
290
|
+
}
|
|
291
|
+
|
|
270
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
|
+
}
|
|
271
300
|
if (dirtyCheck.dirty && !force) {
|
|
272
301
|
return {
|
|
273
302
|
removed: false,
|
|
@@ -333,11 +362,26 @@ export async function cleanupTaskWorktree({
|
|
|
333
362
|
force = false,
|
|
334
363
|
deleteBranch = true,
|
|
335
364
|
gitRunner = defaultGitRunner,
|
|
365
|
+
config,
|
|
366
|
+
env,
|
|
367
|
+
cwdOf,
|
|
336
368
|
}) {
|
|
337
369
|
const current = typeof task === 'object' && task !== null ? task : store?.getTask?.(task)
|
|
338
370
|
if (!current) return { error: 'task-not-found', status: 404 }
|
|
339
371
|
if (!current.worktree) return { noop: true, removed: false }
|
|
340
372
|
|
|
373
|
+
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
374
|
+
const candidateRoots = [
|
|
375
|
+
root,
|
|
376
|
+
repoDir,
|
|
377
|
+
typeof cwdOf === 'function' ? cwdOf() : process.cwd(),
|
|
378
|
+
process.cwd(),
|
|
379
|
+
].filter(Boolean)
|
|
380
|
+
const isFenced = candidateRoots.some((c) => isPathFenced(current.worktree, c))
|
|
381
|
+
if (!isFenced) {
|
|
382
|
+
return { error: 'unfenced-worktree-path', status: 403 }
|
|
383
|
+
}
|
|
384
|
+
|
|
341
385
|
const targetRepoDir = repoDir || current.worktree
|
|
342
386
|
const result = await removeTaskWorktree({
|
|
343
387
|
repoDir: targetRepoDir,
|
|
@@ -395,21 +439,21 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
|
|
|
395
439
|
let diffOut = ''
|
|
396
440
|
if (base) {
|
|
397
441
|
const res = await gitRunner(['diff', `${base}...HEAD`], worktreePath)
|
|
398
|
-
if (res
|
|
442
|
+
if ((res?.exitCode ?? res?.code ?? 0) === 0) {
|
|
399
443
|
diffOut = res.stdout
|
|
400
444
|
}
|
|
401
445
|
}
|
|
402
446
|
|
|
403
447
|
// Include uncommitted changes (unstaged + staged)
|
|
404
448
|
const unstaged = await gitRunner(['diff', 'HEAD'], worktreePath)
|
|
405
|
-
if (unstaged
|
|
449
|
+
if ((unstaged?.exitCode ?? unstaged?.code ?? 0) === 0 && unstaged.stdout.trim() !== '') {
|
|
406
450
|
diffOut = diffOut ? `${diffOut}\n\n# Uncommitted Changes:\n${unstaged.stdout}` : unstaged.stdout
|
|
407
451
|
}
|
|
408
452
|
|
|
409
453
|
// Fallback to plain git diff if base...HEAD produced no output
|
|
410
454
|
if (!diffOut.trim()) {
|
|
411
455
|
const fallback = await gitRunner(['diff'], worktreePath)
|
|
412
|
-
if (fallback
|
|
456
|
+
if ((fallback?.exitCode ?? fallback?.code ?? 0) === 0) diffOut = fallback.stdout
|
|
413
457
|
}
|
|
414
458
|
|
|
415
459
|
// Count affected files
|
|
@@ -445,10 +489,10 @@ export async function getTaskCommits({ worktreePath, baseRef, gitRunner = defaul
|
|
|
445
489
|
|
|
446
490
|
const range = base ? `${base}..HEAD` : '-n 20'
|
|
447
491
|
const logRes = await gitRunner(['log', range, '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
|
|
448
|
-
if (logRes
|
|
492
|
+
if ((logRes?.exitCode ?? logRes?.code ?? 0) !== 0 || !logRes.stdout.trim()) {
|
|
449
493
|
// Fallback to last 10 commits
|
|
450
494
|
const fallback = await gitRunner(['log', '-n', '10', '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
|
|
451
|
-
if (fallback
|
|
495
|
+
if ((fallback?.exitCode ?? fallback?.code ?? 0) !== 0 || !fallback.stdout.trim()) return []
|
|
452
496
|
return fallback.stdout.trim().split('\n').map(parseCommitLine).filter(Boolean)
|
|
453
497
|
}
|
|
454
498
|
|
|
@@ -479,8 +523,15 @@ export async function mergeTaskWorktree({
|
|
|
479
523
|
return { success: false, merged: false, error: 'no-worktree-or-branch' }
|
|
480
524
|
}
|
|
481
525
|
|
|
526
|
+
if (targetBranch && !isSafeBranchName(targetBranch)) {
|
|
527
|
+
return { success: false, merged: false, error: 'invalid-target-branch' }
|
|
528
|
+
}
|
|
529
|
+
|
|
482
530
|
// 1. Verify worktree cleanliness
|
|
483
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
|
+
}
|
|
484
535
|
if (dirtyCheck.dirty) {
|
|
485
536
|
return { success: false, merged: false, error: 'worktree-dirty', files: dirtyCheck.files }
|
|
486
537
|
}
|
|
@@ -488,7 +539,7 @@ export async function mergeTaskWorktree({
|
|
|
488
539
|
// 2. Locate main repository root
|
|
489
540
|
let mainRepoDir = ''
|
|
490
541
|
const revParse = await gitRunner(['rev-parse', '--git-common-dir'], task.worktree)
|
|
491
|
-
if (revParse
|
|
542
|
+
if ((revParse?.exitCode ?? revParse?.code ?? 0) === 0 && revParse.stdout.trim()) {
|
|
492
543
|
const commonDir = revParse.stdout.trim()
|
|
493
544
|
mainRepoDir = commonDir.endsWith('/.git') ? commonDir.slice(0, -5) : commonDir
|
|
494
545
|
}
|
|
@@ -499,16 +550,19 @@ export async function mergeTaskWorktree({
|
|
|
499
550
|
|
|
500
551
|
// 3. Resolve target merge branch
|
|
501
552
|
let target = targetBranch
|
|
553
|
+
if (target && !isSafeBranchName(target)) {
|
|
554
|
+
return { success: false, merged: false, error: 'invalid-target-branch' }
|
|
555
|
+
}
|
|
502
556
|
if (!target) {
|
|
503
557
|
target = await determineBaseRef(mainRepoDir, gitRunner)
|
|
504
558
|
}
|
|
505
|
-
if (!target) target = 'main'
|
|
559
|
+
if (!target || !isSafeBranchName(target)) target = 'main'
|
|
506
560
|
|
|
507
561
|
// 4. Switch or verify target branch in main repository
|
|
508
562
|
const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
|
|
509
563
|
if (currentBranch.stdout.trim() !== target) {
|
|
510
|
-
const checkoutRes = await gitRunner(['checkout', target], mainRepoDir)
|
|
511
|
-
if (checkoutRes
|
|
564
|
+
const checkoutRes = await gitRunner(['checkout', '--', target], mainRepoDir)
|
|
565
|
+
if ((checkoutRes?.exitCode ?? checkoutRes?.code ?? 0) !== 0) {
|
|
512
566
|
return { success: false, merged: false, error: 'checkout-target-failed', detail: checkoutRes.stderr }
|
|
513
567
|
}
|
|
514
568
|
}
|
|
@@ -516,7 +570,7 @@ export async function mergeTaskWorktree({
|
|
|
516
570
|
// 5. Execute git merge --no-ff
|
|
517
571
|
const mergeMsg = `Merge branch '${task.branch}' for task ${task.id}`
|
|
518
572
|
const mergeRes = await gitRunner(['merge', '--no-ff', task.branch, '-m', mergeMsg], mainRepoDir)
|
|
519
|
-
if (mergeRes
|
|
573
|
+
if ((mergeRes?.exitCode ?? mergeRes?.code ?? 0) !== 0) {
|
|
520
574
|
// Roll back merge on conflict
|
|
521
575
|
await gitRunner(['merge', '--abort'], mainRepoDir)
|
|
522
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",
|