Skip to content

Commit 44949ae

Browse files
committed
add transition operations to the gerrit client
SubmitChange, AbandonChange, RestoreChange, SetWorkInProgress, and SetReadyForReview cover the five change-state transitions, each behind the same write gate as reviews. go-gerrit exposes no /wip endpoint, so SetWorkInProgress rides a review via ReviewInput.work_in_progress -- same wire effect. Gerrit refusals (blocked submit, restore of a merged change) reach the caller verbatim through the recovered error body. Refs: #18
1 parent eb3e783 commit 44949ae

2 files changed

Lines changed: 265 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package gerritclient
2+
3+
import (
4+
"context"
5+
6+
"dev.gaijin.team/go/golib/e"
7+
"dev.gaijin.team/go/golib/fields"
8+
gerrit "github.com/andygrunwald/go-gerrit"
9+
)
10+
11+
// Sentinels for change-state transitions.
12+
var (
13+
ErrSubmitChange = e.New("submit change")
14+
ErrAbandonChange = e.New("abandon change")
15+
ErrRestoreChange = e.New("restore change")
16+
ErrSetWIP = e.New("set work in progress")
17+
ErrSetReady = e.New("set ready for review")
18+
)
19+
20+
// SubmitChange submits a change (NEW -> MERGED). Gerrit refuses submits that
21+
// fail submit requirements; the refusal reason is carried in the wrapped
22+
// error. Trail-leaving — gated by project scoping and the own-changes
23+
// restriction.
24+
func (c *Client) SubmitChange(ctx context.Context, changeID string) (*gerrit.ChangeInfo, error) {
25+
if err := c.checkWriteScope(ctx, changeID); err != nil {
26+
return nil, ErrSubmitChange.Wrap(err)
27+
}
28+
29+
info, resp, err := c.gerrit.Changes.SubmitChange(ctx, changeID, &gerrit.SubmitInput{})
30+
if err != nil {
31+
return nil, ErrSubmitChange.Wrap(apiError(resp, err), fields.F("change", changeID))
32+
}
33+
34+
if info == nil {
35+
return nil, ErrSubmitChange.Wrap(errEmptyResponse, fields.F("change", changeID))
36+
}
37+
38+
return info, nil
39+
}
40+
41+
// AbandonChange abandons a change (NEW -> ABANDONED) with an optional
42+
// message. Trail-leaving — gated by project scoping and the own-changes
43+
// restriction.
44+
func (c *Client) AbandonChange(ctx context.Context, changeID, message string) (*gerrit.ChangeInfo, error) {
45+
if err := c.checkWriteScope(ctx, changeID); err != nil {
46+
return nil, ErrAbandonChange.Wrap(err)
47+
}
48+
49+
info, resp, err := c.gerrit.Changes.AbandonChange(ctx, changeID, &gerrit.AbandonInput{Message: message})
50+
if err != nil {
51+
return nil, ErrAbandonChange.Wrap(apiError(resp, err), fields.F("change", changeID))
52+
}
53+
54+
if info == nil {
55+
return nil, ErrAbandonChange.Wrap(errEmptyResponse, fields.F("change", changeID))
56+
}
57+
58+
return info, nil
59+
}
60+
61+
// RestoreChange restores an abandoned change (ABANDONED -> NEW) with an
62+
// optional message. Trail-leaving — gated by project scoping and the
63+
// own-changes restriction.
64+
func (c *Client) RestoreChange(ctx context.Context, changeID, message string) (*gerrit.ChangeInfo, error) {
65+
if err := c.checkWriteScope(ctx, changeID); err != nil {
66+
return nil, ErrRestoreChange.Wrap(err)
67+
}
68+
69+
info, resp, err := c.gerrit.Changes.RestoreChange(ctx, changeID, &gerrit.RestoreInput{Message: message})
70+
if err != nil {
71+
return nil, ErrRestoreChange.Wrap(apiError(resp, err), fields.F("change", changeID))
72+
}
73+
74+
if info == nil {
75+
return nil, ErrRestoreChange.Wrap(errEmptyResponse, fields.F("change", changeID))
76+
}
77+
78+
return info, nil
79+
}
80+
81+
// SetWorkInProgress marks a change as work-in-progress with an optional
82+
// message. go-gerrit exposes no /wip endpoint, so the toggle rides a review
83+
// via ReviewInput.work_in_progress — same wire effect. Trail-leaving — gated
84+
// by project scoping and the own-changes restriction (via SetReview).
85+
func (c *Client) SetWorkInProgress(ctx context.Context, changeID, message string) error {
86+
input := &gerrit.ReviewInput{
87+
Message: message,
88+
WorkInProgress: true,
89+
}
90+
91+
if _, err := c.SetReview(ctx, changeID, "", input); err != nil {
92+
return ErrSetWIP.Wrap(err)
93+
}
94+
95+
return nil
96+
}
97+
98+
// SetReadyForReview marks a work-in-progress change as ready for review with
99+
// an optional message. Trail-leaving — gated by project scoping and the
100+
// own-changes restriction.
101+
func (c *Client) SetReadyForReview(ctx context.Context, changeID, message string) error {
102+
if err := c.checkWriteScope(ctx, changeID); err != nil {
103+
return ErrSetReady.Wrap(err)
104+
}
105+
106+
resp, err := c.gerrit.Changes.SetReadyForReview(ctx, changeID, &gerrit.ReadyForReviewInput{Message: message})
107+
if err != nil {
108+
return ErrSetReady.Wrap(apiError(resp, err), fields.F("change", changeID))
109+
}
110+
111+
return nil
112+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package gerritclient_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"dev.gaijin.team/go/go-gerrit-mcp/internal/gerritclient"
12+
)
13+
14+
// transitionCalls maps every transition operation to a uniform closure so the
15+
// endpoint and gating tables below can iterate over them.
16+
func transitionCalls() []struct {
17+
name string
18+
call func(t *testing.T, c *gerritclient.Client) error
19+
wantPath string
20+
} {
21+
return []struct {
22+
name string
23+
call func(t *testing.T, c *gerritclient.Client) error
24+
wantPath string
25+
}{
26+
{
27+
name: "submit",
28+
call: func(t *testing.T, c *gerritclient.Client) error {
29+
t.Helper()
30+
31+
_, err := c.SubmitChange(t.Context(), "123")
32+
33+
return err
34+
},
35+
wantPath: "/a/changes/123/submit",
36+
},
37+
{
38+
name: "abandon",
39+
call: func(t *testing.T, c *gerritclient.Client) error {
40+
t.Helper()
41+
42+
_, err := c.AbandonChange(t.Context(), "123", "stale")
43+
44+
return err
45+
},
46+
wantPath: "/a/changes/123/abandon",
47+
},
48+
{
49+
name: "restore",
50+
call: func(t *testing.T, c *gerritclient.Client) error {
51+
t.Helper()
52+
53+
_, err := c.RestoreChange(t.Context(), "123", "back")
54+
55+
return err
56+
},
57+
wantPath: "/a/changes/123/restore",
58+
},
59+
{
60+
name: "wip",
61+
call: func(t *testing.T, c *gerritclient.Client) error {
62+
t.Helper()
63+
64+
return c.SetWorkInProgress(t.Context(), "123", "parking")
65+
},
66+
wantPath: "/a/changes/123/revisions/current/review",
67+
},
68+
{
69+
name: "ready",
70+
call: func(t *testing.T, c *gerritclient.Client) error {
71+
t.Helper()
72+
73+
return c.SetReadyForReview(t.Context(), "123", "ptal")
74+
},
75+
wantPath: "/a/changes/123/ready",
76+
},
77+
}
78+
}
79+
80+
func Test_Transitions_Endpoints(t *testing.T) {
81+
t.Parallel()
82+
83+
for _, tt := range transitionCalls() {
84+
t.Run(tt.name, func(t *testing.T) {
85+
t.Parallel()
86+
87+
client, posts := reviewClient(t, testConfig(""), 42)
88+
89+
require.NoError(t, tt.call(t, client))
90+
91+
require.Len(t, *posts, 1)
92+
assert.Equal(t, tt.wantPath, (*posts)[0])
93+
})
94+
}
95+
}
96+
97+
func Test_Transitions_Gating(t *testing.T) {
98+
t.Parallel()
99+
100+
for _, tt := range transitionCalls() {
101+
t.Run(tt.name+" refused on foreign change", func(t *testing.T) {
102+
t.Parallel()
103+
104+
client, posts := reviewClient(t, testConfig(""), 7)
105+
106+
err := tt.call(t, client)
107+
108+
require.Error(t, err)
109+
require.ErrorIs(t, err, gerritclient.ErrOwnChangesOnly)
110+
assert.Empty(t, *posts, "no mutating request may leave the process")
111+
})
112+
}
113+
}
114+
115+
// Test_Transitions_ConflictSurfaced pins that a Gerrit refusal (409 with the
116+
// reason in the body) reaches the caller verbatim — the agent must see why a
117+
// submit was blocked.
118+
func Test_Transitions_ConflictSurfaced(t *testing.T) {
119+
t.Parallel()
120+
121+
changeJSON := ")]}'\n" + `{"_number":123,"project":"core","branch":"main",` +
122+
`"owner":{"_account_id":42,"username":"bot"}}`
123+
124+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
125+
switch {
126+
case r.URL.Path == "/a/accounts/self":
127+
_, _ = w.Write([]byte(scopedSelfJSON))
128+
129+
case r.Method == http.MethodPost:
130+
w.WriteHeader(http.StatusConflict)
131+
132+
_, _ = w.Write([]byte("Failed to submit 1 change due to the following problems:\n" +
133+
"Change 123: submit requirement 'Code-Review' is unsatisfied."))
134+
135+
default:
136+
_, _ = w.Write([]byte(changeJSON))
137+
}
138+
}))
139+
t.Cleanup(srv.Close)
140+
141+
cfg := testConfig("")
142+
143+
cfg.GerritURL = srv.URL
144+
145+
client, err := gerritclient.New(t.Context(), cfg)
146+
require.NoError(t, err)
147+
148+
_, err = client.SubmitChange(t.Context(), "123")
149+
150+
require.Error(t, err)
151+
require.ErrorIs(t, err, gerritclient.ErrSubmitChange)
152+
assert.ErrorContains(t, err, "submit requirement 'Code-Review' is unsatisfied")
153+
}

0 commit comments

Comments
 (0)