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