@goodandready/dsh-kanban 0.2.33 → 0.2.35
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/client.js +170 -379
- package/lib/config.js +31 -1
- package/lib/index.js +120 -1205
- package/lib/remote-relay.js +149 -0
- package/lib/remote-runner.js +164 -0
- package/lib/routes/board.js +282 -0
- package/lib/routes/security.js +274 -0
- package/lib/routes/task-actions.js +345 -0
- package/lib/routes/task-worktree.js +107 -0
- package/lib/routes/task.js +211 -0
- package/lib/routes.js +71 -1206
- package/lib/server/route-handlers.js +367 -0
- package/lib/server/services.js +225 -0
- package/lib/server/task-handler.js +530 -0
- package/lib/server/timers.js +204 -0
- package/lib/store.js +9 -2
- package/lib/watchdog.js +169 -0
- package/lib/worktree.js +35 -2
- package/package.json +1 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const MAX_BODY_BYTES = 256 * 1024
|
|
4
|
+
|
|
5
|
+
export function isLoopbackAddress(addr) {
|
|
6
|
+
if (!addr || typeof addr !== 'string') return false
|
|
7
|
+
const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
|
|
8
|
+
return (
|
|
9
|
+
clean === '127.0.0.1' ||
|
|
10
|
+
clean === '::1' ||
|
|
11
|
+
clean === 'localhost' ||
|
|
12
|
+
clean === 'localhost.' ||
|
|
13
|
+
clean.startsWith('127.')
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isLanAddress(addr) {
|
|
18
|
+
if (!addr || typeof addr !== 'string') return false
|
|
19
|
+
const clean = addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
|
|
20
|
+
if (isLoopbackAddress(clean)) return true
|
|
21
|
+
if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
|
|
22
|
+
const m = clean.match(/^172\.(\d+)\./)
|
|
23
|
+
if (m) {
|
|
24
|
+
const octet = parseInt(m[1], 10)
|
|
25
|
+
if (octet >= 16 && octet <= 31) return true
|
|
26
|
+
}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isTrustedHost(host) {
|
|
31
|
+
if (!host || typeof host !== 'string') return false
|
|
32
|
+
let hostname = host.trim()
|
|
33
|
+
if (hostname.startsWith('[')) {
|
|
34
|
+
const end = hostname.indexOf(']')
|
|
35
|
+
if (end > 0) hostname = hostname.slice(1, end)
|
|
36
|
+
} else {
|
|
37
|
+
hostname = hostname.split(':')[0]
|
|
38
|
+
}
|
|
39
|
+
hostname = hostname.toLowerCase()
|
|
40
|
+
if (!hostname) return false
|
|
41
|
+
if (hostname === 'localhost' || hostname === 'localhost.' || hostname.endsWith('.local')) return true
|
|
42
|
+
if (isLoopbackAddress(hostname) || isLanAddress(hostname)) return true
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeIp(addr) {
|
|
47
|
+
if (!addr || typeof addr !== 'string') return ''
|
|
48
|
+
return addr.toLowerCase().replace(/^::ffff:/, '').replace(/^\[|\]$/g, '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function cookieNameForAuthority(authority) {
|
|
52
|
+
if (!authority || typeof authority !== 'string') return ''
|
|
53
|
+
const hash = crypto.createHash('sha256').update(authority.trim()).digest('base64url')
|
|
54
|
+
return 'dsh-auth-' + hash
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isLoopbackCookieName(name, host = '') {
|
|
58
|
+
if (!name || typeof name !== 'string' || !name.startsWith('dsh-auth-')) return false
|
|
59
|
+
const authorities = [
|
|
60
|
+
host,
|
|
61
|
+
'127.0.0.1:3000',
|
|
62
|
+
'127.0.0.1:3080',
|
|
63
|
+
'127.0.0.1:3082',
|
|
64
|
+
'127.0.0.1',
|
|
65
|
+
'localhost:3000',
|
|
66
|
+
'localhost:3080',
|
|
67
|
+
'localhost:3082',
|
|
68
|
+
'localhost',
|
|
69
|
+
'[::1]:3000',
|
|
70
|
+
'[::1]',
|
|
71
|
+
]
|
|
72
|
+
for (const auth of authorities) {
|
|
73
|
+
if (auth && isLoopbackAddress(auth.split(':')[0])) {
|
|
74
|
+
if (cookieNameForAuthority(auth) === name) return true
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function hasClientCredentials(req) {
|
|
81
|
+
if (!req || !req.headers) return false
|
|
82
|
+
if (req.headers.authorization) return true
|
|
83
|
+
if (req.headers['x-dsh-lan-pin']) return true
|
|
84
|
+
const cookie = req.headers.cookie
|
|
85
|
+
if (!cookie || typeof cookie !== 'string') return false
|
|
86
|
+
if (cookie.includes('dsh_token=') || cookie.includes('dsh_lan_pin=')) return true
|
|
87
|
+
|
|
88
|
+
const parts = cookie.split(';')
|
|
89
|
+
for (const part of parts) {
|
|
90
|
+
const trimmed = part.trim()
|
|
91
|
+
const eqIdx = trimmed.indexOf('=')
|
|
92
|
+
if (eqIdx === -1) continue
|
|
93
|
+
const name = trimmed.slice(0, eqIdx).trim()
|
|
94
|
+
if (name.startsWith('dsh-auth-')) {
|
|
95
|
+
if (!isLoopbackCookieName(name, req.headers.host)) {
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getClientIp(req) {
|
|
104
|
+
const socketAddress =
|
|
105
|
+
req?.socket?.remoteAddress ||
|
|
106
|
+
req?.connection?.remoteAddress ||
|
|
107
|
+
req?.info?.remoteAddress ||
|
|
108
|
+
''
|
|
109
|
+
const socketIp = normalizeIp(socketAddress)
|
|
110
|
+
const xff = req?.headers?.['x-forwarded-for']
|
|
111
|
+
if (xff && typeof xff === 'string' && (isLoopbackAddress(socketIp) || !socketIp)) {
|
|
112
|
+
const first = xff.split(',')[0].trim()
|
|
113
|
+
if (first) {
|
|
114
|
+
return normalizeIp(first)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return socketIp
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Cross-site request, DNS rebinding, and authentication validation (#261, #369, #384).
|
|
122
|
+
*
|
|
123
|
+
* Requirements:
|
|
124
|
+
* 1. If DSH connection service is available (options.connection), requestRejection(req)
|
|
125
|
+
* is the authoritative gate for browser session authentication and trusted network access.
|
|
126
|
+
* If rejection is returned, the numeric status (e.g. 401 or 403) is returned.
|
|
127
|
+
* 2. Host header is required and must target a trusted host (loopback, LAN, .local).
|
|
128
|
+
* 3. Cross-site requests ('sec-fetch-site: cross-site' or origin mismatch) are rejected with 403.
|
|
129
|
+
* 4. Origin & Referer headers (if present) must match Host or a trusted host.
|
|
130
|
+
* 5. Network boundary validation:
|
|
131
|
+
* - Loopback callers (127.0.0.1, ::1) are allowed.
|
|
132
|
+
* - Non-loopback / LAN callers require valid connection authentication (fail-closed, return 401).
|
|
133
|
+
* - Unknown remote address (socket present but empty remoteAddress) fails closed with 403.
|
|
134
|
+
*
|
|
135
|
+
* @param {import('node:http').IncomingMessage} req
|
|
136
|
+
* @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
|
|
137
|
+
* @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
|
|
138
|
+
*/
|
|
139
|
+
export function getRejectionStatus(req, options = {}) {
|
|
140
|
+
const connection = options?.connection
|
|
141
|
+
if (connection && typeof connection.requestRejection === 'function') {
|
|
142
|
+
try {
|
|
143
|
+
const rejection = connection.requestRejection(req)
|
|
144
|
+
if (rejection !== undefined) {
|
|
145
|
+
if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
|
|
146
|
+
return rejection
|
|
147
|
+
}
|
|
148
|
+
return rejection === 401 ? 401 : 403
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// Fail closed if connection service throws
|
|
152
|
+
return 403
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!req || !req.headers) return 403
|
|
157
|
+
|
|
158
|
+
const host = req.headers.host
|
|
159
|
+
if (!host || !isTrustedHost(host)) {
|
|
160
|
+
return 403
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const site = req.headers['sec-fetch-site']
|
|
164
|
+
|
|
165
|
+
// 1. Explicit cross-site or same-site requests are rejected
|
|
166
|
+
if (site === 'cross-site' || site === 'same-site') {
|
|
167
|
+
return 403
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 2. Validate origin against trusted hosts and host header
|
|
171
|
+
const origin = req.headers.origin
|
|
172
|
+
if (origin !== undefined) {
|
|
173
|
+
if (origin === 'null' || !origin) return 403
|
|
174
|
+
try {
|
|
175
|
+
const parsed = new URL(origin)
|
|
176
|
+
if (!isTrustedHost(parsed.hostname)) return 403
|
|
177
|
+
if (parsed.host && parsed.host !== host) return 403
|
|
178
|
+
} catch {
|
|
179
|
+
return 403
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3. Validate referer against trusted hosts and host header if present
|
|
184
|
+
const referer = req.headers.referer
|
|
185
|
+
if (referer !== undefined && referer) {
|
|
186
|
+
try {
|
|
187
|
+
const parsedRef = new URL(referer)
|
|
188
|
+
if (!isTrustedHost(parsedRef.hostname)) return 403
|
|
189
|
+
if (parsedRef.host && parsedRef.host !== host) return 403
|
|
190
|
+
} catch {
|
|
191
|
+
return 403
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 4. Validate remote address and network boundary
|
|
196
|
+
const hasSocket = !!(req.socket || req.connection)
|
|
197
|
+
const remote = getClientIp(req)
|
|
198
|
+
|
|
199
|
+
if (hasSocket) {
|
|
200
|
+
if (!remote) {
|
|
201
|
+
return 403
|
|
202
|
+
}
|
|
203
|
+
if (!isLoopbackAddress(remote) && !isLanAddress(remote)) {
|
|
204
|
+
return 403
|
|
205
|
+
}
|
|
206
|
+
// Remote / LAN callers without connection authentication are rejected (#384)
|
|
207
|
+
if (!isLoopbackAddress(remote)) {
|
|
208
|
+
if (!connection) {
|
|
209
|
+
return 401
|
|
210
|
+
}
|
|
211
|
+
// If request was forwarded through a reverse proxy (x-forwarded-for),
|
|
212
|
+
// the proxy may have injected loopback credentials. Validate that the client
|
|
213
|
+
// itself supplied authentic credentials (bearer token, PIN, or client session cookie).
|
|
214
|
+
const xff = req.headers['x-forwarded-for']
|
|
215
|
+
if (xff && !hasClientCredentials(req)) {
|
|
216
|
+
return 401
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
// Synthetic mock request without socket
|
|
221
|
+
const xff = req.headers['x-forwarded-for']
|
|
222
|
+
if (xff) {
|
|
223
|
+
const first = xff.split(',')[0].trim()
|
|
224
|
+
if (first && !isLoopbackAddress(normalizeIp(first))) {
|
|
225
|
+
if (!connection || !hasClientCredentials(req)) {
|
|
226
|
+
return 401
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} else if (!connection) {
|
|
230
|
+
const hostname = String(host).split(':')[0].toLowerCase()
|
|
231
|
+
if (!isLoopbackAddress(hostname) && hostname !== 'localhost') {
|
|
232
|
+
return 401
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 5. Browser request with declared site:
|
|
238
|
+
// Must be same-origin or none
|
|
239
|
+
if (site && site !== 'same-origin' && site !== 'none') {
|
|
240
|
+
return 403
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 6. Request without declared site from non-loopback requires connection
|
|
244
|
+
if (!site && remote && !isLoopbackAddress(remote) && !connection) {
|
|
245
|
+
return 401
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function isTrustedRequest(req, options = {}) {
|
|
252
|
+
return getRejectionStatus(req, options) === undefined
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function rejectUntrustedRequest(req, res, options = {}) {
|
|
256
|
+
const status = getRejectionStatus(req, options)
|
|
257
|
+
if (status !== undefined) {
|
|
258
|
+
try {
|
|
259
|
+
res.writeHead(status, {
|
|
260
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
261
|
+
'Cache-Control': 'no-store',
|
|
262
|
+
})
|
|
263
|
+
res.end(
|
|
264
|
+
JSON.stringify({
|
|
265
|
+
error: status === 401 ? 'unauthorized' : 'cross-site',
|
|
266
|
+
})
|
|
267
|
+
)
|
|
268
|
+
} catch {
|
|
269
|
+
/* socket closed */
|
|
270
|
+
}
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended actions for task route handlers (comments, reports, checklist, triage, subtasks, PR, nudge).
|
|
3
|
+
* Zero Cyrillic in lib/ according to Issue #366.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function handleTaskChecklist({ store, task, input }) {
|
|
7
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
8
|
+
const list = Array.isArray(input?.checklist) ? input.checklist : Array.isArray(input) ? input : []
|
|
9
|
+
const updated = store.updateChecklist(task.id, list)
|
|
10
|
+
return { status: 200, body: { task: updated } }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Set completion report for task.
|
|
15
|
+
*/
|
|
16
|
+
export function handleTaskReport({ store, task, input }) {
|
|
17
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
18
|
+
const updated = store.setReport(task.id, input)
|
|
19
|
+
return { status: 200, body: { task: updated } }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get comments for task.
|
|
24
|
+
*/
|
|
25
|
+
export function handleGetTaskComments({ task }) {
|
|
26
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
27
|
+
return { status: 200, body: { comments: task.comments || [] } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Add comment to task.
|
|
32
|
+
*/
|
|
33
|
+
export function handleAddTaskComment({ store, task, input }) {
|
|
34
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
35
|
+
const author = input?.author || 'user'
|
|
36
|
+
const role = input?.role || 'user'
|
|
37
|
+
const text = input?.text || ''
|
|
38
|
+
const updated = store.addComment(task.id, { author, role, text })
|
|
39
|
+
return { status: 200, body: { task: updated } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Accept task review (transition to done).
|
|
44
|
+
*/
|
|
45
|
+
export function handleAcceptTask({ store, task }) {
|
|
46
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
47
|
+
const updated = store.acceptTask(task.id)
|
|
48
|
+
return { status: 200, body: { task: updated } }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reject task review (return to in_progress).
|
|
53
|
+
*/
|
|
54
|
+
export function handleRejectTask({ store, task, input }) {
|
|
55
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
56
|
+
const reason = String(input?.reason || '').trim()
|
|
57
|
+
if (!reason) return { status: 400, body: { error: 'reason-required' } }
|
|
58
|
+
const updated = store.rejectTask(task.id, reason)
|
|
59
|
+
return { status: 200, body: { task: updated } }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Save task progress dump snapshot.
|
|
64
|
+
*/
|
|
65
|
+
export function handleProgressDump({ store, task, input, formatProgressDump }) {
|
|
66
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
67
|
+
const formatted = typeof input === 'string' ? input : formatProgressDump(input)
|
|
68
|
+
const updated = store.updateTask(task.id, { progressDump: formatted })
|
|
69
|
+
return { status: 200, body: { task: updated } }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decompose a task into subtasks and make the parent an Epic.
|
|
74
|
+
*/
|
|
75
|
+
export async function handleTriageTask({ store, gitea, task, input }) {
|
|
76
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
77
|
+
const subtasksInput = Array.isArray(input?.subtasks) ? input.subtasks : []
|
|
78
|
+
if (subtasksInput.length === 0) {
|
|
79
|
+
return { status: 400, body: { error: 'subtasks-required' } }
|
|
80
|
+
}
|
|
81
|
+
const createGitea = input?.createGiteaIssues === true && Boolean(task.owner && task.repo && gitea)
|
|
82
|
+
|
|
83
|
+
// Convert parent into Epic
|
|
84
|
+
store.updateTask(task.id, { isEpic: true })
|
|
85
|
+
|
|
86
|
+
const created = []
|
|
87
|
+
for (const item of subtasksInput) {
|
|
88
|
+
const title = String(item?.title ?? '').trim()
|
|
89
|
+
if (!title) continue
|
|
90
|
+
const body = String(item?.body ?? '').trim()
|
|
91
|
+
let issueNumber = null
|
|
92
|
+
let issueUrl = null
|
|
93
|
+
|
|
94
|
+
if (createGitea) {
|
|
95
|
+
try {
|
|
96
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
97
|
+
const giteaIssue = await gitea.createIssue({
|
|
98
|
+
owner: task.owner,
|
|
99
|
+
repo: task.repo,
|
|
100
|
+
title,
|
|
101
|
+
body: issueBody,
|
|
102
|
+
})
|
|
103
|
+
if (giteaIssue?.number) {
|
|
104
|
+
issueNumber = giteaIssue.number
|
|
105
|
+
issueUrl = giteaIssue.html_url || null
|
|
106
|
+
}
|
|
107
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sub = store.createTask({
|
|
111
|
+
board: task.board || 'main',
|
|
112
|
+
column: 'backlog',
|
|
113
|
+
title,
|
|
114
|
+
body,
|
|
115
|
+
parentId: task.id,
|
|
116
|
+
owner: task.owner || null,
|
|
117
|
+
repo: task.repo || null,
|
|
118
|
+
issueNumber,
|
|
119
|
+
issueUrl,
|
|
120
|
+
labels: Array.isArray(item?.labels) ? item.labels : [],
|
|
121
|
+
workspaceId: task.workspaceId || '',
|
|
122
|
+
})
|
|
123
|
+
created.push(sub)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
store.addTransition({
|
|
127
|
+
taskId: task.id,
|
|
128
|
+
fromCol: task.column,
|
|
129
|
+
toCol: task.column,
|
|
130
|
+
source: 'manual',
|
|
131
|
+
detail: `decomposed into ${created.length} subtasks (epic triage)`,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
status: 200,
|
|
136
|
+
body: {
|
|
137
|
+
epic: store.getTask(task.id),
|
|
138
|
+
subtasks: store.listSubtasks(task.id),
|
|
139
|
+
counts: store.countSubtasks(task.id),
|
|
140
|
+
createdCount: created.length,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get subtasks of a task.
|
|
147
|
+
*/
|
|
148
|
+
export function handleGetSubtasks({ store, task }) {
|
|
149
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
150
|
+
return {
|
|
151
|
+
status: 200,
|
|
152
|
+
body: {
|
|
153
|
+
subtasks: store.listSubtasks(task.id),
|
|
154
|
+
counts: store.countSubtasks(task.id),
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create a single subtask linked to a parent task.
|
|
161
|
+
*/
|
|
162
|
+
export async function handleCreateSubtask({ store, gitea, task, input }) {
|
|
163
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
164
|
+
const title = String(input?.title ?? '').trim()
|
|
165
|
+
if (!title) return { status: 400, body: { error: 'title-required' } }
|
|
166
|
+
const body = String(input?.body ?? '').trim()
|
|
167
|
+
let issueNumber = null
|
|
168
|
+
let issueUrl = null
|
|
169
|
+
|
|
170
|
+
if (input?.createGiteaIssue === true && task.owner && task.repo && gitea) {
|
|
171
|
+
try {
|
|
172
|
+
const issueBody = body ? `${body}\n\nParent epic: #${task.issueNumber || task.id}` : `Parent epic: #${task.issueNumber || task.id}`
|
|
173
|
+
const giteaIssue = await gitea.createIssue({
|
|
174
|
+
owner: task.owner,
|
|
175
|
+
repo: task.repo,
|
|
176
|
+
title,
|
|
177
|
+
body: issueBody,
|
|
178
|
+
})
|
|
179
|
+
if (giteaIssue?.number) {
|
|
180
|
+
issueNumber = giteaIssue.number
|
|
181
|
+
issueUrl = giteaIssue.html_url || null
|
|
182
|
+
}
|
|
183
|
+
} catch { /* ignore gitea subtask creation failure */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
store.updateTask(task.id, { isEpic: true })
|
|
187
|
+
const sub = store.createTask({
|
|
188
|
+
board: task.board || 'main',
|
|
189
|
+
column: input?.column || 'backlog',
|
|
190
|
+
title,
|
|
191
|
+
body,
|
|
192
|
+
parentId: task.id,
|
|
193
|
+
owner: task.owner || null,
|
|
194
|
+
repo: task.repo || null,
|
|
195
|
+
issueNumber,
|
|
196
|
+
issueUrl,
|
|
197
|
+
labels: Array.isArray(input?.labels) ? input.labels : [],
|
|
198
|
+
workspaceId: task.workspaceId || '',
|
|
199
|
+
})
|
|
200
|
+
return { status: 200, body: { subtask: sub, counts: store.countSubtasks(task.id) } }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Toggle a checklist item atomically by index or text.
|
|
205
|
+
*/
|
|
206
|
+
export function handleToggleChecklistItem({ store, task, input }) {
|
|
207
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
208
|
+
const current = Array.isArray(task.checklist) ? [...task.checklist] : []
|
|
209
|
+
let targetIndex = -1
|
|
210
|
+
|
|
211
|
+
if (typeof input?.index === 'number' && input.index >= 0 && input.index < current.length) {
|
|
212
|
+
targetIndex = input.index
|
|
213
|
+
} else if (typeof input?.text === 'string' && input.text.trim()) {
|
|
214
|
+
const q = input.text.trim().toLowerCase()
|
|
215
|
+
targetIndex = current.findIndex((item) => {
|
|
216
|
+
const t = typeof item === 'string' ? item : item?.text
|
|
217
|
+
return typeof t === 'string' && t.toLowerCase().includes(q)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (targetIndex === -1) {
|
|
222
|
+
return { status: 404, body: { error: 'checklist-item-not-found' } }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const item = current[targetIndex]
|
|
226
|
+
let updatedItem
|
|
227
|
+
if (typeof item === 'string') {
|
|
228
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : true
|
|
229
|
+
updatedItem = { text: item, completed: checked, done: checked }
|
|
230
|
+
} else {
|
|
231
|
+
const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
|
|
232
|
+
const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
|
|
233
|
+
updatedItem = { ...item, completed: checked, done: checked }
|
|
234
|
+
}
|
|
235
|
+
current[targetIndex] = updatedItem
|
|
236
|
+
const updated = store.updateChecklist(task.id, current)
|
|
237
|
+
return { status: 200, body: { task: updated, item: updatedItem, index: targetIndex } }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Create a Gitea Pull Request for a task.
|
|
242
|
+
*/
|
|
243
|
+
export async function handleCreateTaskPr({ store, gitea, task, input }) {
|
|
244
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
245
|
+
if (!task.owner || !task.repo) {
|
|
246
|
+
return { status: 400, body: { error: 'task-no-repo' } }
|
|
247
|
+
}
|
|
248
|
+
const head = String(input?.head || task.branch || '').trim()
|
|
249
|
+
if (!head) return { status: 400, body: { error: 'branch-required' } }
|
|
250
|
+
const base = String(input?.base || 'main').trim()
|
|
251
|
+
const title = String(input?.title || task.title || `PR: ${task.title}`).trim()
|
|
252
|
+
const body = String(input?.body || (task.issueNumber ? `Closes #${task.issueNumber}` : '')).trim()
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const pr = await gitea.createPullRequest({
|
|
256
|
+
owner: task.owner,
|
|
257
|
+
repo: task.repo,
|
|
258
|
+
title,
|
|
259
|
+
body,
|
|
260
|
+
head,
|
|
261
|
+
base,
|
|
262
|
+
})
|
|
263
|
+
store.addTransition({
|
|
264
|
+
taskId: task.id,
|
|
265
|
+
fromCol: task.column,
|
|
266
|
+
toCol: task.column,
|
|
267
|
+
source: 'manual',
|
|
268
|
+
detail: `Created PR #${pr?.number || ''}: ${head} -> ${base}`,
|
|
269
|
+
})
|
|
270
|
+
return { status: 200, body: { pr, prUrl: pr?.html_url } }
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Handle interactive live steering (nudge) for an active task agent (#373).
|
|
277
|
+
*
|
|
278
|
+
* @param {{
|
|
279
|
+
* store: object,
|
|
280
|
+
* id: string,
|
|
281
|
+
* prompt: string,
|
|
282
|
+
* agents?: object,
|
|
283
|
+
* createMessage?: Function,
|
|
284
|
+
* eventHub?: object,
|
|
285
|
+
* now?: number
|
|
286
|
+
* }} options
|
|
287
|
+
* @returns {Promise<{ status: number, body: object }>}
|
|
288
|
+
*/
|
|
289
|
+
export async function handleTaskNudge({ store, id, prompt, agents, createMessage, eventHub, now = Date.now() }) {
|
|
290
|
+
if (!store || !id) return { status: 400, body: { error: 'invalid-parameters' } }
|
|
291
|
+
const task = store.getTask(id)
|
|
292
|
+
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
293
|
+
|
|
294
|
+
const text = typeof prompt === 'string' ? prompt.trim() : ''
|
|
295
|
+
if (!text) return { status: 400, body: { error: 'prompt-required' } }
|
|
296
|
+
|
|
297
|
+
const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
|
|
298
|
+
if (!sessionId) return { status: 409, body: { error: 'no-session' } }
|
|
299
|
+
|
|
300
|
+
let agent
|
|
301
|
+
try { agent = agents?.get?.(sessionId) } catch { agent = undefined }
|
|
302
|
+
if (!agent || typeof agent.followup !== 'function') {
|
|
303
|
+
return { status: 409, body: { error: 'session-not-live' } }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const msg = typeof createMessage === 'function'
|
|
307
|
+
? createMessage({
|
|
308
|
+
content: [{ type: 'text', text }],
|
|
309
|
+
source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'task-nudge' },
|
|
310
|
+
})
|
|
311
|
+
: { content: [{ type: 'text', text }] }
|
|
312
|
+
|
|
313
|
+
agent.followup(msg)
|
|
314
|
+
|
|
315
|
+
// Append user steering comment to task
|
|
316
|
+
if (typeof store.addComment === 'function') {
|
|
317
|
+
store.addComment(id, {
|
|
318
|
+
author: 'user',
|
|
319
|
+
role: 'user',
|
|
320
|
+
text: `[Steering Nudge] ${text}`,
|
|
321
|
+
})
|
|
322
|
+
} else if (typeof store.addTaskComment === 'function') {
|
|
323
|
+
store.addTaskComment(id, {
|
|
324
|
+
author: 'user',
|
|
325
|
+
role: 'user',
|
|
326
|
+
text: `[Steering Nudge] ${text}`,
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Broadcast change via eventHub
|
|
331
|
+
if (eventHub && typeof eventHub.broadcast === 'function') {
|
|
332
|
+
eventHub.broadcast('task:change', { id, col: task.col || task.column })
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
status: 200,
|
|
337
|
+
body: {
|
|
338
|
+
success: true,
|
|
339
|
+
id,
|
|
340
|
+
sessionId,
|
|
341
|
+
prompt: text,
|
|
342
|
+
nudgedAt: now,
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
}
|