@foundation0/git 1.2.0 → 1.2.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 CHANGED
@@ -197,12 +197,21 @@ await api.repo.sync()
197
197
  ### 5) Team triage with labels
198
198
 
199
199
  ```ts
200
- await api.label.list()
201
- await api.label.create({ data: { name: 'priority-high', color: '#d73a4a' } })
202
- await api.label.edit('priority-high', {
203
- data: { color: '#fbca04', description: 'Escalated issue' },
200
+ await api.repo.label.listManaged()
201
+
202
+ await api.repo.label.upsert('priority-high', {
203
+ color: '#d73a4a',
204
+ description: 'Initial escalation label',
204
205
  })
205
- await api.label.delete('priority-high')
206
+
207
+ await api.repo.label.getByName('priority-high')
208
+
209
+ await api.repo.label.upsert('priority-high', {
210
+ color: '#fbca04',
211
+ description: 'Escalated issue',
212
+ })
213
+
214
+ await api.repo.label.deleteByName('priority-high')
206
215
  ```
207
216
 
208
217
  ### 6) Search and discovery
package/mcp/README.md CHANGED
@@ -199,7 +199,7 @@ Tips:
199
199
 
200
200
  - Prefer `https://` for `GITEA_HOST` to avoid leaking `GITEA_TOKEN` over plaintext HTTP.
201
201
  - The CLI entrypoint in `packages/git/mcp/src/cli.ts` rejects `http://` hosts unless `--allow-insecure-http` is passed for local testing.
202
- - Keep `GITEA_TOKEN` in environment variables (or an uncommitted `.env`); use the repo root `.env.example` as a template.
202
+ - Set `GITEA_TOKEN` in the MCP process environment. The package does not auto-load `.env` files.
203
203
 
204
204
  ## API surface
205
205
 
@@ -207,3 +207,5 @@ Tips:
207
207
  - `GitMcpClient`
208
208
  - `createGitMcpClient(options)`
209
209
  - `normalizeToolCallNameForServer(prefix, toolName)`
210
+ - Includes label-management tools exposed from the API object:
211
+ `repo.label.listManaged`, `repo.label.getByName`, `repo.label.upsert`, and `repo.label.deleteByName`
package/mcp/src/cli.ts CHANGED
@@ -41,14 +41,18 @@ if (hasFlag('--help') || hasFlag('-h')) {
41
41
  process.exit(0)
42
42
  }
43
43
 
44
- const owner = getArgValue('--default-owner', process.env.GITEA_TEST_OWNER ?? 'example-org')
45
- const repo = getArgValue('--default-repo', process.env.GITEA_TEST_REPO ?? 'example-repo')
46
- const host = getArgValue('--gitea-host', process.env.GITEA_HOST ?? process.env.EXAMPLE_GITEA_HOST ?? 'https://gitea.example.com')?.trim()
44
+ const owner = getArgValue('--default-owner')?.trim()
45
+ const repo = getArgValue('--default-repo')?.trim()
46
+ const host = getArgValue('--gitea-host', process.env.GITEA_HOST)?.trim()
47
47
  const token = process.env.GITEA_TOKEN
48
48
  const serverName = getArgValue('--server-name', 'f0-git-mcp')
49
49
  const serverVersion = getArgValue('--server-version', '1.0.0')
50
50
  const toolsPrefix = getArgValue('--tools-prefix') ?? process.env.MCP_TOOLS_PREFIX
51
51
 
