@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/README.md +7 -3
- package/README.ru.md +7 -3
- package/README.zh.md +4 -0
- package/lib/client.js +124 -23
- package/lib/commands.js +1 -0
- package/lib/config.js +8 -0
- package/lib/git-provider.js +156 -0
- package/lib/gitea.js +3 -1
- package/lib/github.js +302 -0
- package/lib/import.js +3 -1
- package/lib/index.js +89 -40
- package/lib/launcher.js +1 -1
- package/lib/markdown.js +1 -1
- package/lib/routes.js +44 -30
- package/lib/store.js +24 -8
- package/lib/time.js +2 -1
- package/lib/webhook.js +21 -4
- package/lib/worktree.js +6 -1
- package/package.json +3 -2
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
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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) })
|