Skip to content

Commit b709469

Browse files
committed
add gerrit client wrapper
Authenticated go-gerrit client with startup credential validation via the self account. apiError recovers Gerrit error response bodies the library discards - on non-2xx go-gerrit returns the response unread and unclosed, a contract pinned by a learning test - and attaches status and message as structured fields. All Gerrit traffic flows through this package; scoping restrictions hook in here later. Refs: #4
1 parent fc55e9d commit b709469

4 files changed

Lines changed: 314 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Package gerritclient wraps the go-gerrit REST client with the concerns the
2+
// library leaves to callers: credential validation at startup and recovery of
3+
// Gerrit error response bodies, which the library's errors discard. All
4+
// Gerrit traffic flows through this package — scoping restrictions hook in
5+
// here.
6+
package gerritclient
7+
8+
import (
9+
"context"
10+
"io"
11+
"net/http"
12+
"strings"
13+
"time"
14+
15+
"dev.gaijin.team/go/golib/e"
16+
gerrit "github.com/andygrunwald/go-gerrit"
17+
18+
"dev.gaijin.team/go/go-gerrit-mcp/internal/config"
19+
)
20+
21+
const (
22+
httpTimeout = 30 * time.Second
23+
// maxErrorBody caps how much of a Gerrit error response is attached to an
24+
// error; Gerrit error bodies are short plain-text messages.
25+
maxErrorBody = 4 << 10
26+
)
27+
28+
// Sentinels for the two startup failure classes.
29+
var (
30+
ErrClientInit = e.New("create gerrit client")
31+
ErrCredentials = e.New("gerrit credential validation")
32+
errEmptyAccount = e.New("gerrit returned an empty account")
33+
)
34+
35+
// Client is the process-wide authenticated Gerrit API client.
36+
type Client struct {
37+
gerrit *gerrit.Client
38+
self gerrit.AccountInfo
39+
}
40+
41+
// New builds an authenticated client and validates the credentials against
42+
// the instance by fetching the calling account. A failure is reported with
43+
// Gerrit's own message where one exists.
44+
func New(ctx context.Context, cfg *config.Config) (*Client, error) {
45+
g, err := gerrit.NewClient(ctx, cfg.GerritURL, &http.Client{Timeout: httpTimeout})
46+
if err != nil {
47+
return nil, ErrClientInit.Wrap(err)
48+
}
49+
50+
g.Authentication.SetBasicAuth(cfg.Username, cfg.Token)
51+
52+
self, resp, err := g.Accounts.GetAccount(ctx, "self")
53+
if err != nil {
54+
return nil, ErrCredentials.Wrap(apiError(resp, err))
55+
}
56+
57+
if self == nil {
58+
return nil, ErrCredentials.Wrap(errEmptyAccount)
59+
}
60+
61+
return &Client{gerrit: g, self: *self}, nil
62+
}
63+
64+
// Self reports the authenticated account as validated at startup.
65+
func (c *Client) Self() gerrit.AccountInfo {
66+
return c.self
67+
}
68+
69+
// apiError converts a go-gerrit response/error pair into an error carrying
70+
// Gerrit's own message. The library's error holds only the status line; the
71+
// response body — readable and unclosed on the error path — holds the reason
72+
// a human can act on.
73+
func apiError(resp *gerrit.Response, err error) error {
74+
if resp == nil || resp.Body == nil {
75+
return e.From(err)
76+
}
77+
78+
defer func() { _ = resp.Body.Close() }()
79+
80+
res := e.From(err).WithField("status", resp.StatusCode)
81+
82+
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
83+
if readErr != nil {
84+
return res
85+
}
86+
87+
msg := strings.TrimSpace(string(gerrit.RemoveMagicPrefixLine(body)))
88+
if msg != "" {
89+
res = res.WithField("gerrit_message", msg)
90+
}
91+
92+
return res
93+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package gerritclient
2+
3+
import (
4+
"errors"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
gerrit "github.com/andygrunwald/go-gerrit"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
var (
16+
errAPICall = errors.New("api call failed")
17+
errConnRefused = errors.New("connection refused")
18+
)
19+
20+
func errResponse(status int, body string) *gerrit.Response {
21+
return &gerrit.Response{
22+
Response: &http.Response{ //nolint:exhaustruct // synthetic response, only fields apiError reads
23+
StatusCode: status,
24+
Body: io.NopCloser(strings.NewReader(body)),
25+
},
26+
}
27+
}
28+
29+
func Test_apiError(t *testing.T) {
30+
t.Parallel()
31+
32+
tests := []struct {
33+
name string
34+
giveStatus int
35+
giveBody string
36+
wantParts []string
37+
}{
38+
{
39+
name: "404 plain body",
40+
giveStatus: http.StatusNotFound,
41+
giveBody: "Not found: change 999",
42+
wantParts: []string{"status=404", "Not found: change 999", "api call failed"},
43+
},
44+
{
45+
name: "409 with xssi prefix",
46+
giveStatus: http.StatusConflict,
47+
giveBody: ")]}'\nblocked by Verified",
48+
wantParts: []string{"status=409", "blocked by Verified"},
49+
},
50+
{
51+
name: "empty body keeps status only",
52+
giveStatus: http.StatusBadRequest,
53+
giveBody: "",
54+
wantParts: []string{"status=400"},
55+
},
56+
}
57+
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
t.Parallel()
61+
62+
err := apiError(errResponse(tt.giveStatus, tt.giveBody), errAPICall)
63+
64+
require.Error(t, err)
65+
66+
for _, part := range tt.wantParts {
67+
assert.ErrorContains(t, err, part)
68+
}
69+
})
70+
}
71+
72+
t.Run("nil response passes error through", func(t *testing.T) {
73+
t.Parallel()
74+
75+
err := apiError(nil, errConnRefused)
76+
77+
require.Error(t, err)
78+
assert.ErrorContains(t, err, "connection refused")
79+
})
80+
81+
t.Run("oversized body is truncated", func(t *testing.T) {
82+
t.Parallel()
83+
84+
huge := strings.Repeat("x", maxErrorBody*2)
85+
err := apiError(errResponse(http.StatusBadRequest, huge), errAPICall)
86+
87+
require.Error(t, err)
88+
assert.LessOrEqual(t, len(err.Error()), maxErrorBody+256)
89+
})
90+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package gerritclient_test
2+
3+
import (
4+
"encoding/base64"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"dev.gaijin.team/go/go-gerrit-mcp/internal/config"
13+
"dev.gaijin.team/go/go-gerrit-mcp/internal/gerritclient"
14+
)
15+
16+
func testConfig(url string) *config.Config {
17+
return &config.Config{
18+
GerritURL: url,
19+
Username: "bot",
20+
Token: "s3cret",
21+
Groups: []config.Group{config.GroupRead},
22+
}
23+
}
24+
25+
func basicAuth(user, pass string) string {
26+
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
27+
}
28+
29+
func Test_New(t *testing.T) {
30+
t.Parallel()
31+
32+
t.Run("validates credentials against self account", func(t *testing.T) {
33+
t.Parallel()
34+
35+
var gotPath, gotAuth string
36+
37+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38+
gotPath = r.URL.Path
39+
gotAuth = r.Header.Get("Authorization")
40+
41+
w.Header().Set("Content-Type", "application/json")
42+
43+
_, _ = w.Write([]byte(")]}'\n{\"_account_id\":42,\"name\":\"Review Bot\",\"username\":\"bot\"}"))
44+
}))
45+
t.Cleanup(srv.Close)
46+
47+
client, err := gerritclient.New(t.Context(), testConfig(srv.URL))
48+
require.NoError(t, err)
49+
50+
assert.Equal(t, "/a/accounts/self", gotPath)
51+
assert.Equal(t, basicAuth("bot", "s3cret"), gotAuth)
52+
53+
self := client.Self()
54+
assert.Equal(t, 42, self.AccountID)
55+
assert.Equal(t, "bot", self.Username)
56+
})
57+
58+
t.Run("bad credentials surface gerrit message", func(t *testing.T) {
59+
t.Parallel()
60+
61+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
62+
w.WriteHeader(http.StatusUnauthorized)
63+
64+
_, _ = w.Write([]byte("Unauthorized: token expired"))
65+
}))
66+
t.Cleanup(srv.Close)
67+
68+
_, err := gerritclient.New(t.Context(), testConfig(srv.URL))
69+
70+
require.Error(t, err)
71+
assert.ErrorContains(t, err, "credential validation")
72+
assert.ErrorContains(t, err, "401")
73+
assert.ErrorContains(t, err, "token expired")
74+
})
75+
76+
t.Run("invalid url fails client construction", func(t *testing.T) {
77+
t.Parallel()
78+
79+
_, err := gerritclient.New(t.Context(), testConfig(""))
80+
81+
require.Error(t, err)
82+
assert.ErrorContains(t, err, "create gerrit client")
83+
})
84+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package gerritclient_test
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
gerrit "github.com/andygrunwald/go-gerrit"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// Test_Learning_GoGerritErrorBody pins the go-gerrit contract the wrapper's
15+
// error enrichment is built on: on a non-2xx response the library returns the
16+
// wrapped *Response alongside the error WITHOUT reading or closing the body,
17+
// so the caller can still read Gerrit's message from it. If an upgrade breaks
18+
// this test, apiError silently stops carrying Gerrit messages — redesign it.
19+
func Test_Learning_GoGerritErrorBody(t *testing.T) {
20+
t.Parallel()
21+
22+
const gerritMessage = "change is not submittable: blocked by Code-Review"
23+
24+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
25+
w.WriteHeader(http.StatusConflict)
26+
27+
_, _ = w.Write([]byte(gerritMessage))
28+
}))
29+
t.Cleanup(srv.Close)
30+
31+
client, err := gerrit.NewClient(t.Context(), srv.URL, nil)
32+
require.NoError(t, err)
33+
34+
_, resp, err := client.Changes.GetChange(t.Context(), "123", nil)
35+
36+
require.Error(t, err)
37+
assert.ErrorContains(t, err, "409")
38+
39+
require.NotNil(t, resp)
40+
require.NotNil(t, resp.Body)
41+
42+
body, readErr := io.ReadAll(resp.Body)
43+
require.NoError(t, readErr)
44+
require.NoError(t, resp.Body.Close())
45+
46+
assert.Equal(t, gerritMessage, string(body))
47+
}

0 commit comments

Comments
 (0)