@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/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