Skip to content

Commit 8ee0db4

Browse files
committed
add set_vote tool
Sets a label vote with an optional message, riding the gated SetReview call as a labels map -- value 0 clears an own vote. The label name is required; range and label validation stay with Gerrit, whose rejection (unknown label, value outside the permitted range, vote on a merged change) is reported verbatim. Refs: #17
1 parent 44949ae commit 8ee0db4

4 files changed

Lines changed: 228 additions & 1 deletion

File tree

internal/tools/get-change_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func Test_GetChange(t *testing.T) {
104104

105105
assert.ElementsMatch(t, []string{
106106
"search_changes", "get_change", "list_change_files", "get_file_diff", "get_change_comments",
107-
"post_comments",
107+
"post_comments", "set_vote",
108108
}, names)
109109
})
110110

internal/tools/set-vote.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
"dev.gaijin.team/go/golib/e"
8+
gerrit "github.com/andygrunwald/go-gerrit"
9+
"github.com/modelcontextprotocol/go-sdk/mcp"
10+
11+
"dev.gaijin.team/go/go-gerrit-mcp/internal/gerritclient"
12+
"dev.gaijin.team/go/go-gerrit-mcp/internal/llmxml"
13+
)
14+
15+
var errVoteNoLabel = e.New("vote label must not be empty")
16+
17+
type setVoteInput struct {
18+
Change string `json:"change" jsonschema:"Change identifier: numeric ID, project~number, or Change-Id"`
19+
Label string `json:"label" jsonschema:"Label name, e.g. Code-Review or Verified"`
20+
Value int `json:"value" jsonschema:"Numeric vote within the label's range; 0 clears an own vote"`
21+
Message string `json:"message,omitempty" jsonschema:"Optional message accompanying the vote"`
22+
}
23+
24+
func setVote(c *gerritclient.Client) Tool {
25+
return Tool{
26+
Name: NameSetVote,
27+
Register: func(s *mcp.Server) {
28+
mcp.AddTool(s, &mcp.Tool{
29+
Name: NameSetVote,
30+
Description: "Set a vote on a Gerrit change: label name plus numeric value, with an " +
31+
"optional message. Value 0 clears an own vote. Gerrit validates the label and " +
32+
"range; its refusal is reported verbatim. Refused on changes not owned by the " +
33+
"authenticated account unless the operator disabled the own-changes restriction.",
34+
}, func(ctx context.Context, _ *mcp.CallToolRequest, in setVoteInput,
35+
) (*mcp.CallToolResult, any, error) {
36+
label := strings.TrimSpace(in.Label)
37+
if label == "" {
38+
return nil, nil, errVoteNoLabel
39+
}
40+
41+
input := &gerrit.ReviewInput{
42+
Message: in.Message,
43+
Labels: map[string]int{label: in.Value},
44+
}
45+
46+
if _, err := c.SetReview(ctx, in.Change, "", input); err != nil {
47+
return nil, nil, err
48+
}
49+
50+
return textResult(llmxml.NewElement("vote_set",
51+
llmxml.Attr("change", in.Change),
52+
llmxml.Attr("label", label),
53+
llmxml.Attr("value", in.Value),
54+
).String()), nil, nil
55+
})
56+
},
57+
}
58+
}

