@foundation0/git 1.3.0 → 1.3.2
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/README.md +291 -291
- package/gitea-swagger.json +28627 -28627
- package/mcp/README.md +262 -247
- package/mcp/cli.mjs +37 -37
- package/mcp/src/cli.ts +76 -76
- package/mcp/src/client.ts +143 -134
- package/mcp/src/index.ts +7 -7
- package/mcp/src/redaction.ts +207 -207
- package/mcp/src/server.ts +2313 -814
- package/package.json +3 -1
- package/src/actions-api.ts +860 -637
- package/src/api.ts +69 -69
- package/src/ci-api.ts +544 -544
- package/src/git-service-api.ts +822 -754
- package/src/git-service-feature-spec.generated.ts +5341 -5341
- package/src/index.ts +55 -55
- package/src/issue-dependencies.ts +533 -533
- package/src/label-management.ts +587 -587
- package/src/platform/config.ts +62 -62
- package/src/platform/gitea-adapter.ts +460 -460
- package/src/platform/gitea-rules.ts +129 -129
- package/src/platform/index.ts +44 -44
- package/src/repository.ts +151 -151
- package/src/spec-mock.ts +45 -45
package/src/label-management.ts
CHANGED
|
@@ -1,587 +1,587 @@
|
|
|
1
|
-
import type { GitServiceApi, GitServiceApiExecutionResult, GitServiceApiMethod } from './git-service-api'
|
|
2
|
-
|
|
3
|
-
const LABEL_LIST_PAGE_SIZE = 100
|
|
4
|
-
const MAX_LABEL_LIST_PAGES = 100
|
|
5
|
-
|
|
6
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
7
|
-
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
8
|
-
|
|
9
|
-
const toTrimmedString = (value: unknown): string | null => {
|
|
10
|
-
if (typeof value !== 'string') {
|
|
11
|
-
return null
|
|
12
|
-
}
|
|
13
|
-
const trimmed = value.trim()
|
|
14
|
-
return trimmed.length > 0 ? trimmed : null
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const toPositiveInteger = (value: unknown): number | null => {
|
|
18
|
-
const candidate = Number(value)
|
|
19
|
-
if (!Number.isFinite(candidate) || candidate <= 0 || Math.floor(candidate) !== candidate) {
|
|
20
|
-
return null
|
|
21
|
-
}
|
|
22
|
-
return candidate
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const toBoolean = (value: unknown, fallback: boolean): boolean => {
|
|
26
|
-
if (typeof value === 'boolean') {
|
|
27
|
-
return value
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (typeof value === 'string') {
|
|
31
|
-
const normalized = value.trim().toLowerCase()
|
|
32
|
-
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
|
33
|
-
return true
|
|
34
|
-
}
|
|
35
|
-
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
|
36
|
-
return false
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return fallback
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const toQueryRecord = (raw: unknown): Record<string, string | number | boolean> => {
|
|
44
|
-
if (!isRecord(raw)) {
|
|
45
|
-
return {}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const query: Record<string, string | number | boolean> = {}
|
|
49
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
50
|
-
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
51
|
-
query[key] = value
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return query
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
type ScopeArgs = {
|
|
58
|
-
owner?: string
|
|
59
|
-
repo?: string
|
|
60
|
-
options: Record<string, unknown>
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
type NamedScopeArgs = ScopeArgs & {
|
|
64
|
-
name: string
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const normalizeScopeArgs = (
|
|
68
|
-
ownerOrOptions?: unknown,
|
|
69
|
-
repoOrOptions?: unknown,
|
|
70
|
-
maybeOptions?: unknown,
|
|
71
|
-
): ScopeArgs => {
|
|
72
|
-
const options: Record<string, unknown> = {}
|
|
73
|
-
let owner: string | undefined
|
|
74
|
-
let repo: string | undefined
|
|
75
|
-
|
|
76
|
-
const absorb = (value: unknown): string | undefined => {
|
|
77
|
-
if (value === undefined || value === null) {
|
|
78
|
-
return undefined
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (isRecord(value)) {
|
|
82
|
-
Object.assign(options, value)
|
|
83
|
-
return undefined
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const asText = String(value).trim()
|
|
87
|
-
return asText.length > 0 ? asText : undefined
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
owner = absorb(ownerOrOptions)
|
|
91
|
-
repo = absorb(repoOrOptions)
|
|
92
|
-
|
|
93
|
-
if (isRecord(maybeOptions)) {
|
|
94
|
-
Object.assign(options, maybeOptions)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return {
|
|
98
|
-
...(owner ? { owner } : {}),
|
|
99
|
-
...(repo ? { repo } : {}),
|
|
100
|
-
options,
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const normalizeNamedScopeArgs = (
|
|
105
|
-
labelName: unknown,
|
|
106
|
-
ownerOrOptions?: unknown,
|
|
107
|
-
repoOrOptions?: unknown,
|
|
108
|
-
maybeOptions?: unknown,
|
|
109
|
-
): NamedScopeArgs => {
|
|
110
|
-
const name = toTrimmedString(labelName)
|
|
111
|
-
if (!name) {
|
|
112
|
-
throw new Error('Label name is required.')
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
name,
|
|
117
|
-
...normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions),
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export interface GitLabelManagementDefaults {
|
|
122
|
-
defaultOwner?: string
|
|
123
|
-
defaultRepo?: string
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const resolveOwnerRepo = (
|
|
127
|
-
scope: Pick<ScopeArgs, 'owner' | 'repo'>,
|
|
128
|
-
defaults: GitLabelManagementDefaults,
|
|
129
|
-
): { owner: string; repo: string } => {
|
|
130
|
-
const owner = scope.owner ?? defaults.defaultOwner
|
|
131
|
-
const repo = scope.repo ?? defaults.defaultRepo
|
|
132
|
-
|
|
133
|
-
if (!owner || !repo) {
|
|
134
|
-
throw new Error(
|
|
135
|
-
'Owner/repo is required. Pass owner and repo args or set defaultOwner/defaultRepo when creating the API.',
|
|
136
|
-
)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return { owner, repo }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const resolveLabelNamespace = (api: GitServiceApi): GitServiceApi => {
|
|
143
|
-
const direct = (api as Record<string, unknown>).label
|
|
144
|
-
if (isRecord(direct)) {
|
|
145
|
-
return direct as GitServiceApi
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const repoNamespace = (api as Record<string, unknown>).repo
|
|
149
|
-
if (isRecord(repoNamespace)) {
|
|
150
|
-
const repoLabel = (repoNamespace as Record<string, unknown>).label
|
|
151
|
-
if (isRecord(repoLabel)) {
|
|
152
|
-
return repoLabel as GitServiceApi
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
throw new Error('Git service API does not expose label namespace')
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
type LabelCrudFeature = 'list' | 'create' | 'edit' | 'delete'
|
|
160
|
-
|
|
161
|
-
const resolveLabelMethod = (api: GitServiceApi, feature: LabelCrudFeature): GitServiceApiMethod => {
|
|
162
|
-
const labelNamespace = resolveLabelNamespace(api)
|
|
163
|
-
const method = (labelNamespace as Record<string, unknown>)[feature]
|
|
164
|
-
if (typeof method !== 'function') {
|
|
165
|
-
throw new Error(`Git service API does not expose label.${feature}`)
|
|
166
|
-
}
|
|
167
|
-
return method as GitServiceApiMethod
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const executeLabelCall = async (
|
|
171
|
-
method: GitServiceApiMethod,
|
|
172
|
-
owner: string,
|
|
173
|
-
repo: string,
|
|
174
|
-
pathArgs: unknown[] = [],
|
|
175
|
-
options: Record<string, unknown> = {},
|
|
176
|
-
): Promise<GitServiceApiExecutionResult<unknown>> => {
|
|
177
|
-
const invocationArgs: unknown[] = [owner, repo, ...pathArgs]
|
|
178
|
-
if (Object.keys(options).length > 0) {
|
|
179
|
-
invocationArgs.push(options)
|
|
180
|
-
}
|
|
181
|
-
return method(...invocationArgs) as Promise<GitServiceApiExecutionResult<unknown>>
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const ensureOk = (result: GitServiceApiExecutionResult<unknown>, context: string): void => {
|
|
185
|
-
if (!result.ok) {
|
|
186
|
-
throw new Error(`${context}: status=${result.status} url=${result.request.url}`)
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export interface GitRepositoryLabel {
|
|
191
|
-
id: number | null
|
|
192
|
-
name: string
|
|
193
|
-
color: string | null
|
|
194
|
-
description: string | null
|
|
195
|
-
exclusive: boolean | null
|
|
196
|
-
url: string | null
|
|
197
|
-
raw: Record<string, unknown>
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const normalizeRepositoryLabel = (raw: unknown): GitRepositoryLabel | null => {
|
|
201
|
-
if (!isRecord(raw)) {
|
|
202
|
-
return null
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const name = toTrimmedString(raw.name)
|
|
206
|
-
if (!name) {
|
|
207
|
-
return null
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const id = toPositiveInteger(raw.id ?? raw.number ?? raw.ID)
|
|
211
|
-
const color = toTrimmedString(raw.color)
|
|
212
|
-
const description = toTrimmedString(raw.description)
|
|
213
|
-
const url = toTrimmedString(raw.url ?? raw.html_url)
|
|
214
|
-
const exclusive = typeof raw.exclusive === 'boolean'
|
|
215
|
-
? raw.exclusive
|
|
216
|
-
: typeof raw.is_exclusive === 'boolean'
|
|
217
|
-
? raw.is_exclusive
|
|
218
|
-
: null
|
|
219
|
-
|
|
220
|
-
return {
|
|
221
|
-
id,
|
|
222
|
-
name,
|
|
223
|
-
color,
|
|
224
|
-
description,
|
|
225
|
-
exclusive,
|
|
226
|
-
url,
|
|
227
|
-
raw: { ...raw },
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const dedupeRepositoryLabels = (labels: GitRepositoryLabel[]): GitRepositoryLabel[] => {
|
|
232
|
-
const seen = new Set<string>()
|
|
233
|
-
const unique: GitRepositoryLabel[] = []
|
|
234
|
-
|
|
235
|
-
for (const label of labels) {
|
|
236
|
-
const key = label.id !== null ? `id:${label.id}` : `name:${label.name.toLowerCase()}`
|
|
237
|
-
if (seen.has(key)) {
|
|
238
|
-
continue
|
|
239
|
-
}
|
|
240
|
-
seen.add(key)
|
|
241
|
-
unique.push(label)
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return unique
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export const extractRepositoryLabels = (raw: unknown): GitRepositoryLabel[] => {
|
|
248
|
-
if (Array.isArray(raw)) {
|
|
249
|
-
const labels = raw
|
|
250
|
-
.map((entry) => normalizeRepositoryLabel(entry))
|
|
251
|
-
.filter((entry): entry is GitRepositoryLabel => entry !== null)
|
|
252
|
-
return dedupeRepositoryLabels(labels)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (isRecord(raw)) {
|
|
256
|
-
if (Array.isArray(raw.labels)) {
|
|
257
|
-
return extractRepositoryLabels(raw.labels)
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (Array.isArray(raw.data)) {
|
|
261
|
-
return extractRepositoryLabels(raw.data)
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return []
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const withBody = <T>(
|
|
269
|
-
result: GitServiceApiExecutionResult<unknown>,
|
|
270
|
-
body: T,
|
|
271
|
-
): GitServiceApiExecutionResult<T> => ({
|
|
272
|
-
...result,
|
|
273
|
-
body,
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
type LabelListContext = {
|
|
277
|
-
labels: GitRepositoryLabel[]
|
|
278
|
-
context: GitServiceApiExecutionResult<unknown>
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const listRepositoryLabels = async (
|
|
282
|
-
api: GitServiceApi,
|
|
283
|
-
owner: string,
|
|
284
|
-
repo: string,
|
|
285
|
-
options: {
|
|
286
|
-
query?: Record<string, string | number | boolean>
|
|
287
|
-
allPages?: boolean
|
|
288
|
-
} = {},
|
|
289
|
-
): Promise<LabelListContext> => {
|
|
290
|
-
const listMethod = resolveLabelMethod(api, 'list')
|
|
291
|
-
const labels: GitRepositoryLabel[] = []
|
|
292
|
-
const query = options.query ?? {}
|
|
293
|
-
const allPages = options.allPages ?? true
|
|
294
|
-
const paginate = allPages && query.page === undefined && query.limit === undefined
|
|
295
|
-
let page = 1
|
|
296
|
-
let context: GitServiceApiExecutionResult<unknown> | null = null
|
|
297
|
-
|
|
298
|
-
while (page <= MAX_LABEL_LIST_PAGES) {
|
|
299
|
-
const queryForCall: Record<string, string | number | boolean> = { ...query }
|
|
300
|
-
if (paginate) {
|
|
301
|
-
queryForCall.limit = LABEL_LIST_PAGE_SIZE
|
|
302
|
-
queryForCall.page = page
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const requestOptions = Object.keys(queryForCall).length > 0
|
|
306
|
-
? { query: queryForCall }
|
|
307
|
-
: {}
|
|
308
|
-
|
|
309
|
-
const result = await executeLabelCall(listMethod, owner, repo, [], requestOptions)
|
|
310
|
-
ensureOk(result, `Failed to list labels for ${owner}/${repo}`)
|
|
311
|
-
context = result
|
|
312
|
-
|
|
313
|
-
const batch = extractRepositoryLabels(result.body)
|
|
314
|
-
labels.push(...batch)
|
|
315
|
-
|
|
316
|
-
if (!paginate || batch.length < LABEL_LIST_PAGE_SIZE) {
|
|
317
|
-
break
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
page += 1
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
if (paginate && page > MAX_LABEL_LIST_PAGES) {
|
|
324
|
-
throw new Error(`Label pagination exceeded ${MAX_LABEL_LIST_PAGES} pages for ${owner}/${repo}.`)
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (!context) {
|
|
328
|
-
throw new Error(`Label listing did not execute for ${owner}/${repo}.`)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
return {
|
|
332
|
-
labels: dedupeRepositoryLabels(labels),
|
|
333
|
-
context,
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
type LabelLookupContext = LabelListContext & {
|
|
338
|
-
match: GitRepositoryLabel | null
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const findRepositoryLabelByName = async (
|
|
342
|
-
api: GitServiceApi,
|
|
343
|
-
owner: string,
|
|
344
|
-
repo: string,
|
|
345
|
-
name: string,
|
|
346
|
-
options: {
|
|
347
|
-
caseSensitive?: boolean
|
|
348
|
-
} = {},
|
|
349
|
-
): Promise<LabelLookupContext> => {
|
|
350
|
-
const listed = await listRepositoryLabels(api, owner, repo, { allPages: true })
|
|
351
|
-
const caseSensitive = options.caseSensitive ?? false
|
|
352
|
-
const target = caseSensitive ? name : name.toLowerCase()
|
|
353
|
-
|
|
354
|
-
const match = listed.labels.find((label) => {
|
|
355
|
-
const candidate = caseSensitive ? label.name : label.name.toLowerCase()
|
|
356
|
-
return candidate === target
|
|
357
|
-
}) ?? null
|
|
358
|
-
|
|
359
|
-
return {
|
|
360
|
-
...listed,
|
|
361
|
-
match,
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const buildLabelUpsertPayload = (
|
|
366
|
-
name: string,
|
|
367
|
-
options: Record<string, unknown>,
|
|
368
|
-
mode: 'create' | 'update',
|
|
369
|
-
): Record<string, unknown> => {
|
|
370
|
-
const payload: Record<string, unknown> = {}
|
|
371
|
-
|
|
372
|
-
const data = options.data
|
|
373
|
-
if (isRecord(data)) {
|
|
374
|
-
Object.assign(payload, data)
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
if (options.color !== undefined) payload.color = options.color
|
|
378
|
-
if (options.description !== undefined) payload.description = options.description
|
|
379
|
-
if (options.exclusive !== undefined) payload.exclusive = options.exclusive
|
|
380
|
-
if (options.isArchived !== undefined) payload.is_archived = options.isArchived
|
|
381
|
-
if (options.is_archived !== undefined) payload.is_archived = options.is_archived
|
|
382
|
-
|
|
383
|
-
if (mode === 'create') {
|
|
384
|
-
payload.name = name
|
|
385
|
-
const color = toTrimmedString(payload.color)
|
|
386
|
-
if (!color) {
|
|
387
|
-
throw new Error(`Label "${name}" requires a non-empty "color" when creating.`)
|
|
388
|
-
}
|
|
389
|
-
payload.color = color
|
|
390
|
-
} else {
|
|
391
|
-
delete payload.name
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return payload
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
type GitLabelListManagedBody = {
|
|
398
|
-
total: number
|
|
399
|
-
labels: GitRepositoryLabel[]
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
type GitLabelGetByNameBody = {
|
|
403
|
-
found: boolean
|
|
404
|
-
label: GitRepositoryLabel | null
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
type GitLabelUpsertBody = {
|
|
408
|
-
action: 'created' | 'updated' | 'unchanged'
|
|
409
|
-
label: GitRepositoryLabel
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
type GitLabelDeleteByNameBody = {
|
|
413
|
-
deleted: boolean
|
|
414
|
-
label: GitRepositoryLabel | null
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
export type GitLabelManagementApi = {
|
|
418
|
-
listManaged: GitServiceApiMethod
|
|
419
|
-
getByName: GitServiceApiMethod
|
|
420
|
-
upsert: GitServiceApiMethod
|
|
421
|
-
deleteByName: GitServiceApiMethod
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
export const createGitLabelManagementApi = (
|
|
425
|
-
api: GitServiceApi,
|
|
426
|
-
defaults: GitLabelManagementDefaults = {},
|
|
427
|
-
): GitLabelManagementApi => {
|
|
428
|
-
return {
|
|
429
|
-
listManaged: async (
|
|
430
|
-
ownerOrOptions?: unknown,
|
|
431
|
-
repoOrOptions?: unknown,
|
|
432
|
-
maybeOptions?: unknown,
|
|
433
|
-
): Promise<GitServiceApiExecutionResult<GitLabelListManagedBody>> => {
|
|
434
|
-
const scope = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
435
|
-
const { owner, repo } = resolveOwnerRepo(scope, defaults)
|
|
436
|
-
const allPages = toBoolean(scope.options.allPages, true)
|
|
437
|
-
const query = toQueryRecord(scope.options.query)
|
|
438
|
-
|
|
439
|
-
const listed = await listRepositoryLabels(api, owner, repo, { allPages, query })
|
|
440
|
-
return withBody(listed.context, {
|
|
441
|
-
total: listed.labels.length,
|
|
442
|
-
labels: listed.labels,
|
|
443
|
-
})
|
|
444
|
-
},
|
|
445
|
-
|
|
446
|
-
getByName: async (
|
|
447
|
-
labelName: unknown,
|
|
448
|
-
ownerOrOptions?: unknown,
|
|
449
|
-
repoOrOptions?: unknown,
|
|
450
|
-
maybeOptions?: unknown,
|
|
451
|
-
): Promise<GitServiceApiExecutionResult<GitLabelGetByNameBody>> => {
|
|
452
|
-
const parsed = normalizeNamedScopeArgs(labelName, ownerOrOptions, repoOrOptions, maybeOptions)
|
|
453
|
-
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
454
|
-
const caseSensitive = toBoolean(parsed.options.caseSensitive, false)
|
|
455
|
-
|
|
456
|
-
const lookup = await findRepositoryLabelByName(api, owner, repo, parsed.name, { caseSensitive })
|
|
457
|
-
return withBody(lookup.context, {
|
|
458
|
-
found: lookup.match !== null,
|
|
459
|
-
label: lookup.match,
|
|
460
|
-
})
|
|
461
|
-
},
|
|
462
|
-
|
|
463
|
-
upsert: async (
|
|
464
|
-
labelName: unknown,
|
|
465
|
-
ownerOrOptions?: unknown,
|
|
466
|
-
repoOrOptions?: unknown,
|
|
467
|
-
maybeOptions?: unknown,
|
|
468
|
-
): Promise<GitServiceApiExecutionResult<GitLabelUpsertBody>> => {
|
|
469
|
-
const parsed = normalizeNamedScopeArgs(labelName, ownerOrOptions, repoOrOptions, maybeOptions)
|
|
470
|
-
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
471
|
-
const caseSensitive = toBoolean(parsed.options.caseSensitive, false)
|
|
472
|
-
|
|
473
|
-
const lookup = await findRepositoryLabelByName(api, owner, repo, parsed.name, { caseSensitive })
|
|
474
|
-
if (lookup.match) {
|
|
475
|
-
const payload = buildLabelUpsertPayload(parsed.name, parsed.options, 'update')
|
|
476
|
-
if (Object.keys(payload).length === 0) {
|
|
477
|
-
return withBody(lookup.context, {
|
|
478
|
-
action: 'unchanged',
|
|
479
|
-
label: lookup.match,
|
|
480
|
-
})
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
if (lookup.match.id === null) {
|
|
484
|
-
throw new Error(`Label "${parsed.name}" does not expose an id and cannot be updated.`)
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
const editMethod = resolveLabelMethod(api, 'edit')
|
|
488
|
-
const result = await executeLabelCall(editMethod, owner, repo, [lookup.match.id], { data: payload })
|
|
489
|
-
ensureOk(result, `Failed to update label "${parsed.name}" in ${owner}/${repo}`)
|
|
490
|
-
|
|
491
|
-
const updated = normalizeRepositoryLabel(result.body)
|
|
492
|
-
if (!updated) {
|
|
493
|
-
throw new Error(`Update label response invalid for "${parsed.name}" in ${owner}/${repo}.`)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
return withBody(result, {
|
|
497
|
-
action: 'updated',
|
|
498
|
-
label: updated,
|
|
499
|
-
})
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
const payload = buildLabelUpsertPayload(parsed.name, parsed.options, 'create')
|
|
503
|
-
const createMethod = resolveLabelMethod(api, 'create')
|
|
504
|
-
const result = await executeLabelCall(createMethod, owner, repo, [], { data: payload })
|
|
505
|
-
ensureOk(result, `Failed to create label "${parsed.name}" in ${owner}/${repo}`)
|
|
506
|
-
|
|
507
|
-
const created = normalizeRepositoryLabel(result.body)
|
|
508
|
-
if (!created) {
|
|
509
|
-
throw new Error(`Create label response invalid for "${parsed.name}" in ${owner}/${repo}.`)
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
return withBody(result, {
|
|
513
|
-
action: 'created',
|
|
514
|
-
label: created,
|
|
515
|
-
})
|
|
516
|
-
},
|
|
517
|
-
|
|
518
|
-
deleteByName: async (
|
|
519
|
-
labelName: unknown,
|
|
520
|
-
ownerOrOptions?: unknown,
|
|
521
|
-
repoOrOptions?: unknown,
|
|
522
|
-
maybeOptions?: unknown,
|
|
523
|
-
): Promise<GitServiceApiExecutionResult<GitLabelDeleteByNameBody>> => {
|
|
524
|
-
const parsed = normalizeNamedScopeArgs(labelName, ownerOrOptions, repoOrOptions, maybeOptions)
|
|
525
|
-
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
526
|
-
const strict = toBoolean(parsed.options.strict, false)
|
|
527
|
-
const caseSensitive = toBoolean(parsed.options.caseSensitive, false)
|
|
528
|
-
|
|
529
|
-
const lookup = await findRepositoryLabelByName(api, owner, repo, parsed.name, { caseSensitive })
|
|
530
|
-
if (!lookup.match) {
|
|
531
|
-
if (strict) {
|
|
532
|
-
throw new Error(`Label "${parsed.name}" was not found in ${owner}/${repo}.`)
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
return withBody(lookup.context, {
|
|
536
|
-
deleted: false,
|
|
537
|
-
label: null,
|
|
538
|
-
})
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
if (lookup.match.id === null) {
|
|
542
|
-
throw new Error(`Label "${parsed.name}" does not expose an id and cannot be deleted.`)
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
const deleteMethod = resolveLabelMethod(api, 'delete')
|
|
546
|
-
const result = await executeLabelCall(deleteMethod, owner, repo, [lookup.match.id])
|
|
547
|
-
ensureOk(result, `Failed to delete label "${parsed.name}" in ${owner}/${repo}`)
|
|
548
|
-
|
|
549
|
-
return withBody(result, {
|
|
550
|
-
deleted: true,
|
|
551
|
-
label: lookup.match,
|
|
552
|
-
})
|
|
553
|
-
},
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const ensureNamespace = (root: GitServiceApi, path: string[]): GitServiceApi => {
|
|
558
|
-
let cursor: GitServiceApi = root
|
|
559
|
-
for (const segment of path) {
|
|
560
|
-
const current = cursor[segment]
|
|
561
|
-
if (!isRecord(current)) {
|
|
562
|
-
cursor[segment] = {}
|
|
563
|
-
}
|
|
564
|
-
cursor = cursor[segment] as GitServiceApi
|
|
565
|
-
}
|
|
566
|
-
return cursor
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
export const attachGitLabelManagementApi = (
|
|
570
|
-
api: GitServiceApi,
|
|
571
|
-
defaults: GitLabelManagementDefaults = {},
|
|
572
|
-
): void => {
|
|
573
|
-
const methods = createGitLabelManagementApi(api, defaults)
|
|
574
|
-
const targets = [
|
|
575
|
-
ensureNamespace(api, ['label']),
|
|
576
|
-
ensureNamespace(api, ['repo', 'label']),
|
|
577
|
-
]
|
|
578
|
-
|
|
579
|
-
for (const target of targets) {
|
|
580
|
-
for (const [name, method] of Object.entries(methods)) {
|
|
581
|
-
if (!(name in target)) {
|
|
582
|
-
target[name] = method
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
1
|
+
import type { GitServiceApi, GitServiceApiExecutionResult, GitServiceApiMethod } from './git-service-api'
|
|
2
|
+
|
|
3
|
+
const LABEL_LIST_PAGE_SIZE = 100
|
|
4
|
+
const MAX_LABEL_LIST_PAGES = 100
|
|
5
|
+
|
|
6
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
7
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
8
|
+
|
|
9
|
+
const toTrimmedString = (value: unknown): string | null => {
|
|
10
|
+
if (typeof value !== 'string') {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
const trimmed = value.trim()
|
|
14
|
+
return trimmed.length > 0 ? trimmed : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const toPositiveInteger = (value: unknown): number | null => {
|
|
18
|
+
const candidate = Number(value)
|
|
19
|
+
if (!Number.isFinite(candidate) || candidate <= 0 || Math.floor(candidate) !== candidate) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
return candidate
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const toBoolean = (value: unknown, fallback: boolean): boolean => {
|
|
26
|
+
if (typeof value === 'boolean') {
|
|
27
|
+
return value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
const normalized = value.trim().toLowerCase()
|
|
32
|
+
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return fallback
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const toQueryRecord = (raw: unknown): Record<string, string | number | boolean> => {
|
|
44
|
+
if (!isRecord(raw)) {
|
|
45
|
+
return {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const query: Record<string, string | number | boolean> = {}
|
|
49
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
50
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
51
|
+
query[key] = value
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return query
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ScopeArgs = {
|
|
58
|
+
owner?: string
|
|
59
|
+
repo?: string
|
|
60
|
+
options: Record<string, unknown>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type NamedScopeArgs = ScopeArgs & {
|
|
64
|
+
name: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const normalizeScopeArgs = (
|
|
68
|
+
ownerOrOptions?: unknown,
|
|
69
|
+
repoOrOptions?: unknown,
|
|
70
|
+
maybeOptions?: unknown,
|
|
71
|
+
): ScopeArgs => {
|
|
72
|
+
const options: Record<string, unknown> = {}
|
|
73
|
+
let owner: string | undefined
|
|
74
|
+
let repo: string | undefined
|
|
75
|
+
|
|
76
|
+
const absorb = (value: unknown): string | undefined => {
|
|
77
|
+
if (value === undefined || value === null) {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isRecord(value)) {
|
|
82
|
+
Object.assign(options, value)
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const asText = String(value).trim()
|
|
87
|
+
return asText.length > 0 ? asText : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
owner = absorb(ownerOrOptions)
|
|
91
|
+
repo = absorb(repoOrOptions)
|
|
92
|
+
|
|
93
|
+
if (isRecord(maybeOptions)) {
|
|
94
|
+
Object.assign(options, maybeOptions)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
...(owner ? { owner } : {}),
|
|
99
|
+
...(repo ? { repo } : {}),
|
|
100
|
+
options,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const normalizeNamedScopeArgs = (
|
|
105
|
+
labelName: unknown,
|
|
106
|
+
ownerOrOptions?: unknown,
|
|
107
|
+
repoOrOptions?: unknown,
|
|
108
|
+
maybeOptions?: unknown,
|
|
109
|
+
): NamedScopeArgs => {
|
|
110
|
+
const name = toTrimmedString(labelName)
|
|
111
|
+
if (!name) {
|
|
112
|
+
throw new Error('Label name is required.')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
name,
|
|
117
|
+
...normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions),
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface GitLabelManagementDefaults {
|
|
122
|
+
defaultOwner?: string
|
|
123
|
+
defaultRepo?: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resolveOwnerRepo = (
|
|
127
|
+
scope: Pick<ScopeArgs, 'owner' | 'repo'>,
|
|
128
|
+
defaults: GitLabelManagementDefaults,
|
|
129
|
+
): { owner: string; repo: string } => {
|
|
130
|
+
const owner = scope.owner ?? defaults.defaultOwner
|
|
131
|
+
const repo = scope.repo ?? defaults.defaultRepo
|
|
132
|
+
|
|
133
|
+
if (!owner || !repo) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
'Owner/repo is required. Pass owner and repo args or set defaultOwner/defaultRepo when creating the API.',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { owner, repo }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resolveLabelNamespace = (api: GitServiceApi): GitServiceApi => {
|
|
143
|
+
const direct = (api as Record<string, unknown>).label
|
|
144
|
+
if (isRecord(direct)) {
|
|
145
|
+
return direct as GitServiceApi
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const repoNamespace = (api as Record<string, unknown>).repo
|
|
149
|
+
if (isRecord(repoNamespace)) {
|
|
150
|
+
const repoLabel = (repoNamespace as Record<string, unknown>).label
|
|
151
|
+
if (isRecord(repoLabel)) {
|
|
152
|
+
return repoLabel as GitServiceApi
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new Error('Git service API does not expose label namespace')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type LabelCrudFeature = 'list' | 'create' | 'edit' | 'delete'
|
|
160
|
+
|
|
161
|
+
const resolveLabelMethod = (api: GitServiceApi, feature: LabelCrudFeature): GitServiceApiMethod => {
|
|
162
|
+
const labelNamespace = resolveLabelNamespace(api)
|
|
163
|
+
const method = (labelNamespace as Record<string, unknown>)[feature]
|
|
164
|
+
if (typeof method !== 'function') {
|
|
165
|
+
throw new Error(`Git service API does not expose label.${feature}`)
|
|
166
|
+
}
|
|
167
|
+
return method as GitServiceApiMethod
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const executeLabelCall = async (
|
|
171
|
+
method: GitServiceApiMethod,
|
|
172
|
+
owner: string,
|
|
173
|
+
repo: string,
|
|
174
|
+
pathArgs: unknown[] = [],
|
|
175
|
+
options: Record<string, unknown> = {},
|
|
176
|
+
): Promise<GitServiceApiExecutionResult<unknown>> => {
|
|
177
|
+
const invocationArgs: unknown[] = [owner, repo, ...pathArgs]
|
|
178
|
+
if (Object.keys(options).length > 0) {
|
|
179
|
+
invocationArgs.push(options)
|
|
180
|
+
}
|
|
181
|
+
return method(...invocationArgs) as Promise<GitServiceApiExecutionResult<unknown>>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const ensureOk = (result: GitServiceApiExecutionResult<unknown>, context: string): void => {
|
|
185
|
+
if (!result.ok) {
|
|
186
|
+
throw new Error(`${context}: status=${result.status} url=${result.request.url}`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface GitRepositoryLabel {
|
|
191
|
+
id: number | null
|
|
192
|
+
name: string
|
|
193
|
+
color: string | null
|
|
194
|
+
description: string | null
|
|
195
|
+
exclusive: boolean | null
|
|
196
|
+
url: string | null
|
|
197
|
+
raw: Record<string, unknown>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const normalizeRepositoryLabel = (raw: unknown): GitRepositoryLabel | null => {
|
|
201
|
+
if (!isRecord(raw)) {
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const name = toTrimmedString(raw.name)
|
|
206
|
+
if (!name) {
|
|
207
|
+
return null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const id = toPositiveInteger(raw.id ?? raw.number ?? raw.ID)
|
|
211
|
+
const color = toTrimmedString(raw.color)
|
|
212
|
+
const description = toTrimmedString(raw.description)
|
|
213
|
+
const url = toTrimmedString(raw.url ?? raw.html_url)
|
|
214
|
+
const exclusive = typeof raw.exclusive === 'boolean'
|
|
215
|
+
? raw.exclusive
|
|
216
|
+
: typeof raw.is_exclusive === 'boolean'
|
|
217
|
+
? raw.is_exclusive
|
|
218
|
+
: null
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
id,
|
|
222
|
+
name,
|
|
223
|
+
color,
|
|
224
|
+
description,
|
|
225
|
+
exclusive,
|
|
226
|
+
url,
|
|
227
|
+
raw: { ...raw },
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const dedupeRepositoryLabels = (labels: GitRepositoryLabel[]): GitRepositoryLabel[] => {
|
|
232
|
+
const seen = new Set<string>()
|
|
233
|
+
const unique: GitRepositoryLabel[] = []
|
|
234
|
+
|
|
235
|
+
for (const label of labels) {
|
|
236
|
+
const key = label.id !== null ? `id:${label.id}` : `name:${label.name.toLowerCase()}`
|
|
237
|
+
if (seen.has(key)) {
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
seen.add(key)
|
|
241
|
+
unique.push(label)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return unique
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const extractRepositoryLabels = (raw: unknown): GitRepositoryLabel[] => {
|
|
248
|
+
if (Array.isArray(raw)) {
|
|
249
|
+
const labels = raw
|
|
250
|
+
.map((entry) => normalizeRepositoryLabel(entry))
|
|
251
|
+
.filter((entry): entry is GitRepositoryLabel => entry !== null)
|
|
252
|
+
return dedupeRepositoryLabels(labels)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (isRecord(raw)) {
|
|
256
|
+
if (Array.isArray(raw.labels)) {
|
|
257
|
+
return extractRepositoryLabels(raw.labels)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (Array.isArray(raw.data)) {
|
|
261
|
+
return extractRepositoryLabels(raw.data)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return []
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const withBody = <T>(
|
|
269
|
+
result: GitServiceApiExecutionResult<unknown>,
|
|
270
|
+
body: T,
|
|
271
|
+
): GitServiceApiExecutionResult<T> => ({
|
|
272
|
+
...result,
|
|
273
|
+
body,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
type LabelListContext = {
|
|
277
|
+
labels: GitRepositoryLabel[]
|
|
278
|
+
context: GitServiceApiExecutionResult<unknown>
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const listRepositoryLabels = async (
|
|
282
|
+
api: GitServiceApi,
|
|
283
|
+
owner: string,
|
|
284
|
+
repo: string,
|
|
285
|
+
options: {
|
|
286
|
+
query?: Record<string, string | number | boolean>
|
|
287
|
+
allPages?: boolean
|
|
288
|
+
} = {},
|
|
289
|
+
): Promise<LabelListContext> => {
|
|
290
|
+
const listMethod = resolveLabelMethod(api, 'list')
|
|
291
|
+
const labels: GitRepositoryLabel[] = []
|
|
292
|
+
const query = options.query ?? {}
|
|
293
|
+
const allPages = options.allPages ?? true
|
|
294
|
+
const paginate = allPages && query.page === undefined && query.limit === undefined
|
|
295
|
+
let page = 1
|
|
296
|
+
let context: GitServiceApiExecutionResult<unknown> | null = null
|
|
297
|
+
|
|
298
|
+
while (page <= MAX_LABEL_LIST_PAGES) {
|
|
299
|
+
const queryForCall: Record<string, string | number | boolean> = { ...query }
|
|
300
|
+
if (paginate) {
|
|
301
|
+
queryForCall.limit = LABEL_LIST_PAGE_SIZE
|
|
302
|
+
queryForCall.page = page
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const requestOptions = Object.keys(queryForCall).length > 0
|
|
306
|
+
? { query: queryForCall }
|
|
307
|
+
: {}
|
|
308
|
+
|
|
309
|
+
const result = await executeLabelCall(listMethod, owner, repo, [], requestOptions)
|
|
310
|
+
ensureOk(result, `Failed to list labels for ${owner}/${repo}`)
|
|
311
|
+
context = result
|
|
312
|
+
|
|
313
|
+
const batch = extractRepositoryLabels(result.body)
|
|
314
|
+
labels.push(...batch)
|
|
315
|
+
|
|
316
|
+
if (!paginate || batch.length < LABEL_LIST_PAGE_SIZE) {
|
|
317
|
+
break
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
page += 1
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (paginate && page > MAX_LABEL_LIST_PAGES) {
|
|
324
|
+
throw new Error(`Label pagination exceeded ${MAX_LABEL_LIST_PAGES} pages for ${owner}/${repo}.`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!context) {
|
|
328
|
+
throw new Error(`Label listing did not execute for ${owner}/${repo}.`)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
labels: dedupeRepositoryLabels(labels),
|
|
333
|
+
context,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
type LabelLookupContext = LabelListContext & {
|
|
338
|
+
match: GitRepositoryLabel | null
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const findRepositoryLabelByName = async (
|
|
342
|
+
api: GitServiceApi,
|
|
343
|
+
owner: string,
|
|
344
|
+
repo: string,
|
|
345
|
+
name: string,
|
|
346
|
+
options: {
|
|
347
|
+
caseSensitive?: boolean
|
|
348
|
+
} = {},
|
|
349
|
+
): Promise<LabelLookupContext> => {
|
|
350
|
+
const listed = await listRepositoryLabels(api, owner, repo, { allPages: true })
|
|
351
|
+
const caseSensitive = options.caseSensitive ?? false
|
|
352
|
+
const target = caseSensitive ? name : name.toLowerCase()
|
|
353
|
+
|
|
354
|
+
const match = listed.labels.find((label) => {
|
|
355
|
+
const candidate = caseSensitive ? label.name : label.name.toLowerCase()
|
|
356
|
+
return candidate === target
|
|
357
|
+
}) ?? null
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
...listed,
|
|
361
|
+
match,
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const buildLabelUpsertPayload = (
|
|
366
|
+
name: string,
|
|
367
|
+
options: Record<string, unknown>,
|
|
368
|
+
mode: 'create' | 'update',
|
|
369
|
+
): Record<string, unknown> => {
|
|
370
|
+
const payload: Record<string, unknown> = {}
|
|
371
|
+
|
|
372
|
+
const data = options.data
|
|
373
|
+
if (isRecord(data)) {
|
|
374
|
+
Object.assign(payload, data)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (options.color !== undefined) payload.color = options.color
|
|
378
|
+
if (options.description !== undefined) payload.description = options.description
|
|
379
|
+
if (options.exclusive !== undefined) payload.exclusive = options.exclusive
|
|
380
|
+
if (options.isArchived !== undefined) payload.is_archived = options.isArchived
|
|
381
|
+
if (options.is_archived !== undefined) payload.is_archived = options.is_archived
|
|
382
|
+
|
|
383
|
+
if (mode === 'create') {
|
|
384
|
+
payload.name = name
|
|
385
|
+
const color = toTrimmedString(payload.color)
|
|
386
|
+
if (!color) {
|
|
387
|
+
throw new Error(`Label "${name}" requires a non-empty "color" when creating.`)
|
|
388
|
+
}
|
|
389
|
+
payload.color = color
|
|
390
|
+
} else {
|
|
391
|
+
delete payload.name
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return payload
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
type GitLabelListManagedBody = {
|
|
398
|
+
total: number
|
|
399
|
+
labels: GitRepositoryLabel[]
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
type GitLabelGetByNameBody = {
|
|
403
|
+
found: boolean
|
|
404
|
+
label: GitRepositoryLabel | null
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
type GitLabelUpsertBody = {
|
|
408
|
+
action: 'created' | 'updated' | 'unchanged'
|
|
409
|
+
label: GitRepositoryLabel
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
type GitLabelDeleteByNameBody = {
|
|
413
|
+
deleted: boolean
|
|
414
|
+
label: GitRepositoryLabel | null
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export type GitLabelManagementApi = {
|
|
418
|
+
listManaged: GitServiceApiMethod
|
|
419
|
+
getByName: GitServiceApiMethod
|
|
420
|
+
upsert: GitServiceApiMethod
|
|
421
|
+
deleteByName: GitServiceApiMethod
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export const createGitLabelManagementApi = (
|
|
425
|
+
api: GitServiceApi,
|
|
426
|
+
defaults: GitLabelManagementDefaults = {},
|
|
427
|
+
): GitLabelManagementApi => {
|
|
428
|
+
return {
|
|
429
|
+
listManaged: async (
|
|
430
|
+
ownerOrOptions?: unknown,
|
|
431
|
+
repoOrOptions?: unknown,
|
|
432
|
+
maybeOptions?: unknown,
|
|
433
|
+
): Promise<GitServiceApiExecutionResult<GitLabelListManagedBody>> => {
|
|
434
|
+
const scope = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
435
|
+
const { owner, repo } = resolveOwnerRepo(scope, defaults)
|
|
436
|
+
const allPages = toBoolean(scope.options.allPages, true)
|
|
437
|
+
const query = toQueryRecord(scope.options.query)
|
|
438
|
+
|
|
439
|
+
const listed = await listRepositoryLabels(api, owner, repo, { allPages, query })
|
|
440
|
+
return withBody(listed.context, {
|
|
441
|
+
total: listed.labels.length,
|
|
442
|
+
labels: listed.labels,
|
|
443
|
+
})
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
getByName: async (
|
|
447
|
+
labelName: unknown,
|
|
448
|
+
ownerOrOptions?: unknown,
|
|
449
|
+
repoOrOptions?: unknown,
|
|
450
|
+
maybeOptions?: unknown,
|
|
451
|
+
): Promise<GitServiceApiExecutionResult<GitLabelGetByNameBody>> => {
|
|
452
|
+
const parsed = normalizeNamedScopeArgs(labelName, ownerOrOptions, repoOrOptions, maybeOptions)
|
|
453
|
+
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
454
|
+
const caseSensitive = toBoolean(parsed.options.caseSensitive, false)
|
|
455
|
+
|
|
456
|
+
const lookup = await findRepositoryLabelByName(api, owner, repo, parsed.name, { caseSensitive })
|
|
457
|
+
return withBody(lookup.context, {
|
|
458
|
+
found: lookup.match !== null,
|
|
459
|
+
label: lookup.match,
|
|
460
|
+
})
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
upsert: async (
|
|
464
|
+
labelName: unknown,
|
|
465
|
+
ownerOrOptions?: unknown,
|
|
466
|
+
repoOrOptions?: unknown,
|
|
467
|
+
maybeOptions?: unknown,
|
|
468
|
+
): Promise<GitServiceApiExecutionResult<GitLabelUpsertBody>> => {
|
|
469
|
+
const parsed = normalizeNamedScopeArgs(labelName, ownerOrOptions, repoOrOptions, maybeOptions)
|
|
470
|
+
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
471
|
+
const caseSensitive = toBoolean(parsed.options.caseSensitive, false)
|
|
472
|
+
|
|
473
|
+
const lookup = await findRepositoryLabelByName(api, owner, repo, parsed.name, { caseSensitive })
|
|
474
|
+
if (lookup.match) {
|
|
475
|
+
const payload = buildLabelUpsertPayload(parsed.name, parsed.options, 'update')
|
|
476
|
+
if (Object.keys(payload).length === 0) {
|
|
477
|
+
return withBody(lookup.context, {
|
|
478
|
+
action: 'unchanged',
|
|
479
|
+
label: lookup.match,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (lookup.match.id === null) {
|
|
484
|
+
throw new Error(`Label "${parsed.name}" does not expose an id and cannot be updated.`)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const editMethod = resolveLabelMethod(api, 'edit')
|
|
488
|
+
const result = await executeLabelCall(editMethod, owner, repo, [lookup.match.id], { data: payload })
|
|
489
|
+
ensureOk(result, `Failed to update label "${parsed.name}" in ${owner}/${repo}`)
|
|
490
|
+
|
|
491
|
+
const updated = normalizeRepositoryLabel(result.body)
|
|
492
|
+
if (!updated) {
|
|
493
|
+
throw new Error(`Update label response invalid for "${parsed.name}" in ${owner}/${repo}.`)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return withBody(result, {
|
|
497
|
+
action: 'updated',
|
|
498
|
+
label: updated,
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const payload = buildLabelUpsertPayload(parsed.name, parsed.options, 'create')
|
|
503
|
+
const createMethod = resolveLabelMethod(api, 'create')
|
|
504
|
+
const result = await executeLabelCall(createMethod, owner, repo, [], { data: payload })
|
|
505
|
+
ensureOk(result, `Failed to create label "${parsed.name}" in ${owner}/${repo}`)
|
|
506
|
+
|
|
507
|
+
const created = normalizeRepositoryLabel(result.body)
|
|
508
|
+
if (!created) {
|
|
509
|
+
throw new Error(`Create label response invalid for "${parsed.name}" in ${owner}/${repo}.`)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return withBody(result, {
|
|
513
|
+
action: 'created',
|
|
514
|
+
label: created,
|
|
515
|
+
})
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
deleteByName: async (
|
|
519
|
+
labelName: unknown,
|
|
520
|
+
ownerOrOptions?: unknown,
|
|
521
|
+
repoOrOptions?: unknown,
|
|
522
|
+
maybeOptions?: unknown,
|
|
523
|
+
): Promise<GitServiceApiExecutionResult<GitLabelDeleteByNameBody>> => {
|
|
524
|
+
const parsed = normalizeNamedScopeArgs(labelName, ownerOrOptions, repoOrOptions, maybeOptions)
|
|
525
|
+
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
526
|
+
const strict = toBoolean(parsed.options.strict, false)
|
|
527
|
+
const caseSensitive = toBoolean(parsed.options.caseSensitive, false)
|
|
528
|
+
|
|
529
|
+
const lookup = await findRepositoryLabelByName(api, owner, repo, parsed.name, { caseSensitive })
|
|
530
|
+
if (!lookup.match) {
|
|
531
|
+
if (strict) {
|
|
532
|
+
throw new Error(`Label "${parsed.name}" was not found in ${owner}/${repo}.`)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return withBody(lookup.context, {
|
|
536
|
+
deleted: false,
|
|
537
|
+
label: null,
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (lookup.match.id === null) {
|
|
542
|
+
throw new Error(`Label "${parsed.name}" does not expose an id and cannot be deleted.`)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const deleteMethod = resolveLabelMethod(api, 'delete')
|
|
546
|
+
const result = await executeLabelCall(deleteMethod, owner, repo, [lookup.match.id])
|
|
547
|
+
ensureOk(result, `Failed to delete label "${parsed.name}" in ${owner}/${repo}`)
|
|
548
|
+
|
|
549
|
+
return withBody(result, {
|
|
550
|
+
deleted: true,
|
|
551
|
+
label: lookup.match,
|
|
552
|
+
})
|
|
553
|
+
},
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const ensureNamespace = (root: GitServiceApi, path: string[]): GitServiceApi => {
|
|
558
|
+
let cursor: GitServiceApi = root
|
|
559
|
+
for (const segment of path) {
|
|
560
|
+
const current = cursor[segment]
|
|
561
|
+
if (!isRecord(current)) {
|
|
562
|
+
cursor[segment] = {}
|
|
563
|
+
}
|
|
564
|
+
cursor = cursor[segment] as GitServiceApi
|
|
565
|
+
}
|
|
566
|
+
return cursor
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export const attachGitLabelManagementApi = (
|
|
570
|
+
api: GitServiceApi,
|
|
571
|
+
defaults: GitLabelManagementDefaults = {},
|
|
572
|
+
): void => {
|
|
573
|
+
const methods = createGitLabelManagementApi(api, defaults)
|
|
574
|
+
const targets = [
|
|
575
|
+
ensureNamespace(api, ['label']),
|
|
576
|
+
ensureNamespace(api, ['repo', 'label']),
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
for (const target of targets) {
|
|
580
|
+
for (const [name, method] of Object.entries(methods)) {
|
|
581
|
+
if (!(name in target)) {
|
|
582
|
+
target[name] = method
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|