@foundation0/git 1.2.5 → 1.3.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.
- package/README.md +291 -291
- package/gitea-swagger.json +28627 -28627
- package/mcp/README.md +266 -250
- package/mcp/cli.mjs +37 -37
- package/mcp/src/cli.ts +76 -76
- package/mcp/src/client.ts +147 -147
- package/mcp/src/index.ts +7 -7
- package/mcp/src/redaction.ts +207 -207
- package/mcp/src/server.ts +1778 -718
- package/package.json +3 -1
- package/src/actions-api.ts +900 -531
- package/src/api.ts +69 -69
- package/src/ci-api.ts +544 -0
- package/src/git-service-api.ts +822 -683
- package/src/git-service-feature-spec.generated.ts +5341 -5341
- package/src/index.ts +55 -54
- package/src/issue-dependencies.ts +533 -469
- package/src/label-management.ts +587 -587
- package/src/platform/config.ts +62 -62
- package/src/platform/gitea-adapter.ts +460 -448
- package/src/platform/gitea-rules.ts +129 -129
- package/src/platform/index.ts +44 -44
- package/src/repository.ts +151 -151
- package/src/spec-mock.ts +45 -45
|
@@ -1,448 +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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if (!
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
if (
|
|
414
|
-
return
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
+
}
|