@goodandready/dsh-kanban 0.1.20 → 0.1.21

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'
@@ -674,6 +674,29 @@ export function apply(ctx, config) {
674
674
  reply(res, out)
675
675
  return
676
676
  }
677
+ if (req.method === 'POST' && action === 'assign') {
678
+ const was = store.getTask(id)
679
+ if (was === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
680
+ // Пустой логин снимает назначение; слово «me» просит того, чьим
681
+ // токеном ходит доска: своего понятия пользователя у харнесса нет.
682
+ let login = typeof input.login === 'string' ? input.login.trim() : ''
683
+ if (login === 'me') {
684
+ try {
685
+ login = await gitea.me()
686
+ } catch (error) {
687
+ writeJson(res, 502, { error: 'gitea-unreachable' })
688
+ return
689
+ }
690
+ }
691
+ const out = assignTask({ store, id, login })
692
+ if (out.task !== undefined && config.pushToGitea !== false) {
693
+ // В Gitea уходит очередью: доска не ждёт сеть и работает, когда
694
+ // Gitea лежит.
695
+ outbox.push(id, planAssign(out.task, login))
696
+ }
697
+ reply(res, out)
698
+ return
699
+ }
677
700
  if (req.method === 'POST' && action === 'archive') {
678
701
  reply(res, setArchived({ store, id, archived: true }))
679
702
  return
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