@foundation0/git 1.0.0 → 1.2.1

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.
Files changed (117) hide show
  1. package/{packages/git/README.md → README.md} +14 -5
  2. package/{packages/git/mcp → mcp}/README.md +3 -1
  3. package/{packages/git/mcp → mcp}/cli.mjs +0 -0
  4. package/package.json +43 -13
  5. package/{packages/git/src → src}/git-service-api.ts +3 -0
  6. package/{packages/git/src → src}/index.ts +8 -0
  7. package/src/label-management.ts +587 -0
  8. package/src/platform/config.ts +60 -0
  9. package/.codex.example/config.toml +0 -10
  10. package/.env.example +0 -10
  11. package/packages/fs/README.md +0 -47
  12. package/packages/fs/node_modules/.bin/f0-git-mcp +0 -21
  13. package/packages/fs/node_modules/.bin/f0-git-mcp-server +0 -21
  14. package/packages/fs/node_modules/.bin/f0-git-mcp-server.CMD +0 -12
  15. package/packages/fs/node_modules/.bin/f0-git-mcp-server.ps1 +0 -41
  16. package/packages/fs/node_modules/.bin/f0-git-mcp.CMD +0 -12
  17. package/packages/fs/node_modules/.bin/f0-git-mcp.ps1 +0 -41
  18. package/packages/fs/node_modules/.bin/tsc +0 -21
  19. package/packages/fs/node_modules/.bin/tsc.CMD +0 -12
  20. package/packages/fs/node_modules/.bin/tsc.ps1 +0 -41
  21. package/packages/fs/node_modules/.bin/tsserver +0 -21
  22. package/packages/fs/node_modules/.bin/tsserver.CMD +0 -12
  23. package/packages/fs/node_modules/.bin/tsserver.ps1 +0 -41
  24. package/packages/fs/node_modules/.bin/vite +0 -21
  25. package/packages/fs/node_modules/.bin/vite.CMD +0 -12
  26. package/packages/fs/node_modules/.bin/vite.ps1 +0 -41
  27. package/packages/fs/node_modules/.bin/vitest +0 -21
  28. package/packages/fs/node_modules/.bin/vitest.CMD +0 -12
  29. package/packages/fs/node_modules/.bin/vitest.ps1 +0 -41
  30. package/packages/fs/package.json +0 -28
  31. package/packages/fs/src/cli.ts +0 -74
  32. package/packages/fs/src/git-fs.ts +0 -705
  33. package/packages/fs/src/index.ts +0 -33
  34. package/packages/fs/src/mount.ts +0 -297
  35. package/packages/fs/tsconfig.json +0 -7
  36. package/packages/git/mcp/tests/e2e/git-mcp-e2e.spec.ts +0 -157
  37. package/packages/git/mcp/tests/e2e/server.fixture.ts +0 -109
  38. package/packages/git/node_modules/.bin/tsc +0 -21
  39. package/packages/git/node_modules/.bin/tsc.CMD +0 -12
  40. package/packages/git/node_modules/.bin/tsc.ps1 +0 -41
  41. package/packages/git/node_modules/.bin/tsserver +0 -21
  42. package/packages/git/node_modules/.bin/tsserver.CMD +0 -12
  43. package/packages/git/node_modules/.bin/tsserver.ps1 +0 -41
  44. package/packages/git/node_modules/.bin/vite +0 -21
  45. package/packages/git/node_modules/.bin/vite.CMD +0 -12
  46. package/packages/git/node_modules/.bin/vite.ps1 +0 -41
  47. package/packages/git/node_modules/.bin/vitest +0 -21
  48. package/packages/git/node_modules/.bin/vitest.CMD +0 -12
  49. package/packages/git/node_modules/.bin/vitest.ps1 +0 -41
  50. package/packages/git/node_modules/.vite/vitest/results.json +0 -1
  51. package/packages/git/package.json +0 -60
  52. package/packages/git/scripts/create-issue.mjs +0 -93
  53. package/packages/git/scripts/extract-git-spec.mjs +0 -234
  54. package/packages/git/scripts/fetch-gitea-swagger.mjs +0 -22
  55. package/packages/git/src/platform/config.ts +0 -140
  56. package/packages/git/tests/api.spec.ts +0 -55
  57. package/packages/git/tests/e2e/git-service-feature-e2e.spec.ts +0 -232
  58. package/packages/git/tests/git-service-api-object.spec.ts +0 -97
  59. package/packages/git/tests/git-service-feature-matrix.spec.ts +0 -182
  60. package/packages/git/tests/issue-dependencies.spec.ts +0 -81
  61. package/packages/git/tsconfig.json +0 -7
  62. package/packages/git/vitest.config.ts +0 -7
  63. package/packages/utils/package.json +0 -9
  64. package/packages/utils/src/awk.ts +0 -6
  65. package/packages/utils/src/cat.ts +0 -6
  66. package/packages/utils/src/cd.ts +0 -6
  67. package/packages/utils/src/chgrp.ts +0 -6
  68. package/packages/utils/src/chmod.ts +0 -6
  69. package/packages/utils/src/chown.ts +0 -6
  70. package/packages/utils/src/cp.ts +0 -6
  71. package/packages/utils/src/curl.ts +0 -6
  72. package/packages/utils/src/cut.ts +0 -6
  73. package/packages/utils/src/date.ts +0 -6
  74. package/packages/utils/src/echo.ts +0 -6
  75. package/packages/utils/src/find.ts +0 -6
  76. package/packages/utils/src/grep.ts +0 -6
  77. package/packages/utils/src/gunzip.ts +0 -6
  78. package/packages/utils/src/gzip.ts +0 -6
  79. package/packages/utils/src/head.ts +0 -6
  80. package/packages/utils/src/hostname.ts +0 -6
  81. package/packages/utils/src/index.ts +0 -37
  82. package/packages/utils/src/ls.ts +0 -6
  83. package/packages/utils/src/mkdir.ts +0 -6
  84. package/packages/utils/src/mv.ts +0 -6
  85. package/packages/utils/src/ping.ts +0 -6
  86. package/packages/utils/src/pwd.ts +0 -6
  87. package/packages/utils/src/rm.ts +0 -6
  88. package/packages/utils/src/rmdir.ts +0 -6
  89. package/packages/utils/src/sed.ts +0 -6
  90. package/packages/utils/src/sort.ts +0 -6
  91. package/packages/utils/src/tail.ts +0 -6
  92. package/packages/utils/src/tar.ts +0 -6
  93. package/packages/utils/src/touch.ts +0 -6
  94. package/packages/utils/src/tr.ts +0 -6
  95. package/packages/utils/src/uname.ts +0 -6
  96. package/packages/utils/src/uniq.ts +0 -6
  97. package/packages/utils/src/unzip.ts +0 -6
  98. package/packages/utils/src/util.ts +0 -4
  99. package/packages/utils/src/wc.ts +0 -6
  100. package/packages/utils/src/wget.ts +0 -6
  101. package/packages/utils/src/whoami.ts +0 -6
  102. package/packages/utils/src/zip.ts +0 -6
  103. package/pnpm-workspace.yaml +0 -2
  104. package/tsconfig.base.json +0 -12
  105. /package/{packages/git/gitea-swagger.json → gitea-swagger.json} +0 -0
  106. /package/{packages/git/mcp → mcp}/src/cli.ts +0 -0
  107. /package/{packages/git/mcp → mcp}/src/client.ts +0 -0
  108. /package/{packages/git/mcp → mcp}/src/index.ts +0 -0
  109. /package/{packages/git/mcp → mcp}/src/server.ts +0 -0
  110. /package/{packages/git/src → src}/api.ts +0 -0
  111. /package/{packages/git/src → src}/git-service-feature-spec.generated.ts +0 -0
  112. /package/{packages/git/src → src}/issue-dependencies.ts +0 -0
  113. /package/{packages/git/src → src}/platform/gitea-adapter.ts +0 -0
  114. /package/{packages/git/src → src}/platform/gitea-rules.ts +0 -0
  115. /package/{packages/git/src → src}/platform/index.ts +0 -0
  116. /package/{packages/git/src → src}/repository.ts +0 -0
  117. /package/{packages/git/src → src}/spec-mock.ts +0 -0