internal/tools/set-vote_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package tools_test
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/modelcontextprotocol/go-sdk/mcp"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
const foreignChangeJSON = ")]}'\n" + `{"_number":123,"project":"core","branch":"main",` +
16+
`"owner":{"_account_id":7,"username":"alice"}}`
17+
18+
// voteSession wires a fake Gerrit for the vote flow: self-check, change
19+
// resolve (owned by self), and a recording review POST.
20+
func voteSession(t *testing.T) (cs *mcp.ClientSession, body *map[string]any) {
21+
t.Helper()
22+
23+
body = &map[string]any{}
24+
25+
cs = session(t, func(w http.ResponseWriter, r *http.Request) {
26+
switch {
27+
case r.URL.Path == "/a/accounts/self":
28+
_, _ = w.Write([]byte(selfJSON))
29+
30+
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/review"):
31+
raw, err := io.ReadAll(r.Body)
32+
if err != nil {
33+
t.Errorf("read review body: %v", err)
34+
35+
return
36+
}
37+
38+
if err := json.Unmarshal(raw, body); err != nil {
39+
t.Errorf("unmarshal review body: %v", err)
40+
41+
return
42+
}
43+
44+
_, _ = w.Write([]byte(")]}'\n{}"))
45+
46+
default:
47+
_, _ = w.Write([]byte(ownChangeJSON))
48+
}
49+
})
50+
51+
return cs, body
52+
}
53+
54+
func Test_SetVote(t *testing.T) {
55+
t.Parallel()
56+
57+
t.Run("posts label, value, and message as a review", func(t *testing.T) {
58+
t.Parallel()
59+
60+
cs, body := voteSession(t)
61+
62+
out := callTool(t, cs, "set_vote", map[string]any{
63+
"change": "123",
64+
"label": "Code-Review",
65+
"value": 2,
66+
"message": "lgtm",
67+
})
68+
69+
assert.Contains(t, out, `<vote_set change="123" label="Code-Review" value="2"/>`)
70+
71+
assert.Equal(t, "lgtm", (*body)["message"])
72+
assert.Equal(t, map[string]any{"Code-Review": float64(2)}, (*body)["labels"])
73+
})
74+
75+
t.Run("zero value stays on the wire to clear a vote", func(t *testing.T) {
76+
t.Parallel()
77+
78+
cs, body := voteSession(t)
79+
80+
callTool(t, cs, "set_vote", map[string]any{
81+
"change": "123",
82+
"label": "Code-Review",
83+
"value": 0,
84+
})
85+
86+
assert.Equal(t, map[string]any{"Code-Review": float64(0)}, (*body)["labels"])
87+
})
88+
89+
t.Run("empty label refused", func(t *testing.T) {
90+
t.Parallel()
91+
92+
cs, body := voteSession(t)
93+
94+
res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
95+
Name: "set_vote",
96+
Arguments: map[string]any{"change": "123", "label": " ", "value": 1},
97+
})
98+
require.NoError(t, err)
99+
require.True(t, res.IsError)
100+
101+
assert.Empty(t, *body, "no review may be posted")
102+
})
103+
104+
t.Run("gerrit label rejection surfaced verbatim", func(t *testing.T) {
105+
t.Parallel()
106+
107+
cs := session(t, func(w http.ResponseWriter, r *http.Request) {
108+
switch {
109+
case r.URL.Path == "/a/accounts/self":
110+
_, _ = w.Write([]byte(selfJSON))
111+
112+
case r.Method == http.MethodPost:
113+
w.WriteHeader(http.StatusBadRequest)
114+
115+
_, _ = w.Write([]byte(`label "Bogus" is not a configured label`))
116+
117+
default:
118+
_, _ = w.Write([]byte(ownChangeJSON))
119+
}
120+
})
121+
122+
res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
123+
Name: "set_vote",
124+
Arguments: map[string]any{"change": "123", "label": "Bogus", "value": 1},
125+
})
126+
require.NoError(t, err)
127+
require.True(t, res.IsError)
128+
129+
text, ok := res.Content[0].(*mcp.TextContent)
130+
require.True(t, ok)
131+
assert.Contains(t, text.Text, `label "Bogus" is not a configured label`)
132+
})
133+
134+
t.Run("foreign change refused by own-changes restriction", func(t *testing.T) {
135+
t.Parallel()
136+
137+
posted := false
138+
139+
cs := session(t, func(w http.ResponseWriter, r *http.Request) {
140+
switch {
141+
case r.URL.Path == "/a/accounts/self":
142+
_, _ = w.Write([]byte(selfJSON))
143+
144+
case r.Method == http.MethodPost:
145+
posted = true
146+
147+
_, _ = w.Write([]byte(")]}'\n{}"))
148+
149+
default:
150+
_, _ = w.Write([]byte(foreignChangeJSON))
151+
}
152+
})
153+
154+
res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
155+
Name: "set_vote",
156+
Arguments: map[string]any{"change": "123", "label": "Code-Review", "value": -1},
157+
})
158+
require.NoError(t, err)
159+
require.True(t, res.IsError)
160+
161+
text, ok := res.Content[0].(*mcp.TextContent)
162+
require.True(t, ok)
163+
assert.Contains(t, text.Text, "own-changes")
164+
165+
assert.False(t, posted, "no mutating request may leave the process")
166+
})
167+
}

internal/tools/tools.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
NameGetFileDiff = "get_file_diff"
1818
NameGetChangeComments = "get_change_comments"
1919
NamePostComments = "post_comments"
20+
NameSetVote = "set_vote"
2021
)
2122

2223
// Tool binds a tool name to its MCP registration.
@@ -35,6 +36,7 @@ func All(c *gerritclient.Client) []Tool {
3536
getFileDiff(c),
3637
getChangeComments(c),
3738
postComments(c),
39+
setVote(c),
3840
}
3941
}
4042

0 commit comments

Comments
 (0)