@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/board-tool.js +18 -48
- package/lib/client.js +85 -176
- package/lib/commands.js +21 -38
- package/lib/config.js +30 -55
- package/lib/cron.js +13 -13
- package/lib/events.js +17 -5
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +59 -110
- package/lib/github.js +39 -37
- package/lib/import.js +46 -67
- package/lib/index.js +131 -140
- package/lib/intake.js +18 -26
- package/lib/launcher.js +107 -170
- package/lib/lifecycle.js +11 -17
- package/lib/markdown.js +16 -23
- package/lib/metrics.js +23 -25
- package/lib/order.js +25 -28
- package/lib/outbound.js +28 -28
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +92 -145
- package/lib/store.js +68 -77
- package/lib/sync.js +56 -98
- package/lib/time.js +8 -17
- package/lib/transitions.js +44 -61
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +72 -68
- package/lib/worktree.js +103 -60
- package/package.json +1 -1
package/lib/gitea.js
CHANGED
|
@@ -1,28 +1,24 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Gitea HTTP API client.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
16
|
+
* Path segment sanitized for URL interpolation.
|
|
20
17
|
*
|
|
21
|
-
* `owner`, `repo
|
|
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}
|
|
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
|
-
|
|
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
|
-
*
|
|
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]
|
|
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
|
-
//
|
|
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
|
-
//
|
|
153
|
-
//
|
|
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
|
|
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}
|
|
171
|
+
`/issues?state=${encodeURIComponent(state)}&page=${page}&limit=${size}`),
|
|
183
172
|
})
|
|
184
|
-
return rows.filter((
|
|
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('
|
|
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('
|
|
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
|
-
/**
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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('
|
|
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('
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
23
|
-
|
|
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
|
-
*
|
|
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]
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
161
|
+
/** Clear ETag cache (e.g. on manual refresh). */
|
|
160
162
|
clearCache() {
|
|
161
163
|
etagCache.clear()
|
|
162
164
|
},
|
|
163
165
|
|
|
164
|
-
/**
|
|
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
|
-
/**
|
|
177
|
+
/** Get single issue by number. */
|
|
176
178
|
async getIssue({ owner, repo, index }) {
|
|
177
|
-
const n =
|
|
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
|
-
/**
|
|
184
|
+
/** Post comment on issue. */
|
|
183
185
|
async comment({ owner, repo, index, body }) {
|
|
184
|
-
const n =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
205
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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)
|