@goodandready/dsh-kanban 0.2.19 → 0.2.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
@@ -1,28 +1,24 @@
1
- // Клиент Gitea для доски.
1
+ // Gitea HTTP API client.
2
2
  //
3
- // Своего клиента канбан держит намеренно: токен всё равно живёт в службе
4
- // учётных данных ядра, а не в соседнем плагине, и шесть вызовов REST дешевле,
5
- // чем зависимость от чужого выпуска.
3
+ // Standalone module without external dependencies, communicating directly with Gitea v1 API.
4
+ // Network calls use injectable fetch (`fetchImpl`), and token resolution is provided via callback.
5
+ // The client can be unit tested without making real network requests.
6
6
  //
7
- // Сеть приходит параметром (`fetchImpl`), токен — функцией разрешения.
8
- // Поэтому клиент проверяется без единого реального запроса.
9
- //
10
- // Токен в настройках не хранится: там только ИМЯ учётной записи. Наружу
11
- // значение не отдаётся никогда, включая сообщения об ошибках.
7
+ // Secrets are not stored in settings; only the credential name is stored.
8
+ // Token values are never emitted to logs or error messages.
12
9
 
13
- /** Убрать хвостовые слэши: адрес приходит из поля, куда человек пишет как хочет. */
10
+ /** Strip trailing slashes from base URL. */
14
11
  export function normalizeBaseUrl(url) {
15
12
  return String(url || '').trim().replace(/\/+$/, '')
16
13
  }
17
14
 
18
15
  /**
19
- * Сегмент пути, пригодный для подстановки в URL.
16
+ * Path segment sanitized for URL interpolation.
20
17
  *
21
- * `owner`, `repo` и номер приходят из данных Gitea и из браузера, а не из
22
- * кода. Без проверки сюда подставится `..` или слэш, и запрос уйдёт не туда,
23
- * куда собирались.
18
+ * `owner`, `repo`, and issue index originate from external data or user input.
19
+ * Sanitization prevents directory traversal or path manipulation.
24
20
  *
25
- * @returns {string|undefined} закодированный сегмент либо undefined
21
+ * @returns {string|undefined} encoded segment or undefined if invalid
26
22
  */
27
23
  export function safeSegment(value) {
28
24
  const s = String(value ?? '').trim()
@@ -31,27 +27,30 @@ export function safeSegment(value) {
31
27
  return encodeURIComponent(s)
32
28
  }
33
29
 
34
- /** Имя учётной записи — имя переменной окружения, а не сам секрет. */
30
+ /** Credential reference name (e.g. env var name), distinct from the raw secret. */
35
31
  export function isCredentialName(value) {
36
32
  const str = String(value || '')
37
33
  if (looksLikeSecret(str)) return false
38
34
  return /^[A-Za-z_][A-Za-z0-9_]*$/.test(str)
39
35
  }
40
36
 
41
- /** Похоже на сам токен, а не на его имя. */
37
+ /** Check if value looks like a raw token secret rather than a credential reference name. */
42
38
  export function looksLikeSecret(value) {
43
- return /^[A-Za-z0-9_-]{32,}$/.test(String(value || '').trim())
39
+ const str = String(value || '').trim()
40
+ if (str === '') return false
41
+ if (/^(ghp_|gho_|github_pat_|glpat-|bearer\s+|token\s+)/i.test(str)) return true
42
+ if (/^[0-9a-f]{40}$/i.test(str)) return true
43
+ if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(str)) return true
44
+ // Raw alphanumeric token >= 32 characters without underscores
45
+ if (/^[A-Za-z0-9]{32,}$/.test(str)) return true
46
+ return false
44
47
  }
45
48
 
46
49
  /**
47
- * Упорядочить репозитории по актуальности.
48
- *
49
- * Сверху те, где работа идёт прямо сейчас. Признак «идёт» выбран самый честный
50
- * из доступных без лишних запросов: есть открытые задачи И репозиторий недавно
51
- * трогали. Архивные уходят вниз всегда — там работы не ведут по определению.
50
+ * Rank repositories by activity.
52
51
  *
53
- * Точного «ведётся прямо сейчас» инстанс не знает, и выдумывать его не из чего;
54
- * это приближение, а не истина.
52
+ * Places active repositories with open tasks and recent updates at the top.
53
+ * Archived repositories are sorted to the bottom.
55
54
  */
