@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/gitea.js
CHANGED
|
@@ -36,7 +36,14 @@ export function isCredentialName(value) {
|
|
|
36
36
|
|
|
37
37
|
/** Check if value looks like a raw token secret rather than a credential reference name. */
|
|
38
38
|
export function looksLikeSecret(value) {
|
|
39
|
-
|
|
39
|
+
const str = String(value || '').trim()
|
|
40
|
+
if (str === '') return false
|
|
41
|
+
if (/^(ghp_|gho_|github_pat_|glpat-|bearer\s+|token\s+)/i.test(str)) return true
|
|
42
|
+
if (/^[0-9a-f]{40}$/i.test(str)) return true
|
|
43
|
+
if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(str)) return true
|
|
44
|
+
// Raw alphanumeric token >= 32 characters without underscores
|
|
45
|
+
if (/^[A-Za-z0-9]{32,}$/.test(str)) return true
|
|
46
|
+
return false
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
/**
|
package/lib/github.js
CHANGED
|
@@ -13,12 +13,18 @@ export function normalizeGithubUrl(raw) {
|
|
|
13
13
|
return match ? match[0].replace(/\/+$/, '') : DEFAULT_BASE_URL
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export function safeIssueIndex(value) {
|
|
17
|
+
const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? '').trim(), 10)
|
|
18
|
+
if (!Number.isSafeInteger(n) || n <= 0) return undefined
|
|
19
|
+
if (typeof value === 'string' && String(n) !== value.trim()) return undefined
|
|
20
|
+
return n
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
export function safeSegment(value) {
|
|
17
24
|
const s = String(value ?? '').trim()
|
|
18
|
-
if (!s || s.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return s
|
|
25
|
+
if (!s || s === '.' || s === '..') return undefined
|
|
26
|
+
if (!/^[A-Za-z0-9._-]+$/.test(s)) return undefined
|
|
27
|
+
return encodeURIComponent(s)
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export function isCredentialName(value) {
|
|
@@ -170,18 +176,28 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
|
|
|
170
176
|
|
|
171
177
|
/** Get single issue by number. */
|
|
172
178
|
async getIssue({ owner, repo, index }) {
|
|
173
|
-
const n =
|
|
179
|
+
const n = safeIssueIndex(index)
|
|
174
180
|
if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
|
|
175
181
|
return request('GET', repoPath(owner, repo, `/issues/${n}`))
|
|
176
182
|
},
|
|
177
183
|
|
|
178
184
|
/** Post comment on issue. */
|
|
179
185
|
async comment({ owner, repo, index, body }) {
|
|
180
|
-
const n =
|
|
186
|
+
const n = safeIssueIndex(index)
|
|
181
187
|
if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
|
|
182
188
|
return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
|
|
183
189
|
},
|
|
184
190
|
|
|
191
|
+
/** Create private, empty repository in organization or user account. */
|
|
192
|
+
async createRepo({ owner, name, description = '' }) {
|
|
193
|
+
const safe = safeSegment(name)
|
|
194
|
+
if (safe === undefined) throw Object.assign(new Error('invalid name'), { code: 'bad-segment' })
|
|
195
|
+
const path = owner ? `/orgs/${safeSegment(owner)}/repos` : '/user/repos'
|
|
196
|
+
return request('POST', path, {
|
|
197
|
+
name: safe, description, private: true, auto_init: false,
|
|
198
|
+
})
|
|
199
|
+
},
|
|
200
|
+
|
|
185
201
|
/** Create new issue. */
|
|
186
202
|
async createIssue({ owner, repo, title, body }) {
|
|
187
203
|
const name = String(title ?? '').trim()
|
package/lib/import.js
CHANGED
|
@@ -152,24 +152,24 @@ export async function giteaState(gitea) {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
/** List open issues for import dialog, marking already-imported ones. */
|
|
155
|
-
export async function listImportable({ gitea, store, owner, repo, board = 'main' }) {
|
|
155
|
+
export async function listImportable({ gitea, store, owner, repo, board = 'main', provider, forge }) {
|
|
156
156
|
const state = await giteaState(gitea)
|
|
157
157
|
if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
|
|
158
|
-
const issues = await gitea.listIssues({ owner, repo, state: 'open' })
|
|
158
|
+
const issues = await gitea.listIssues({ owner, repo, state: 'open', provider: forge || provider })
|
|
159
159
|
const existing = store.listTasks({ board })
|
|
160
160
|
return { issues: markImported(issues, existing, { owner, repo }) }
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/** Create a task from an issue. */
|
|
164
|
-
export async function importIssue({ gitea, store, owner, repo, issueNumber, board = 'main', column = 'backlog' }) {
|
|
164
|
+
export async function importIssue({ gitea, store, owner, repo, issueNumber, board = 'main', column = 'backlog', provider, forge }) {
|
|
165
165
|
const state = await giteaState(gitea)
|
|
166
166
|
if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
|
|
167
167
|
if (typeof issueNumber !== 'number') return { error: 'issue-number-required', status: 400 }
|
|
168
168
|
// Simple board is for non-issue tasks. Importing here would bring branches/PRs into columns unable to display them.
|
|
169
169
|
if (store.boardKind(board) !== 'project') return { error: 'board-not-for-issues', status: 400 }
|
|
170
|
-
const issue = await gitea.getIssue({ owner, repo, index: issueNumber })
|
|
170
|
+
const issue = await gitea.getIssue({ owner, repo, index: issueNumber, provider: forge || provider })
|
|
171
171
|
if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
|
|
172
|
-
return { task: store.createTask(issueToTask(issue, { board, column, owner, repo })) }
|
|
172
|
+
return { task: store.createTask(issueToTask(issue, { board, column, owner, repo, forge: forge || provider })) }
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
/** Re-read issue and update task, preserving local state. */
|
|
@@ -179,7 +179,8 @@ export async function refreshTask({ gitea, store, id }) {
|
|
|
179
179
|
const task = store.getTask(id)
|
|
180
180
|
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
181
181
|
if (typeof task.issueNumber !== 'number') return { error: 'task-has-no-issue', status: 400 }
|
|
182
|
-
const
|
|
182
|
+
const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
|
|
183
|
+
const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber, provider: forge, issueUrl: task.issueUrl })
|
|
183
184
|
if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
|
|
184
185
|
return { task: store.updateTask(id, refreshPatch(task, issue)) }
|
|
185
186
|
}
|
|
@@ -199,7 +200,7 @@ export async function searchRepos({ gitea, query, limit = 20 }) {
|
|
|
199
200
|
* Failure midway avoids leaving orphaned cards.
|
|
200
201
|
*/
|
|
201
202
|
export async function createProjectTask({
|
|
202
|
-
gitea, store, owner, repo, newRepo, title, body, board = 'main',
|
|
203
|
+
gitea, store, owner, repo, newRepo, title, body, board = 'main', provider, forge,
|
|
203
204
|
}) {
|
|
204
205
|
const state = await giteaState(gitea)
|
|
205
206
|
if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
|
|
@@ -214,7 +215,7 @@ export async function createProjectTask({
|
|
|
214
215
|
// Validate repository name before API call.
|
|
215
216
|
if (safeSegment(wanted) === undefined) return { error: 'bad-repo-name', status: 400 }
|
|
216
217
|
try {
|
|
217
|
-
await gitea.createRepo({ owner, name: wanted })
|
|
218
|
+
await gitea.createRepo({ owner, name: wanted, provider: forge || provider })
|
|
218
219
|
} catch (error) {
|
|
219
220
|
return { error: 'repo-not-created', status: 502, detail: String(error?.message ?? error) }
|
|
220
221
|
}
|
|
@@ -223,11 +224,11 @@ export async function createProjectTask({
|
|
|
223
224
|
|
|
224
225
|
let issue
|
|
225
226
|
try {
|
|
226
|
-
issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body })
|
|
227
|
+
issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body, provider: forge || provider })
|
|
227
228
|
} catch (error) {
|
|
228
229
|
// Repository may have already been created; avoid deleting on issue creation failure.
|
|
229
230
|
return { error: 'issue-not-created', status: 502, detail: String(error?.message ?? error) }
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo })) }
|
|
233
|
+
return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo, forge: forge || provider })) }
|
|
233
234
|
}
|
package/lib/index.js
CHANGED
|
@@ -119,6 +119,18 @@ export const SETTINGS_NAMESPACE = 'dsh-kanban'
|
|
|
119
119
|
* Scalar values only: client config API only writes scalar fields;
|
|
120
120
|
* dictionaries or arrays here would make the settings card uneditable.
|
|
121
121
|
*/
|
|
122
|
+
// Ensure .volatile() helper exists on Schemastery schema prototype
|
|
123
|
+
try {
|
|
124
|
+
const schemaProto = Object.getPrototypeOf(z.boolean())
|
|
125
|
+
if (schemaProto && typeof schemaProto.volatile !== 'function') {
|
|
126
|
+
schemaProto.volatile = function () {
|
|
127
|
+
return typeof this.extra === 'function' ? this.extra('volatile', true) : this
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// safe fallback
|
|
132
|
+
}
|
|
133
|
+
|
|
122
134
|
export const Config = z.object(Object.fromEntries(
|
|
123
135
|
Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
|
|
124
136
|
// Node type derived from default value type.
|
|
@@ -128,7 +140,8 @@ export const Config = z.object(Object.fromEntries(
|
|
|
128
140
|
if (field === 'giteaTokenRef' || field === 'webhookSecretRef' || field === 'githubTokenRef' || field === 'githubWebhookSecretRef') {
|
|
129
141
|
node = node.role('credential-ref')
|
|
130
142
|
}
|
|
131
|
-
|
|
143
|
+
const described = node.default(fallback).description(CONFIG_HINTS[field] ?? '')
|
|
144
|
+
return [field, typeof described.volatile === 'function' ? described.volatile() : described]
|
|
132
145
|
}),
|
|
133
146
|
))
|
|
134
147
|
|
|
@@ -215,13 +228,57 @@ export function apply(ctx, config) {
|
|
|
215
228
|
}
|
|
216
229
|
}
|
|
217
230
|
|
|
231
|
+
let settingsSvc = null
|
|
232
|
+
|
|
233
|
+
const readServedConfig = () => {
|
|
234
|
+
if (typeof settingsSvc?.describe !== 'function') return null
|
|
235
|
+
try {
|
|
236
|
+
const row = settingsSvc.describe().find((r) => r?.ns === SETTINGS_NAMESPACE)
|
|
237
|
+
if (!row || row.status !== 'ready' || !row.value) return null
|
|
238
|
+
return withDefaults(row.value)
|
|
239
|
+
} catch (err) {
|
|
240
|
+
ctx.logger?.warn?.(`dsh-kanban: settings describe failed: ${err?.message || err}`)
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
218
245
|
ctx.inject(['settings'], (sctx) => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
246
|
+
try {
|
|
247
|
+
settingsSvc = sctx?.settings ?? sctx?.get?.('settings') ?? null
|
|
248
|
+
const served = readServedConfig()
|
|
249
|
+
if (served) {
|
|
250
|
+
effective = served
|
|
251
|
+
rearm()
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
ctx.logger?.warn?.(`dsh-kanban: settings service unavailable: ${err?.message || err}`)
|
|
255
|
+
}
|
|
223
256
|
})
|
|
224
257
|
|
|
258
|
+
// Listen for config and volatile updates from host
|
|
259
|
+
if (typeof ctx?.on === 'function') {
|
|
260
|
+
const applyPatch = (updated) => {
|
|
261
|
+
if (updated && typeof updated === 'object') {
|
|
262
|
+
const incoming = updated[SETTINGS_NAMESPACE] ?? updated
|
|
263
|
+
if (incoming && typeof incoming === 'object') {
|
|
264
|
+
try {
|
|
265
|
+
effective = withDefaults({ ...effective, ...incoming })
|
|
266
|
+
rearm()
|
|
267
|
+
} catch { /* ignore invalid update */ }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (typeof ctx.effect === 'function') {
|
|
272
|
+
ctx.effect(() => ctx.on('loader/volatile-update', applyPatch))
|
|
273
|
+
ctx.effect(() => ctx.on('config', applyPatch))
|
|
274
|
+
ctx.effect(() => ctx.on('settings/document-updated', applyPatch))
|
|
275
|
+
} else {
|
|
276
|
+
ctx.on('loader/volatile-update', applyPatch)
|
|
277
|
+
ctx.on('config', applyPatch)
|
|
278
|
+
ctx.on('settings/document-updated', applyPatch)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
225
282
|
// Dedicated Gitea client resolves token via core credentials service.
|
|
226
283
|
// Secret token values are never exposed externally; only the credential reference name is stored.
|
|
227
284
|
const resolveSecret = async (name) => {
|
|
@@ -470,7 +527,7 @@ export function apply(ctx, config) {
|
|
|
470
527
|
|
|
471
528
|
// Periodic Gitea reconciliation. 0 disables it; overlapping executions are prevented.
|
|
472
529
|
// Outbound push queue to Gitea. Card moves do not block on network; failures retry in background.
|
|
473
|
-
const outbox = createOutbox({ gitea: git, store, logger: ctx.logger })
|
|
530
|
+
const outbox = createOutbox({ gitea: git, get store() { return store }, logger: ctx.logger })
|
|
474
531
|
|
|
475
532
|
const queueOutbound = (taskId, column) => {
|
|
476
533
|
if (getConfig().pushToGitea !== true) return
|
|
@@ -750,14 +807,14 @@ export function apply(ctx, config) {
|
|
|
750
807
|
// Single setting switch controls both board tools.
|
|
751
808
|
const registerBoardTool = () => {
|
|
752
809
|
const off = [
|
|
753
|
-
ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig }))),
|
|
754
|
-
ctx.tools.register(defineTool(boardPlanDefinition({ store }))),
|
|
755
|
-
ctx.tools.register(defineTool(boardChecklistDefinition({ store }))),
|
|
810
|
+
ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig, eventHub }))),
|
|
811
|
+
ctx.tools.register(defineTool(boardPlanDefinition({ store, eventHub }))),
|
|
812
|
+
ctx.tools.register(defineTool(boardChecklistDefinition({ store, eventHub }))),
|
|
756
813
|
ctx.tools.register(defineTool(boardReportDefinition({ store }))),
|
|
757
814
|
ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
|
|
758
815
|
ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
|
|
759
816
|
ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea: git }))),
|
|
760
|
-
ctx.tools.register(defineTool(boardChecklistItemDefinition({ store }))),
|
|
817
|
+
ctx.tools.register(defineTool(boardChecklistItemDefinition({ store, eventHub }))),
|
|
761
818
|
]
|
|
762
819
|
return () => { for (const stop of off) stop?.() }
|
|
763
820
|
}
|
|
@@ -852,7 +909,7 @@ export function apply(ctx, config) {
|
|
|
852
909
|
kind: 'exact',
|
|
853
910
|
path: '/dsh-kanban/events',
|
|
854
911
|
handler: (req, res) => {
|
|
855
|
-
eventHub.handleSseRequest(req, res)
|
|
912
|
+
eventHub.handleSseRequest(req, res, { isTrustedRequest })
|
|
856
913
|
},
|
|
857
914
|
}), 'dsh-kanban: sse events stream')
|
|
858
915
|
|
|
@@ -889,7 +946,11 @@ export function apply(ctx, config) {
|
|
|
889
946
|
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
890
947
|
try {
|
|
891
948
|
const input = await readBody(req)
|
|
892
|
-
|
|
949
|
+
const impResult = importBoard({ store, input })
|
|
950
|
+
if (impResult?.imported > 0) {
|
|
951
|
+
eventHub.broadcast('task:change', { action: 'imported', count: impResult.imported })
|
|
952
|
+
}
|
|
953
|
+
reply(res, impResult)
|
|
893
954
|
} catch {
|
|
894
955
|
writeJson(res, 400, { error: 'bad-request' })
|
|
895
956
|
}
|
|
@@ -908,7 +969,11 @@ export function apply(ctx, config) {
|
|
|
908
969
|
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
909
970
|
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
910
971
|
try {
|
|
911
|
-
|
|
972
|
+
const resObj = createTask({ store, input: await readBody(req) })
|
|
973
|
+
if (resObj?.task) {
|
|
974
|
+
eventHub.broadcast('task:change', { taskId: resObj.task.id, action: 'created' })
|
|
975
|
+
}
|
|
976
|
+
reply(res, resObj)
|
|
912
977
|
} catch (error) {
|
|
913
978
|
writeJson(res, error?.status ?? 500, { error: 'bad-request' })
|
|
914
979
|
}
|
|
@@ -930,12 +995,23 @@ export function apply(ctx, config) {
|
|
|
930
995
|
return
|
|
931
996
|
}
|
|
932
997
|
if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
|
|
933
|
-
if (req.method === 'DELETE' && action === undefined) {
|
|
998
|
+
if (req.method === 'DELETE' && action === undefined) {
|
|
999
|
+
const resObj = deleteTask({ store, id })
|
|
1000
|
+
if (resObj?.deleted) {
|
|
1001
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'deleted' })
|
|
1002
|
+
}
|
|
1003
|
+
reply(res, resObj)
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
934
1006
|
// Card fields update: title, body, labels, priority. Priority is board-local.
|
|
935
1007
|
if (req.method === 'POST' && action === 'update') {
|
|
936
1008
|
try {
|
|
937
1009
|
const input = await readBody(req)
|
|
938
|
-
|
|
1010
|
+
const resObj = updateTask({ store, id, input })
|
|
1011
|
+
if (resObj?.task) {
|
|
1012
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
|
|
1013
|
+
}
|
|
1014
|
+
reply(res, resObj)
|
|
939
1015
|
} catch {
|
|
940
1016
|
writeJson(res, 400, { error: 'bad-request' })
|
|
941
1017
|
}
|
|
@@ -1112,6 +1188,9 @@ export function apply(ctx, config) {
|
|
|
1112
1188
|
}
|
|
1113
1189
|
if (req.method === 'POST' && action === 'checklist-toggle') {
|
|
1114
1190
|
const resObj = handleToggleChecklistItem({ store, task: store.getTask(id), input })
|
|
1191
|
+
if (resObj?.body?.task) {
|
|
1192
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
|
|
1193
|
+
}
|
|
1115
1194
|
writeJson(res, resObj.status, resObj.body)
|
|
1116
1195
|
return
|
|
1117
1196
|
}
|
|
@@ -1122,6 +1201,9 @@ export function apply(ctx, config) {
|
|
|
1122
1201
|
}
|
|
1123
1202
|
if (req.method === 'POST' && action === 'checklist') {
|
|
1124
1203
|
const resObj = handleTaskChecklist({ store, task: store.getTask(id), input })
|
|
1204
|
+
if (resObj?.body?.task) {
|
|
1205
|
+
eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
|
|
1206
|
+
}
|
|
1125
1207
|
writeJson(res, resObj.status, resObj.body)
|
|
1126
1208
|
return
|
|
1127
1209
|
}
|
|
@@ -1174,7 +1256,7 @@ export function apply(ctx, config) {
|
|
|
1174
1256
|
agentPreset: input?.agentPreset || task.agentPreset,
|
|
1175
1257
|
permission: input?.permission || task.permission,
|
|
1176
1258
|
permissions: ctx.get('permissionPresets'),
|
|
1177
|
-
claimAs:
|
|
1259
|
+
claimAs: await whoAmI(),
|
|
1178
1260
|
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
1179
1261
|
resume: true,
|
|
1180
1262
|
})
|