Skip to content

Commit 7184f60

Browse files
Merge branch 'main' into SamMorrowDrums/assign-copilot-poll-linked-pr
2 parents 40849b1 + d88eb83 commit 7184f60

File tree

114 files changed

+2301
-1821
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

114 files changed

+2301
-1821
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,7 @@ The following sets of tools are available:
752752
- **assign_copilot_to_issue** - Assign Copilot to issue
753753
- **Required OAuth Scopes**: `repo`
754754
- `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional)
755+
- `custom_instructions`: Optional custom instructions to guide the agent beyond the issue body. Use this to provide additional context, constraints, or guidance that is not captured in the issue description (string, optional)
755756
- `issue_number`: Issue number (number, required)
756757
- `owner`: Repository owner (string, required)
757758
- `repo`: Repository name (string, required)

cmd/github-mcp-server/generate_docs.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ func generateReadmeDocs(readmePath string) error {
5151
t, _ := translations.TranslationHelper()
5252

5353
// (not available to regular users) while including tools with FeatureFlagDisable.
54-
r := github.NewInventory(t).WithToolsets([]string{"all"}).Build()
54+
// Build() can only fail if WithTools specifies invalid tools - not used here
55+
r, _ := github.NewInventory(t).WithToolsets([]string{"all"}).Build()
5556

5657
// Generate toolsets documentation
5758
toolsetsDoc := generateToolsetsDoc(r)
@@ -341,7 +342,8 @@ func generateRemoteToolsetsDoc() string {
341342
t, _ := translations.TranslationHelper()
342343

343344
// Build inventory - stateless
344-
r := github.NewInventory(t).Build()
345+
// Build() can only fail if WithTools specifies invalid tools - not used here
346+
r, _ := github.NewInventory(t).Build()
345347

346348
// Generate table header (icon is combined with Name column)
347349
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")

cmd/github-mcp-server/list_scopes.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@ func runListScopes() error {
121121
inventoryBuilder = inventoryBuilder.WithTools(enabledTools)
122122
}
123123

124-
inv := inventoryBuilder.Build()
124+
inv, err := inventoryBuilder.Build()
125+
if err != nil {
126+
return fmt.Errorf("failed to build inventory: %w", err)
127+
}
125128

126129
// Collect all tools and their scopes
127130
output := collectToolScopes(inv, readOnly)

internal/ghmcp/server.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,15 +223,18 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
223223
WithDeprecatedAliases(github.DeprecatedToolAliases).
224224
WithReadOnly(cfg.ReadOnly).
225225
WithToolsets(enabledToolsets).
226-
WithTools(github.CleanTools(cfg.EnabledTools)).
226+
WithTools(cfg.EnabledTools).
227227
WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures))
228228

229229
// Apply token scope filtering if scopes are known (for PAT filtering)
230230
if cfg.TokenScopes != nil {
231231
inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes))
232232
}
233233

234-
inventory := inventoryBuilder.Build()
234+
inventory, err := inventoryBuilder.Build()
235+
if err != nil {
236+
return nil, fmt.Errorf("failed to build inventory: %w", err)
237+
}
235238

236239
if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 {
237240
fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", "))

internal/toolsnaps/toolsnaps.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,35 @@ func Test(toolName string, tool any) error {
6767
}
6868

6969
func writeSnap(snapPath string, contents []byte) error {
70+
// Sort the JSON keys recursively to ensure consistent output.
71+
// We do this by unmarshaling and remarshaling, which ensures Go's JSON encoder
72+
// sorts all map keys alphabetically at every level.
73+
sortedJSON, err := sortJSONKeys(contents)
74+
if err != nil {
75+
return fmt.Errorf("failed to sort JSON keys: %w", err)
76+
}
77+
7078
// Ensure the directory exists
7179
if err := os.MkdirAll(filepath.Dir(snapPath), 0700); err != nil {
7280
return fmt.Errorf("failed to create snapshot directory: %w", err)
7381
}
7482

7583
// Write the snapshot file
76-
if err := os.WriteFile(snapPath, contents, 0600); err != nil {
84+
if err := os.WriteFile(snapPath, sortedJSON, 0600); err != nil {
7785
return fmt.Errorf("failed to write snapshot file: %w", err)
7886
}
7987

8088
return nil
8189
}
90+
91+
// sortJSONKeys recursively sorts all object keys in a JSON byte array by
92+
// unmarshaling to map[string]any and remarshaling. Go's JSON encoder
93+
// automatically sorts map keys alphabetically.
94+
func sortJSONKeys(jsonData []byte) ([]byte, error) {
95+
var data any
96+
if err := json.Unmarshal(jsonData, &data); err != nil {
97+
return nil, err
98+
}
99+
100+
return json.MarshalIndent(data, "", " ")
101+
}

internal/toolsnaps/toolsnaps_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,184 @@ func TestMalformedSnapshotJSON(t *testing.T) {
131131
require.Error(t, err)
132132
assert.Contains(t, err.Error(), "failed to parse snapshot JSON for dummy", "expected error about malformed snapshot JSON")
133133
}
134+
135+
func TestSortJSONKeys(t *testing.T) {
136+
tests := []struct {
137+
name string
138+
input string
139+
expected string
140+
}{
141+
{
142+
name: "simple object",
143+
input: `{"z": 1, "a": 2, "m": 3}`,
144+
expected: "{\n \"a\": 2,\n \"m\": 3,\n \"z\": 1\n}",
145+
},
146+
{
147+
name: "nested object",
148+
input: `{"z": {"y": 1, "x": 2}, "a": 3}`,
149+
expected: "{\n \"a\": 3,\n \"z\": {\n \"x\": 2,\n \"y\": 1\n }\n}",
150+
},
151+
{
152+
name: "array with objects",
153+
input: `{"items": [{"z": 1, "a": 2}, {"y": 3, "b": 4}]}`,
154+
expected: "{\n \"items\": [\n {\n \"a\": 2,\n \"z\": 1\n },\n {\n \"b\": 4,\n \"y\": 3\n }\n ]\n}",
155+
},
156+
{
157+
name: "deeply nested",
158+
input: `{"z": {"y": {"x": 1, "a": 2}, "b": 3}, "m": 4}`,
159+
expected: "{\n \"m\": 4,\n \"z\": {\n \"b\": 3,\n \"y\": {\n \"a\": 2,\n \"x\": 1\n }\n }\n}",
160+
},
161+
{
162+
name: "properties field like in toolsnaps",
163+
input: `{"name": "test", "properties": {"repo": {"type": "string"}, "owner": {"type": "string"}, "page": {"type": "number"}}}`,
164+
expected: "{\n \"name\": \"test\",\n \"properties\": {\n \"owner\": {\n \"type\": \"string\"\n },\n \"page\": {\n \"type\": \"number\"\n },\n \"repo\": {\n \"type\": \"string\"\n }\n }\n}",
165+
},
166+
}
167+
168+
for _, tt := range tests {
169+
t.Run(tt.name, func(t *testing.T) {
170+
result, err := sortJSONKeys([]byte(tt.input))
171+
require.NoError(t, err)
172+
assert.Equal(t, tt.expected, string(result))
173+
})
174+
}
175+
}
176+
177+
func TestSortJSONKeysIdempotent(t *testing.T) {
178+
// Given a JSON string that's already sorted
179+
input := `{"a": 1, "b": {"x": 2, "y": 3}, "c": [{"m": 4, "n": 5}]}`
180+
181+
// When we sort it once
182+
sorted1, err := sortJSONKeys([]byte(input))
183+
require.NoError(t, err)
184+
185+
// And sort it again
186+
sorted2, err := sortJSONKeys(sorted1)
187+
require.NoError(t, err)
188+
189+
// Then the results should be identical
190+
assert.Equal(t, string(sorted1), string(sorted2))
191+
}
192+
193+
func TestToolSnapKeysSorted(t *testing.T) {
194+
withIsolatedWorkingDir(t)
195+
196+
// Given a tool with fields that could be in any order
197+
type complexTool struct {
198+
Name string `json:"name"`
199+
Description string `json:"description"`
200+
Properties map[string]interface{} `json:"properties"`
201+
Annotations map[string]interface{} `json:"annotations"`
202+
}
203+
204+
tool := complexTool{
205+
Name: "test_tool",
206+
Description: "A test tool",
207+
Properties: map[string]interface{}{
208+
"zzz": "last",
209+
"aaa": "first",
210+
"mmm": "middle",
211+
"owner": map[string]interface{}{"type": "string", "description": "Owner"},
212+
"repo": map[string]interface{}{"type": "string", "description": "Repo"},
213+
},
214+
Annotations: map[string]interface{}{
215+
"readOnly": true,
216+
"title": "Test",
217+
},
218+
}
219+
220+
// When we write the snapshot
221+
t.Setenv("UPDATE_TOOLSNAPS", "true")
222+
err := Test("complex", tool)
223+
require.NoError(t, err)
224+
225+
// Then the snapshot file should have sorted keys
226+
snapJSON, err := os.ReadFile("__toolsnaps__/complex.snap")
227+
require.NoError(t, err)
228+
229+
// Verify that the JSON is properly sorted by checking key order
230+
var parsed map[string]interface{}
231+
err = json.Unmarshal(snapJSON, &parsed)
232+
require.NoError(t, err)
233+
234+
// Check that properties are sorted
235+
propsJSON, _ := json.MarshalIndent(parsed["properties"], "", " ")
236+
propsStr := string(propsJSON)
237+
// The properties should have "aaa" before "mmm" before "zzz"
238+
aaaIndex := -1
239+
mmmIndex := -1
240+
zzzIndex := -1
241+
for i, line := range propsStr {
242+
if line == 'a' && i+2 < len(propsStr) && propsStr[i:i+3] == "aaa" {
243+
aaaIndex = i
244+
}
245+
if line == 'm' && i+2 < len(propsStr) && propsStr[i:i+3] == "mmm" {
246+
mmmIndex = i
247+
}
248+
if line == 'z' && i+2 < len(propsStr) && propsStr[i:i+3] == "zzz" {
249+
zzzIndex = i
250+
}
251+
}
252+
assert.Greater(t, mmmIndex, aaaIndex, "mmm should come after aaa")
253+
assert.Greater(t, zzzIndex, mmmIndex, "zzz should come after mmm")
254+
}
255+
256+
func TestStructFieldOrderingSortedAlphabetically(t *testing.T) {
257+
withIsolatedWorkingDir(t)
258+
259+
// Given a struct with fields defined in non-alphabetical order
260+
// This test ensures that struct field order doesn't affect the JSON output
261+
type toolWithNonAlphabeticalFields struct {
262+
ZField string `json:"zField"` // Should appear last in JSON
263+
AField string `json:"aField"` // Should appear first in JSON
264+
MField string `json:"mField"` // Should appear in the middle
265+
}
266+
267+
tool := toolWithNonAlphabeticalFields{
268+
ZField: "z value",
269+
AField: "a value",
270+
MField: "m value",
271+
}
272+
273+
// When we write the snapshot
274+
t.Setenv("UPDATE_TOOLSNAPS", "true")
275+
err := Test("struct_field_order", tool)
276+
require.NoError(t, err)
277+
278+
// Then the snapshot file should have alphabetically sorted keys despite struct field order
279+
snapJSON, err := os.ReadFile("__toolsnaps__/struct_field_order.snap")
280+
require.NoError(t, err)
281+
282+
snapStr := string(snapJSON)
283+
284+
// Find the positions of each field in the JSON string
285+
aFieldIndex := -1
286+
mFieldIndex := -1
287+
zFieldIndex := -1
288+
for i := 0; i < len(snapStr)-7; i++ {
289+
switch snapStr[i : i+6] {
290+
case "aField":
291+
aFieldIndex = i
292+
case "mField":
293+
mFieldIndex = i
294+
case "zField":
295+
zFieldIndex = i
296+
}
297+
}
298+
299+
// Verify alphabetical ordering in the JSON output
300+
require.NotEqual(t, -1, aFieldIndex, "aField should be present")
301+
require.NotEqual(t, -1, mFieldIndex, "mField should be present")
302+
require.NotEqual(t, -1, zFieldIndex, "zField should be present")
303+
assert.Less(t, aFieldIndex, mFieldIndex, "aField should appear before mField")
304+
assert.Less(t, mFieldIndex, zFieldIndex, "mField should appear before zField")
305+
306+
// Also verify idempotency - running the test again should produce identical output
307+
err = Test("struct_field_order", tool)
308+
require.NoError(t, err)
309+
310+
snapJSON2, err := os.ReadFile("__toolsnaps__/struct_field_order.snap")
311+
require.NoError(t, err)
312+
313+
assert.Equal(t, string(snapJSON), string(snapJSON2), "Multiple runs should produce identical output")
314+
}

pkg/github/__toolsnaps__/actions_get.snap

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,8 @@
55
},
66
"description": "Get details about specific GitHub Actions resources.\nUse this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs.\n",
77
"inputSchema": {
8-
"type": "object",
9-
"required": [
10-
"method",
11-
"owner",
12-
"repo",
13-
"resource_id"
14-
],
158
"properties": {
169
"method": {
17-
"type": "string",
1810
"description": "The method to execute",
1911
"enum": [
2012
"get_workflow",
@@ -23,21 +15,29 @@
2315
"download_workflow_run_artifact",
2416
"get_workflow_run_usage",
2517
"get_workflow_run_logs_url"
26-
]
18+
],
19+
"type": "string"
2720
},
2821
"owner": {
29-
"type": "string",
30-
"description": "Repository owner"
22+
"description": "Repository owner",
23+
"type": "string"
3124
},
3225
"repo": {
33-
"type": "string",
34-
"description": "Repository name"
26+
"description": "Repository name",
27+
"type": "string"
3528
},
3629
"resource_id": {
37-
"type": "string",
38-
"description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n"
30+
"description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method.\n- Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods.\n- Provide an artifact ID for 'download_workflow_run_artifact' method.\n- Provide a job ID for 'get_workflow_job' method.\n",
31+
"type": "string"
3932
}
40-
}
33+
},
34+
"required": [
35+
"method",
36+
"owner",
37+
"repo",
38+
"resource_id"
39+
],
40+
"type": "object"
4141
},
4242
"name": "actions_get"
4343
}

0 commit comments

Comments
 (0)