@goodandready/dsh-kanban 0.1.14

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 ADDED
@@ -0,0 +1,309 @@
1
+ // Клиент Gitea для доски.
2
+ //
3
+ // Своего клиента канбан держит намеренно: токен всё равно живёт в службе
4
+ // учётных данных ядра, а не в соседнем плагине, и шесть вызовов REST дешевле,
5
+ // чем зависимость от чужого выпуска.
6
+ //
7
+ // Сеть приходит параметром (`fetchImpl`), токен — функцией разрешения.
8
+ // Поэтому клиент проверяется без единого реального запроса.
9
+ //
10
+ // Токен в настройках не хранится: там только ИМЯ учётной записи. Наружу
11
+ // значение не отдаётся никогда, включая сообщения об ошибках.
12
+
13
+ /** Убрать хвостовые слэши: адрес приходит из поля, куда человек пишет как хочет. */
14
+ export function normalizeBaseUrl(url) {
15
+ return String(url || '').trim().replace(/\/+$/, '')
16
+ }
17
+
18
+ /**
19
+ * Сегмент пути, пригодный для подстановки в URL.
20
+ *
21
+ * `owner`, `repo` и номер приходят из данных Gitea и из браузера, а не из
22
+ * кода. Без проверки сюда подставится `..` или слэш, и запрос уйдёт не туда,
23
+ * куда собирались.
24
+ *
25
+ * @returns {string|undefined} закодированный сегмент либо undefined
26
+ */
27
+ export function safeSegment(value) {
28
+ const s = String(value ?? '').trim()
29
+ if (s === '' || s === '.' || s === '..') return undefined
30
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) return undefined
31
+ return encodeURIComponent(s)
32
+ }
33
+
34
+ /** Имя учётной записи — имя переменной окружения, а не сам секрет. */
35
+ export function isCredentialName(value) {
36
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(value || ''))
37
+ }
38
+
39
+ /** Похоже на сам токен, а не на его имя. */
40
+ export function looksLikeSecret(value) {
41
+ return /^[A-Za-z0-9_-]{32,}$/.test(String(value || '').trim())
42
+ }
43
+
44
+ /**
45
+ * Упорядочить репозитории по актуальности.
46
+ *
47
+ * Сверху те, где работа идёт прямо сейчас. Признак «идёт» выбран самый честный
48
+ * из доступных без лишних запросов: есть открытые задачи И репозиторий недавно
49
+ * трогали. Архивные уходят вниз всегда — там работы не ведут по определению.
50
+ *
51
+ * Точного «ведётся прямо сейчас» инстанс не знает, и выдумывать его не из чего;
52
+ * это приближение, а не истина.
53
+ */
54
+ export function rankRepos(repos) {
55
+ const rank = (r) => {
56
+ if (r.archived) return 3
57
+ if ((r.openIssues ?? 0) > 0) return 0
58
+ return 1
59
+ }
60
+ return [...(repos ?? [])].sort((a, b) => {
61
+ const byRank = rank(a) - rank(b)
62
+ if (byRank !== 0) return byRank
63
+ const byTime = String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))
64
+ if (byTime !== 0) return byTime
65
+ return String(a.fullName || '').localeCompare(String(b.fullName || ''))
66
+ })
67
+ }
68
+
69
+ /**
70
+ * Клиент Gitea.
71
+ *
72
+ * @param {object} options
73
+ * @param {() => object} options.getConfig текущие настройки плагина
74
+ * @param {(name: string) => Promise<string>} options.resolveToken разрешение учётной записи
75
+ * @param {Function} [options.fetchImpl] реализация fetch
76
+ * @param {number} [options.timeoutMs]
77
+ */
78
+ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch, timeoutMs = 30_000 }) {
79
+ async function request(method, path, body) {
80
+ const config = getConfig()
81
+ const baseUrl = normalizeBaseUrl(config?.giteaUrl)
82
+ if (baseUrl === '') throw Object.assign(new Error('адрес Gitea не задан'), { code: 'unconfigured' })
83
+
84
+ const token = await resolveToken(config?.giteaTokenRef)
85
+ if (!token) throw Object.assign(new Error('учётная запись с токеном не настроена'), { code: 'unconfigured' })
86
+
87
+ const controller = new AbortController()
88
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
89
+ try {
90
+ const res = await fetchImpl(`${baseUrl}/api/v1${path}`, {
91
+ method,
92
+ headers: {
93
+ // Заголовком, а не basic-авторизацией: на внутренних сетях basic
94
+ // отдаёт «Missing API key».
95
+ 'Authorization': `token ${token}`,
96
+ 'Content-Type': 'application/json',
97
+ 'Accept': 'application/json',
98
+ },
99
+ body: body === undefined ? undefined : JSON.stringify(body),
100
+ signal: controller.signal,
101
+ })
102
+ const text = await res.text()
103
+ let payload
104
+ try { payload = text.trim() === '' ? null : JSON.parse(text) } catch { payload = null }
105
+ if (!res.ok) {
106
+ // Тело ответа наружу не выносим: в нём может быть эхо заголовков.
107
+ throw Object.assign(new Error(`Gitea ответил ${res.status}`), { code: 'http', status: res.status })
108
+ }
109
+ return payload
110
+ } finally {
111
+ clearTimeout(timer)
112
+ }
113
+ }
114
+
115
+ function repoPath(owner, repo, suffix = '') {
116
+ const o = safeSegment(owner)
117
+ const r = safeSegment(repo)
118
+ if (o === undefined || r === undefined) {
119
+ throw Object.assign(new Error('недопустимый владелец или репозиторий'), { code: 'bad-segment' })
120
+ }
121
+ return `/repos/${o}/${r}${suffix}`
122
+ }
123
+
124
+ /**
125
+ * Собрать голый массив со всех страниц.
126
+ *
127
+ * Gitea отдаёт списки порциями, и запрос без перелистывания молча теряет
128
+ * хвост. Признак конца — неполная страница: инстанс отдал меньше, чем
129
+ * просили, значит дальше пусто. Верхний предел стоит, чтобы очень большой
130
+ * ответ не превратил сверку в бесконечный поход.
131
+ *
132
+ * @param {object} options
133
+ * @param {(page: number, size: number) => string} options.buildPath путь страницы
134
+ * @param {number} [options.limit] размер страницы
135
+ * @param {number} [options.maxPages] верхний предел страниц
136
+ * @returns {Promise<Array>} все строки со всех прочитанных страниц
137
+ */
138
+ async function collectPages({ buildPath, limit, maxPages = 10 }) {
139
+ const size = Math.min(Math.max(Number(limit) || 50, 1), 50)
140
+ const out = []
141
+ for (let page = 1; page <= maxPages; page += 1) {
142
+ const rows = await request('GET', buildPath(page, size))
143
+ if (!Array.isArray(rows)) break
144
+ out.push(...rows)
145
+ if (rows.length < size) break
146
+ }
147
+ return out
148
+ }
149
+
150
+ // Признак «настроено» кэшируется по ключу настроек. `isConfigured` стоит в
151
+ // горячем пути — его спрашивают на каждый запрос списка issue и поиска
152
+ // репозиториев, — а резолв токена это поход в хранилище учётных данных.
153
+ // Пока адрес и имя учётной записи не менялись, ответ не мог измениться.
154
+ let configuredCache
155
+ const configuredKey = () => {
156
+ const config = getConfig()
157
+ return `${normalizeBaseUrl(config?.giteaUrl)}\u0000${String(config?.giteaTokenRef ?? '')}`
158
+ }
159
+
160
+ return {
161
+ /** Настроен ли доступ. Значение токена наружу не отдаётся. */
162
+ async isConfigured() {
163
+ const key = configuredKey()
164
+ if (configuredCache?.key === key) return configuredCache.value
165
+ const config = getConfig()
166
+ const value = normalizeBaseUrl(config?.giteaUrl) !== ''
167
+ && isCredentialName(config?.giteaTokenRef)
168
+ && await resolveToken(config.giteaTokenRef).then(Boolean).catch(() => false)
169
+ configuredCache = { key, value }
170
+ return value
171
+ },
172
+
173
+ async listIssues({ owner, repo, state = 'open', limit = 50 }) {
174
+ // API отдаёт issue и pull request одним списком; доске нужны только issue.
175
+ // Перелистываем до конца: при 50+ открытых issue хвост иначе теряется
176
+ // молча, и подхват задач смотрит на неполную картину.
177
+ const rows = await collectPages({
178
+ limit,
179
+ buildPath: (page, size) => repoPath(owner, repo,
180
+ `/issues?state=${encodeURIComponent(state)}&page=${page}&limit=${size}&sort=updated&direction=desc`),
181
+ })
182
+ return rows.filter((r) => !r.pull_request)
183
+ },
184
+
185
+ async getIssue({ owner, repo, index }) {
186
+ const n = safeSegment(index)
187
+ if (n === undefined) throw Object.assign(new Error('недопустимый номер issue'), { code: 'bad-segment' })
188
+ return request('GET', repoPath(owner, repo, `/issues/${n}`))
189
+ },
190
+
191
+ async comment({ owner, repo, index, body }) {
192
+ const n = safeSegment(index)
193
+ if (n === undefined) throw Object.assign(new Error('недопустимый номер issue'), { code: 'bad-segment' })
194
+ return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
195
+ },
196
+
197
+ /** Завести issue. Метки не ставим: доска чужой разметкой не распоряжается. */
198
+ async createIssue({ owner, repo, title, body }) {
199
+ const name = String(title ?? '').trim()
200
+ if (name === '') throw Object.assign(new Error('пустой заголовок'), { code: 'bad-title' })
201
+ return request('POST', repoPath(owner, repo, '/issues'), { title: name, body: String(body ?? '') })
202
+ },
203
+
204
+ /**
205
+ * Завести репозиторий в организации.
206
+ *
207
+ * Приватный и пустой — так решил владелец: расширить права потом дешевле,
208
+ * чем убирать лишнее из публичного, а пустой репозиторий честнее чужих
209
+ * заготовок.
210
+ */
211
+ async createRepo({ owner, name, description = '' }) {
212
+ const safe = safeSegment(name)
213
+ if (safe === undefined) throw Object.assign(new Error('недопустимое имя'), { code: 'bad-segment' })
214
+ return request('POST', `/orgs/${safeSegment(owner)}/repos`, {
215
+ name: safe, description, private: true, auto_init: false,
216
+ })
217
+ },
218
+
219
+ async closeIssue({ owner, repo, index }) {
220
+ const n = safeSegment(index)
221
+ if (n === undefined) throw Object.assign(new Error('недопустимый номер issue'), { code: 'bad-segment' })
222
+ return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { state: 'closed' })
223
+ },
224
+
225
+ /** Организации, доступные токену. Нужны, чтобы не спрашивать владельца лишний раз. */
226
+ async listOrgs() {
227
+ const rows = await request('GET', '/user/orgs?limit=50')
228
+ return (Array.isArray(rows) ? rows : [])
229
+ .map((o) => String(o?.username ?? o?.name ?? ''))
230
+ .filter((name) => name !== '')
231
+ },
232
+
233
+ /**
234
+ * Репозитории организации с числом открытых задач.
235
+ *
236
+ * Число приходит в том же ответе, и по нему отсеиваются пустые: ходить за
237
+ * задачами в репозиторий, где их нет, — полсотни запросов впустую каждые
238
+ * две минуты. Перелистываем: при 100+ репозиториях одна страница теряла бы
239
+ * хвост, и доска смотрела бы на неполную организацию.
240
+ */
241
+ async listOrgRepos({ owner, limit = 100 }) {
242
+ const rows = await collectPages({
243
+ limit,
244
+ buildPath: (page, size) => `/orgs/${safeSegment(owner)}/repos?page=${page}&limit=${size}`,
245
+ })
246
+ return rows.map((r) => ({
247
+ name: String(r?.name ?? ''),
248
+ openIssues: Number(r?.open_issues_count ?? 0),
249
+ archived: r?.archived === true,
250
+ })).filter((r) => r.name !== '')
251
+ },
252
+
253
+ /** Pull request-ы репозитория. */
254
+ async listPulls({ owner, repo, state = 'all', limit = 50 }) {
255
+ return collectPages({
256
+ limit,
257
+ buildPath: (page, size) => repoPath(owner, repo,
258
+ `/pulls?state=${encodeURIComponent(state)}&page=${page}&limit=${size}&sort=recentupdate`),
259
+ })
260
+ },
261
+
262
+ /**
263
+ * Ветки репозитория: по ним видно, начата ли работа и сделан ли cleanup.
264
+ *
265
+ * Ветки перелистываются не ради полноты списка, а ради правды наблюдения:
266
+ * `branchOfTask` не найдёт ветку задачи на усечённой странице и сочтёт её
267
+ * отсутствующей — карточка уедет не туда на пустом месте.
268
+ */
269
+ async listBranches({ owner, repo, limit = 100 }) {
270
+ const rows = await collectPages({
271
+ limit,
272
+ buildPath: (page, size) => repoPath(owner, repo, `/branches?page=${page}&limit=${size}`),
273
+ })
274
+ return rows.map((b) => ({ name: b?.name ?? '' })).filter((b) => b.name !== '')
275
+ },
276
+
277
+ /**
278
+ * Репозитории инстанса, все страницы.
279
+ *
280
+ * Одной страницы не хватает: Gitea отдаёт их порциями, и запрос без
281
+ * перелистывания молча теряет хвост. Верхний предел стоит, чтобы очень
282
+ * большой инстанс не превратил открытие диалога в долгую загрузку.
283
+ *
284
+ * Число открытых задач и время правки приходят в том же ответе — считать
285
+ * их отдельными запросами не нужно.
286
+ */
287
+ async searchRepos({ query, limit = 50, maxPages = 10 }) {
288
+ const q = encodeURIComponent(String(query || ''))
289
+ const size = Math.min(Math.max(Number(limit) || 50, 1), 50)
290
+ const out = []
291
+ for (let page = 1; page <= maxPages; page += 1) {
292
+ const rows = await request('GET', `/repos/search?q=${q}&limit=${size}&page=${page}`)
293
+ const data = Array.isArray(rows?.data) ? rows.data : []
294
+ for (const r of data) {
295
+ out.push({
296
+ owner: r.owner?.login ?? '',
297
+ repo: r.name ?? '',
298
+ fullName: r.full_name ?? '',
299
+ openIssues: typeof r.open_issues_count === 'number' ? r.open_issues_count : 0,
300
+ updatedAt: typeof r.updated_at === 'string' ? r.updated_at : '',
301
+ archived: r.archived === true,
302
+ })
303
+ }
304
+ if (data.length < size) break
305
+ }
306
+ return rankRepos(out)
307
+ },
308
+ }
309
+ }
package/lib/import.js ADDED
@@ -0,0 +1,214 @@
1
+ // Перенос issue Gitea в задачу доски.
2
+ //
3
+ // Синхронизация в 0.1 односторонняя: Gitea → канбан, в момент импорта и по
4
+ // кнопке обновления. Двусторонняя синхронизация — отдельный выпуск, и тянуть
5
+ // её сюда нельзя.
6
+ //
7
+ // Клиент Gitea приходит параметром — его собирает `lib/gitea.js`. Здесь только
8
+ // перенос полей и решение, что делать, когда доступ не настроен.
9
+
10
+ import { safeSegment } from './gitea.js'
11
+ import { colorsOfIssue } from './filters.js'
12
+
13
+ /**
14
+ * Когда issue завели в Gitea.
15
+ *
16
+ * Без этого карточка показывала бы дату подхвата: задача годичной давности
17
+ * выглядела бы заведённой сегодня, и по дате нельзя было бы понять ничего.
18
+ */
19
+ export function issueBornAt(issue) {
20
+ const at = Date.parse(issue?.created_at ?? '')
21
+ return Number.isFinite(at) && at > 0 ? at : undefined
22
+ }
23
+
24
+ /**
25
+ * Кто завёл issue.
26
+ *
27
+ * Берём `login`: он же стоит в Gitea под задачей и по нему её ищут. Полное
28
+ * имя заполнено не у всех, и подставлять его вместо логина значило бы
29
+ * показывать то одно, то другое.
30
+ */
31
+ export function issueAuthor(issue) {
32
+ const login = issue?.user?.login
33
+ return typeof login === 'string' ? login : ''
34
+ }
35
+
36
+ /**
37
+ * Поля задачи из issue.
38
+ *
39
+ * `owner` и `repo` берутся из запроса, а не из issue: API Gitea возвращает их
40
+ * не во всех формах ответа, и полагаться на это нельзя.
41
+ *
42
+ * Поля `branch`, `worktree`, `sessionId`, `model` НЕ заполняются: они
43
+ * наблюдаются позже, когда работа начнётся. Плагин ветки не создаёт.
44
+ */
45
+ export function issueToTask(issue, { board = 'main', column = 'backlog', owner, repo }) {
46
+ return {
47
+ board,
48
+ column,
49
+ title: typeof issue?.title === 'string' ? issue.title : '',
50
+ body: typeof issue?.body === 'string' ? issue.body : '',
51
+ labels: Array.isArray(issue?.labels)
52
+ ? issue.labels.map((l) => (typeof l === 'string' ? l : l?.name)).filter((n) => typeof n === 'string')
53
+ : [],
54
+ // Цвета едут рядом с именами, а не вместо них: имена читают отборы,
55
+ // сверка, чип и поиск.
56
+ labelColors: colorsOfIssue(issue?.labels),
57
+ owner,
58
+ repo,
59
+ issueNumber: typeof issue?.number === 'number' ? issue.number : undefined,
60
+ issueUrl: typeof issue?.html_url === 'string' ? issue.html_url : undefined,
61
+ createdAt: issueBornAt(issue),
62
+ author: issueAuthor(issue),
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Пометить issue, которые уже лежат на доске.
68
+ *
69
+ * Сравнение идёт по тройке владелец–репозиторий–номер. Сравнение по одному
70
+ * номеру пометило бы задачу №1 из чужого репозитория как уже импортированную.
71
+ *
72
+ * Помеченные не прячутся, а приглушаются: задачу иногда заводят второй раз
73
+ * осознанно.
74
+ */
75
+ export function markImported(issues, existing, { owner, repo }) {
76
+ const seen = new Set(
77
+ (existing ?? [])
78
+ .filter((t) => t.owner === owner && t.repo === repo && typeof t.issueNumber === 'number')
79
+ .map((t) => t.issueNumber),
80
+ )
81
+ return (issues ?? []).map((issue) => ({ ...issue, imported: seen.has(issue?.number) }))
82
+ }
83
+
84
+ /**
85
+ * Что переписать в задаче при обновлении из issue.
86
+ *
87
+ * Переписываются только поля, пришедшие из Gitea. Локальные — колонка,
88
+ * позиция, сессия, модель — не трогаются: иначе обновление сбрасывало бы
89
+ * работу, которая уже идёт.
90
+ */
91
+ export function refreshPatch(task, issue) {
92
+ const patch = {}
93
+ if (typeof issue?.title === 'string') patch.title = issue.title
94
+ if (typeof issue?.body === 'string') patch.body = issue.body
95
+ if (Array.isArray(issue?.labels)) {
96
+ patch.labels = issue.labels
97
+ .map((l) => (typeof l === 'string' ? l : l?.name))
98
+ .filter((n) => typeof n === 'string')
99
+ patch.labelColors = colorsOfIssue(issue.labels)
100
+ }
101
+ if (typeof issue?.html_url === 'string') patch.issueUrl = issue.html_url
102
+ const born = issueBornAt(issue)
103
+ if (born !== undefined) patch.createdAt = born
104
+ const author = issueAuthor(issue)
105
+ if (author !== '') patch.author = author
106
+ return patch
107
+ }
108
+
109
+ /**
110
+ * Клиент Gitea готов к работе. Отсутствие клиента и ненастроенный доступ —
111
+ * разные беды, и человеку они говорят разное.
112
+ *
113
+ * Проверка асинхронная, потому что готовность включает разрешение учётной
114
+ * записи с токеном, а это обращение к службе учётных данных.
115
+ */
116
+ export async function giteaState(gitea) {
117
+ if (gitea === undefined || gitea === null) return 'absent'
118
+ if (typeof gitea.isConfigured !== 'function') return 'ready'
119
+ try {
120
+ return (await gitea.isConfigured()) === true ? 'ready' : 'unconfigured'
121
+ } catch {
122
+ return 'unconfigured'
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Список issue для диалога импорта: открытые, уже импортированные помечены.
128
+ */
129
+ export async function listImportable({ gitea, store, owner, repo, board = 'main' }) {
130
+ const state = await giteaState(gitea)
131
+ if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
132
+ const issues = await gitea.listIssues({ owner, repo, state: 'open' })
133
+ const existing = store.listTasks({ board })
134
+ return { issues: markImported(issues, existing, { owner, repo }) }
135
+ }
136
+
137
+ /** Завести задачу из issue. */
138
+ export async function importIssue({ gitea, store, owner, repo, issueNumber, board = 'main', column = 'backlog' }) {
139
+ const state = await giteaState(gitea)
140
+ if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
141
+ if (typeof issueNumber !== 'number') return { error: 'issue-number-required', status: 400 }
142
+ // Простая доска — для задач без issue. Импорт на неё привёл бы задачу с
143
+ // веткой и PR в набор колонок, где их некуда показать.
144
+ if (store.boardKind(board) !== 'project') return { error: 'board-not-for-issues', status: 400 }
145
+ const issue = await gitea.getIssue({ owner, repo, index: issueNumber })
146
+ if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
147
+ return { task: store.createTask(issueToTask(issue, { board, column, owner, repo })) }
148
+ }
149
+
150
+ /** Перечитать issue и обновить задачу, не трогая локальные поля. */
151
+ export async function refreshTask({ gitea, store, id }) {
152
+ const state = await giteaState(gitea)
153
+ if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
154
+ const task = store.getTask(id)
155
+ if (task === undefined) return { error: 'task-not-found', status: 404 }
156
+ if (typeof task.issueNumber !== 'number') return { error: 'task-has-no-issue', status: 400 }
157
+ const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber })
158
+ if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
159
+ return { task: store.updateTask(id, refreshPatch(task, issue)) }
160
+ }
161
+
162
+ /** Поиск репозиториев для диалога импорта. */
163
+ export async function searchRepos({ gitea, query, limit = 20 }) {
164
+ const state = await giteaState(gitea)
165
+ if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
166
+ return { repos: await gitea.searchRepos({ query, limit }) }
167
+ }
168
+
169
+ /**
170
+ * Завести проектную задачу: issue в Gitea плюс карточка, привязанная к нему.
171
+ *
172
+ * Репозиторий либо выбирается из существующих, либо заводится по имени.
173
+ * Заводит его ЧЕЛОВЕК кнопкой: инструмента у агента для этого нет — он
174
+ * пользуется `gitea_issue_create` из соседнего плагина, а карточка приезжает
175
+ * подхватом.
176
+ *
177
+ * Порядок неслучаен: сперва репозиторий, потом issue, потом карточка. Обрыв на
178
+ * середине не должен оставить карточку привязанной к репозиторию без issue —
179
+ * лучше не завести карточку вовсе и сказать об этом, чем завести полупустую.
180
+ */
181
+ export async function createProjectTask({
182
+ gitea, store, owner, repo, newRepo, title, body, board = 'main',
183
+ }) {
184
+ const state = await giteaState(gitea)
185
+ if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
186
+
187
+ const name = typeof title === 'string' ? title.trim() : ''
188
+ if (name === '') return { error: 'title-required', status: 400 }
189
+
190
+ let targetRepo = typeof repo === 'string' ? repo.trim() : ''
191
+ if (targetRepo === '') {
192
+ const wanted = typeof newRepo === 'string' ? newRepo.trim() : ''
193
+ if (wanted === '') return { error: 'repo-required', status: 400 }
194
+ // Имя проверяем ДО запроса: отказ Gitea пришёл бы позже и невнятнее.
195
+ if (safeSegment(wanted) === undefined) return { error: 'bad-repo-name', status: 400 }
196
+ try {
197
+ await gitea.createRepo({ owner, name: wanted })
198
+ } catch (error) {
199
+ return { error: 'repo-not-created', status: 502, detail: String(error?.message ?? error) }
200
+ }
201
+ targetRepo = wanted
202
+ }
203
+
204
+ let issue
205
+ try {
206
+ issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body })
207
+ } catch (error) {
208
+ // Репозиторий, возможно, уже создан. Не удаляем его: удаление чужого
209
+ // добра из-за своей неудачи хуже лишнего пустого репозитория.
210
+ return { error: 'issue-not-created', status: 502, detail: String(error?.message ?? error) }
211
+ }
212
+
213
+ return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo })) }
214
+ }