@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.
@@ -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
+