Skip to content

Commit 3b83de1

Browse files
heiskrrseseCopilot
authored
Fix GitHub CLI nested object parameter generation (#56235)
Co-authored-by: Robert Sese <[email protected]> Co-authored-by: GitHub Copilot <[email protected]>
1 parent 500cc2e commit 3b83de1

File tree

2 files changed

+288
-10
lines changed

2 files changed

+288
-10
lines changed

src/rest/components/get-rest-code-samples.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,27 @@ export function getGHExample(
206206
}
207207

208208
if (typeof bodyParameters === 'object') {
209-
requestBodyParams += handleObjectParameter(bodyParameters as NestedObjectParameter)
209+
// For complex objects with arrays, use --input with JSON
210+
const hasArrays = hasNestedArrays(bodyParameters as NestedObjectParameter)
211+
if (hasArrays) {
212+
const jsonBody = JSON.stringify(
213+
bodyParameters,
214+
(key: string, value: any) => {
215+
// Convert numeric strings back to numbers for API compatibility
216+
if (typeof value === 'string' && /^\d+$/.test(value)) {
217+
return parseInt(value, 10)
218+
}
219+
// Convert boolean strings to actual booleans
220+
if (value === 'true') return true
221+
if (value === 'false') return false
222+
return value
223+
},
224+
2,
225+
).replace(/'/g, "'\\''")
226+
requestBodyParams = `--input - <<< '${jsonBody}'`
227+
} else {
228+
requestBodyParams += handleObjectParameter(bodyParameters as NestedObjectParameter)
229+
}
210230
} else {
211231
requestBodyParams += handleSingleParameter('', bodyParameters)
212232
}
@@ -233,6 +253,21 @@ type NestedObjectParameter =
233253
| { [key: string]: NestedObjectParameter }
234254
| NestedObjectParameter[]
235255

256+
// Helper function to detect if an object has nested arrays
257+
function hasNestedArrays(obj: NestedObjectParameter): boolean {
258+
if (Array.isArray(obj)) {
259+
return true
260+
}
261+
if (typeof obj === 'object' && obj !== null) {
262+
for (const value of Object.values(obj)) {
263+
if (hasNestedArrays(value)) {
264+
return true
265+
}
266+
}
267+
}
268+
return false
269+
}
270+
236271
function handleSingleParameter(
237272
key: string,
238273
value: NestedObjectParameter,
@@ -252,19 +287,22 @@ function handleSingleParameter(
252287
} else if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
253288
cliLine += ` -F "${keyString}${separator}${value}"`
254289
} else if (Array.isArray(value)) {
255-
for (const param of value) {
290+
// For simple arrays, use individual parameters with indices
291+
for (let i = 0; i < value.length; i++) {
292+
const param = value[i]
256293
if (Array.isArray(param)) {
257294
throw new Error('Nested arrays are not valid in the bodyParameters')
258295
}
259296

260297
if (typeof param === 'object' && param !== null) {
261298
cliLine += handleObjectParameter(
262299
param,
263-
(nextKey: string): string => `${keyString}[]${nextKey}`,
300+
(nextKey: string): string => `${keyString}[${i}]${nextKey}`,
264301
)
265302
} else {
266-
// Transform key in this case needs to account for the `key` being passed in
267-
cliLine += handleSingleParameter(key, param, (nextKey: string): string => `${nextKey}[]`)
303+
// Transform key in this case needs to account for the `key` being passed in and use array index
304+
const arrayTransform = () => `${transformKey(key)}[${i}]`
305+
cliLine += handleSingleParameter(key, param, arrayTransform)
268306
}
269307
}
270308
} else if (typeof value === 'object') {
@@ -280,7 +318,8 @@ function handleObjectParameter(
280318
let cliLine = ''
281319
for (const [key, value] of Object.entries(objectParams)) {
282320
if (Array.isArray(value)) {
283-
for (const param of value) {
321+
for (let i = 0; i < value.length; i++) {
322+
const param = value[i]
284323
// This isn't valid in a REST context, our REST API should not be designed to take
285324
// something like { "letterSegments": [["a", "b", "c"], ["d", "e", "f"]] }
286325
// If this is a possibility, we can update the code to handle it
@@ -290,22 +329,26 @@ function handleObjectParameter(
290329

291330
if (typeof param === 'object' && param !== null) {
292331
// When an array of objects, we want to display the key and value as two separate parameters
293-
// E.g. -F "properties[][property_name]=repo" -F "properties[][value]=docs-internal"
332+
// E.g. -F "properties[0][property_name]=repo" -F "properties[0][value]=docs-internal"
294333
for (const [nestedKey, nestedValue] of Object.entries(param)) {
295334
cliLine += handleSingleParameter(
296-
`${key}[][${nestedKey}]`,
335+
`${key}[${i}][${nestedKey}]`,
297336
nestedValue as NestedObjectParameter,
298337
transformKey,
299338
)
300339
}
301340
} else {
302-
cliLine += handleSingleParameter(`${key}[]`, param, transformKey)
341+
cliLine += handleSingleParameter(
342+
key,
343+
param,
344+
(nextKey: string) => `${transformKey(nextKey)}[${i}]`,
345+
)
303346
}
304347
}
305348
} else if (typeof value === 'object' && value !== null) {
306349
cliLine += handleObjectParameter(
307350
value as NestedObjectParameter,
308-
(nextKey: string) => `${key}[${nextKey}]`,
351+
(nextKey: string) => `${transformKey(key)}[${nextKey}]`,
309352
)
310353
} else {
311354
cliLine += handleSingleParameter(key, value, transformKey)
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { getGHExample } from '../components/get-rest-code-samples'
3+
import type { Operation, CodeSample } from '../components/types'
4+
import { type VersionItem } from '@/frame/components/context/MainContext'
5+
6+
describe('getGHExample - GitHub CLI code generation', () => {
7+
test('handles nested conditions object correctly', () => {
8+
const operation: Operation = {
9+
serverUrl: 'https://api.github.com',
10+
verb: 'post',
11+
requestPath: '/orgs/{org}/rulesets',
12+
title: 'Create an organization repository ruleset',
13+
descriptionHTML: '<p>Creates a repository ruleset.</p>',
14+
previews: [],
15+
statusCodes: [],
16+
bodyParameters: [],
17+
category: 'orgs',
18+
subcategory: 'rules',
19+
parameters: [
20+
{
21+
name: 'org',
22+
in: 'path',
23+
required: true,
24+
description: 'The organization name',
25+
schema: { type: 'string' },
26+
},
27+
],
28+
codeExamples: [],
29+
progAccess: {
30+
permissions: [],
31+
userToServerRest: true,
32+
serverToServer: true,
33+
fineGrainedPat: true,
34+
},
35+
}
36+
37+
const codeSample: CodeSample = {
38+
key: 'default',
39+
request: {
40+
contentType: 'application/json',
41+
description: 'Example',
42+
acceptHeader: 'application/vnd.github+json',
43+
bodyParameters: {
44+
name: 'super cool ruleset',
45+
target: 'branch',
46+
enforcement: 'active',
47+
bypass_actors: [
48+
{
49+
actor_id: '234',
50+
actor_type: 'Team',
51+
bypass_mode: 'always',
52+
},
53+
],
54+
conditions: {
55+
ref_name: {
56+
include: ['refs/heads/main', 'refs/heads/master'],
57+
exclude: ['refs/heads/dev*'],
58+
},
59+
repository_name: {
60+
include: ['important_repository', 'another_important_repository'],
61+
exclude: ['unimportant_repository'],
62+
protected: 'true',
63+
},
64+
},
65+
rules: [
66+
{
67+
type: 'commit_author_email_pattern',
68+
parameters: {
69+
operator: 'contains',
70+
pattern: '@github.com$',
71+
},
72+
},
73+
],
74+
} as any,
75+
parameters: {
76+
org: 'ORG',
77+
},
78+
},
79+
response: {
80+
statusCode: '201',
81+
contentType: 'application/json',
82+
description: 'Response',
83+
example: {},
84+
},
85+
}
86+
87+
const currentVersion = 'fpt'
88+
const allVersions: Record<string, VersionItem> = {
89+
fpt: {
90+
version: 'fpt',
91+
versionTitle: 'Free, Pro, & Team',
92+
apiVersions: ['2022-11-28'],
93+
latestApiVersion: '2022-11-28',
94+
},
95+
}
96+
97+
const result = getGHExample(operation, codeSample, currentVersion, allVersions)
98+
99+
// The result should use --input for complex objects with arrays
100+
expect(result).toContain("--input - <<< '")
101+
expect(result).toContain('"bypass_actors": [')
102+
expect(result).toContain('"actor_id": 234')
103+
expect(result).toContain('"conditions": {')
104+
expect(result).toContain('"ref_name": {')
105+
expect(result).toContain('"rules": [')
106+
expect(result).toContain('"type": "commit_author_email_pattern"')
107+
108+
// Verify the JSON structure is properly formatted
109+
expect(result).toContain('"name": "super cool ruleset"')
110+
expect(result).toContain('"target": "branch"')
111+
expect(result).toContain('"enforcement": "active"')
112+
})
113+
114+
test('handles simple nested objects correctly', () => {
115+
const operation: Operation = {
116+
serverUrl: 'https://api.github.com',
117+
verb: 'post',
118+
requestPath: '/test',
119+
title: 'Test operation',
120+
descriptionHTML: '<p>Test operation</p>',
121+
previews: [],
122+
statusCodes: [],
123+
bodyParameters: [],
124+
category: 'test',
125+
subcategory: 'test',
126+
parameters: [],
127+
codeExamples: [],
128+
progAccess: {
129+
permissions: [],
130+
userToServerRest: true,
131+
serverToServer: true,
132+
fineGrainedPat: true,
133+
},
134+
}
135+
136+
const codeSample: CodeSample = {
137+
key: 'default',
138+
request: {
139+
contentType: 'application/json',
140+
description: 'Example',
141+
acceptHeader: 'application/vnd.github+json',
142+
bodyParameters: {
143+
config: {
144+
enabled: 'true',
145+
settings: {
146+
timeout: '30',
147+
},
148+
},
149+
} as any,
150+
parameters: {},
151+
},
152+
response: {
153+
statusCode: '200',
154+
contentType: 'application/json',
155+
description: 'Response',
156+
example: {},
157+
},
158+
}
159+
160+
const currentVersion = 'fpt'
161+
const allVersions: Record<string, VersionItem> = {
162+
fpt: {
163+
version: 'fpt',
164+
versionTitle: 'Free, Pro, & Team',
165+
apiVersions: ['2022-11-28'],
166+
latestApiVersion: '2022-11-28',
167+
},
168+
}
169+
170+
const result = getGHExample(operation, codeSample, currentVersion, allVersions)
171+
172+
expect(result).toContain('config[enabled]=true')
173+
expect(result).toContain('config[settings][timeout]=30')
174+
})
175+
176+
test('handles arrays of simple values correctly', () => {
177+
const operation: Operation = {
178+
serverUrl: 'https://api.github.com',
179+
verb: 'post',
180+
requestPath: '/test',
181+
title: 'Test operation',
182+
descriptionHTML: '<p>Test operation</p>',
183+
previews: [],
184+
statusCodes: [],
185+
bodyParameters: [],
186+
category: 'test',
187+
subcategory: 'test',
188+
parameters: [],
189+
codeExamples: [],
190+
progAccess: {
191+
permissions: [],
192+
userToServerRest: true,
193+
serverToServer: true,
194+
fineGrainedPat: true,
195+
},
196+
}
197+
198+
const codeSample: CodeSample = {
199+
key: 'default',
200+
request: {
201+
contentType: 'application/json',
202+
description: 'Example',
203+
acceptHeader: 'application/vnd.github+json',
204+
bodyParameters: {
205+
tags: ['tag1', 'tag2', 'tag3'],
206+
},
207+
parameters: {},
208+
},
209+
response: {
210+
statusCode: '200',
211+
contentType: 'application/json',
212+
description: 'Response',
213+
example: {},
214+
},
215+
}
216+
217+
const currentVersion = 'fpt'
218+
const allVersions: Record<string, VersionItem> = {
219+
fpt: {
220+
version: 'fpt',
221+
versionTitle: 'Free, Pro, & Team',
222+
apiVersions: ['2022-11-28'],
223+
latestApiVersion: '2022-11-28',
224+
},
225+
}
226+
227+
const result = getGHExample(operation, codeSample, currentVersion, allVersions)
228+
229+
expect(result).toContain('--input - <<<')
230+
expect(result).toContain('"tags": [')
231+
expect(result).toContain('"tag1"')
232+
expect(result).toContain('"tag2"')
233+
expect(result).toContain('"tag3"')
234+
})
235+
})

0 commit comments

Comments
 (0)