@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,460 +1,460 @@
1
- import type { GitServiceFeature } from '../git-service-feature-spec.generated'
2
- import { buildGitApiMockResponse, type GitApiMockResponse } from '../spec-mock'
3
- import { getDefaultGiteaRule, getExactGiteaRule, type GiteaRouteRule } from './gitea-rules'
4
-
5
- type FlagValue = string | boolean | undefined
6
-
7
- const DEFAULT_HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
8
- const READ_OPERATION_ACTIONS = new Set([
9
- 'view',
10
- 'get',
11
- 'list',
12
- 'status',
13
- 'exists',
14
- 'search',
15
- 'stats',
16
- 'verify',
17
- 'check',
18
- ])
19
- const DELETE_OPERATION_ACTIONS = new Set([
20
- 'delete',
21
- 'remove',
22
- 'unarchive',
23
- 'unstar',
24
- 'unwatch',
25
- 'unpin',
26
- 'unlink',
27
- 'unsubscribe',
28
- 'unfollow',
29
- 'deprovision',
30
- 'expire',
31
- ])
32
- const PATCH_OPERATION_ACTIONS = new Set([
33
- 'edit',
34
- 'update',
35
- 'close',
36
- 'open',
37
- 'pin',
38
- 'reopen',
39
- 'rename',
40
- 'set',
41
- 'transfer',
42
- 'enable',
43
- 'disable',
44
- 'lock',
45
- 'unlock',
46
- 'set-default',
47
- 'follow',
48
- 'watch',
49
- 'subscribe',
50
- 'archive',
51
- 'merge',
52
- 'approve',
53
- ])
54
- const POST_OPERATION_ACTIONS = new Set([
55
- 'create',
56
- 'add',
57
- 'run',
58
- 'dispatch',
59
- 'comment',
60
- 'fork',
61
- 'sync',
62
- 'set-upstream',
63
- 'upload',
64
- 'install',
65
- 'request',
66
- 'approve',
67
- 'create-team',
68
- 'apply',
69
- 'start',
70
- 'stop',
71
- 'rerun',
72
- 'reset',
73
- ])
74
-
75
- const normalizeHttpMethod = (value?: string): string | undefined => {
76
- if (!value || typeof value !== 'string') {
77
- return undefined
78
- }
79
-
80
- const method = value.trim().toUpperCase()
81
- return DEFAULT_HTTP_METHODS.has(method) ? method : undefined
82
- }
83
-
84
- const inferFallbackMethod = (featurePath: string[]): string => {
85
- if (featurePath.length === 0) {
86
- return 'GET'
87
- }
88
-
89
- const last = featurePath[featurePath.length - 1].toLowerCase()
90
-
91
- if (READ_OPERATION_ACTIONS.has(last)) {
92
- return 'GET'
93
- }
94
-
95
- if (DELETE_OPERATION_ACTIONS.has(last)) {
96
- return 'DELETE'
97
- }
98
-
99
- if (PATCH_OPERATION_ACTIONS.has(last)) {
100
- return 'PATCH'
101
- }
102
-
103
- if (POST_OPERATION_ACTIONS.has(last)) {
104
- return 'POST'
105
- }
106
-
107
- if (featurePath.length === 1) {
108
- return 'GET'
109
- }
110
-
111
- return 'POST'
112
- }
113
-
114
- export interface GitApiFeatureContext {
115
- feature: GitServiceFeature
116
- args?: string[]
117
- flagValues?: Record<string, FlagValue>
118
- method?: string
119
- }
120
-
121
- export interface GitApiFeatureMapping {
122
- platform: 'GITEA'
123
- featurePath: string[]
124
- mappedPath: string[]
125
- method: string
126
- query: string[]
127
- headers: string[]
128
- apiBase: string
129
- swaggerPath: string
130
- mapped: boolean
131
- apiVersion?: string
132
- reason?: string
133
- }
134
-
135
- export interface GiteaSwagger {
136
- paths?: Record<string, Record<string, unknown>>
137
- }
138
-
139
- export interface GiteaPlatformAdapterOptions {
140
- giteaHost: string
141
- giteaToken?: string
142
- giteaSwaggerPath?: string
143
- apiVersion?: string
144
- swaggerSpec?: GiteaSwagger
145
- }
146
-
147
- export class GiteaPlatformAdapter {
148
- private readonly apiBase: string
149
- private readonly apiVersion?: string
150
- private readonly token?: string
151
- private readonly swaggerUrl: string
152
- private readonly swaggerSpec?: GiteaSwagger
153
-
154
- public constructor(options: GiteaPlatformAdapterOptions) {
155
- this.apiBase = this.buildApiBase(options.giteaHost)
156
- this.apiVersion = options.apiVersion
157
- this.token = options.giteaToken
158
- this.swaggerUrl = this.buildSwaggerUrl(options.giteaHost, options.giteaSwaggerPath)
159
- this.swaggerSpec = options.swaggerSpec
160
- }
161
-
162
- public get platform() {
163
- return 'GITEA' as const
164
- }
165
-
166
- public async mapFeature(context: GitApiFeatureContext): Promise<GitApiFeatureMapping> {
167
- const args = context.args ?? []
168
- const flagValues = context.flagValues ?? {}
169
- const matchedRule = getExactGiteaRule(context.feature.path) ?? getDefaultGiteaRule(context.feature.path)
170
-
171
- if (!matchedRule) {
172
- return this.mapFallback(
173
- context.feature.path,
174
- args,
175
- this.mapFlags(flagValues, context.feature.path),
176
- context.method,
177
- )
178
- }
179
-
180
- return this.mapRule(matchedRule, args, flagValues, context.method)
181
- }
182
-
183
- public async mapFeatureResponse(context: GitApiFeatureContext): Promise<GitApiMockResponse> {
184
- const mapping = await this.mapFeature(context)
185
- return buildGitApiMockResponse({
186
- path: mapping.mappedPath,
187
- method: mapping.method,
188
- query: mapping.query,
189
- headers: mapping.headers,
190
- apiBase: mapping.apiBase,
191
- apiVersion: mapping.apiVersion,
192
- })
193
- }
194
-
195
- public async validateFeature(context: GitApiFeatureContext): Promise<GitApiFeatureMapping> {
196
- const mapping = await this.mapFeature(context)
197
- const spec = await this.fetchSwaggerSpec()
198
- const pathKey = `/${mapping.mappedPath.join('/')}`
199
- const methods = spec.paths?.[pathKey]
200
-
201
- if (!methods) {
202
- return {
203
- ...mapping,
204
- mapped: false,
205
- reason: `Swagger endpoint not found: ${pathKey}`,
206
- }
207
- }
208
-
209
- const hasMethod = Object.prototype.hasOwnProperty.call(methods, mapping.method.toLowerCase())
210
-
211
- const reason = hasMethod ? undefined : `Swagger method mismatch for ${pathKey}: ${mapping.method}`
212
-
213
- return {
214
- ...mapping,
215
- mapped: hasMethod,
216
- reason,
217
- }
218
- }
219
-
220
- public async fetchSwaggerSpec(): Promise<GiteaSwagger> {
221
- if (this.swaggerSpec) {
222
- return this.swaggerSpec
223
- }
224
-
225
- const response = await fetch(this.swaggerUrl, {
226
- headers: this.token ? { Authorization: `token ${this.token}` } : undefined,
227
- })
228
-
229
- if (!response.ok) {
230
- throw new Error(`Could not fetch swagger spec from ${this.swaggerUrl}: ${response.status}`)
231
- }
232
-
233
- const payload = (await response.json()) as GiteaSwagger
234
- return payload
235
- }
236
-
237
- private mapRule(
238
- rule: GiteaRouteRule,
239
- args: string[] = [],
240
- flagValues: Record<string, FlagValue> = {},
241
- contextMethod?: string,
242
- ): GitApiFeatureMapping {
243
- const pathSegments = this.mapSwaggerPath(rule.swaggerPath, args, this.shouldUseContentsRoot(rule, args))
244
- const query = this.mapFeatureQuery(rule, flagValues)
245
- const headers = this.mapHeaders()
246
- const method = normalizeHttpMethod(contextMethod) ?? rule.method
247
-
248
- return {
249
- platform: this.platform,
250
- featurePath: rule.featurePath,
251
- mappedPath: pathSegments,
252
- method,
253
- query,
254
- headers,
255
- apiBase: this.apiBase,
256
- swaggerPath: rule.swaggerPath,
257
- mapped: true,
258
- apiVersion: this.apiVersion,
259
- reason: rule.notes,
260
- }
261
- }
262
-
263
- private mapFallback(
264
- featurePath: string[],
265
- args: string[],
266
- flagValues: string[],
267
- contextMethod?: string,
268
- ): GitApiFeatureMapping {
269
- const { owner, repo, tail } = this.extractOwnerRepoArgs(args)
270
- const mappedPath = this.sanitizePath(['repos', owner, repo, ...featurePath, ...tail])
271
- const headers = this.mapHeaders()
272
- const method = normalizeHttpMethod(contextMethod) ?? inferFallbackMethod(featurePath)
273
-
274
- return {
275
- platform: this.platform,
276
- featurePath,
277
- mappedPath,
278
- method,
279
- query: flagValues,
280
- headers,
281
- apiBase: this.apiBase,
282
- swaggerPath: `/${mappedPath.join('/')}`,
283
- mapped: false,
284
- apiVersion: this.apiVersion,
285
- reason: `No direct mapping for feature: ${featurePath.join(' ')}`,
286
- }
287
- }
288
-
289
- private mapFeatureQuery(rule: GiteaRouteRule, flagValues: Record<string, FlagValue>): string[] {
290
- const query = [...(rule.staticQuery ?? [])]
291
- query.push(...this.mapFlags(flagValues, rule.featurePath))
292
- return query
293
- }
294
-
295
- private mapFlags(flagValues: Record<string, FlagValue>, featurePath: string[]): string[] {
296
- const query: string[] = []
297
-
298
- if (featurePath[0] === 'search') {
299
- if (featurePath[1] === 'prs') {
300
- query.push('type=pr')
301
- }
302
-
303
- if (featurePath[1] === 'issues') {
304
- query.push('type=issue')
305
- }
306
- }
307
-
308
- for (const [flagName, value] of Object.entries(flagValues)) {
309
- if (value === undefined) {
310
- continue
311
- }
312
-
313
- const normalized = this.normalizeFlagKey(flagName)
314
- if (typeof value === 'boolean') {
315
- query.push(`${normalized}=${value}`)
316
- continue
317
- }
318
-
319
- query.push(`${normalized}=${value}`)
320
- }
321
-
322
- return query
323
- }
324
-
325
- private mapHeaders(): string[] {
326
- const headers = ['Accept: application/json']
327
- if (this.token) {
328
- headers.push(`Authorization: token ${this.token}`)
329
- }
330
-
331
- return headers
332
- }
333
-
334
- private shouldUseContentsRoot(rule: GiteaRouteRule, args: string[]): boolean {
335
- if (rule.swaggerPath !== '/repos/{owner}/{repo}/contents/{filepath}') {
336
- return false
337
- }
338
-
339
- if (rule.featurePath[0] !== 'contents' || rule.featurePath[1] !== 'list') {
340
- return false
341
- }
342
-
343
- const [, , filePath] = args
344
- if (filePath === undefined || filePath === '') {
345
- return true
346
- }
347
-
348
- return String(filePath).trim() === ''
349
- }
350
-
351
- private mapSwaggerPath(swaggerPath: string, args: string[] = [], trimContentsFilepath = false): string[] {
352
- const values = this.normalizeSwaggerPathArgs(swaggerPath, args)
353
- const parts = this.sanitizePath(swaggerPath.split('/'))
354
- let fillIndex = 0
355
-
356
- const mapped = parts.flatMap((segment) => {
357
- if (!segment.startsWith('{') || !segment.endsWith('}')) {
358
- return [segment]
359
- }
360
-
361
- const value = values[fillIndex]
362
- fillIndex += 1
363
-
364
- const isAutoFilledPlaceholder = /^\\{v\\d+\\}$/.test(value)
365
-
366
- if (trimContentsFilepath && segment === '{filepath}' && (!value || value.trim() === '' || isAutoFilledPlaceholder)) {
367
- return []
368
- }
369
-
370
- return [value ? value : segment]
371
- })
372
-
373
- return this.sanitizePath(mapped)
374
- }
375
-
376
- private normalizeSwaggerPathArgs(swaggerPath: string, args: string[]): string[] {
377
- const values = [...args]
378
- const segments = swaggerPath.split('/').filter((segment) => segment.startsWith('{') && segment.endsWith('}'))
379
- const placeholderCount = segments.length
380
-
381
- if (placeholderCount <= 1) {
382
- return values
383
- }
384
-
385
- if (!values[0]) {
386
- return values
387
- }
388
-
389
- const isRepoScoped =
390
- segments.length >= 2 &&
391
- segments[0] === '{owner}' &&
392
- segments[1] === '{repo}'
393
-
394
- if (values[0].includes('/') && placeholderCount >= 2) {
395
- const split = values[0].split('/')
396
- if (split.length >= 2 && split[0] && split[1]) {
397
- values[0] = split[0]
398
- values.splice(1, 0, split[1])
399
- }
400
- }
401
-
402
- // For repo-scoped commands, allow passing the "rest" of the arguments (number/id/path) without
403
- // explicitly providing owner/repo. The git-service layer can hydrate {owner}/{repo} from defaults.
404
- //
405
- // Example: swaggerPath "/repos/{owner}/{repo}/pulls/{number}" with args ["123"] should map to:
406
- // owner="{owner}" repo="{repo}" number="123"
407
- //
408
- // We achieve this by left-padding args with empty strings so mapSwaggerPath leaves placeholders intact.
409
- if (isRepoScoped && placeholderCount > 2 && values.length === 1 && !values[0].includes('/')) {
410
- values.unshift('', '')
411
- }
412
-
413
- if (placeholderCount >= 2 && values.length >= 2) {
414
- return values
415
- }
416
-
417
- return values
418
- }
419
-
420
- private extractOwnerRepoArgs(args: string[]): { owner: string; repo: string; tail: string[] } {
421
- if (!args.length) {
422
- return { owner: '{owner}', repo: '{repo}', tail: [] }
423
- }
424
-
425
- if (args.length >= 2) {
426
- return { owner: args[0], repo: args[1], tail: args.slice(2) }
427
- }
428
-
429
- const arg = args[0]
430
- const split = arg.split('/')
431
- if (split.length === 2 && split[0] && split[1]) {
432
- return { owner: split[0], repo: split[1], tail: [] }
433
- }
434
-
435
- return { owner: '{owner}', repo: '{repo}', tail: args }
436
- }
437
-
438
- private buildApiBase(host: string): string {
439
- const sanitizedHost = host.replace(/\/$/, '')
440
- if (sanitizedHost.endsWith('/api/v1')) {
441
- return sanitizedHost
442
- }
443
-
444
- return `${sanitizedHost}/api/v1`
445
- }
446
-
447
- private buildSwaggerUrl(host: string, path = '/swagger.v1.json'): string {
448
- const base = this.buildApiBase(host).replace(/\/api\/v1$/, '')
449
- const swaggerPath = path.startsWith('/') ? path : `/${path}`
450
- return `${base}${swaggerPath}`
451
- }
452
-
453
- private sanitizePath(parts: string[]): string[] {
454
- return parts.filter((part) => part.length > 0)
455
- }
456
-
457
- private normalizeFlagKey(flag: string): string {
458
- return flag.replace(/^--/, '').trim()
459
- }
460
- }
1
+ import type { GitServiceFeature } from '../git-service-feature-spec.generated'
2
+ import { buildGitApiMockResponse, type GitApiMockResponse } from '../spec-mock'
3
+ import { getDefaultGiteaRule, getExactGiteaRule, type GiteaRouteRule } from './gitea-rules'
4
+
5
+ type FlagValue = string | boolean | undefined
6
+
7
+ const DEFAULT_HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
8
+ const READ_OPERATION_ACTIONS = new Set([
9
+ 'view',
10
+ 'get',
11
+ 'list',
12
+ 'status',
13
+ 'exists',
14
+ 'search',
15
+ 'stats',
16
+ 'verify',
17
+ 'check',
18
+ ])
19
+ const DELETE_OPERATION_ACTIONS = new Set([
20
+ 'delete',
21
+ 'remove',
22
+ 'unarchive',
23
+ 'unstar',
24
+ 'unwatch',
25
+ 'unpin',
26
+ 'unlink',
27
+ 'unsubscribe',
28
+ 'unfollow',
29
+ 'deprovision',
30
+ 'expire',
31
+ ])
32
+ const PATCH_OPERATION_ACTIONS = new Set([
33
+ 'edit',
34
+ 'update',
35
+ 'close',
36
+ 'open',
37
+ 'pin',
38
+ 'reopen',
39
+ 'rename',
40
+ 'set',
41
+ 'transfer',
42
+ 'enable',
43
+ 'disable',
44
+ 'lock',
45
+ 'unlock',
46
+ 'set-default',
47
+ 'follow',
48
+ 'watch',
49
+ 'subscribe',
50
+ 'archive',
51
+ 'merge',
52
+ 'approve',
53
+ ])
54
+ const POST_OPERATION_ACTIONS = new Set([
55
+ 'create',
56
+ 'add',
57
+ 'run',
58
+ 'dispatch',
59
+ 'comment',
60
+ 'fork',
61
+ 'sync',
62
+ 'set-upstream',
63
+ 'upload',
64
+ 'install',
65
+ 'request',
66
+ 'approve',
67
+ 'create-team',
68
+ 'apply',
69
+ 'start',
70
+ 'stop',
71
+ 'rerun',
72
+ 'reset',
73
+ ])
74
+
75
+ const normalizeHttpMethod = (value?: string): string | undefined => {
76
+ if (!value || typeof value !== 'string') {
77
+ return undefined
78
+ }
79
+
80
+ const method = value.trim().toUpperCase()
81
+ return DEFAULT_HTTP_METHODS.has(method) ? method : undefined
82
+ }
83
+
84
+ const inferFallbackMethod = (featurePath: string[]): string => {
85
+ if (featurePath.length === 0) {
86
+ return 'GET'
87
+ }
88
+
89
+ const last = featurePath[featurePath.length - 1].toLowerCase()
90
+
91
+ if (READ_OPERATION_ACTIONS.has(last)) {
92
+ return 'GET'
93
+ }
94
+
95
+ if (DELETE_OPERATION_ACTIONS.has(last)) {
96
+ return 'DELETE'
97
+ }
98
+
99
+ if (PATCH_OPERATION_ACTIONS.has(last)) {
100
+ return 'PATCH'
101
+ }
102
+
103
+ if (POST_OPERATION_ACTIONS.has(last)) {
104
+ return 'POST'
105
+ }
106
+
107
+ if (featurePath.length === 1) {
108
+ return 'GET'
109
+ }
110
+
111
+ return 'POST'
112
+ }
113
+
114
+ export interface GitApiFeatureContext {
115
+ feature: GitServiceFeature
116
+ args?: string[]
117
+ flagValues?: Record<string, FlagValue>
118
+ method?: string
119
+ }
120
+
121
+ export interface GitApiFeatureMapping {
122
+ platform: 'GITEA'
123
+ featurePath: string[]
124
+ mappedPath: string[]
125
+ method: string
126
+ query: string[]
127
+ headers: string[]
128
+ apiBase: string
129
+ swaggerPath: string
130
+ mapped: boolean
131
+ apiVersion?: string
132
+ reason?: string
133
+ }
134
+
135
+ export interface GiteaSwagger {
136
+ paths?: Record<string, Record<string, unknown>>
137
+ }
138
+
139
+ export interface GiteaPlatformAdapterOptions {
140
+ giteaHost: string
141
+ giteaToken?: string
142
+ giteaSwaggerPath?: string
143
+ apiVersion?: string
144
+ swaggerSpec?: GiteaSwagger
145
+ }
146
+
147
+ export class GiteaPlatformAdapter {
148
+ private readonly apiBase: string
149
+ private readonly apiVersion?: string
150
+ private readonly token?: string
151
+ private readonly swaggerUrl: string
152
+ private readonly swaggerSpec?: GiteaSwagger
153
+
154
+ public constructor(options: GiteaPlatformAdapterOptions) {
155
+ this.apiBase = this.buildApiBase(options.giteaHost)
156
+ this.apiVersion = options.apiVersion
157
+ this.token = options.giteaToken
158
+ this.swaggerUrl = this.buildSwaggerUrl(options.giteaHost, options.giteaSwaggerPath)
159
+ this.swaggerSpec = options.swaggerSpec
160
+ }
161
+
162
+ public get platform() {
163
+ return 'GITEA' as const
164
+ }
165
+
166
+ public async mapFeature(context: GitApiFeatureContext): Promise<GitApiFeatureMapping> {
167
+ const args = context.args ?? []
168
+ const flagValues = context.flagValues ?? {}
169
+ const matchedRule = getExactGiteaRule(context.feature.path) ?? getDefaultGiteaRule(context.feature.path)
170
+
171
+ if (!matchedRule) {
172
+ return this.mapFallback(
173
+ context.feature.path,
174
+ args,
175
+ this.mapFlags(flagValues, context.feature.path),
176
+ context.method,
177
+ )
178
+ }
179
+
180
+ return this.mapRule(matchedRule, args, flagValues, context.method)
181
+ }
182
+
183
+ public async mapFeatureResponse(context: GitApiFeatureContext): Promise<GitApiMockResponse> {
184
+ const mapping = await this.mapFeature(context)
185
+ return buildGitApiMockResponse({
186
+ path: mapping.mappedPath,
187
+ method: mapping.method,
188
+ query: mapping.query,
189
+ headers: mapping.headers,
190
+ apiBase: mapping.apiBase,
191
+ apiVersion: mapping.apiVersion,
192
+ })
193
+ }
194
+
195
+ public async validateFeature(context: GitApiFeatureContext): Promise<GitApiFeatureMapping> {
196
+ const mapping = await this.mapFeature(context)
197
+ const spec = await this.fetchSwaggerSpec()
198
+ const pathKey = `/${mapping.mappedPath.join('/')}`
199
+ const methods = spec.paths?.[pathKey]
200
+
201
+ if (!methods) {
202
+ return {
203
+ ...mapping,
204
+ mapped: false,
205
+ reason: `Swagger endpoint not found: ${pathKey}`,
206
+ }
207
+ }
208
+
209
+ const hasMethod = Object.prototype.hasOwnProperty.call(methods, mapping.method.toLowerCase())
210
+
211
+ const reason = hasMethod ? undefined : `Swagger method mismatch for ${pathKey}: ${mapping.method}`
212
+
213
+ return {
214
+ ...mapping,
215
+ mapped: hasMethod,
216
+ reason,
217
+ }
218
+ }
219
+
220
+ public async fetchSwaggerSpec(): Promise<GiteaSwagger> {
221
+ if (this.swaggerSpec) {
222
+ return this.swaggerSpec
223
+ }
224
+
225
+ const response = await fetch(this.swaggerUrl, {
226
+ headers: this.token ? { Authorization: `token ${this.token}` } : undefined,
227
+ })
228
+
229
+ if (!response.ok) {
230
+ throw new Error(`Could not fetch swagger spec from ${this.swaggerUrl}: ${response.status}`)
231
+ }
232
+
233
+ const payload = (await response.json()) as GiteaSwagger
234
+ return payload
235
+ }
236
+
237
+ private mapRule(
238
+ rule: GiteaRouteRule,
239
+ args: string[] = [],
240
+ flagValues: Record<string, FlagValue> = {},
241
+ contextMethod?: string,
242
+ ): GitApiFeatureMapping {
243
+ const pathSegments = this.mapSwaggerPath(rule.swaggerPath, args, this.shouldUseContentsRoot(rule, args))
244
+ const query = this.mapFeatureQuery(rule, flagValues)
245
+ const headers = this.mapHeaders()
246
+ const method = normalizeHttpMethod(contextMethod) ?? rule.method
247
+
248
+ return {
249
+ platform: this.platform,
250
+ featurePath: rule.featurePath,
251
+ mappedPath: pathSegments,
252
+ method,
253
+ query,
254
+ headers,
255
+ apiBase: this.apiBase,
256
+ swaggerPath: rule.swaggerPath,
257
+ mapped: true,
258
+ apiVersion: this.apiVersion,
259
+ reason: rule.notes,
260
+ }
261
+ }
262
+
263
+ private mapFallback(
264
+ featurePath: string[],
265
+ args: string[],
266
+ flagValues: string[],
267
+ contextMethod?: string,
268
+ ): GitApiFeatureMapping {
269
+ const { owner, repo, tail } = this.extractOwnerRepoArgs(args)
270
+ const mappedPath = this.sanitizePath(['repos', owner, repo, ...featurePath, ...tail])
271
+ const headers = this.mapHeaders()
272
+ const method = normalizeHttpMethod(contextMethod) ?? inferFallbackMethod(featurePath)
273
+
274
+ return {
275
+ platform: this.platform,
276
+ featurePath,
277
+ mappedPath,
278
+ method,
279
+ query: flagValues,
280
+ headers,
281
+ apiBase: this.apiBase,
282
+ swaggerPath: `/${mappedPath.join('/')}`,
283
+ mapped: false,
284
+ apiVersion: this.apiVersion,
285
+ reason: `No direct mapping for feature: ${featurePath.join(' ')}`,
286
+ }
287
+ }
288
+
289
+ private mapFeatureQuery(rule: GiteaRouteRule, flagValues: Record<string, FlagValue>): string[] {
290
+ const query = [...(rule.staticQuery ?? [])]
291
+ query.push(...this.mapFlags(flagValues, rule.featurePath))
292
+ return query
293
+ }
294
+
295
+ private mapFlags(flagValues: Record<string, FlagValue>, featurePath: string[]): string[] {
296
+ const query: string[] = []
297
+
298
+ if (featurePath[0] === 'search') {
299
+ if (featurePath[1] === 'prs') {
300
+ query.push('type=pr')
301
+ }
302
+
303
+ if (featurePath[1] === 'issues') {
304
+ query.push('type=issue')
305
+ }
306
+ }
307
+
308
+ for (const [flagName, value] of Object.entries(flagValues)) {
309
+ if (value === undefined) {
310
+ continue
311
+ }
312
+
313
+ const normalized = this.normalizeFlagKey(flagName)
314
+ if (typeof value === 'boolean') {
315
+ query.push(`${normalized}=${value}`)
316
+ continue
317
+ }
318
+
319
+ query.push(`${normalized}=${value}`)
320
+ }
321
+
322
+ return query
323
+ }
324
+
325
+ private mapHeaders(): string[] {
326
+ const headers = ['Accept: application/json']
327
+ if (this.token) {
328
+ headers.push(`Authorization: token ${this.token}`)
329
+ }
330
+
331
+ return headers
332
+ }
333
+
334
+ private shouldUseContentsRoot(rule: GiteaRouteRule, args: string[]): boolean {
335
+ if (rule.swaggerPath !== '/repos/{owner}/{repo}/contents/{filepath}') {
336
+ return false
337
+ }
338
+
339
+ if (rule.featurePath[0] !== 'contents' || rule.featurePath[1] !== 'list') {
340
+ return false
341
+ }
342
+
343
+ const [, , filePath] = args
344
+ if (filePath === undefined || filePath === '') {
345
+ return true
346
+ }
347
+
348
+ return String(filePath).trim() === ''
349
+ }
350
+
351
+ private mapSwaggerPath(swaggerPath: string, args: string[] = [], trimContentsFilepath = false): string[] {
352
+ const values = this.normalizeSwaggerPathArgs(swaggerPath, args)
353
+ const parts = this.sanitizePath(swaggerPath.split('/'))
354
+ let fillIndex = 0
355
+
356
+ const mapped = parts.flatMap((segment) => {
357
+ if (!segment.startsWith('{') || !segment.endsWith('}')) {
358
+ return [segment]
359
+ }
360
+
361
+ const value = values[fillIndex]
362
+ fillIndex += 1
363
+
364
+ const isAutoFilledPlaceholder = /^\\{v\\d+\\}$/.test(value)
365
+
366
+ if (trimContentsFilepath && segment === '{filepath}' && (!value || value.trim() === '' || isAutoFilledPlaceholder)) {
367
+ return []
368
+ }
369
+
370
+ return [value ? value : segment]
371
+ })
372
+
373
+ return this.sanitizePath(mapped)
374
+ }
375
+
376
+ private normalizeSwaggerPathArgs(swaggerPath: string, args: string[]): string[] {
377
+ const values = [...args]
378
+ const segments = swaggerPath.split('/').filter((segment) => segment.startsWith('{') && segment.endsWith('}'))
379
+ const placeholderCount = segments.length
380
+
381
+ if (placeholderCount <= 1) {
382
+ return values
383
+ }
384
+
385
+ if (!values[0]) {
386
+ return values
387
+ }
388
+
389
+ const isRepoScoped =
390
+ segments.length >= 2 &&
391
+ segments[0] === '{owner}' &&
392
+ segments[1] === '{repo}'
393
+
394
+ if (values[0].includes('/') && placeholderCount >= 2) {
395
+ const split = values[0].split('/')
396
+ if (split.length >= 2 && split[0] && split[1]) {
397
+ values[0] = split[0]
398
+ values.splice(1, 0, split[1])
399
+ }
400
+ }
401
+
402
+ // For repo-scoped commands, allow passing the "rest" of the arguments (number/id/path) without
403
+ // explicitly providing owner/repo. The git-service layer can hydrate {owner}/{repo} from defaults.
404
+ //
405
+ // Example: swaggerPath "/repos/{owner}/{repo}/pulls/{number}" with args ["123"] should map to:
406
+ // owner="{owner}" repo="{repo}" number="123"
407
+ //
408
+ // We achieve this by left-padding args with empty strings so mapSwaggerPath leaves placeholders intact.
409
+ if (isRepoScoped && placeholderCount > 2 && values.length === 1 && !values[0].includes('/')) {
410
+ values.unshift('', '')
411
+ }
412
+
413
+ if (placeholderCount >= 2 && values.length >= 2) {
414
+ return values
415
+ }
416
+
417
+ return values
418
+ }
419
+
420
+ private extractOwnerRepoArgs(args: string[]): { owner: string; repo: string; tail: string[] } {
421
+ if (!args.length) {
422
+ return { owner: '{owner}', repo: '{repo}', tail: [] }
423
+ }
424
+
425
+ if (args.length >= 2) {
426
+ return { owner: args[0], repo: args[1], tail: args.slice(2) }
427
+ }
428
+
429
+ const arg = args[0]
430
+ const split = arg.split('/')
431
+ if (split.length === 2 && split[0] && split[1]) {
432
+ return { owner: split[0], repo: split[1], tail: [] }
433
+ }
434
+
435
+ return { owner: '{owner}', repo: '{repo}', tail: args }
436
+ }
437
+
438
+ private buildApiBase(host: string): string {
439
+ const sanitizedHost = host.replace(/\/$/, '')
440
+ if (sanitizedHost.endsWith('/api/v1')) {
441
+ return sanitizedHost
442
+ }
443
+
444
+ return `${sanitizedHost}/api/v1`
445
+ }
446
+
447
+ private buildSwaggerUrl(host: string, path = '/swagger.v1.json'): string {
448
+ const base = this.buildApiBase(host).replace(/\/api\/v1$/, '')
449
+ const swaggerPath = path.startsWith('/') ? path : `/${path}`
450
+ return `${base}${swaggerPath}`
451
+ }
452
+
453
+ private sanitizePath(parts: string[]): string[] {
454
+ return parts.filter((part) => part.length > 0)
455
+ }
456
+
457
+ private normalizeFlagKey(flag: string): string {
458
+ return flag.replace(/^--/, '').trim()
459
+ }
460
+ }