@goodandready/dsh-kanban 0.1.20 → 0.1.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/README.md +46 -1
- package/lib/client.js +111 -6
- package/lib/filters.js +307 -288
- package/lib/gitea.js +28 -0
- package/lib/import.js +23 -0
- package/lib/index.js +50 -2
- package/lib/launcher.js +32 -2
- package/lib/outbound.js +22 -0
- package/lib/routes.js +355 -331
- package/lib/store.js +392 -389
- package/lib/sync.js +7 -1
- package/package.json +3 -2
package/lib/gitea.js
CHANGED
|
@@ -222,6 +222,34 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
|
|
|
222
222
|
return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { state: 'closed' })
|
|
223
223
|
},
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Назначить ответственного за issue.
|
|
227
|
+
*
|
|
228
|
+
* Пустой список снимает назначение: Gitea понимает это как «никто», и
|
|
229
|
+
* доска обязана уметь то же самое — иначе снять с себя задачу можно было
|
|
230
|
+
* бы только руками в Gitea.
|
|
231
|
+
*/
|
|
232
|
+
async setAssignees({ owner, repo, index, logins }) {
|
|
233
|
+
const n = safeSegment(index)
|
|
234
|
+
if (n === undefined) throw Object.assign(new Error('недопустимый номер issue'), { code: 'bad-segment' })
|
|
235
|
+
const list = (Array.isArray(logins) ? logins : [])
|
|
236
|
+
.filter((one) => typeof one === 'string' && one !== '')
|
|
237
|
+
return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { assignees: list })
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Кто мы для Gitea.
|
|
242
|
+
*
|
|
243
|
+
* «Взять себе» на доске значит «назначить того, чьим токеном доска ходит»:
|
|
244
|
+
* своего понятия пользователя у харнесса нет, и выдумывать его вместо
|
|
245
|
+
* ответа сервера нельзя.
|
|
246
|
+
*/
|
|
247
|
+
async me() {
|
|
248
|
+
const row = await request('GET', '/user')
|
|
249
|
+
const login = row?.login
|
|
250
|
+
return typeof login === 'string' ? login : ''
|
|
251
|
+
},
|
|
252
|
+
|
|
225
253
|
/** Организации, доступные токену. Нужны, чтобы не спрашивать владельца лишний раз. */
|
|
226
254
|
async listOrgs() {
|
|
227
255
|
const rows = await request('GET', '/user/orgs?limit=50')
|
package/lib/import.js
CHANGED
|
@@ -33,6 +33,22 @@ export function issueAuthor(issue) {
|
|
|
33
33
|
return typeof login === 'string' ? login : ''
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Кто отвечает за issue.
|
|
38
|
+
*
|
|
39
|
+
* Gitea отдаёт назначенного двумя способами: одиночным `assignee` и списком
|
|
40
|
+
* `assignees`. Берём первого из списка, когда одиночного нет: доска показывает
|
|
41
|
+
* ОДНОГО ответственного, потому что «отвечают все» означает «не отвечает
|
|
42
|
+
* никто».
|
|
43
|
+
*/
|
|
44
|
+
export function issueAssignee(issue) {
|
|
45
|
+
const one = issue?.assignee?.login
|
|
46
|
+
if (typeof one === 'string' && one !== '') return one
|
|
47
|
+
const many = Array.isArray(issue?.assignees) ? issue.assignees : []
|
|
48
|
+
const first = many.find((x) => typeof x?.login === 'string' && x.login !== '')
|
|
49
|
+
return first === undefined ? '' : first.login
|
|
50
|
+
}
|
|
51
|
+
|
|
36
52
|
/**
|
|
37
53
|
* Поля задачи из issue.
|
|
38
54
|
*
|
|
@@ -60,6 +76,7 @@ export function issueToTask(issue, { board = 'main', column = 'backlog', owner,
|
|
|
60
76
|
issueUrl: typeof issue?.html_url === 'string' ? issue.html_url : undefined,
|
|
61
77
|
createdAt: issueBornAt(issue),
|
|
62
78
|
author: issueAuthor(issue),
|
|
79
|
+
assignee: issueAssignee(issue),
|
|
63
80
|
}
|
|
64
81
|
}
|
|
65
82
|
|
|
@@ -103,6 +120,12 @@ export function refreshPatch(task, issue) {
|
|
|
103
120
|
if (born !== undefined) patch.createdAt = born
|
|
104
121
|
const author = issueAuthor(issue)
|
|
105
122
|
if (author !== '') patch.author = author
|
|
123
|
+
// Снятие ответственного — тоже факт, поэтому пустое значение пишется наравне
|
|
124
|
+
// с именем: иначе доска показывала бы вчерашнего владельца задачи, с которой
|
|
125
|
+
// его уже сняли.
|
|
126
|
+
if (issue?.assignee !== undefined || Array.isArray(issue?.assignees)) {
|
|
127
|
+
patch.assignee = issueAssignee(issue)
|
|
128
|
+
}
|
|
106
129
|
return patch
|
|
107
130
|
}
|
|
108
131
|
|
package/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
MAX_BODY_BYTES, isTrustedRequest, parseTaskPath,
|
|
23
23
|
buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
|
|
24
24
|
taskBody,
|
|
25
|
-
taskLog, taskBySession, exportBoard, importBoard,
|
|
25
|
+
taskLog, taskBySession, exportBoard, importBoard, assignTask,
|
|
26
26
|
} from './routes.js'
|
|
27
27
|
import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
|
|
28
28
|
import {
|
|
@@ -36,7 +36,7 @@ import { isWatched } from './intake.js'
|
|
|
36
36
|
import { handleSessionEvent } from './lifecycle.js'
|
|
37
37
|
import { boardMoveDefinition, boardPlanDefinition } from './board-tool.js'
|
|
38
38
|
import { verifySignature, parseEvent } from './webhook.js'
|
|
39
|
-
import { planOutbound, createOutbox } from './outbound.js'
|
|
39
|
+
import { planOutbound, planAssign, createOutbox } from './outbound.js'
|
|
40
40
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
41
41
|
|
|
42
42
|
export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
|
|
@@ -387,6 +387,7 @@ export function apply(ctx, config) {
|
|
|
387
387
|
agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
|
|
388
388
|
permission: typeof input.permission === 'string' ? input.permission : undefined,
|
|
389
389
|
permissions: ctx.get('permissionPresets'),
|
|
390
|
+
claimAs: await whoAmI(),
|
|
390
391
|
}))
|
|
391
392
|
} catch (error) {
|
|
392
393
|
writeJson(res, error?.status ?? 500, { error: 'batch-failed' })
|
|
@@ -525,6 +526,33 @@ export function apply(ctx, config) {
|
|
|
525
526
|
// самый путь, на котором с первого дня живёт импорт issue из Gitea. Два
|
|
526
527
|
// обработчика на одном пути означают, что один из них не отвечает вовсе, и
|
|
527
528
|
// какой именно — решает порядок регистрации, а не мы.
|
|
529
|
+
/**
|
|
530
|
+
* Кто мы для Gitea, с памятью на время работы.
|
|
531
|
+
*
|
|
532
|
+
* Логин нужен и «взять себе», и запуску работы. Спрашивать его у Gitea
|
|
533
|
+
* каждый раз значит добавить в путь запуска ещё одну сеть, которая может не
|
|
534
|
+
* ответить; поэтому спрашиваем один раз и помним. Не ответила — работаем без
|
|
535
|
+
* назначения, а не отказываем в запуске.
|
|
536
|
+
*/
|
|
537
|
+
let knownLogin
|
|
538
|
+
const whoAmI = async () => {
|
|
539
|
+
if (knownLogin !== undefined) return knownLogin
|
|
540
|
+
try {
|
|
541
|
+
knownLogin = await gitea.me()
|
|
542
|
+
} catch (error) {
|
|
543
|
+
knownLogin = ''
|
|
544
|
+
}
|
|
545
|
+
return knownLogin
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
ctx.effect(() => ctx.webServer.register({
|
|
549
|
+
kind: 'exact',
|
|
550
|
+
path: '/dsh-kanban/whoami',
|
|
551
|
+
// Пустой логин — законный ответ: Gitea может быть не настроен или не
|
|
552
|
+
// отвечать. Браузер по нему просто не рисует «Мои», а не показывает ноль.
|
|
553
|
+
handler: async (req, res) => { reply(res, { login: await whoAmI() }) },
|
|
554
|
+
}), 'dsh-kanban: кто мы для Gitea')
|
|
555
|
+
|
|
528
556
|
ctx.effect(() => ctx.webServer.register({
|
|
529
557
|
kind: 'exact',
|
|
530
558
|
path: '/dsh-kanban/snapshot',
|
|
@@ -619,6 +647,7 @@ export function apply(ctx, config) {
|
|
|
619
647
|
agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
|
|
620
648
|
permission: typeof input.permission === 'string' ? input.permission : undefined,
|
|
621
649
|
permissions: ctx.get('permissionPresets'),
|
|
650
|
+
claimAs: await whoAmI(),
|
|
622
651
|
}))
|
|
623
652
|
} catch (error) {
|
|
624
653
|
ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
|
|
@@ -674,6 +703,25 @@ export function apply(ctx, config) {
|
|
|
674
703
|
reply(res, out)
|
|
675
704
|
return
|
|
676
705
|
}
|
|
706
|
+
if (req.method === 'POST' && action === 'assign') {
|
|
707
|
+
const was = store.getTask(id)
|
|
708
|
+
if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
709
|
+
// Пустой логин снимает назначение; слово «me» просит того, чьим
|
|
710
|
+
// токеном ходит доска: своего понятия пользователя у харнесса нет.
|
|
711
|
+
let login = typeof input.login === 'string' ? input.login.trim() : ''
|
|
712
|
+
if (login === 'me') {
|
|
713
|
+
login = await whoAmI()
|
|
714
|
+
if (login === '') { writeJson(res, 502, { error: 'gitea-unreachable' }); return }
|
|
715
|
+
}
|
|
716
|
+
const out = assignTask({ store, id, login })
|
|
717
|
+
if (out.task !== undefined && config.pushToGitea !== false) {
|
|
718
|
+
// В Gitea уходит очередью: доска не ждёт сеть и работает, когда
|
|
719
|
+
// Gitea лежит.
|
|
720
|
+
outbox.push(id, planAssign(out.task, login))
|
|
721
|
+
}
|
|
722
|
+
reply(res, out)
|
|
723
|
+
return
|
|
724
|
+
}
|
|
677
725
|
if (req.method === 'POST' && action === 'archive') {
|
|
678
726
|
reply(res, setArchived({ store, id, archived: true }))
|
|
679
727
|
return
|
package/lib/launcher.js
CHANGED
|
@@ -199,10 +199,34 @@ export async function obtainAgent({
|
|
|
199
199
|
* @param {string} [text] сообщение человека; пусто — встроенная заготовка
|
|
200
200
|
* @returns {{sessionId: string, mode: string, task: object}}
|
|
201
201
|
*/
|
|
202
|
+
/**
|
|
203
|
+
* Взять задачу на себя при запуске.
|
|
204
|
+
*
|
|
205
|
+
* Запустить работу и оставить задачу ничьей — значит показать её свободной,
|
|
206
|
+
* когда над ней уже работают. Чужое назначение не трогаем: задачу могли отдать
|
|
207
|
+
* другому осознанно.
|
|
208
|
+
*
|
|
209
|
+
* @returns {string} логин, если назначение случилось; иначе пустая строка
|
|
210
|
+
*/
|
|
211
|
+
export function claimOnStart({ store, task, login }) {
|
|
212
|
+
const who = typeof login === 'string' ? login.trim() : ''
|
|
213
|
+
if (who === '') return ''
|
|
214
|
+
if (typeof task?.assignee === 'string' && task.assignee !== '') return ''
|
|
215
|
+
store.updateTask(task.id, { assignee: who })
|
|
216
|
+
store.addTransition({
|
|
217
|
+
taskId: task.id,
|
|
218
|
+
fromCol: task.column,
|
|
219
|
+
toCol: task.column,
|
|
220
|
+
source: 'manual',
|
|
221
|
+
detail: `ответственный: ${who} (взял при запуске)`,
|
|
222
|
+
})
|
|
223
|
+
return who
|
|
224
|
+
}
|
|
225
|
+
|
|
202
226
|
export async function runTask({
|
|
203
227
|
agents, store, task, config, provider, model,
|
|
204
228
|
mintSessionId, createMessage, cwdOf, logger, text,
|
|
205
|
-
agentPreset, permission, permissions,
|
|
229
|
+
agentPreset, permission, permissions, claimAs,
|
|
206
230
|
}) {
|
|
207
231
|
const taken = await obtainAgent({
|
|
208
232
|
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
@@ -220,6 +244,9 @@ export async function runTask({
|
|
|
220
244
|
const before = store.getTask(task.id)
|
|
221
245
|
const patch = { sessionId: taken.sessionId, provider, model }
|
|
222
246
|
store.updateTask(task.id, patch)
|
|
247
|
+
// Запустил — значит отвечаешь: свободная задача становится твоей, чужая
|
|
248
|
+
// остаётся чужой.
|
|
249
|
+
claimOnStart({ store, task: before, login: claimAs })
|
|
223
250
|
|
|
224
251
|
// Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
|
|
225
252
|
// поймёт, куда делась переписка.
|
|
@@ -297,7 +324,7 @@ export function buildQueuedMessage(task, config, at, total) {
|
|
|
297
324
|
export async function runBatch({
|
|
298
325
|
agents, store, tasks, config, provider, model,
|
|
299
326
|
mintSessionId, createMessage, cwdOf, logger, text, now = Date.now(),
|
|
300
|
-
agentPreset, permission, permissions,
|
|
327
|
+
agentPreset, permission, permissions, claimAs,
|
|
301
328
|
}) {
|
|
302
329
|
const list = (tasks ?? []).filter((task) => task !== undefined)
|
|
303
330
|
if (list.length === 0) return { error: 'nothing-picked', status: 400 }
|
|
@@ -329,6 +356,9 @@ export async function runBatch({
|
|
|
329
356
|
// даже когда всё случилось в одну миллисекунду.
|
|
330
357
|
const patch = { sessionId: taken.sessionId, provider, model, queuedAt: at === 0 ? 0 : now + at }
|
|
331
358
|
store.updateTask(task.id, patch)
|
|
359
|
+
// Пачку берёт на себя тот, кто её запустил: пять карточек в работе без
|
|
360
|
+
// ответственного выглядят свободными.
|
|
361
|
+
claimOnStart({ store, task: before, login: claimAs })
|
|
332
362
|
if (at === 0 && before.column === 'backlog') {
|
|
333
363
|
store.moveTask(task.id, { column: 'in-progress' })
|
|
334
364
|
store.addTransition({
|
package/lib/outbound.js
CHANGED
|
@@ -13,6 +13,22 @@
|
|
|
13
13
|
// из состояния issue, ветки и pull request, а метка лишь пересказывала в Gitea
|
|
14
14
|
// то, что там уже написано. Взамен доска не трогает чужую разметку вовсе.
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Что отправить в Gitea при смене ответственного.
|
|
18
|
+
*
|
|
19
|
+
* Своей задачи (без issue) это не касается: назначать там некого и негде.
|
|
20
|
+
*/
|
|
21
|
+
export function planAssign(task, login) {
|
|
22
|
+
if (typeof task?.issueNumber !== 'number') return []
|
|
23
|
+
return [{
|
|
24
|
+
kind: 'assign',
|
|
25
|
+
owner: task.owner,
|
|
26
|
+
repo: task.repo,
|
|
27
|
+
index: task.issueNumber,
|
|
28
|
+
login: typeof login === 'string' ? login : '',
|
|
29
|
+
}]
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
export function planOutbound(task, column, config) {
|
|
17
33
|
if (typeof task?.issueNumber !== 'number') return []
|
|
18
34
|
// Закрытие issue — единственное, что доска сообщает Gitea: это факт, а не
|
|
@@ -58,6 +74,12 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
|
|
|
58
74
|
if (item.op.kind === 'close') {
|
|
59
75
|
await gitea.closeIssue({ owner: item.op.owner, repo: item.op.repo, index: item.op.index })
|
|
60
76
|
}
|
|
77
|
+
if (item.op.kind === 'assign') {
|
|
78
|
+
await gitea.setAssignees({
|
|
79
|
+
owner: item.op.owner, repo: item.op.repo, index: item.op.index,
|
|
80
|
+
logins: item.op.login === '' ? [] : [item.op.login],
|
|
81
|
+
})
|
|
82
|
+
}
|
|
61
83
|
sent += 1
|
|
62
84
|
} catch (error) {
|
|
63
85
|
item.attempts += 1
|