Skip to content

Commit 3ba9a03

Browse files
fix(projects): preserve omitted inputs and exact JSON keys
Reuse omission-aware argument serialization for exported Projects inputs and reject case-insensitive aliases during decoding. Add regressions for omitted, explicit-null and zero inputs and mutation targets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 715ebc8 commit 3ba9a03

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

‎pkg/github/projects_types.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,58 @@ type ProjectsWriteInput struct {
9393
Iterations ProjectParameter[[]ProjectIterationInput] `json:"iterations"`
9494
}
9595

96+
func (input ProjectsListInput) MarshalJSON() ([]byte, error) {
97+
return marshalProjectInput(input)
98+
}
99+
100+
func (input ProjectsGetInput) MarshalJSON() ([]byte, error) {
101+
return marshalProjectInput(input)
102+
}
103+
104+
func (input ProjectsWriteInput) MarshalJSON() ([]byte, error) {
105+
return marshalProjectInput(input)
106+
}
107+
108+
func marshalProjectInput(input any) ([]byte, error) {
109+
args, err := projectArguments(input)
110+
if err != nil {
111+
return nil, err
112+
}
113+
return json.Marshal(args)
114+
}
115+
116+
func (input *ProjectsListInput) UnmarshalJSON(raw []byte) error {
117+
return unmarshalProjectInput(raw, input)
118+
}
119+
120+
func (input *ProjectsGetInput) UnmarshalJSON(raw []byte) error {
121+
return unmarshalProjectInput(raw, input)
122+
}
123+
124+
func (input *ProjectsWriteInput) UnmarshalJSON(raw []byte) error {
125+
return unmarshalProjectInput(raw, input)
126+
}
127+
128+
// Legacy map handlers recognize only exact JSON keys; encoding/json's default
129+
// case-insensitive struct matching must not turn ignored keys into write targets.
130+
func unmarshalProjectInput(raw []byte, input any) error {
131+
var args map[string]json.RawMessage
132+
if err := json.Unmarshal(raw, &args); err != nil {
133+
return err
134+
}
135+
value := reflect.ValueOf(input).Elem()
136+
value.SetZero()
137+
inputType := value.Type()
138+
for i := range value.NumField() {
139+
if argument, ok := args[inputType.Field(i).Tag.Get("json")]; ok {
140+
if err := json.Unmarshal(argument, value.Field(i).Addr().Interface()); err != nil {
141+
return err
142+
}
143+
}
144+
}
145+
return nil
146+
}
147+
96148
type ProjectUpdatedFieldInput struct {
97149
ID *int64 `json:"id,omitempty"`
98150
Name *string `json:"name,omitempty"`

‎pkg/github/typed_projects_outputs_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ func TestTypedProjectsWireErrors(t *testing.T) {
269269
{"projects_write", map[string]any{"method": "create_project", "owner": "o"}, "owner_type is required for create_project"},
270270
{"projects_write", map[string]any{"method": "create_project_status_update", "owner": "o", "owner_type": "org", "project_number": 2, "status": "BAD"}, `invalid status "BAD": must be one of INACTIVE, ON_TRACK, AT_RISK, OFF_TRACK, COMPLETE`},
271271
{"projects_write", map[string]any{"method": "update_project_items", "owner": "o", "owner_type": "org", "project_number": 2, "items": []any{}}, "items must contain at least one entry"},
272+
{"projects_write", map[string]any{"method": "delete_project_item", "owner": "o", "owner_type": "org", "project_number": 2, "ITEM_ID": 4}, "missing required parameter: item_id"},
272273
}
273274
for _, protocol := range typedGitGistProtocols {
274275
t.Run("protocol="+protocol, func(t *testing.T) {
@@ -284,3 +285,55 @@ func TestTypedProjectsWireErrors(t *testing.T) {
284285
})
285286
}
286287
}
288+
289+
func TestTypedProjectsInputRoundtrip(t *testing.T) {
290+
for _, tc := range []struct {
291+
name string
292+
input any
293+
want string
294+
}{
295+
{"list", ProjectsListInput{Method: ProjectParameter[string]{Value: "list_projects"}, Owner: ProjectParameter[string]{Value: "o"}}, `{"method":"list_projects","owner":"o"}`},
296+
{"get", ProjectsGetInput{Method: ProjectParameter[string]{Value: "get_project_view"}, ViewID: ProjectParameter[string]{Value: "V"}}, `{"method":"get_project_view","view_id":"V"}`},
297+
{"issue_reference", ProjectsWriteInput{
298+
Method: ProjectParameter[string]{Value: "update_project_item"},
299+
ItemOwner: ProjectParameter[string]{Value: "o"}, ItemRepo: ProjectParameter[string]{Value: "r"},
300+
IssueNumber: ProjectParameter[int]{Value: 7},
301+
}, `{"method":"update_project_item","item_owner":"o","item_repo":"r","issue_number":7}`},
302+
{"view_name_only", ProjectsWriteInput{Method: ProjectParameter[string]{Value: "update_project_view"}, Name: ProjectParameter[string]{Value: "New name"}}, `{"method":"update_project_view","name":"New name"}`},
303+
} {
304+
t.Run(tc.name, func(t *testing.T) {
305+
assert.JSONEq(t, tc.want, mustMarshalJSON(t, tc.input))
306+
})
307+
}
308+
for _, input := range []any{&ProjectsListInput{}, &ProjectsGetInput{}, &ProjectsWriteInput{}} {
309+
raw := `{"method":"example","owner":"o","project_number":0}`
310+
require.NoError(t, json.Unmarshal([]byte(raw), input))
311+
assert.JSONEq(t, raw, mustMarshalJSON(t, input))
312+
}
313+
raw := `{"method":"update_project_view","filter":null,"layout":"","item_id":0}`
314+
var input ProjectsWriteInput
315+
require.NoError(t, json.Unmarshal([]byte(raw), &input))
316+
assert.JSONEq(t, raw, mustMarshalJSON(t, input))
317+
require.NoError(t, json.Unmarshal([]byte(`{"method":"delete_project_item","ITEM_ID":4,"ItemID":5}`), &input))
318+
args, err := projectArguments(input)
319+
require.NoError(t, err)
320+
assert.NotContains(t, args, "item_id")
321+
assert.JSONEq(t, `{"method":"delete_project_item"}`, mustMarshalJSON(t, input))
322+
require.NoError(t, json.Unmarshal([]byte(`{"method":"delete_project_item","item_id":3,"ITEM_ID":4}`), &input))
323+
assert.JSONEq(t, `{"method":"delete_project_item","item_id":3}`, mustMarshalJSON(t, input))
324+
}
325+
326+
func TestTypedProjectsCaseSensitiveMutationTarget(t *testing.T) {
327+
deps := projectWireDeps(t, projectWireCase{})
328+
request := createMCPRequest(map[string]any{
329+
"method": "delete_project_item", "owner": "o", "owner_type": "org",
330+
"project_number": 2, "ITEM_ID": 4,
331+
})
332+
tool := ProjectsWrite(translations.NullTranslationHelper)
333+
result, err := tool.Handler(deps)(
334+
ContextWithDeps(context.Background(), deps), &request)
335+
require.NoError(t, err)
336+
require.True(t, result.IsError)
337+
assert.Equal(t, "missing required parameter: item_id", getTextResult(t, result).Text)
338+
assert.Nil(t, result.StructuredContent)
339+
}

0 commit comments

Comments
 (0)