@goodandready/dsh-kanban 0.2.13 → 0.2.15

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/github.js ADDED
@@ -0,0 +1,302 @@
1
+ // Клиент GitHub REST API v3.
2
+ //
3
+ // Выполняет те же операции, что и GiteaClient: чтение issues, pull requests,
4
+ // веток, репозиториев, создание PR и комментирование.
5
+ // Дополнительно поддерживает условные запросы с ETag (If-None-Match) для защиты
6
+ // от исчерпания часового лимита запросов GitHub (5 000 req/hr).
7
+ //
8
+ // Модуль изолирован от файловой системы и DSH: токен и конфигурация
9
+ // передаются фабрикой createGitHubClient.
10
+
11
+ const DEFAULT_BASE_URL = 'https://api.github.com'
12
+
13
+ export function normalizeGithubUrl(raw) {
14
+ const s = String(raw ?? '').trim()
15
+ if (!s) return DEFAULT_BASE_URL
16
+ const match = s.match(/^https?:\/\/[^\s/]+/i)
17
+ return match ? match[0].replace(/\/+$/, '') : DEFAULT_BASE_URL
18
+ }
19
+
20
+ export function safeSegment(value) {
21
+ const s = String(value ?? '').trim()
22
+ if (!s || s.includes('/') || s.includes('\\') || s.includes('..') || s.includes('\0')) {
23
+ return undefined
24
+ }
25
+ return s
26
+ }
27
+
28
+ export function isCredentialName(value) {
29
+ if (typeof value !== 'string') return false
30
+ const trimmed = value.trim()
31
+ return trimmed.length > 0 && !/[\s/]/.test(trimmed)
32
+ }
33
+
34
+ /**
35
+ * Создать клиент GitHub API.
36
+ *
37
+ * @param {object} options
38
+ * @param {() => object} options.getConfig функция получения конфигурации
39
+ * @param {(name: string) => Promise<string>} options.resolveToken получение токена
40
+ * @param {typeof fetch} [options.fetchImpl] реализация fetch
41
+ * @param {number} [options.timeoutMs] таймаут запроса в мс
42
+ */
43
+ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch, timeoutMs = 30_000 }) {
44
+ // Кэш условных запросов ETag: url -> { etag, data }
45
+ const etagCache = new Map()
46
+
47
+ async function request(method, path, body, { useEtag = false } = {}) {
48
+ const config = getConfig()
49
+ const baseUrl = normalizeGithubUrl(config?.githubUrl)
50
+ const tokenRef = config?.githubTokenRef || 'GITHUB_TOKEN'
51
+
52
+ const token = await resolveToken(tokenRef)
53
+ if (!token) {
54
+ throw Object.assign(new Error('GitHub credential token is not configured'), { code: 'unconfigured' })
55
+ }
56
+
57
+ const url = `${baseUrl}${path}`
58
+ const headers = {
59
+ 'Authorization': `Bearer ${token}`,
60
+ 'Accept': 'application/vnd.github+json',
61
+ 'X-GitHub-Api-Version': '2022-11-28',
62
+ 'User-Agent': 'dsh-kanban',
63
+ }
64
+
65
+ let cacheEntry
66
+ if (useEtag && method === 'GET') {
67
+ cacheEntry = etagCache.get(url)
68
+ if (cacheEntry?.etag) {
69
+ headers['If-None-Match'] = cacheEntry.etag
70
+ }
71
+ }
72
+
73
+ if (body !== undefined) {
74
+ headers['Content-Type'] = 'application/json'
75
+ }
76
+
77
+ const controller = new AbortController()
78
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
79
+
80
+ try {
81
+ const res = await fetchImpl(url, {
82
+ method,
83
+ headers,
84
+ body: body === undefined ? undefined : JSON.stringify(body),
85
+ signal: controller.signal,
86
+ })
87
+
88
+ if (res.status === 304 && cacheEntry) {
89
+ return cacheEntry.data
90
+ }
91
+
92
+ const text = await res.text()
93
+ let payload
94
+ try { payload = text.trim() === '' ? null : JSON.parse(text) } catch { payload = null }
95
+
96
+ if (!res.ok) {
97
+ throw Object.assign(new Error(`GitHub HTTP error ${res.status}`), {
98
+ code: 'http',
99
+ status: res.status,
100
+ detail: payload?.message,
101
+ })
102
+ }
103
+
104
+ if (useEtag && method === 'GET') {
105
+ const etag = res.headers?.get?.('etag') || res.headers?.etag
106
+ if (etag) {
107
+ etagCache.set(url, { etag, data: payload })
108
+ }
109
+ }
110
+
111
+ return payload
112
+ } finally {
113
+ clearTimeout(timer)
114
+ }
115
+ }
116
+
117
+ function repoPath(owner, repo, suffix = '') {
118
+ const o = safeSegment(owner)
119
+ const r = safeSegment(repo)
120
+ if (o === undefined || r === undefined) {
121
+ throw Object.assign(new Error('Invalid owner or repository segment'), { code: 'bad-segment' })
122
+ }
123
+ return `/repos/${encodeURIComponent(o)}/${encodeURIComponent(r)}${suffix}`
124
+ }
125
+
126
+ async function collectPages({ buildPath, limit = 50, maxPages = 10, useEtag = false }) {
127
+ const size = Math.min(Math.max(Number(limit) || 50, 1), 100)
128
+ const out = []
129
+ for (let page = 1; page <= maxPages; page += 1) {
130
+ const rows = await request('GET', buildPath(page, size), undefined, { useEtag: useEtag && page === 1 })
131
+ if (!Array.isArray(rows)) break
132
+ out.push(...rows)
133
+ if (rows.length < size) break
134
+ }
135
+ return out
136
+ }
137
+
138
+ let configuredCache
139
+ const configuredKey = () => {
140
+ const config = getConfig()
141
+ return `${normalizeGithubUrl(config?.githubUrl)}\u0000${String(config?.githubTokenRef ?? 'GITHUB_TOKEN')}`
142
+ }
143
+
144
+ return {
145
+ provider: 'github',
146
+
147
+ /** Проверен ли токен доступа. */
148
+ async isConfigured() {
149
+ const key = configuredKey()
150
+ if (configuredCache?.key === key) return configuredCache.value
151
+ const config = getConfig()
152
+ const tokenRef = config?.githubTokenRef || 'GITHUB_TOKEN'
153
+ const value = isCredentialName(tokenRef)
154
+ && await resolveToken(tokenRef).then(Boolean).catch(() => false)
155
+ configuredCache = { key, value }
156
+ return value
157
+ },
158
+
159
+ /** Очистить кэш ETag (например, при явном ручном обновлении). */
160
+ clearCache() {
161
+ etagCache.clear()
162
+ },
163
+
164
+ /** Список issues репозитория (без PR). */
165
+ async listIssues({ owner, repo, state = 'open', limit = 50 }) {
166
+ const rows = await collectPages({
167
+ limit,
168
+ useEtag: true,
169
+ buildPath: (page, size) => repoPath(owner, repo,
170
+ `/issues?state=${encodeURIComponent(state)}&page=${page}&per_page=${size}&sort=updated&direction=desc`),
171
+ })
172
+ return rows.filter((r) => !r.pull_request)
173
+ },
174
+
175
+ /** Получить один issue по номеру. */
176
+ async getIssue({ owner, repo, index }) {
177
+ const n = safeSegment(index)
178
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
179
+ return request('GET', repoPath(owner, repo, `/issues/${n}`))
180
+ },
181
+
182
+ /** Оставить комментарий в issue. */
183
+ async comment({ owner, repo, index, body }) {
184
+ const n = safeSegment(index)
185
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
186
+ return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
187
+ },
188
+
189
+ /** Создать новый issue. */
190
+ async createIssue({ owner, repo, title, body }) {
191
+ const name = String(title ?? '').trim()
192
+ if (name === '') throw Object.assign(new Error('empty issue title'), { code: 'bad-title' })
193
+ return request('POST', repoPath(owner, repo, '/issues'), { title: name, body: String(body ?? '') })
194
+ },
195
+
196
+ /** Закрыть issue. */
197
+ async closeIssue({ owner, repo, index }) {
198
+ const n = safeSegment(index)
199
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
200
+ return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { state: 'closed' })
201
+ },
202
+
203
+ /**
204
+ * Назначить ответственных за issue.
205
+ * В GitHub REST API PATCH /issues/{n} со свойством assignees заменяет ответственных целиком.
206
+ */
207
+ async setAssignees({ owner, repo, index, logins }) {
208
+ const n = safeSegment(index)
209
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
210
+ const list = (Array.isArray(logins) ? logins : [])
211
+ .filter((one) => typeof one === 'string' && one !== '')
212
+ return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { assignees: list })
213
+ },
214
+
215
+ /** Логин текущего пользователя токена. */
216
+ async me() {
217
+ const row = await request('GET', '/user')
218
+ const login = row?.login
219
+ return typeof login === 'string' ? login : ''
220
+ },
221
+
222
+ /** Организации, доступные токену. */
223
+ async listOrgs() {
224
+ const rows = await request('GET', '/user/orgs?per_page=50')
225
+ return (Array.isArray(rows) ? rows : [])
226
+ .map((o) => String(o?.login ?? o?.name ?? ''))
227
+ .filter((name) => name !== '')
228
+ },
229
+
230
+ /** Репозитории организации с числом открытых задач. */
231
+ async listOrgRepos({ owner, limit = 100 }) {
232
+ const rows = await collectPages({
233
+ limit,
234
+ useEtag: true,
235
+ buildPath: (page, size) => `/orgs/${encodeURIComponent(safeSegment(owner))}/repos?page=${page}&per_page=${size}`,
236
+ })
237
+ return rows.map((r) => ({
238
+ name: String(r?.name ?? ''),
239
+ openIssues: Number(r?.open_issues_count ?? 0),
240
+ archived: r?.archived === true,
241
+ })).filter((r) => r.name !== '')
242
+ },
243
+
244
+ /** Pull requests репозитория. */
245
+ async listPulls({ owner, repo, state = 'all', limit = 50 }) {
246
+ return collectPages({
247
+ limit,
248
+ useEtag: true,
249
+ buildPath: (page, size) => repoPath(owner, repo,
250
+ `/pulls?state=${encodeURIComponent(state)}&page=${page}&per_page=${size}&sort=updated&direction=desc`),
251
+ })
252
+ },
253
+
254
+ /** Создать Pull Request. */
255
+ async createPullRequest({ owner, repo, title, body, head, base = 'main' }) {
256
+ const name = String(title ?? '').trim()
257
+ if (name === '') throw Object.assign(new Error('empty PR title'), { code: 'bad-title' })
258
+ const h = String(head ?? '').trim()
259
+ const b = String(base ?? 'main').trim()
260
+ if (!h) throw Object.assign(new Error('head branch is required'), { code: 'bad-head' })
261
+ return request('POST', repoPath(owner, repo, '/pulls'), {
262
+ title: name,
263
+ body: String(body ?? ''),
264
+ head: h,
265
+ base: b,
266
+ })
267
+ },
268
+
269
+ /** Список веток репозитория. */
270
+ async listBranches({ owner, repo, limit = 100 }) {
271
+ const rows = await collectPages({
272
+ limit,
273
+ buildPath: (page, size) => repoPath(owner, repo, `/branches?page=${page}&per_page=${size}`),
274
+ })
275
+ return rows.map((b) => ({ name: String(b?.name ?? '') })).filter((b) => b.name !== '')
276
+ },
277
+
278
+ /** Поиск репозиториев. */
279
+ async searchRepos({ query, limit = 50, maxPages = 5 }) {
280
+ const q = encodeURIComponent(String(query || ''))
281
+ const size = Math.min(Math.max(Number(limit) || 50, 1), 50)
282
+ const out = []
283
+ for (let page = 1; page <= maxPages; page += 1) {
284
+ const rows = await request('GET', `/search/repositories?q=${q}&per_page=${size}&page=${page}`)
285
+ const data = Array.isArray(rows?.items) ? rows.items : []
286
+ for (const r of data) {
287
+ out.push({
288
+ name: String(r?.name ?? ''),
289
+ fullName: String(r?.full_name ?? ''),
290
+ description: String(r?.description ?? ''),
291
+ openIssues: Number(r?.open_issues_count ?? 0),
292
+ updatedAt: r?.updated_at ? new Date(r.updated_at).getTime() : 0,
293
+ stars: Number(r?.stargazers_count ?? 0),
294
+ provider: 'github',
295
+ })
296
+ }
297
+ if (data.length < size) break
298
+ }
299
+ return out
300
+ },
301
+ }
302
+ }
package/lib/import.js CHANGED
@@ -69,10 +69,12 @@ export function issueMilestone(issue) {
69
69
  * Поля `branch`, `worktree`, `sessionId`, `model` НЕ заполняются: они
70
70
  * наблюдаются позже, когда работа начнётся. Плагин ветки не создаёт.
71
71
  */
72
- export function issueToTask(issue, { board = 'main', column = 'backlog', owner, repo }) {
72
+ export function issueToTask(issue, { board = 'main', column = 'backlog', owner, repo, provider }) {
73
+ const p = provider || (typeof issue?.html_url === 'string' && issue.html_url.includes('github.com') ? 'github' : 'gitea')
73
74
  return {
74
75
  board,
75
76
  column,
77
+ provider: p,
76
78
  title: typeof issue?.title === 'string' ? issue.title : '',
77
79
  body: typeof issue?.body === 'string' ? issue.body : '',
78
80
  labels: Array.isArray(issue?.labels)
package/lib/index.js CHANGED
@@ -58,6 +58,8 @@ import {
58
58
  import { cleanupTaskWorktree, checkWorktreeDirty, getTaskDiff, getTaskCommits, mergeTaskWorktree } from './worktree.js'
59
59
  import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
60
60
  import { createGiteaClient } from './gitea.js'
61
+ import { createGitHubClient } from './github.js'
62
+ import { createGitProvider } from './git-provider.js'
61
63
  import { createKanbanTaskService } from './task-service.js'
62
64
  import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
63
65
  import { isWatched } from './intake.js'
@@ -77,6 +79,9 @@ import { planOutbound, planAssign, createOutbox } from './outbound.js'
77
79
  import { computeNextRun } from './cron.js'
78
80
  import { PowerInhibitor } from './power.js'
79
81
  import { formatProgressDump, parseProgressDump } from './progress-dump.js'
82
+ import { randomUUID } from 'node:crypto'
83
+ import { SessionId } from '@deepseek-ai/dsh-session'
84
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
80
85
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
81
86
  import { defineTool } from '@deepseek-ai/dsh-tools'
82
87
  import z from '@deepseek-ai/schemastery'
@@ -92,7 +97,7 @@ import {
92
97
  rootOf,
93
98
  } from './config.js'
94
99
  import { eventHub } from './events.js'
95
- import { TASK_TEMPLATES } from './templates.js'
100
+ import { TASK_TEMPLATES, getTemplateById } from './templates.js'
96
101
 
97
102
  export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
98
103
 
@@ -128,7 +133,7 @@ export const Config = z.object(Object.fromEntries(
128
133
  let node = typeof fallback === 'number' ? z.number()
129
134
  : typeof fallback === 'boolean' ? z.boolean()
130
135
  : z.string()
131
- if (field === 'giteaTokenRef' || field === 'webhookSecretRef') {
136
+ if (field === 'giteaTokenRef' || field === 'webhookSecretRef' || field === 'githubTokenRef' || field === 'githubWebhookSecretRef') {
132
137
  node = node.role('credential-ref')
133
138
  }
134
139
  return [field, node.default(fallback).description(CONFIG_HINTS[field] ?? '')]
@@ -246,6 +251,17 @@ export function apply(ctx, config) {
246
251
  resolveToken: resolveSecret,
247
252
  })
248
253
 
254
+ const github = createGitHubClient({
255
+ getConfig,
256
+ resolveToken: resolveSecret,
257
+ })
258
+
259
+ const git = createGitProvider({
260
+ gitea,
261
+ github,
262
+ getConfig,
263
+ })
264
+
249
265
  // Хранилище открывается один раз и закрывается уборщиком того же эффекта:
250
266
  // выгрузка плагина обязана отпустить файл базы.
251
267
  let store
@@ -454,7 +470,7 @@ export function apply(ctx, config) {
454
470
  // Очередь отправки в Gitea. Перенос карточки не ждёт сети: доска обязана
455
471
  // работать и при недоступном Gitea, а неудачная отправка повторяется и, если
456
472
  // так и не удалась, попадает в журнал задачи.
457
- const outbox = createOutbox({ gitea, store, logger: ctx.logger })
473
+ const outbox = createOutbox({ gitea: git, store, logger: ctx.logger })
458
474
 
459
475
  const queueOutbound = (taskId, column) => {
460
476
  if (getConfig().pushToGitea !== true) return
@@ -499,10 +515,34 @@ export function apply(ctx, config) {
499
515
  // Порядок важен. Сперва забираем новое: свежая задача должна попасть на
500
516
  // доску и тут же быть сверена, а не ждать следующего прохода. Архивация
501
517
  // последней — она смотрит на итог, а не на исходное.
502
- const taken = await intakeAll({
503
- gitea, store, config: getConfig(), owner: await defaultOwner(), logger: ctx.logger,
504
- })
505
- const out = await syncAll({ gitea, store, logger: ctx.logger })
518
+ const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
519
+ const providersToSync = []
520
+ if (mode === 'github') {
521
+ providersToSync.push(github)
522
+ } else if (mode === 'gitea') {
523
+ providersToSync.push(gitea)
524
+ } else {
525
+ if (await gitea.isConfigured()) providersToSync.push(gitea)
526
+ if (await github.isConfigured()) providersToSync.push(github)
527
+ if (providersToSync.length === 0) providersToSync.push(gitea)
528
+ }
529
+
530
+ let taken = { added: 0, skipped: 0, failed: 0 }
531
+ for (const p of providersToSync) {
532
+ const owner = p === github
533
+ ? (String(getConfig()?.githubOwner ?? '').trim() || await p.me().catch(() => ''))
534
+ : await defaultOwner()
535
+ const res = await intakeAll({
536
+ gitea: p, store, config: getConfig(), owner, logger: ctx.logger,
537
+ })
538
+ if (res && typeof res.added === 'number') {
539
+ taken.added = (taken.added || 0) + res.added
540
+ taken.skipped = (taken.skipped || 0) + (res.skipped || 0)
541
+ taken.failed = (taken.failed || 0) + (res.failed || 0)
542
+ }
543
+ if (res?.problem) taken.problem = res.problem
544
+ }
545
+ const out = await syncAll({ gitea: git, store, logger: ctx.logger })
506
546
  const tidied = archiveOverdue({ store, config: getConfig() })
507
547
  // Отложенные отправки уходят той же волной: отдельный таймер ради них
508
548
  // означал бы второй источник обращений к Gitea без всякой пользы.
@@ -547,7 +587,7 @@ export function apply(ctx, config) {
547
587
  try {
548
588
  const input = await readBody(req)
549
589
  reply(res, await createProjectTask({
550
- gitea, store,
590
+ gitea: git, store,
551
591
  owner: input.owner || await defaultOwner(),
552
592
  repo: input.repo, newRepo: input.newRepo,
553
593
  title: input.title, body: input.body,
@@ -663,10 +703,13 @@ export function apply(ctx, config) {
663
703
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
664
704
  try {
665
705
  const raw = await readRaw(req)
666
- const secret = await resolveSecret(getConfig().webhookSecretRef)
667
- // Без секрета вебхук не принимается вовсе: иначе карточки двигал бы
668
- // кто угодно снаружи.
669
- if (!verifySignature(secret, raw, req.headers['x-gitea-signature'])) {
706
+ const isGithub = Boolean(req.headers['x-github-event'] || req.headers['x-hub-signature-256'])
707
+ const secretRef = isGithub
708
+ ? (getConfig().githubWebhookSecretRef || getConfig().webhookSecretRef)
709
+ : getConfig().webhookSecretRef
710
+ const secret = await resolveSecret(secretRef)
711
+ const sig = isGithub ? req.headers['x-hub-signature-256'] : req.headers['x-gitea-signature']
712
+ if (!verifySignature(secret, raw, sig)) {
670
713
  writeJson(res, 401, { error: 'bad-signature' })
671
714
  return
672
715
  }
@@ -675,29 +718,21 @@ export function apply(ctx, config) {
675
718
  const target = parseEvent(payload)
676
719
  if (target === undefined) { writeJson(res, 202, { ignored: true }); return }
677
720
 
678
- // Сперва подхват, потом сверка — тот же порядок, что и по таймеру:
679
- // новый issue должен стать карточкой и тут же быть сверен. Без этого
680
- // он ждал бы таймера, хотя вебхук про него уже знает, а вебхук мы и
681
- // заводили ради двух секунд вместо ста двадцати.
682
- //
683
- // Только по репозиторию события: полный обход при каждом чихе в любом
684
- // из отслеживаемых означал бы обращение ко всем.
685
- // Событие всегда называет свой репозиторий, и владельца выяснять не
686
- // надо: подхватываем его напрямую, если он вообще под наблюдением.
687
- const owner = await defaultOwner()
721
+ const activeGit = isGithub ? github : gitea
722
+ const owner = isGithub
723
+ ? (String(getConfig().githubOwner ?? '').trim() || target.owner)
724
+ : await defaultOwner()
688
725
  const taken = target.owner === owner || isWatched({
689
726
  config: getConfig(), owner: target.owner, defaultOwner: owner, repo: target.repo,
690
727
  })
691
728
  ? await intakeAll({
692
- gitea, store, config: getConfig(), owner: await defaultOwner(), logger: ctx.logger,
729
+ gitea: activeGit, store, config: getConfig(), owner, logger: ctx.logger,
693
730
  only: (pair) => pair.owner === target.owner && pair.repo === target.repo,
694
731
  })
695
732
  : { added: 0, skipped: 0, failed: 0 }
696
733
 
697
- // Вебхук говорит «здесь что-то произошло», а решает сверка: у неё перед
698
- // глазами полная картина, а у события — только его кусок.
699
734
  const out = await syncAll({
700
- gitea, store, logger: ctx.logger,
735
+ gitea: git, store, logger: ctx.logger,
701
736
  only: (t) => t.owner === target.owner && t.repo === target.repo
702
737
  && (target.issueNumber === undefined || t.issueNumber === target.issueNumber),
703
738
  })
@@ -730,7 +765,7 @@ export function apply(ctx, config) {
730
765
  ctx.tools.register(defineTool(boardReportDefinition({ store }))),
731
766
  ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
732
767
  ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
733
- ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea }))),
768
+ ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea: git }))),
734
769
  ctx.tools.register(defineTool(boardChecklistItemDefinition({ store }))),
735
770
  ]
736
771
  return () => { for (const stop of off) stop?.() }
@@ -838,10 +873,25 @@ export function apply(ctx, config) {
838
873
  },
839
874
  }), 'dsh-kanban: sse events stream')
840
875
 
876
+ // Очистка SSE-клиентов и heartbeat таймера при выгрузке плагина (#282)
877
+ if (typeof ctx.on === 'function') {
878
+ ctx.on('dispose', () => {
879
+ eventHub.closeAll()
880
+ })
881
+ }
882
+
841
883
  ctx.effect(() => ctx.webServer.register({
842
884
  kind: 'exact',
843
885
  path: '/dsh-kanban/templates',
844
886
  handler: (req, res) => {
887
+ const u = new URL(req.url, 'http://localhost')
888
+ const tid = u.searchParams.get('id')
889
+ if (tid) {
890
+ const tmpl = getTemplateById(tid)
891
+ if (!tmpl) { writeJson(res, 404, { error: 'template-not-found' }); return }
892
+ reply(res, { template: tmpl })
893
+ return
894
+ }
845
895
  reply(res, { templates: TASK_TEMPLATES })
846
896
  },
847
897
  }), 'dsh-kanban: task templates')
@@ -911,7 +961,7 @@ export function apply(ctx, config) {
911
961
  return
912
962
  }
913
963
  if (req.method === 'POST' && action === 'refresh') {
914
- reply(res, await refreshTask({ gitea, store, id }))
964
+ reply(res, await refreshTask({ gitea: git, store, id }))
915
965
  return
916
966
  }
917
967
  if (req.method === 'POST' && action === 'start') {
@@ -1033,12 +1083,12 @@ export function apply(ctx, config) {
1033
1083
  return
1034
1084
  }
1035
1085
  if (req.method === 'POST' && action === 'triage') {
1036
- const resObj = await handleTriageTask({ store, gitea, task: store.getTask(id), input })
1086
+ const resObj = await handleTriageTask({ store, gitea: git, task: store.getTask(id), input })
1037
1087
  writeJson(res, resObj.status, resObj.body)
1038
1088
  return
1039
1089
  }
1040
1090
  if (req.method === 'POST' && (action === 'subtask' || action === 'subtasks')) {
1041
- const resObj = await handleCreateSubtask({ store, gitea, task: store.getTask(id), input })
1091
+ const resObj = await handleCreateSubtask({ store, gitea: git, task: store.getTask(id), input })
1042
1092
  writeJson(res, resObj.status, resObj.body)
1043
1093
  return
1044
1094
  }
@@ -1048,7 +1098,7 @@ export function apply(ctx, config) {
1048
1098
  return
1049
1099
  }
1050
1100
  if (req.method === 'POST' && action === 'pr') {
1051
- const resObj = await handleCreateTaskPr({ store, gitea, task: store.getTask(id), input })
1101
+ const resObj = await handleCreateTaskPr({ store, gitea: git, task: store.getTask(id), input })
1052
1102
  writeJson(res, resObj.status, resObj.body)
1053
1103
  return
1054
1104
  }
@@ -1098,16 +1148,15 @@ export function apply(ctx, config) {
1098
1148
  config: getConfig(),
1099
1149
  provider: picked.provider,
1100
1150
  model: picked.model,
1101
- mintSessionId,
1102
- createMessage,
1103
- cwdOf,
1151
+ mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
1152
+ createMessage: createUserMessage,
1153
+ cwdOf: undefined,
1104
1154
  logger: ctx.logger,
1105
- text: input?.text,
1155
+ text: typeof input?.text === 'string' ? input.text : undefined,
1106
1156
  agentPreset: input?.agentPreset || task.agentPreset,
1107
1157
  permission: input?.permission || task.permission,
1108
1158
  permissions: ctx.get('permissionPresets'),
1109
- claimAs: req.headers['x-dsh-user'],
1110
- gitRunner,
1159
+ claimAs: req.headers['x-dsh-user'] || (await whoAmI()),
1111
1160
  workspaceRegistry: ctx.get('workspaceRegistry'),
1112
1161
  resume: true,
1113
1162
  })
@@ -1206,7 +1255,7 @@ export function apply(ctx, config) {
1206
1255
  path: '/dsh-kanban/gitea/repos',
1207
1256
  handler: async (req, res) => {
1208
1257
  const url = urlOf(req)
1209
- reply(res, await searchRepos({ gitea, query: url.searchParams.get('q') || '' }))
1258
+ reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
1210
1259
  },
1211
1260
  }), 'dsh-kanban: repo search')
1212
1261
 
@@ -1216,7 +1265,7 @@ export function apply(ctx, config) {
1216
1265
  handler: async (req, res) => {
1217
1266
  const url = urlOf(req)
1218
1267
  reply(res, await listImportable({
1219
- gitea,
1268
+ gitea: git,
1220
1269
  store,
1221
1270
  owner: url.searchParams.get('owner') || undefined,
1222
1271
  repo: url.searchParams.get('repo') || undefined,
@@ -1281,7 +1330,7 @@ export function apply(ctx, config) {
1281
1330
  handler: (req, res) => {
1282
1331
  const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
1283
1332
  if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
1284
- reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]) }))
1333
+ reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]), agents: ctx.agents }))
1285
1334
  },
1286
1335
  }), 'dsh-kanban: task route by session')
1287
1336
 
package/lib/launcher.js CHANGED
@@ -416,7 +416,7 @@ export async function runTask({
416
416
  })
417
417
  }
418
418
 
419
- return { sessionId: taken.sessionId, mode: taken.mode, task: current }
419
+ return { sessionId: taken.sessionId, mode: revivalKind(taken.mode), task: current }
420
420
  }
421
421
 
422
422
  /** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
package/lib/markdown.js CHANGED
@@ -119,7 +119,7 @@ export function parseSpans(text) {
119
119
  const href = piece.slice(cut + 2, -1)
120
120
  // Небезопасная схема — не ссылка, а текст. Молча выбрасывать нельзя:
121
121
  // человек должен видеть, что там было написано.
122
- if (SAFE_LINK.test(href)) out.push({ kind: 'link', text: label, href })
122
+ if (isSafeHref(href)) out.push({ kind: 'link', text: label, href })
123
123
  else out.push({ kind: 'text', text: piece })
124
124
  } else if (piece.startsWith('**') || piece.startsWith('__')) {
125
125
  out.push({ kind: 'strong', text: piece.slice(2, -2) })