@@ -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
+
@@ -0,0 +1,60 @@
1
+ export type PlatformName = 'GITEA' | 'GITHUB'
2
+
3
+ export interface GitPlatformConfig {
4
+ platform: PlatformName
5
+ giteaHost: string
6
+ giteaToken?: string
7
+ giteaApiVersion?: string
8
+ giteaSwaggerPath?: string
9
+ }
10
+
11
+ const DEFAULT_GITEA_HOST = 'https://gitea.example.com'
12
+ const DEFAULT_PLATFORM: PlatformName = 'GITEA'
13
+ const DEFAULT_GITEA_SWAGGER_PATH = '/swagger.v1.json'
14
+
15
+ const normalizePlatform = (value?: string): PlatformName => {
16
+ const platform = (value ?? '').toUpperCase()
17
+ if (platform === 'GITEA') {
18
+ return 'GITEA'
19
+ }
20
+
21
+ if (platform === 'GITHUB') {
22
+ return 'GITHUB'
23
+ }
24
+
25
+ return DEFAULT_PLATFORM
26
+ }
27
+
28
+ export const getGitPlatformConfig = (overrides: Partial<GitPlatformConfig> = {}): GitPlatformConfig => {
29
+ const platform = normalizePlatform(
30
+ overrides.platform ??
31
+ process.env.PLATFORM ??
32
+ process.env.GIT_PLATFORM,
33
+ )
34
+
35
+ const giteaHost =
36
+ overrides.giteaHost ??
37
+ process.env.GITEA_HOST ??
38
+ DEFAULT_GITEA_HOST
39
+
40
+ const giteaToken =
41
+ overrides.giteaToken ??
42
+ process.env.GITEA_TOKEN
43
+
44
+ const giteaApiVersion =
45
+ overrides.giteaApiVersion ??
46
+ process.env.GITEA_API_VERSION
47
+
48
+ const giteaSwaggerPath =
49
+ overrides.giteaSwaggerPath ??
50
+ process.env.GITEA_SWAGGER_PATH ??
51
+ DEFAULT_GITEA_SWAGGER_PATH
52
+
53
+ return {
54
+ platform,
55
+ giteaHost,
56
+ giteaToken,
57
+ giteaApiVersion,
58
+ giteaSwaggerPath,
59
+ }
60
+ }
@@ -1,10 +0,0 @@
1
- [mcp_servers.git]
2
- command = "npx"
3
- args = ["-y", "-p", "@foundation0/git", "f0-git-mcp", "--tools-prefix", "git"]
4
- enabled = true
5
- startup_timeout_ms = 20_000
6
- env = {
7
- GITEA_HOST = "https://gitea.example.com",
8
- GITEA_USER = "your-user",
9
- GITEA_TOKEN = "your-token"
10
- }
package/.env.example DELETED
@@ -1,10 +0,0 @@
1
- # Copy to ".env" and fill in values. Do not commit ".env".
2
-
3
- # Gitea MCP server (packages/git/mcp)
4
- GITEA_HOST=https://gitea.example.com
5
- GITEA_TOKEN=
6
-
7
- # Optional defaults for tests / dev
8
- GITEA_TEST_OWNER=example-org
9
- GITEA_TEST_REPO=example-repo
10
-
@@ -1,47 +0,0 @@
1
- # @workspace/fs
2
-
3
- `@workspace/fs` provides a Git-backed filesystem API implemented on top of `@foundation0/git` and an optional FUSE mount entrypoint.
4
-
5
- ## API shape
6
-
7
- The package exposes promise-based drop-in helpers that mirror common `node:fs` methods:
8
-
9
- - `readFile`, `writeFile`, `appendFile`, `mkdir`, `readdir`, `rename`, `copyFile`, `unlink`, `rm`, `rmdir`, `stat`, `lstat`, `exists`, `access`
10
- - `promises` and default instance exports (`createGitFs`, `defaultGitFs`)
11
- - sync methods are intentionally not implemented and raise `ENOSYS`
12
-
13
- All paths are interpreted as `owner/repo/...`.
14
-
15
- If `defaultOwner` and `defaultRepo` are configured, one-segment paths map into that repo directly:
16
-
17
- - `README.md` -> `<defaultOwner>/<defaultRepo>/README.md`
18
- - `docs/guide.md` -> `<defaultOwner>/<defaultRepo>/docs/guide.md`
19
-
20
- ### Usage
21
-
22
- ```ts
23
- import { readFile, writeFile, readdir, promises } from '@workspace/fs'
24
-
25
- await writeFile('example-org/example-repo/README.md', 'hello world')
26
- const content = await readFile('example-org/example-repo/README.md', { encoding: 'utf8' })
27
- const entries = await readdir('example-org/example-repo')
28
- await promises.readFile('example-org/example-repo/README.md')
29
- ```
30
-
31
- ### Mount
32
-
33
- If `fuse-bindings` is installed, run:
34
-
35
- ```bash
36
- bun run ./src/cli.ts --mount ./mnt --default-owner example-org --default-repo example-repo
37
- ```
38
-
39
- Windows mount support options:
40
-
41
- - `--backend dokan` - load `node-fuse-bindings` (uses Dokany as the driver layer on Windows).
42
- - `--backend winfsp` - try `node-fuse-bindings` first, then `fuse-bindings`.
43
- - `--backend auto` (default) - on Windows, tries `dokan` then fallback to `fuse-bindings`; on non-Windows, uses `fuse-bindings`.
44
-
45
- When using Dokan/WinFsp backends, make sure the native libraries are installed and discoverable for native addon compilation.
46
-
47
- Note: these native mount backends currently do not resolve under Bun runtime on Windows in this environment (ABI 24/`bun`). If you need mount on Windows, run the CLI with Node.js instead of Bun, or use WSL.