@goodandready/dsh-kanban 0.2.34 → 0.2.36

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.
@@ -0,0 +1,237 @@
1
+ import { parseBody } from '../markdown.js'
2
+ import { columnsOf, wipLimitField } from '../config.js'
3
+ import { withPlan } from './board.js'
4
+ import { checkMoveAllowed, validateDependencies, findUnblockedDependents } from '../dependencies.js'
5
+
6
+ export function parseTaskPath(pathname) {
7
+ const m = /^\/dsh-kanban\/task\/([^/]+)(?:\/([a-z-]+))?\/?$/.exec(pathname ?? '')
8
+ if (m === null) return undefined
9
+ return { id: decodeURIComponent(m[1]), action: m[2] }
10
+ }
11
+
12
+ /**
13
+ * Enrich task with parsed plan, state, and filters.
14
+ *
15
+ * Plan is pre-parsed on server because client bundle cannot import `lib/plan.js`.
16
+ * `liveOf` returns active agent state or undefined if session is inactive.
17
+ */
18
+
19
+ export function applyMove({ store, config, id, column, beforeId, afterId, source = 'manual', detail = '' }) {
20
+ const before = store.getTask(id)
21
+ if (before === undefined) return { error: 'task-not-found', status: 404 }
22
+ if (column !== undefined && column !== null) {
23
+ const allowed = columnsOf(store.boardKind(before.board))
24
+ if (!allowed.includes(column)) {
25
+ return { error: 'invalid-column', status: 400 }
26
+ }
27
+ if (column === 'in-progress' && before.column !== 'in-progress') {
28
+ const moveCheck = checkMoveAllowed(before, column, store)
29
+ if (!moveCheck.allowed) {
30
+ return {
31
+ error: 'task-blocked',
32
+ status: 400,
33
+ message: moveCheck.reason,
34
+ pendingBlockers: moveCheck.pendingBlockers,
35
+ }
36
+ }
37
+ }
38
+ }
39
+ const task = store.moveTask(id, { column, beforeId, afterId })
40
+ if (task.column !== before.column) {
41
+ store.addTransition({
42
+ taskId: id, fromCol: before.column, toCol: task.column, source, detail,
43
+ })
44
+ }
45
+ const limit = wipLimitField(config, task.column)
46
+ const count = store.countInColumn(task.board, task.column)
47
+ const unblocked = (task.column === 'done' && before.column !== 'done')
48
+ ? findUnblockedDependents(id, store, () => store.listTasks({ board: task.board }))
49
+ : []
50
+ return { task, overLimit: limit !== undefined && count > limit, unblocked }
51
+ }
52
+
53
+ /** Create local board task (not linked to Gitea). */
54
+ export function createTask({ store, input }) {
55
+ const title = typeof input?.title === 'string' ? input.title.trim() : ''
56
+ if (title === '') return { error: 'title-required', status: 400 }
57
+ const board = input?.board ?? 'main'
58
+ // Validate column against board kind; simple boards do not have deploy/cleanup.
59
+ const allowed = columnsOf(store.boardKind(board))
60
+ const column = allowed.includes(input?.column) ? input.column : 'backlog'
61
+ const task = store.createTask({
62
+ board,
63
+ column,
64
+ title,
65
+ body: typeof input?.body === 'string' ? input.body : '',
66
+ labels: Array.isArray(input?.labels) ? input.labels.filter((l) => typeof l === 'string') : [],
67
+ })
68
+ return { task }
69
+ }
70
+
71
+ /** Update card fields. Column transitions are handled via applyMove. */
72
+ export function updateTask({ store, id, input }) {
73
+ const current = store.getTask(id)
74
+ if (current === undefined) return { error: 'task-not-found', status: 404 }
75
+ const patch = {}
76
+ if (typeof input?.title === 'string' && input.title.trim() !== '') patch.title = input.title.trim()
77
+ if (typeof input?.body === 'string') patch.body = input.body
78
+ if (Array.isArray(input?.labels)) patch.labels = input.labels.filter((l) => typeof l === 'string')
79
+ // Priority is local to board and not synced back to Gitea.
80
+ if (PRIORITIES.includes(input?.priority)) patch.priority = input.priority
81
+ // Deadline is local. 0 clears deadline; positive value sets it.
82
+ if (typeof input?.dueAt === 'number' && Number.isFinite(input.dueAt) && input.dueAt >= 0) {
83
+ patch.dueAt = Math.floor(input.dueAt)
84
+ }
85
+ if (typeof input?.cron === 'string') {
86
+ patch.cron = input.cron.trim()
87
+ patch.nextRunAt = patch.cron ? (computeNextRun(patch.cron) || 0) : 0
88
+ }
89
+ if (Array.isArray(input?.checklist)) patch.checklist = input.checklist
90
+ if (input?.report && typeof input.report === 'object') patch.report = input.report
91
+ if (Array.isArray(input?.comments)) patch.comments = input.comments
92
+ if (typeof input?.rejectReason === 'string') patch.rejectReason = input.rejectReason
93
+ if (typeof input?.progressDump === 'string') {
94
+ patch.progressDump = input.progressDump
95
+ }
96
+ if (typeof input?.permission === 'string') {
97
+ patch.permission = input.permission
98
+ if (input.permission !== current.permission) {
99
+ patch.permissionConfirmedAt = 0
100
+ patch.permissionConfirmedTier = ''
101
+ }
102
+ }
103
+
104
+ if (typeof input?.reasoningEffort === 'string') {
105
+ const val = input.reasoningEffort.trim().toLowerCase()
106
+ if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
107
+ patch.reasoningEffort = val
108
+ }
109
+ }
110
+ if (typeof input?.forge === 'string') {
111
+ const val = input.forge.trim().toLowerCase()
112
+ if (val === 'github' || val === 'gitea') patch.forge = val
113
+ }
114
+ if (Array.isArray(input?.blockedBy)) {
115
+ const newBlockedBy = input.blockedBy.filter((b) => typeof b === 'string' && b.trim() !== '')
116
+ const check = validateDependencies(id, newBlockedBy, store)
117
+ if (!check.valid) {
118
+ return { error: 'circular-dependency', status: 400, message: check.error }
119
+ }
120
+ patch.blockedBy = newBlockedBy
121
+ }
122
+ if (typeof input?.inceptionSnapshotId === 'string') {
123
+ patch.inceptionSnapshotId = input.inceptionSnapshotId.trim()
124
+ }
125
+ return { task: store.updateTask(id, patch) }
126
+ }
127
+
128
+ export function confirmPermission({ store, id, tier }) {
129
+ const task = store.getTask(id)
130
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
131
+ const effectiveTier = typeof tier === 'string' && tier ? tier : (task.permission || 'default')
132
+ return { task: store.updateTask(id, { permissionConfirmedAt: Date.now(), permissionConfirmedTier: effectiveTier }) }
133
+ }
134
+
135
+ /** Known priority values. Empty string indicates unprioritized. */
136
+ const PRIORITIES = ['', 'high', 'medium', 'low']
137
+
138
+ /**
139
+ * Append note to task body.
140
+ * Handled on server to prevent overwriting edits made elsewhere.
141
+ */
142
+ export function appendNote({ store, id, input }) {
143
+ const task = store.getTask(id)
144
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
145
+ const note = typeof input?.text === 'string' ? input.text.trim() : ''
146
+ if (note === '') return { error: 'note-required', status: 400 }
147
+ const body = typeof task.body === 'string' ? task.body : ''
148
+ return { task: store.updateTask(id, { body: body === '' ? note : `${body}\n\n${note}` }) }
149
+ }
150
+
151
+ /**
152
+ * Archive or unarchive card.
153
+ * Unarchiving restores card to its original column and position.
154
+ */
155
+ export function setArchived({ store, id, archived, now = Date.now() }) {
156
+ const task = store.getTask(id)
157
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
158
+ return { task: store.setArchived(id, archived ? now : 0) }
159
+ }
160
+
161
+ /** Query archived tasks list. */
162
+ export function listArchive({ store }) {
163
+ return { tasks: store.listArchived() }
164
+ }
165
+
166
+ /**
167
+ * Assign task or clear assignment.
168
+ * Empty login unassigns the task.
169
+ *
170
+ * @returns {{task: object}|{error: string, status: number}}
171
+ */
172
+ export function assignTask({ store, id, login }) {
173
+ const task = store.getTask(id)
174
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
175
+ const who = typeof login === 'string' ? login.trim() : ''
176
+ if (who === (task.assignee ?? '')) return { task }
177
+ store.updateTask(id, { assignee: who })
178
+ store.addTransition({
179
+ taskId: id,
180
+ fromCol: task.column,
181
+ toCol: task.column,
182
+ source: 'manual',
183
+ detail: who === '' ? 'unassigned' : `assigned: ${who}`,
184
+ })
185
+ return { task: store.getTask(id) }
186
+ }
187
+
188
+ export function deleteTask({ store, id }) {
189
+ if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
190
+ store.deleteTask(id)
191
+ return { ok: true }
192
+ }
193
+
194
+ /**
195
+ * Parse task markdown body into structured block elements.
196
+ * Done on server so client bundle does not need markdown parser.
197
+ */
198
+ /**
199
+ * Board metrics: WIP distribution and stale tasks.
200
+ *
201
+ * @returns {{columns: object[], done: object, stale: object[], tasks: number}}
202
+ */
203
+
204
+ export function canRevive(task, live) {
205
+ if (task?.stalled) return true
206
+ const has = typeof task?.sessionId === 'string' && task.sessionId !== ''
207
+ return has && (live === undefined || live === null)
208
+ }
209
+
210
+ export function taskBody({ store, id }) {
211
+ const task = store.getTask(id)
212
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
213
+ return { blocks: parseBody(task.body) }
214
+ }
215
+
216
+ export function taskLog({ store, id }) {
217
+ if (store.getTask(id) === undefined) return { error: 'task-not-found', status: 404 }
218
+ return { transitions: store.listTransitions(id) }
219
+ }
220
+
221
+ /** Find task associated with session; absence is normal response. */
222
+ export function taskBySession({ store, sessionId, agents }) {
223
+ const task = store.findTaskBySession(sessionId)
224
+ if (task === undefined) return { task: null }
225
+ let live
226
+ try { live = agents?.get?.(sessionId) } catch { live = undefined }
227
+ // Board kind returned with task so UI displays matching column choices.
228
+ return {
229
+ task,
230
+ columns: columnsOf(store.boardKind(task.board)),
231
+ canRevive: canRevive(task, live),
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Export full board snapshot for backup or migration.
237
+ */