blob: 6ce60e9474243c655a0d4fd383a1c3d228e3a817 [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
132type gerritReviewRequest struct {
133 Message string `json:"message,omitempty"`
134 Labels map[string]int `json:"labels,omitempty"`
135 Reviewers []string `json:"reviewers,omitempty"`
136 Comments map[string][]gerritComment `json:"comments,omitempty"`
137}
138
139type gerritComment struct {
140 Line int `json:"line"`
141 Message string `json:"message"`
142}
143
144// CreatePullRequest creates a new change (pull request) on Gerrit
145func (g *GerritPullRequestProvider) CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error) {
146 reqBody := gerritCreateChangeRequest{
147 Project: g.project,
148 Subject: options.Title,
149 Description: options.Description,
150 Branch: options.BaseBranch,
151 Topic: options.HeadBranch, // Use head branch as topic
152 }
153
154 jsonBody, err := json.Marshal(reqBody)
155 if err != nil {
156 return nil, fmt.Errorf("failed to marshal request body: %w", err)
157 }
158
159 url := fmt.Sprintf("%s/a/changes/", g.config.BaseURL)
160 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
161 if err != nil {
162 return nil, fmt.Errorf("failed to create request: %w", err)
163 }
164
165 req.SetBasicAuth(g.config.Username, g.config.Password)
166 req.Header.Set("Content-Type", "application/json")
167
168 resp, err := g.config.HTTPClient.Do(req)
169 if err != nil {
170 return nil, fmt.Errorf("failed to make request: %w", err)
171 }
172 defer resp.Body.Close()
173
174 if resp.StatusCode != http.StatusCreated {
175 return nil, fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
176 }
177
178 // Gerrit returns the change ID in the response body
179 var changeID string
180 if err := json.NewDecoder(resp.Body).Decode(&changeID); err != nil {
181 return nil, fmt.Errorf("failed to decode response: %w", err)
182 }
183
184 // Remove the ")]}'" prefix that Gerrit adds to responses
185 changeID = strings.TrimPrefix(changeID, ")]}'")
186
187 // Fetch the created change
188 return g.GetPullRequest(ctx, changeID)
189}
190
191// GetPullRequest retrieves a change by ID
192func (g *GerritPullRequestProvider) GetPullRequest(ctx context.Context, id string) (*PullRequest, error) {
193 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)
194 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
195 if err != nil {
196 return nil, fmt.Errorf("failed to create request: %w", err)
197 }
198
199 req.SetBasicAuth(g.config.Username, g.config.Password)
200
201 resp, err := g.config.HTTPClient.Do(req)
202 if err != nil {
203 return nil, fmt.Errorf("failed to make request: %w", err)
204 }
205 defer resp.Body.Close()
206
207 if resp.StatusCode != http.StatusOK {
208 return nil, fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
209 }
210
211 // Read and remove Gerrit's ")]}'" prefix
212 body := make([]byte, 4)
213 if _, err := resp.Body.Read(body); err != nil {
214 return nil, fmt.Errorf("failed to read response prefix: %w", err)
215 }
216 if string(body) != ")]}'" {
217 return nil, fmt.Errorf("unexpected response prefix: %s", string(body))
218 }
219
220 var gerritChange gerritChange
221 if err := json.NewDecoder(resp.Body).Decode(&gerritChange); err != nil {
222 return nil, fmt.Errorf("failed to decode response: %w", err)
223 }
224
225 return g.convertGerritChange(gerritChange), nil
226}
227
228// ListPullRequests lists changes
229func (g *GerritPullRequestProvider) ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error) {
230 url := fmt.Sprintf("%s/a/changes/", g.config.BaseURL)
231 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
232 if err != nil {
233 return nil, fmt.Errorf("failed to create request: %w", err)
234 }
235
236 // Add query parameters
237 q := req.URL.Query()
238 q.Add("q", fmt.Sprintf("project:%s", g.project))
239
240 if options.State != "" {
241 switch options.State {
242 case "open":
243 q.Add("q", "status:open")
244 case "closed":
245 q.Add("q", "status:closed")
246 }
247 }
248 if options.Author != "" {
249 q.Add("q", fmt.Sprintf("owner:%s", options.Author))
250 }
251 if options.Assignee != "" {
252 q.Add("q", fmt.Sprintf("reviewer:%s", options.Assignee))
253 }
254 if options.BaseBranch != "" {
255 q.Add("q", fmt.Sprintf("branch:%s", options.BaseBranch))
256 }
257 if options.Limit > 0 {
258 q.Add("n", strconv.Itoa(options.Limit))
259 }
260 req.URL.RawQuery = q.Encode()
261
262 req.SetBasicAuth(g.config.Username, g.config.Password)
263
264 resp, err := g.config.HTTPClient.Do(req)
265 if err != nil {
266 return nil, fmt.Errorf("failed to make request: %w", err)
267 }
268 defer resp.Body.Close()
269
270 if resp.StatusCode != http.StatusOK {
271 return nil, fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
272 }
273
274 // Read and remove Gerrit's ")]}'" prefix
275 body := make([]byte, 4)
276 if _, err := resp.Body.Read(body); err != nil {
277 return nil, fmt.Errorf("failed to read response prefix: %w", err)
278 }
279 if string(body) != ")]}'" {
280 return nil, fmt.Errorf("unexpected response prefix: %s", string(body))
281 }
282
283 var gerritChanges []gerritChange
284 if err := json.NewDecoder(resp.Body).Decode(&gerritChanges); err != nil {
285 return nil, fmt.Errorf("failed to decode response: %w", err)
286 }
287
288 prs := make([]PullRequest, len(gerritChanges))
289 for i, change := range gerritChanges {
290 prs[i] = *g.convertGerritChange(change)
291 }
292
293 return prs, nil
294}
295
296// UpdatePullRequest updates a change
297func (g *GerritPullRequestProvider) UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error) {
298 reqBody := gerritUpdateChangeRequest{
299 Subject: options.Title,
300 Description: options.Description,
301 }
302
303 jsonBody, err := json.Marshal(reqBody)
304 if err != nil {
305 return nil, fmt.Errorf("failed to marshal request body: %w", err)
306 }
307
308 url := fmt.Sprintf("%s/a/changes/%s", g.config.BaseURL, id)
309 req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewBuffer(jsonBody))
310 if err != nil {
311 return nil, fmt.Errorf("failed to create request: %w", err)
312 }
313
314 req.SetBasicAuth(g.config.Username, g.config.Password)
315 req.Header.Set("Content-Type", "application/json")
316
317 resp, err := g.config.HTTPClient.Do(req)
318 if err != nil {
319 return nil, fmt.Errorf("failed to make request: %w", err)
320 }
321 defer resp.Body.Close()
322
323 if resp.StatusCode != http.StatusOK {
324 return nil, fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
325 }
326
327 // Fetch the updated change
328 return g.GetPullRequest(ctx, id)
329}
330
331// ClosePullRequest closes a change
332func (g *GerritPullRequestProvider) ClosePullRequest(ctx context.Context, id string) error {
333 reqBody := gerritUpdateChangeRequest{
334 Status: "ABANDONED",
335 }
336
337 jsonBody, err := json.Marshal(reqBody)
338 if err != nil {
339 return fmt.Errorf("failed to marshal request body: %w", err)
340 }
341
342 url := fmt.Sprintf("%s/a/changes/%s/abandon", g.config.BaseURL, id)
343 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
344 if err != nil {
345 return fmt.Errorf("failed to create request: %w", err)
346 }
347
348 req.SetBasicAuth(g.config.Username, g.config.Password)
349 req.Header.Set("Content-Type", "application/json")
350
351 resp, err := g.config.HTTPClient.Do(req)
352 if err != nil {
353 return fmt.Errorf("failed to make request: %w", err)
354 }
355 defer resp.Body.Close()
356
357 if resp.StatusCode != http.StatusOK {
358 return fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
359 }
360
361 return nil
362}
363
364// MergePullRequest merges a change
365func (g *GerritPullRequestProvider) MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error {
366 url := fmt.Sprintf("%s/a/changes/%s/submit", g.config.BaseURL, id)
367 req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
368 if err != nil {
369 return fmt.Errorf("failed to create request: %w", err)
370 }
371
372 req.SetBasicAuth(g.config.Username, g.config.Password)
373
374 resp, err := g.config.HTTPClient.Do(req)
375 if err != nil {
376 return fmt.Errorf("failed to make request: %w", err)
377 }
378 defer resp.Body.Close()
379
380 if resp.StatusCode != http.StatusOK {
381 return fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
382 }
383
384 return nil
385}
386
387// convertGerritChange converts a Gerrit change to our PullRequest type
388func (g *GerritPullRequestProvider) convertGerritChange(change gerritChange) *PullRequest {
389 // Extract labels
390 var labels []string
391 for labelName := range change.Labels {
392 labels = append(labels, labelName)
393 }
394
395 // Extract reviewers
396 var reviewers []Author
397 if reviewersMap, exists := change.Reviewers["REVIEWER"]; exists {
398 for _, reviewer := range reviewersMap {
399 reviewers = append(reviewers, Author{
400 Name: reviewer.Name,
401 Email: reviewer.Email,
402 })
403 }
404 }
405
406 // Extract comments from messages
407 var comments []PullRequestComment
408 for _, message := range change.Messages {
409 if message.Message != "" {
410 comments = append(comments, PullRequestComment{
411 ID: message.ID,
412 Author: Author{
413 Name: message.Author.Name,
414 Email: message.Author.Email,
415 },
416 Content: message.Message,
417 CreatedAt: message.Date,
418 UpdatedAt: message.Date,
419 })
420 }
421 }
422
423 // Determine state
424 state := "open"
425 switch change.Status {
426 case "MERGED":
427 state = "merged"
428 case "ABANDONED":
429 state = "closed"
430 }
431
432 return &PullRequest{
433 ID: change.ID,
434 Number: change.Number,
435 Title: change.Subject,
436 Description: change.Description,
437 State: state,
438 Author: Author{
439 Name: change.Owner.Name,
440 Email: change.Owner.Email,
441 },
442 CreatedAt: change.Created,
443 UpdatedAt: change.Updated,
444 BaseBranch: change.Branch,
445 HeadBranch: change.Topic, // Use topic as head branch
446 BaseRepo: g.project,
447 HeadRepo: g.project, // Gerrit changes are within the same project
448 Labels: labels,
449 Assignees: []Author{}, // Gerrit doesn't have assignees in the same way
450 Reviewers: reviewers,
451 Commits: []Commit{}, // Would need additional API call to populate
452 Comments: comments,
453 }
454}