52
+ if (!host) {
53
+ throw new Error('GITEA_HOST is required. Set process.env.GITEA_HOST or pass --gitea-host.')
54
+ }
55
+
52
56
  if (isInsecureHttpUrl(host) && !hasFlag('--allow-insecure-http')) {
53
57
  throw new Error(
54
58
  'Refusing to send requests to an insecure http:// Gitea host. Use https:// or pass --allow-insecure-http if you really need this for local testing.',
@@ -63,8 +67,8 @@ void runGitMcpServer({
63
67
  giteaHost: host,
64
68
  giteaToken: token,
65
69
  },
66
- defaultOwner: owner,
67
- defaultRepo: repo,
70
+ ...(owner ? { defaultOwner: owner } : {}),
71
+ ...(repo ? { defaultRepo: repo } : {}),
68
72
  toolsPrefix,
69
73
  }).catch((error) => {
70
74
  console.error('Failed to start MCP git server', error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foundation0/git",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Foundation 0 Git API and MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,7 @@ import { createGitPlatformAdapter, type GitPlatformAdapterFactoryDeps } from './
2
2
  import { getGitPlatformConfig, type GitPlatformConfig } from './platform/config'
3
3
  import { type GitServiceFlag, type GitServiceFeature, gitServiceFeatureSpec } from './git-service-feature-spec.generated'
4
4
  import { type GitApiFeatureMapping } from './platform/gitea-adapter'
5
+ import { attachGitLabelManagementApi } from './label-management'
5
6
  import { spawn } from 'node:child_process'
6
7
  import crypto from 'node:crypto'
7
8
 
@@ -217,6 +218,22 @@ const buildUrl = (
217
218
  return url.toString()
218
219
  }
219
220
 
221
+ const unresolvedPathParamPattern = /^\{[^{}]+\}$/
222
+
223
+ const assertResolvedMappedPath = (
224
+ mappedPath: string[],
225
+ featurePath: string[],
226
+ ): void => {
227
+ const unresolved = mappedPath.filter((segment) => unresolvedPathParamPattern.test(segment))
228
+ if (unresolved.length === 0) {
229
+ return
230
+ }
231
+
232
+ throw new Error(
233
+ `Missing required path arguments for "${featurePath.join('.')}". Unresolved parameters: ${unresolved.join(', ')}`,
234
+ )
235
+ }
236
+
220
237
  const canUseAbortSignalTimeout = (): boolean =>
221
238
  typeof AbortSignal !== 'undefined' && typeof (AbortSignal as unknown as { timeout?: unknown }).timeout === 'function'
222
239
 
@@ -368,6 +385,7 @@ const createMethod = (
368
385
 
369
386
  return segment
370
387
  })
388
+ assertResolvedMappedPath(hydratedPath, feature.path)
371
389
 
372
390
  const requestBody = buildRequestBody(mapping.method, bodyOptions, unhandled)
373
391
  const headers = {
@@ -529,8 +547,8 @@ export const createGitServiceApi = (options: GitServiceApiFactoryOptions = {}):
529
547
  const log = options.log
530
548
 
531
549
  const defaults = {
532
- defaultOwner: options.defaultOwner ?? process.env.GITEA_TEST_OWNER ?? 'example-org',
533
- defaultRepo: options.defaultRepo ?? process.env.GITEA_TEST_REPO ?? 'example-repo',
550
+ defaultOwner: options.defaultOwner,
551
+ defaultRepo: options.defaultRepo,
534
552
  }
535
553
 
536
554
  const root: GitServiceApi = {}
@@ -556,7 +574,29 @@ export const createGitServiceApi = (options: GitServiceApiFactoryOptions = {}):
556
574
  }
557
575
  }
558
576
 
577
+ attachGitLabelManagementApi(root, defaults)
578
+
559
579
  return root
560
580
  }
561
581
 
562
- export const gitServiceApi = createGitServiceApi()
582
+ const createUnavailableGitServiceApi = (error: Error): GitServiceApi => {
583
+ return new Proxy(
584
+ {},
585
+ {
586
+ get: (): never => {
587
+ throw error
588
+ },
589
+ },
590
+ ) as GitServiceApi
591
+ }
592
+
593
+ export const gitServiceApi: GitServiceApi = (() => {
594
+ try {
595
+ return createGitServiceApi()
596
+ } catch (error) {
597
+ const message = error instanceof Error ? error.message : String(error)
598
+ return createUnavailableGitServiceApi(
599
+ new Error(`Failed to initialize gitServiceApi singleton: ${message}`),
600
+ )
601
+ }
602
+ })()
package/src/index.ts CHANGED
@@ -36,6 +36,14 @@ export {
36
36
  type GitIssueDependencyPayload,
37
37
  type GitIssueDependencySyncResult,
38
38
  } from './issue-dependencies'
39
+ export {
40
+ attachGitLabelManagementApi,
41
+ createGitLabelManagementApi,
42
+ extractRepositoryLabels,
43
+ type GitLabelManagementApi,
44
+ type GitLabelManagementDefaults,
45
+ type GitRepositoryLabel,
46
+ } from './label-management'
39
47
  export { resolveProjectRepoIdentity, type GitRepositoryIdentity } from './repository'
40
48
  export type {
41
49
  GitServiceApi,
@@ -2,7 +2,6 @@ import type { GitServiceApiExecutionResult } from './git-service-api'
2
2
  import { spawn } from 'node:child_process'
3
3
  import crypto from 'node:crypto'
4
4
 
5
- const DEFAULT_GITEA_HOST = 'https://gitea.example.com'
6
5
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
7
6
 
8
7
  const parseRequestTimeoutMs = (value: unknown): number | null => {
@@ -274,6 +273,14 @@ const resolveGiteaApiBase = (host: string): string => {
274
273
  return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`
275
274
  }
276
275
 
276
+ const resolveRequiredGiteaHost = (host: string | undefined): string => {
277
+ const resolved = host?.trim() ?? process.env.GITEA_HOST?.trim()
278
+ if (!resolved) {
279
+ throw new Error('GITEA_HOST is required. Pass host explicitly or set process.env.GITEA_HOST.')
280
+ }
281
+ return resolved
282
+ }
283
+
277
284
  const buildIssueDependenciesUrl = (host: string, owner: string, repo: string, issueNumber: number): string => {
278
285
  return `${resolveGiteaApiBase(host)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/dependencies`
279
286
  }
@@ -283,11 +290,12 @@ export async function callIssueDependenciesApi(
283
290
  owner: string,
284
291
  repo: string,
285
292
  issueNumber: number,
286
- host: string = DEFAULT_GITEA_HOST,
293
+ host: string | undefined,
287
294
  token: string | undefined,
288
295
  payload?: GitIssueDependencyPayload
289
296
  ): Promise<GitServiceApiExecutionResult<unknown>> {
290
- const requestUrl = buildIssueDependenciesUrl(host, owner, repo, issueNumber)
297
+ const resolvedHost = resolveRequiredGiteaHost(host)
298
+ const requestUrl = buildIssueDependenciesUrl(resolvedHost, owner, repo, issueNumber)
291
299
  const headers = {
292
300
  Accept: 'application/json',
293
301
  ...(token ? { Authorization: `token ${token}` } : {}),
@@ -343,7 +351,7 @@ export async function callIssueDependenciesApi(
343
351
  method,
344
352
  query: [],
345
353
  headers: [],
346
- apiBase: resolveGiteaApiBase(host),
354
+ apiBase: resolveGiteaApiBase(resolvedHost),
347
355
  swaggerPath: '/repos/{owner}/{repo}/issues/{index}/dependencies',
348
356
  mapped: true,
349
357
  },
@@ -0,0 +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,6 +1,3 @@
1
- import { existsSync, readFileSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
3
-
4
1
  export type PlatformName = 'GITEA' | 'GITHUB'
5
2
 
6
3
  export interface GitPlatformConfig {
@@ -11,79 +8,9 @@ export interface GitPlatformConfig {
11
8
  giteaSwaggerPath?: string
12
9
  }
13
10
 
14
- interface DotEnv {
15
- [key: string]: string
16
- }
17
-
18
- const DEFAULT_GITEA_HOST = 'https://gitea.example.com'
19
11
  const DEFAULT_PLATFORM: PlatformName = 'GITEA'
20
12
  const DEFAULT_GITEA_SWAGGER_PATH = '/swagger.v1.json'
21
13
 
22
- const parseLine = (line: string): [string, string] | null => {
23
- const trimmed = line.trim()
24
- if (!trimmed || trimmed.startsWith('#')) {
25
- return null
26
- }
27
-
28
- const index = trimmed.indexOf('=')
29
- if (index < 0) {
30
- return null
31
- }
32
-
33
- const key = trimmed.slice(0, index).trim()
34
- const rawValue = trimmed.slice(index + 1).trim()
35
- if (!key) {
36
- return null
37
- }
38
-
39
- if (rawValue.length >= 2 && rawValue.startsWith('"') && rawValue.endsWith('"')) {
40
- return [key, rawValue.slice(1, -1)]
41
- }
42
-
43
- if (rawValue.length >= 2 && rawValue.startsWith("'") && rawValue.endsWith("'")) {
44
- return [key, rawValue.slice(1, -1)]
45
- }
46
-
47
- return [key, rawValue]
48
- }
49
-
50
- const readDotEnv = (filePath: string): DotEnv => {
51
- if (!existsSync(filePath)) {
52
- return {}
53
- }
54
-
55
- const env: DotEnv = {}
56
-
57
- const contents = readFileSync(filePath, 'utf8')
58
- for (const line of contents.split(/\r?\n/)) {
59
- const parsed = parseLine(line)
60
- if (!parsed) {
61
- continue
62
- }
63
-
64
- const [key, value] = parsed
65
- env[key] = value
66
- }
67
-
68
- return env
69
- }
70
-
71
- const searchDotEnv = (): DotEnv => {
72
- const candidates = [
73
- join(process.cwd(), '.env'),
74
- join(dirname(process.cwd()), '.env'),
75
- ]
76
-
77
- for (const candidate of candidates) {
78
- const values = readDotEnv(candidate)
79
- if (Object.keys(values).length > 0) {
80
- return values
81
- }
82
- }
83
-
84
- return {}
85
- }
86
-
87
14
  const normalizePlatform = (value?: string): PlatformName => {
88
15
  const platform = (value ?? '').toUpperCase()
89
16
  if (platform === 'GITEA') {
@@ -98,41 +25,36 @@ const normalizePlatform = (value?: string): PlatformName => {
98
25
  }
99
26
 
100
27
  export const getGitPlatformConfig = (overrides: Partial<GitPlatformConfig> = {}): GitPlatformConfig => {
101
- const env = searchDotEnv()
102
-
103
28
  const platform = normalizePlatform(
104
29
  overrides.platform ??
105
30
  process.env.PLATFORM ??
106
- process.env.GIT_PLATFORM ??
107
- env.PLATFORM ??
108
- env.GIT_PLATFORM,
31
+ process.env.GIT_PLATFORM,
109
32
  )
110
33
 
111
34
  const giteaHost =
112
35
  overrides.giteaHost ??
113
- process.env.GITEA_HOST ??
114
- env.GITEA_HOST ??
115
- DEFAULT_GITEA_HOST
36
+ process.env.GITEA_HOST
116
37
 
117
38
  const giteaToken =
118
39
  overrides.giteaToken ??
119
- process.env.GITEA_TOKEN ??
120
- env.GITEA_TOKEN
40
+ process.env.GITEA_TOKEN
121
41
 
122
42
  const giteaApiVersion =
123
43
  overrides.giteaApiVersion ??
124
- process.env.GITEA_API_VERSION ??
125
- env.GITEA_API_VERSION
44
+ process.env.GITEA_API_VERSION
126
45
 
127
46
  const giteaSwaggerPath =
128
47
  overrides.giteaSwaggerPath ??
129
48
  process.env.GITEA_SWAGGER_PATH ??
130
- env.GITEA_SWAGGER_PATH ??
131
49
  DEFAULT_GITEA_SWAGGER_PATH
132
50
 
51
+ if (!giteaHost || giteaHost.trim().length === 0) {
52
+ throw new Error('GITEA_HOST is required. Set process.env.GITEA_HOST or pass config.giteaHost explicitly.')
53
+ }
54
+
133
55
  return {
134
56
  platform,
135
- giteaHost,
57
+ giteaHost: giteaHost.trim(),
136
58
  giteaToken,
137
59
  giteaApiVersion,
138
60
  giteaSwaggerPath,