Skip to content

Commit d05fa61

Browse files
committed
add remaining read-group tools
search_changes pages Gerrit queries and teaches the agent the core operators in its description. list_change_files renders sorted diffstat entries with binary and rename markers. get_file_diff reconstructs unified-style prefixed lines with skip markers and a bodyless binary form. get_change_comments rebuilds threads from reply chains - chronological order, orphan replies as roots, resolution taken from the last comment - and filters all/resolved/unresolved. The read group now exposes all five change-read tools. Refs: #7, #8, #9
1 parent 92fa442 commit d05fa61

9 files changed

Lines changed: 768 additions & 7 deletions

File tree

internal/registry/registry.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import (
1515
func groupTools() map[config.Group][]string {
1616
return map[config.Group][]string{
1717
config.GroupRead: {
18+
tools.NameSearchChanges,
1819
tools.NameGetChange,
20+
tools.NameListChangeFiles,
21+
tools.NameGetFileDiff,
22+
tools.NameGetChangeComments,
1923
},
2024
config.GroupComment: {}, // arrives with the comment group implementation
2125
config.GroupTransition: {}, // arrives with the transition group implementation

internal/registry/registry_test.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@ func Test_Resolve(t *testing.T) {
1919
want []string
2020
}{
2121
{
22-
name: "read group exposes get_change",
22+
name: "read group exposes the change-read tools",
2323
give: []config.Group{config.GroupRead},
24-
want: []string{tools.NameGetChange},
24+
want: []string{
25+
tools.NameSearchChanges,
26+
tools.NameGetChange,
27+
tools.NameListChangeFiles,
28+
tools.NameGetFileDiff,
29+
tools.NameGetChangeComments,
30+
},
2531
},
2632
{
2733
name: "no groups no tools",
@@ -31,7 +37,13 @@ func Test_Resolve(t *testing.T) {
3137
{
3238
name: "duplicate groups collapse",
3339
give: []config.Group{config.GroupRead, config.GroupRead},
34-
want: []string{tools.NameGetChange},
40+
want: []string{
41+
tools.NameSearchChanges,
42+
tools.NameGetChange,
43+
tools.NameListChangeFiles,
44+
tools.NameGetFileDiff,
45+
tools.NameGetChangeComments,
46+
},
3547
},
3648
{
3749
name: "write groups expose nothing yet",
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"slices"
7+
"strings"
8+
9+
"dev.gaijin.team/go/golib/e"
10+
gerrit "github.com/andygrunwald/go-gerrit"
11+
"github.com/modelcontextprotocol/go-sdk/mcp"
12+
13+
"dev.gaijin.team/go/go-gerrit-mcp/internal/gerritclient"
14+
"dev.gaijin.team/go/go-gerrit-mcp/internal/llmxml"
15+
)
16+
17+
// Thread status filter values.
18+
const (
19+
statusAll = "all"
20+
statusResolved = "resolved"
21+
statusUnresolved = "unresolved"
22+
)
23+
24+
var errInvalidStatus = e.New("invalid status filter, expected all, resolved, or unresolved")
25+
26+
type getChangeCommentsInput struct {
27+
Change string `json:"change" jsonschema:"Change identifier: numeric ID, project~number, or Change-Id"`
28+
Status string `json:"status,omitempty" jsonschema:"Thread filter: all, resolved, or unresolved; default all"`
29+
}
30+
31+
func getChangeComments(c *gerritclient.Client) Tool {
32+
return Tool{
33+
Name: NameGetChangeComments,
34+
Register: func(s *mcp.Server) {
35+
mcp.AddTool(s, &mcp.Tool{
36+
Name: NameGetChangeComments,
37+
Description: "List the published review comments of a Gerrit change, grouped by file " +
38+
"and reconstructed into threads with their resolved state. Comment ids are the " +
39+
"reply anchors for posting follow-ups.",
40+
}, func(ctx context.Context, _ *mcp.CallToolRequest, in getChangeCommentsInput,
41+
) (*mcp.CallToolResult, any, error) {
42+
if in.Status == "" {
43+
in.Status = statusAll
44+
}
45+
46+
if in.Status != statusAll && in.Status != statusResolved && in.Status != statusUnresolved {
47+
return nil, nil, errInvalidStatus.WithField("status", in.Status)
48+
}
49+
50+
comments, err := c.ListChangeComments(ctx, in.Change)
51+
if err != nil {
52+
return nil, nil, err
53+
}
54+
55+
return textResult(renderComments(in, comments)), nil, nil
56+
})
57+
},
58+
}
59+
}
60+
61+
// thread is one comment thread: the root comment and its replies in
62+
// chronological order. Its resolved state is the state of the last comment.
63+
type thread struct {
64+
comments []gerrit.CommentInfo
65+
unresolved bool
66+
}
67+
68+
// buildThreads reconstructs threads from a flat comment list. Comments are
69+
// ordered chronologically; each reply chain is walked to its root, and a
70+
// reply whose parent is missing starts its own thread.
71+
func buildThreads(comments []gerrit.CommentInfo) []thread {
72+
ordered := make([]gerrit.CommentInfo, len(comments))
73+
copy(ordered, comments)
74+
75+
slices.SortStableFunc(ordered, compareUpdated)
76+
77+
parent := make(map[string]string, len(ordered))
78+
for _, comment := range ordered {
79+
parent[comment.ID] = comment.InReplyTo
80+
}
81+
82+
var (
83+
roots []string
84+
grouped = map[string][]gerrit.CommentInfo{}
85+
)
86+
87+
for _, comment := range ordered {
88+
root := rootOf(parent, comment.ID)
89+
if _, ok := grouped[root]; !ok {
90+
roots = append(roots, root)
91+
}
92+
93+
grouped[root] = append(grouped[root], comment)
94+
}
95+
96+
threads := make([]thread, 0, len(roots))
97+
98+
for _, root := range roots {
99+
group := grouped[root]
100+
last := group[len(group)-1]
101+
102+
threads = append(threads, thread{
103+
comments: group,
104+
unresolved: last.Unresolved != nil && *last.Unresolved,
105+
})
106+
}
107+
108+
return threads
109+
}
110+
111+
func compareUpdated(a, b gerrit.CommentInfo) int {
112+
switch {
113+
case a.Updated == nil || b.Updated == nil:
114+
return 0
115+
case a.Updated.Before(b.Updated.Time):
116+
return -1
117+
case b.Updated.Before(a.Updated.Time):
118+
return 1
119+
default:
120+
return 0
121+
}
122+
}
123+
124+
// rootOf walks a reply chain to its root; the iteration cap guards against
125+
// reference cycles in malformed data.
126+
func rootOf(parent map[string]string, id string) string {
127+
for range len(parent) {
128+
up, ok := parent[id]
129+
if !ok || up == "" {
130+
return id
131+
}
132+
133+
id = up
134+
}
135+
136+
return id
137+
}
138+
139+
func matchesStatus(t thread, status string) bool {
140+
switch status {
141+
case statusResolved:
142+
return !t.unresolved
143+
case statusUnresolved:
144+
return t.unresolved
145+
default:
146+
return true
147+
}
148+
}
149+
150+
func renderComments(in getChangeCommentsInput, byFile map[string][]gerrit.CommentInfo) string {
151+
paths := make([]string, 0, len(byFile))
152+
for path := range byFile {
153+
paths = append(paths, path)
154+
}
155+
156+
slices.Sort(paths)
157+
158+
var (
159+
rendered []string
160+
threadCount int
161+
)
162+
163+
for _, path := range paths {
164+
threads := buildThreads(byFile[path])
165+
166+
var fileThreads []string
167+
168+
for _, t := range threads {
169+
if !matchesStatus(t, in.Status) {
170+
continue
171+
}
172+
173+
threadCount++
174+
175+
fileThreads = append(fileThreads, renderThread(t))
176+
}
177+
178+
if len(fileThreads) == 0 {
179+
continue
180+
}
181+
182+
rendered = append(rendered, llmxml.NewElement("file", llmxml.Attr("path", path)).
183+
WrapText(strings.Join(fileThreads, "\n")).String())
184+
}
185+
186+
root := llmxml.NewElement("comments",
187+
llmxml.Attr("change", in.Change),
188+
llmxml.Attr("filter", in.Status),
189+
llmxml.Attr("threads", threadCount),
190+
)
191+
192+
if len(rendered) == 0 {
193+
return root.String()
194+
}
195+
196+
return root.WrapText(strings.Join(rendered, "\n")).String()
197+
}
198+
199+
func renderThread(t thread) string {
200+
rendered := make([]string, 0, len(t.comments))
201+
202+
for _, comment := range t.comments {
203+
el := llmxml.NewElement("comment",
204+
llmxml.Attr("id", comment.ID),
205+
llmxml.Attr("author", accountLabel(comment.Author)),
206+
)
207+
208+
if comment.Updated != nil {
209+
el.Attr(llmxml.Attr("date", timestamp(*comment.Updated)))
210+
}
211+
212+
if comment.PatchSet > 0 {
213+
el.Attr(llmxml.Attr("patch_set", comment.PatchSet))
214+
}
215+
216+
if comment.Range != nil {
217+
el.Attr(llmxml.Attr("lines", rangeLabel(comment.Range)))
218+
} else if comment.Line > 0 {
219+
el.Attr(llmxml.Attr("line", comment.Line))
220+
}
221+
222+
if comment.InReplyTo != "" {
223+
el.Attr(llmxml.Attr("in_reply_to", comment.InReplyTo))
224+
}
225+
226+
rendered = append(rendered, el.WrapText(comment.Message).String())
227+
}
228+
229+
return llmxml.NewElement("thread", llmxml.Attr("resolved", !t.unresolved)).
230+
WrapText(strings.Join(rendered, "\n")).String()
231+
}
232+
233+
func rangeLabel(r *gerrit.CommentRange) string {
234+
return fmt.Sprintf("%d-%d", r.StartLine, r.EndLine)
235+
}

internal/tools/get-change_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func session(t *testing.T, gerritHandler http.HandlerFunc) *mcp.ClientSession {
8383
func Test_GetChange(t *testing.T) {
8484
t.Parallel()
8585

86-
t.Run("lists exactly one tool", func(t *testing.T) {
86+
t.Run("lists all read tools", func(t *testing.T) {
8787
t.Parallel()
8888

8989
cs := session(t, func(w http.ResponseWriter, _ *http.Request) {
@@ -93,8 +93,14 @@ func Test_GetChange(t *testing.T) {
9393
res, err := cs.ListTools(t.Context(), nil)
9494
require.NoError(t, err)
9595

96-
require.Len(t, res.Tools, 1)
97-
assert.Equal(t, "get_change", res.Tools[0].Name)
96+
names := make([]string, 0, len(res.Tools))
97+
for _, tool := range res.Tools {
98+
names = append(names, tool.Name)
99+
}
100+
101+
assert.ElementsMatch(t, []string{
102+
"search_changes", "get_change", "list_change_files", "get_file_diff", "get_change_comments",
103+
}, names)
98104
})
99105

100106
t.Run("renders change as llmxml", func(t *testing.T) {

internal/tools/get-file-diff.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
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+
type getFileDiffInput struct {
16+
Change string `json:"change" jsonschema:"Change identifier: numeric ID, project~number, or Change-Id"`
17+
File string `json:"file" jsonschema:"File path within the change; /COMMIT_MSG addresses the commit message"`
18+
Revision string `json:"revision,omitempty" jsonschema:"Patch set number or SHA, defaults to current"`
19+
}
20+
21+
func getFileDiff(c *gerritclient.Client) Tool {
22+
return Tool{
23+
Name: NameGetFileDiff,
24+
Register: func(s *mcp.Server) {
25+
mcp.AddTool(s, &mcp.Tool{
26+
Name: NameGetFileDiff,
27+
Description: "Fetch the diff of one file in a Gerrit change revision. Lines are " +
28+
"prefixed unified-diff style: space for context, - for deleted, + for added.",
29+
}, func(ctx context.Context, _ *mcp.CallToolRequest, in getFileDiffInput) (*mcp.CallToolResult, any, error) {
30+
diff, err := c.GetDiff(ctx, in.Change, in.Revision, in.File)
31+
if err != nil {
32+
return nil, nil, err
33+
}
34+
35+
return textResult(renderDiff(in, diff)), nil, nil
36+
})
37+
},
38+
}
39+
}
40+
41+
func renderDiff(in getFileDiffInput, diff *gerrit.DiffInfo) string {
42+
root := llmxml.NewElement("diff",
43+
llmxml.Attr("change", in.Change),
44+
llmxml.Attr("revision", revisionLabel(in.Revision)),
45+
llmxml.Attr("file", in.File),
46+
llmxml.Attr("change_type", diff.ChangeType),
47+
)
48+
49+
if diff.Binary {
50+
root.Attr(llmxml.Attr("binary", true))
51+
52+
return root.String()
53+
}
54+
55+
var b strings.Builder
56+
57+
for _, content := range diff.Content {
58+
if content.Skip > 0 {
59+
fmt.Fprintf(&b, "... %d common lines skipped ...\n", content.Skip)
60+
}
61+
62+
writePrefixed(&b, " ", content.AB)
63+
writePrefixed(&b, "-", content.A)
64+
writePrefixed(&b, "+", content.B)
65+
}
66+
67+
body := strings.TrimSuffix(b.String(), "\n")
68+
if body == "" {
69+
return root.String()
70+
}
71+
72+
return root.WrapText(body).String()
73+
}
74+
75+
func writePrefixed(b *strings.Builder, prefix string, lines []string) {
76+
for _, line := range lines {
77+
b.WriteString(prefix)
78+
b.WriteString(line)
79+
b.WriteByte('\n')
80+
}
81+
}

0 commit comments

Comments
 (0)