56
55
  export function rankRepos(repos) {
57
56
  const rank = (r) => {
@@ -69,12 +68,12 @@ export function rankRepos(repos) {
69
68
  }
70
69
 
71
70
  /**
72
- * Клиент Gitea.
71
+ * Gitea client factory.
73
72
  *
74
73
  * @param {object} options
75
- * @param {() => object} options.getConfig текущие настройки плагина
76
- * @param {(name: string) => Promise<string>} options.resolveToken разрешение учётной записи
77
- * @param {Function} [options.fetchImpl] реализация fetch
74
+ * @param {() => object} options.getConfig current plugin configuration
75
+ * @param {(name: string) => Promise<string>} options.resolveToken credential resolver callback
76
+ * @param {Function} [options.fetchImpl] fetch implementation
78
77
  * @param {number} [options.timeoutMs]
79
78
  */
80
79
  export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch, timeoutMs = 30_000 }) {
@@ -92,8 +91,7 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
92
91
  const res = await fetchImpl(`${baseUrl}/api/v1${path}`, {
93
92
  method,
94
93
  headers: {
95
- // Заголовком, а не basic-авторизацией: на внутренних сетях basic
96
- // отдаёт «Missing API key».
94
+ // Use header authorization rather than basic auth to avoid 'Missing API key' on private instances.
97
95
  'Authorization': `token ${token}`,
98
96
  'Content-Type': 'application/json',
99
97
  'Accept': 'application/json',
@@ -105,7 +103,7 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
105
103
  let payload
106
104
  try { payload = text.trim() === '' ? null : JSON.parse(text) } catch { payload = null }
107
105
  if (!res.ok) {
108
- // Тело ответа наружу не выносим: в нём может быть эхо заголовков.
106
+ // Do not leak raw response body in error messages to prevent echoing sensitive headers.
109
107
  throw Object.assign(new Error(`Gitea HTTP error ${res.status}`), { code: 'http', status: res.status })
110
108
  }
111
109
  return payload
@@ -124,18 +122,13 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
124
122
  }
125
123
 
126
124
  /**
127
- * Собрать голый массив со всех страниц.
128
- *
129
- * Gitea отдаёт списки порциями, и запрос без перелистывания молча теряет
130
- * хвост. Признак конца — неполная страница: инстанс отдал меньше, чем
131
- * просили, значит дальше пусто. Верхний предел стоит, чтобы очень большой
132
- * ответ не превратил сверку в бесконечный поход.
125
+ * Paginate and collect all items from Gitea endpoints up to maxPages.
133
126
  *
134
127
  * @param {object} options
135
- * @param {(page: number, size: number) => string} options.buildPath путь страницы
136
- * @param {number} [options.limit] размер страницы
137
- * @param {number} [options.maxPages] верхний предел страниц
138
- * @returns {Promise<Array>} все строки со всех прочитанных страниц
128
+ * @param {(page: number, size: number) => string} options.buildPath page path builder
129
+ * @param {number} [options.limit] page size
130
+ * @param {number} [options.maxPages] maximum pages to retrieve
131
+ * @returns {Promise<Array>} concatenated results from all pages
139
132
  */
140
133
  async function collectPages({ buildPath, limit, maxPages = 10 }) {
141
134
  const size = Math.min(Math.max(Number(limit) || 50, 1), 50)
@@ -149,10 +142,8 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
149
142
  return out
150
143
  }
151
144
 
152
- // Признак «настроено» кэшируется по ключу настроек. `isConfigured` стоит в
153
- // горячем пути — его спрашивают на каждый запрос списка issue и поиска
154
- // репозиториев, — а резолв токена это поход в хранилище учётных данных.
155
- // Пока адрес и имя учётной записи не менялись, ответ не мог измениться.
145
+ // Cache configuration check result keyed on URL and token reference to avoid
146
+ // repeated credential lookups on hot paths.
156
147
  let configuredCache
157
148
  const configuredKey = () => {
158
149
  const config = getConfig()
@@ -160,7 +151,7 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
160
151
  }
161
152
 
162
153
  return {
163
- /** Настроен ли доступ. Значение токена наружу не отдаётся. */
154
+ /** Check whether access is configured. Token value is never exposed. */
164
155
  async isConfigured() {
165
156
  const key = configuredKey()
166
157
  if (configuredCache?.key === key) return configuredCache.value
@@ -173,46 +164,38 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
173
164
  },
174
165
 
175
166
  async listIssues({ owner, repo, state = 'open', limit = 50 }) {
176
- // API отдаёт issue и pull request одним списком; доске нужны только issue.
177
- // Перелистываем до конца: при 50+ открытых issue хвост иначе теряется
178
- // молча, и подхват задач смотрит на неполную картину.
167
+ // API returns issues and PRs together; filter out pull requests and paginate completely.
179
168
  const rows = await collectPages({
180
169
  limit,
181
170
  buildPath: (page, size) => repoPath(owner, repo,
182
- `/issues?state=${encodeURIComponent(state)}&page=${page}&limit=${size}&sort=updated&direction=desc`),
171
+ `/issues?state=${encodeURIComponent(state)}&page=${page}&limit=${size}`),
183
172
  })
184
- return rows.filter((r) => !r.pull_request)
173
+ return rows.filter((i) => i.pull_request === undefined)
185
174
  },
186
175
 
187
176
  async getIssue({ owner, repo, index }) {
188
177
  const n = safeSegment(index)
189
- if (n === undefined) throw Object.assign(new Error('недопустимый номер issue'), { code: 'bad-segment' })
178
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
190
179
  return request('GET', repoPath(owner, repo, `/issues/${n}`))
191
180
  },
192
181
 
193
182
  async comment({ owner, repo, index, body }) {
194
183
  const n = safeSegment(index)
195
- if (n === undefined) throw Object.assign(new Error('недопустимый номер issue'), { code: 'bad-segment' })
184
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
196
185
  return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
197
186
  },
198
187
 
199
- /** Завести issue. Метки не ставим: доска чужой разметкой не распоряжается. */
188
+ /** Create issue. Labels are omitted here to respect repository conventions. */
200
189
  async createIssue({ owner, repo, title, body }) {
201
190
  const name = String(title ?? '').trim()
202
- if (name === '') throw Object.assign(new Error('пустой заголовок'), { code: 'bad-title' })
191
+ if (name === '') throw Object.assign(new Error('empty title'), { code: 'bad-title' })
203
192
  return request('POST', repoPath(owner, repo, '/issues'), { title: name, body: String(body ?? '') })
204
193
  },
205
194
 
206
- /**
207
- * Завести репозиторий в организации.
208
- *
209
- * Приватный и пустой — так решил владелец: расширить права потом дешевле,
210
- * чем убирать лишнее из публичного, а пустой репозиторий честнее чужих
211
- * заготовок.
212
- */
195
+ /** Create private, empty repository in organization. */
213
196
  async createRepo({ owner, name, description = '' }) {
214
197
  const safe = safeSegment(name)
215
- if (safe === undefined) throw Object.assign(new Error('недопустимое имя'), { code: 'bad-segment' })
198
+ if (safe === undefined) throw Object.assign(new Error('invalid name'), { code: 'bad-segment' })
216
199
  return request('POST', `/orgs/${safeSegment(owner)}/repos`, {
217
200
  name: safe, description, private: true, auto_init: false,
218
201
  })
@@ -220,39 +203,27 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
220
203
 
221
204
  async closeIssue({ owner, repo, index }) {
222
205
  const n = safeSegment(index)
223
- if (n === undefined) throw Object.assign(new Error('недопустимый номер issue'), { code: 'bad-segment' })
206
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
224
207
  return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { state: 'closed' })
225
208
  },
226
209
 
227
- /**
228
- * Назначить ответственного за issue.
229
- *
230
- * Пустой список снимает назначение: Gitea понимает это как «никто», и
231
- * доска обязана уметь то же самое — иначе снять с себя задачу можно было
232
- * бы только руками в Gitea.
233
- */
210
+ /** Set issue assignees. Empty array clears assignees. */
234
211
  async setAssignees({ owner, repo, index, logins }) {
235
212
  const n = safeSegment(index)
236
- if (n === undefined) throw Object.assign(new Error('недопустимый номер issue'), { code: 'bad-segment' })
213
+ if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
237
214
  const list = (Array.isArray(logins) ? logins : [])
238
215
  .filter((one) => typeof one === 'string' && one !== '')
239
216
  return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { assignees: list })
240
217
  },
241
218
 
242
- /**
243
- * Кто мы для Gitea.
244
- *
245
- * «Взять себе» на доске значит «назначить того, чьим токеном доска ходит»:
246
- * своего понятия пользователя у харнесса нет, и выдумывать его вместо
247
- * ответа сервера нельзя.
248
- */
219
+ /** Authenticated user identity for the token. */
249
220
  async me() {
250
221
  const row = await request('GET', '/user')
251
222
  const login = row?.login
252
223
  return typeof login === 'string' ? login : ''
253
224
  },
254
225
 
255
- /** Организации, доступные токену. Нужны, чтобы не спрашивать владельца лишний раз. */
226
+ /** List organizations accessible to the token. */
256
227
  async listOrgs() {
257
228
  const rows = await request('GET', '/user/orgs?limit=50')
258
229
  return (Array.isArray(rows) ? rows : [])
@@ -260,14 +231,7 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
260
231
  .filter((name) => name !== '')
261
232
  },
262
233
 
263
- /**
264
- * Репозитории организации с числом открытых задач.
265
- *
266
- * Число приходит в том же ответе, и по нему отсеиваются пустые: ходить за
267
- * задачами в репозиторий, где их нет, — полсотни запросов впустую каждые
268
- * две минуты. Перелистываем: при 100+ репозиториях одна страница теряла бы
269
- * хвост, и доска смотрела бы на неполную организацию.
270
- */
234
+ /** List organization repositories with open issue counts, paginating to handle large orgs. */
271
235
  async listOrgRepos({ owner, limit = 100 }) {
272
236
  const rows = await collectPages({
273
237
  limit,
@@ -280,7 +244,7 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
280
244
  })).filter((r) => r.name !== '')
281
245
  },
282
246
 
283
- /** Pull request-ы репозитория. */
247
+ /** List pull requests for repository. */
284
248
  async listPulls({ owner, repo, state = 'all', limit = 50 }) {
285
249
  return collectPages({
286
250
  limit,
@@ -289,13 +253,13 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
289
253
  })
290
254
  },
291
255
 
292
- /** Создать Pull Request в репозитории. */
256
+ /** Create pull request in repository. */
293
257
  async createPullRequest({ owner, repo, title, body, head, base = 'main' }) {
294
258
  const name = String(title ?? '').trim()
295
- if (name === '') throw Object.assign(new Error('пустой заголовок'), { code: 'bad-title' })
259
+ if (name === '') throw Object.assign(new Error('empty title'), { code: 'bad-title' })
296
260
  const h = String(head ?? '').trim()
297
261
  const b = String(base ?? 'main').trim()
298
- if (!h) throw Object.assign(new Error('ветка head обязательна'), { code: 'bad-head' })
262
+ if (!h) throw Object.assign(new Error('head branch required'), { code: 'bad-head' })
299
263
  return request('POST', repoPath(owner, repo, '/pulls'), {
300
264
  title: name,
301
265
  body: String(body ?? ''),
@@ -304,13 +268,7 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
304
268
  })
305
269
  },
306
270
 
307
- /**
308
- * Ветки репозитория: по ним видно, начата ли работа и сделан ли cleanup.
309
- *
310
- * Ветки перелистываются не ради полноты списка, а ради правды наблюдения:
311
- * `branchOfTask` не найдёт ветку задачи на усечённой странице и сочтёт её
312
- * отсутствующей — карточка уедет не туда на пустом месте.
313
- */
271
+ /** List repository branches with pagination to avoid false negatives when detecting active branches. */
314
272
  async listBranches({ owner, repo, limit = 100 }) {
315
273
  const rows = await collectPages({
316
274
  limit,
@@ -319,16 +277,7 @@ export function createGiteaClient({ getConfig, resolveToken, fetchImpl = fetch,
319
277
  return rows.map((b) => ({ name: b?.name ?? '' })).filter((b) => b.name !== '')
320
278
  },
321
279
 
322
- /**
323
- * Репозитории инстанса, все страницы.
324
- *
325
- * Одной страницы не хватает: Gitea отдаёт их порциями, и запрос без
326
- * перелистывания молча теряет хвост. Верхний предел стоит, чтобы очень
327
- * большой инстанс не превратил открытие диалога в долгую загрузку.
328
- *
329
- * Число открытых задач и время правки приходят в том же ответе — считать
330
- * их отдельными запросами не нужно.
331
- */
280
+ /** Search repositories across instance with pagination up to maxPages. */
332
281
  async searchRepos({ query, limit = 50, maxPages = 10 }) {
333
282
  const q = encodeURIComponent(String(query || ''))
334
283
  const size = Math.min(Math.max(Number(limit) || 50, 1), 50)
package/lib/github.js CHANGED
@@ -1,12 +1,8 @@
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.
1
+ // GitHub REST API v3 client.
2
+ // Equivalent functionality to GiteaClient: reading issues, pull requests,
3
+ // branches, repositories, creating PRs and comments.
4
+ // Supports conditional requests with ETag (If-None-Match) to avoid rate limits.
5
+ // Isolated from filesystem and DSH: token and configuration injected via factory.
10
6
 
11
7
  const DEFAULT_BASE_URL = 'https://api.github.com'
12
8
 
@@ -17,12 +13,18 @@ export function normalizeGithubUrl(raw) {
17
13
  return match ? match[0].replace(/\/+$/, '') : DEFAULT_BASE_URL
18
14
  }
19
15
 
16
+ export function safeIssueIndex(value) {
17
+ const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? '').trim(), 10)
18
+ if (!Number.isSafeInteger(n) || n <= 0) return undefined
19
+ if (typeof value === 'string' && String(n) !== value.trim()) return undefined
20
+ return n
21
+ }
22
+
20
23
  export function safeSegment(value) {
21
24
  const s = String(value ?? '').trim()
22
- if (!s || s.includes('/') || s.includes('\\') || s.includes('..') || s.includes('\0')) {
23
- return undefined
24
- }
25
- return s
25
+ if (!s || s === '.' || s === '..') return undefined
26
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) return undefined
27
+ return encodeURIComponent(s)
26
28
  }
27
29
 
28
30
  export function isCredentialName(value) {
@@ -32,16 +34,16 @@ export function isCredentialName(value) {
32
34
  }
33
35
 
34
36
  /**
35
- * Создать клиент GitHub API.
37
+ * Create GitHub API client.
36
38
  *
37
39
  * @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] таймаут запроса в мс
40
+ * @param {() => object} options.getConfig config getter
41
+ * @param {(name: string) => Promise<string>} options.resolveToken token resolver
42
+ * @param {typeof fetch} [options.fetchImpl] fetch implementation
43
+ * @param {number} [options.timeoutMs] request timeout in ms
42
44
  */
43
45
  export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch, timeoutMs = 30_000 }) {
44
- // Кэш условных запросов ETag: url -> { etag, data }
46
+ // ETag conditional request cache: url -> { etag, data }
45
47
  const etagCache = new Map()
46
48
 
47
49
  async function request(method, path, body, { useEtag = false } = {}) {
@@ -144,7 +146,7 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
144
146
  return {
145
147
  provider: 'github',
146
148
 
147
- /** Проверен ли токен доступа. */
149
+ /** Whether access token is valid. */
148
150
  async isConfigured() {
149
151
  const key = configuredKey()
150
152
  if (configuredCache?.key === key) return configuredCache.value
@@ -156,12 +158,12 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
156
158
  return value
157
159
  },
158
160
 
159
- /** Очистить кэш ETag (например, при явном ручном обновлении). */
161
+ /** Clear ETag cache (e.g. on manual refresh). */
160
162
  clearCache() {
161
163
  etagCache.clear()
162
164
  },
163
165
 
164
- /** Список issues репозитория (без PR). */
166
+ /** List repository issues (excluding PRs). */
165
167
  async listIssues({ owner, repo, state = 'open', limit = 50 }) {
166
168
  const rows = await collectPages({
167
169
  limit,
@@ -172,28 +174,28 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
172
174
  return rows.filter((r) => !r.pull_request)
173
175
  },
174
176
 
175
- /** Получить один issue по номеру. */
177
+ /** Get single issue by number. */
176
178
  async getIssue({ owner, repo, index }) {
177
- const n = safeSegment(index)
179
+ const n = safeIssueIndex(index)
178
180
  if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
179
181
  return request('GET', repoPath(owner, repo, `/issues/${n}`))
180
182
  },
181
183
 
182
- /** Оставить комментарий в issue. */
184
+ /** Post comment on issue. */
183
185
  async comment({ owner, repo, index, body }) {
184
- const n = safeSegment(index)
186
+ const n = safeIssueIndex(index)
185
187
  if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
186
188
  return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
187
189
  },
188
190
 
189
- /** Создать новый issue. */
191
+ /** Create new issue. */
190
192
  async createIssue({ owner, repo, title, body }) {
191
193
  const name = String(title ?? '').trim()
192
194
  if (name === '') throw Object.assign(new Error('empty issue title'), { code: 'bad-title' })
193
195
  return request('POST', repoPath(owner, repo, '/issues'), { title: name, body: String(body ?? '') })
194
196
  },
195
197
 
196
- /** Закрыть issue. */
198
+ /** Close issue. */
197
199
  async closeIssue({ owner, repo, index }) {
198
200
  const n = safeSegment(index)
199
201
  if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
@@ -201,8 +203,8 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
201
203
  },
202
204
 
203
205
  /**
204
- * Назначить ответственных за issue.
205
- * В GitHub REST API PATCH /issues/{n} со свойством assignees заменяет ответственных целиком.
206
+ * Set assignees for issue.
207
+ * In GitHub REST API PATCH /issues/{n} with assignees replaces the entire list.
206
208
  */
207
209
  async setAssignees({ owner, repo, index, logins }) {
208
210
  const n = safeSegment(index)
@@ -212,14 +214,14 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
212
214
  return request('PATCH', repoPath(owner, repo, `/issues/${n}`), { assignees: list })
213
215
  },
214
216
 
215
- /** Логин текущего пользователя токена. */
217
+ /** Login of current token owner. */
216
218
  async me() {
217
219
  const row = await request('GET', '/user')
218
220
  const login = row?.login
219
221
  return typeof login === 'string' ? login : ''
220
222
  },
221
223
 
222
- /** Организации, доступные токену. */
224
+ /** Organizations available to token. */
223
225
  async listOrgs() {
224
226
  const rows = await request('GET', '/user/orgs?per_page=50')
225
227
  return (Array.isArray(rows) ? rows : [])
@@ -227,7 +229,7 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
227
229
  .filter((name) => name !== '')
228
230
  },
229
231
 
230
- /** Репозитории организации с числом открытых задач. */
232
+ /** Organization repositories with open task count. */
231
233
  async listOrgRepos({ owner, limit = 100 }) {
232
234
  const rows = await collectPages({
233
235
  limit,
@@ -241,7 +243,7 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
241
243
  })).filter((r) => r.name !== '')
242
244
  },
243
245
 
244
- /** Pull requests репозитория. */
246
+ /** Repository pull requests. */
245
247
  async listPulls({ owner, repo, state = 'all', limit = 50 }) {
246
248
  return collectPages({
247
249
  limit,
@@ -251,7 +253,7 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
251
253
  })
252
254
  },
253
255
 
254
- /** Создать Pull Request. */
256
+ /** Create Pull Request. */
255
257
  async createPullRequest({ owner, repo, title, body, head, base = 'main' }) {
256
258
  const name = String(title ?? '').trim()
257
259
  if (name === '') throw Object.assign(new Error('empty PR title'), { code: 'bad-title' })
@@ -266,7 +268,7 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
266
268
  })
267
269
  },
268
270
 
269
- /** Список веток репозитория. */
271
+ /** List repository branches. */
270
272
  async listBranches({ owner, repo, limit = 100 }) {
271
273
  const rows = await collectPages({
272
274
  limit,
@@ -275,7 +277,7 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
275
277
  return rows.map((b) => ({ name: String(b?.name ?? '') })).filter((b) => b.name !== '')
276
278
  },
277
279
 
278
- /** Поиск репозиториев. */
280
+ /** Search repositories. */
279
281
  async searchRepos({ query, limit = 50, maxPages = 5 }) {
280
282
  const q = encodeURIComponent(String(query || ''))
281
283
  const size = Math.min(Math.max(Number(limit) || 50, 1), 50)