blob: d7950793335124af094ef2fb2d985a86c9969721 [file] [log] [blame]
iomodo1d173602025-07-26 15:35:57 +04001package git
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12)
13
14// GerritConfig holds configuration for Gerrit operations
15type GerritConfig struct {
16 Username string
17 Password string // Can be HTTP password or API token
18 BaseURL string
19 HTTPClient *http.Client
20}
21
22// GerritPullRequestProvider implements PullRequestProvider for Gerrit
23type GerritPullRequestProvider struct {
24 config GerritConfig
25 project string
26}
27
28// NewGerritPullRequestProvider creates a new Gerrit pull request provider
29func NewGerritPullRequestProvider(project string, config GerritConfig) PullRequestProvider {
30 if config.HTTPClient == nil {
31 config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
32 }
33
34 return &GerritPullRequestProvider{
35 config: config,
36 project: project,
37 }
38}
39
40// Gerrit API response types
41type gerritChange struct {
42 ID string `json:"id"`
43 Number int `json:"_number"`
44 Subject string `json:"subject"`
45 Description string `json:"description"`
46 Status string `json:"status"`
47 Owner gerritAccount `json:"owner"`
48 Created time.Time `json:"created"`
49 Updated time.Time `json:"updated"`
50 Branch string `json:"branch"`
51 Topic string `json:"topic"`
52 Labels map[string]gerritLabelInfo `json:"labels"`
53 Reviewers map[string][]gerritAccount `json:"reviewers"`
54 CurrentRevision string `json:"current_revision"`
55 Revisions map[string]gerritRevision `json:"revisions"`
56 Messages []gerritMessage `json:"messages"`
57}
58
59type gerritAccount struct {
60 AccountID int `json:"_account_id"`
61 Name string `json:"name"`
62 Email string `json:"email"`
63 Username string `json:"username"`
64}
65
66type gerritLabelInfo struct {
67 All []gerritApproval `json:"all"`
68}
69
70type gerritApproval struct {
71 AccountID int `json:"_account_id"`
72 Name string `json:"name"`
73 Email string `json:"email"`
74 Value int `json:"value"`
75}
76
77type gerritRevision struct {
78 Number int `json:"_number"`
79 Ref string `json:"ref"`
80 Fetch map[string]gerritFetchInfo `json:"fetch"`
81 Commit gerritCommit `json:"commit"`
82 Files map[string]gerritFileInfo `json:"files"`
83}
84
85type gerritFetchInfo struct {
86 URL string `json:"url"`
87 Ref string `json:"ref"`
88}
89
90type gerritCommit struct {
91 Subject string `json:"subject"`
92 Message string `json:"message"`
93 Author gerritPerson `json:"author"`
94}
95
96type gerritPerson struct {
97 Name string `json:"name"`
98 Email string `json:"email"`
99 Date string `json:"date"`
100}
101
102type gerritFileInfo struct {
103 Status string `json:"status"`
104 LinesInserted int `json:"lines_inserted"`
105 LinesDeleted int `json:"lines_deleted"`
106}
107
108type gerritMessage struct {
109 ID string `json:"id"`
110 Author gerritAccount `json:"author"`
111 Message string `json:"message"`
112 Date time.Time `json:"date"`
113 RevisionNumber int `json:"_revision_number"`
114}
115
116type gerritCreateChangeRequest struct {
117 Project string `json:"project"`
118 Subject string `json:"subject"`
119 Description string `json:"description"`
120 Branch string `json:"branch"`
121 Topic string `json:"topic,omitempty"`
122 Base string `json:"base,omitempty"`
123}
124
125type gerritUpdateChangeRequest struct {
126 Subject string `json:"subject,omitempty"`
127 Description string `json:"description,omitempty"`
128 Topic string `json:"topic,omitempty"`
129 Status string `json:"status,omitempty"`
130}
131
iomodo1d173602025-07-26 15:35:57 +0400132// CreatePullRequest creates a new change (pull request) on Gerrit
133func (g *GerritPullRequestProvider) CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error) {
134 reqBody := gerritCreateChangeRequest{
135 Project: g.project,
136 Subject: options.Title,
137 Description: options.Description,
138 Branch: options.BaseBranch,
139 Topic: options.HeadBranch, // Use head branch as topic
140 }
141
142 jsonBody, err := json.Marshal(reqBody)
143 if err != nil {
144 return nil, fmt.Errorf("failed to marshal request body: %w", err)
145 }
146
147 url := fmt.Sprintf("%s/a/changes/", g.config.BaseURL)
148 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
149 if err != nil {
150 return nil, fmt.Errorf("failed to create request: %w", err)
151 }
152
153 req.SetBasicAuth(g.config.Username, g.config.Password)
154 req.Header.Set("Content-Type", "application/json")
155
156 resp, err := g.config.HTTPClient.Do(req)
157 if err != nil {
158 return nil, fmt.Errorf("failed to make request: %w", err)
159 }
160 defer resp.Body.Close()
161
162 if resp.StatusCode != http.StatusCreated {
iomodof645da72025-07-26 15:47:00 +0400163 return nil, fmt.Errorf("gerrit API error: %d", resp.StatusCode)
iomodo1d173602025-07-26 15:35:57 +0400164 }
165
166 // Gerrit returns the change ID in the response body
167 var changeID string
168 if err := json.NewDecoder(resp.Body).Decode(&changeID); err != nil {
169 return nil, fmt.Errorf("failed to decode response: %w", err)
170 }
171
172 // Remove the ")]}'" prefix that Gerrit adds to responses
173 changeID = strings.TrimPrefix(changeID, ")]}'")
174
175 // Fetch the created change
176 return g.GetPullRequest(ctx, changeID)
177}
178
179// GetPullRequest retrieves a change by ID
180func (g *GerritPullRequestProvider) GetPullRequest(ctx context.Context, id string) (*PullRequest, error) {
181 url := fmt.Sprintf("%s/a/changes/%s?o=DETAILED_ACCOUNTS&o=DETAILED_LABELS&o=MESSAGES&o=CURRENT_REVISION&o=CURRENT_COMMIT", g.config.BaseURL, id)
182 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
183 if err != nil {
184 return nil, fmt.Errorf("failed to create request: %w", err)
185 }
186
187 req.SetBasicAuth(g.config.Username, g.config.Password)
188
189 resp, err := g.config.HTTPClient.Do(req)
190 if err != nil {
191 return nil, fmt.Errorf("failed to make request: %w", err)
192 }
193 defer resp.Body.Close()
194
195 if resp.StatusCode != http.StatusOK {
iomodof645da72025-07-26 15:47:00 +0400196 return nil, fmt.Errorf("gerrit API error: %d", resp.StatusCode)
iomodo1d173602025-07-26 15:35:57 +0400197 }
198
199 // Read and remove Gerrit's ")]}'" prefix
200 body := make([]byte, 4)
201 if _, err := resp.Body.Read(body); err != nil {
202 return nil, fmt.Errorf("failed to read response prefix: %w", err)
203 }
204 if string(body) != ")]}'" {
205 return nil, fmt.Errorf("unexpected response prefix: %s", string(body))
206 }
207
208 var gerritChange gerritChange
209 if err := json.NewDecoder(resp.Body).Decode(&gerritChange); err != nil {
210 return nil, fmt.Errorf("failed to decode response: %w", err)
211 }
212
213 return g.convertGerritChange(gerritChange), nil
214}
215
216// ListPullRequests lists changes
217func (g *GerritPullRequestProvider) ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error) {
218 url := fmt.Sprintf("%s/a/changes/", g.config.BaseURL)
219 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
220 if err != nil {
221 return nil, fmt.Errorf("failed to create request: %w", err)
222 }
223
224 // Add query parameters
225 q := req.URL.Query()
226 q.Add("q", fmt.Sprintf("project:%s", g.project))
227
228 if options.State != "" {
229 switch options.State {
230 case "open":
231 q.Add("q", "status:open")
232 case "closed":
233 q.Add("q", "status:closed")
234 }
235 }
236 if options.Author != "" {
237 q.Add("q", fmt.Sprintf("owner:%s", options.Author))
238 }
239 if options.Assignee != "" {
240 q.Add("q", fmt.Sprintf("reviewer:%s", options.Assignee))
241 }
242 if options.BaseBranch != "" {
243 q.Add("q", fmt.Sprintf("branch:%s", options.BaseBranch))
244 }
245 if options.Limit > 0 {
246 q.Add("n", strconv.Itoa(options.Limit))
247 }
248 req.URL.RawQuery = q.Encode()
249
250 req.SetBasicAuth(g.config.Username, g.config.Password)
251
252 resp, err := g.config.HTTPClient.Do(req)
253 if err != nil {
254 return nil, fmt.Errorf("failed to make request: %w", err)
255 }
256 defer resp.Body.Close()
257
258 if resp.StatusCode != http.StatusOK {
iomodof645da72025-07-26 15:47:00 +0400259 return nil, fmt.Errorf("gerrit API error: %d", resp.StatusCode)
iomodo1d173602025-07-26 15:35:57 +0400260 }
261
262 // Read and remove Gerrit's ")]}'" prefix
263 body := make([]byte, 4)
264 if _, err := resp.Body.Read(body); err != nil {
265 return nil, fmt.Errorf("failed to read response prefix: %w", err)
266 }
267 if string(body) != ")]}'" {
268 return nil, fmt.Errorf("unexpected response prefix: %s", string(body))
269 }
270
271 var gerritChanges []gerritChange
272 if err := json.NewDecoder(resp.Body).Decode(&gerritChanges); err != nil {
273 return nil, fmt.Errorf("failed to decode response: %w", err)
274 }
275
276 prs := make([]PullRequest, len(gerritChanges))
277 for i, change := range gerritChanges {
278 prs[i] = *g.convertGerritChange(change)
279 }
280
281 return prs, nil
282}
283
284// UpdatePullRequest updates a change
285func (g *GerritPullRequestProvider) UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error) {
286 reqBody := gerritUpdateChangeRequest{
287 Subject: options.Title,
288 Description: options.Description,
289 }
290
291 jsonBody, err := json.Marshal(reqBody)
292 if err != nil {
293 return nil, fmt.Errorf("failed to marshal request body: %w", err)
294 }
295
296 url := fmt.Sprintf("%s/a/changes/%s", g.config.BaseURL, id)
297 req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewBuffer(jsonBody))
298 if err != nil {
299 return nil, fmt.Errorf("failed to create request: %w", err)
300 }
301
302 req.SetBasicAuth(g.config.Username, g.config.Password)
303 req.Header.Set("Content-Type", "application/json")
304
305 resp, err := g.config.HTTPClient.Do(req)
306 if err != nil {
307 return nil, fmt.Errorf("failed to make request: %w", err)
308 }
309 defer resp.Body.Close()
310
311 if resp.StatusCode != http.StatusOK {
iomodof645da72025-07-26 15:47:00 +0400312 return nil, fmt.Errorf("gerrit API error: %d", resp.StatusCode)
iomodo1d173602025-07-26 15:35:57 +0400313 }
314
315 // Fetch the updated change
316 return g.GetPullRequest(ctx, id)
317}
318
319// ClosePullRequest closes a change
320func (g *GerritPullRequestProvider) ClosePullRequest(ctx context.Context, id string) error {
321 reqBody := gerritUpdateChangeRequest{
322 Status: "ABANDONED",
323 }
324
325 jsonBody, err := json.Marshal(reqBody)
326 if err != nil {
327 return fmt.Errorf("failed to marshal request body: %w", err)
328 }
329
330 url := fmt.Sprintf("%s/a/changes/%s/abandon", g.config.BaseURL, id)
331 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
332 if err != nil {
333 return fmt.Errorf("failed to create request: %w", err)
334 }
335
336 req.SetBasicAuth(g.config.Username, g.config.Password)
337 req.Header.Set("Content-Type", "application/json")
338
339 resp, err := g.config.HTTPClient.Do(req)
340 if err != nil {
341 return fmt.Errorf("failed to make request: %w", err)
342 }
343 defer resp.Body.Close()
344
345 if resp.StatusCode != http.StatusOK {
iomodof645da72025-07-26 15:47:00 +0400346 return fmt.Errorf("gerrit API error: %d", resp.StatusCode)
iomodo1d173602025-07-26 15:35:57 +0400347 }
348
349 return nil
350}
351
352// MergePullRequest merges a change
353func (g *GerritPullRequestProvider) MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error {
354 url := fmt.Sprintf("%s/a/changes/%s/submit", g.config.BaseURL, id)
355 req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
356 if err != nil {
357 return fmt.Errorf("failed to create request: %w", err)
358 }
359
360 req.SetBasicAuth(g.config.Username, g.config.Password)
361
362 resp, err := g.config.HTTPClient.Do(req)
363 if err != nil {
364 return fmt.Errorf("failed to make request: %w", err)
365 }
366 defer resp.Body.Close()
367
368 if resp.StatusCode != http.StatusOK {
iomodof645da72025-07-26 15:47:00 +0400369 return fmt.Errorf("gerrit API error: %d", resp.StatusCode)
iomodo1d173602025-07-26 15:35:57 +0400370 }
371
372 return nil
373}
374
375// convertGerritChange converts a Gerrit change to our PullRequest type
376func (g *GerritPullRequestProvider) convertGerritChange(change gerritChange) *PullRequest {
377 // Extract labels
378 var labels []string
379 for labelName := range change.Labels {
380 labels = append(labels, labelName)
381 }
382
383 // Extract reviewers
384 var reviewers []Author
385 if reviewersMap, exists := change.Reviewers["REVIEWER"]; exists {
386 for _, reviewer := range reviewersMap {
387 reviewers = append(reviewers, Author{
388 Name: reviewer.Name,
389 Email: reviewer.Email,
390 })
391 }
392 }
393
394 // Extract comments from messages
395 var comments []PullRequestComment
396 for _, message := range change.Messages {
397 if message.Message != "" {
398 comments = append(comments, PullRequestComment{
399 ID: message.ID,
400 Author: Author{
401 Name: message.Author.Name,
402 Email: message.Author.Email,
403 },
404 Content: message.Message,
405 CreatedAt: message.Date,
406 UpdatedAt: message.Date,
407 })
408 }
409 }
410
411 // Determine state
412 state := "open"
413 switch change.Status {
414 case "MERGED":
415 state = "merged"
416 case "ABANDONED":
417 state = "closed"
418 }
419
420 return &PullRequest{
421 ID: change.ID,
422 Number: change.Number,
423 Title: change.Subject,
424 Description: change.Description,
425 State: state,
426 Author: Author{
427 Name: change.Owner.Name,
428 Email: change.Owner.Email,
429 },
430 CreatedAt: change.Created,
431 UpdatedAt: change.Updated,
432 BaseBranch: change.Branch,
433 HeadBranch: change.Topic, // Use topic as head branch
434 BaseRepo: g.project,
435 HeadRepo: g.project, // Gerrit changes are within the same project
436 Labels: labels,
437 Assignees: []Author{}, // Gerrit doesn't have assignees in the same way
438 Reviewers: reviewers,
439 Commits: []Commit{}, // Would need additional API call to populate
440 Comments: comments,
441 }
442}