blob: 87ec363467b324bfcf4638a7865084155e4110cf [file] [log] [blame]
iomodo1d173602025-07-26 15:35:57 +04001package git
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
iomodo62da94a2025-07-28 19:01:55 +04008 "log/slog"
iomodo1d173602025-07-26 15:35:57 +04009 "net/http"
10 "time"
11)
12
13// GitHubConfig holds configuration for GitHub operations
14type GitHubConfig struct {
15 Token string
16 BaseURL string // Default: https://api.github.com
17 HTTPClient *http.Client
iomodo62da94a2025-07-28 19:01:55 +040018 Logger *slog.Logger
iomodo1d173602025-07-26 15:35:57 +040019}
20
21// GitHubPullRequestProvider implements PullRequestProvider for GitHub
22type GitHubPullRequestProvider struct {
23 config GitHubConfig
24 owner string
25 repo string
iomodo62da94a2025-07-28 19:01:55 +040026 logger *slog.Logger
iomodo1d173602025-07-26 15:35:57 +040027}
28
29// NewGitHubPullRequestProvider creates a new GitHub pull request provider
30func NewGitHubPullRequestProvider(owner, repo string, config GitHubConfig) PullRequestProvider {
31 if config.BaseURL == "" {
32 config.BaseURL = "https://api.github.com"
33 }
34 if config.HTTPClient == nil {
35 config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
36 }
iomodo62da94a2025-07-28 19:01:55 +040037 if config.Logger == nil {
38 config.Logger = slog.Default()
39 }
iomodo1d173602025-07-26 15:35:57 +040040
41 return &GitHubPullRequestProvider{
42 config: config,
43 owner: owner,
44 repo: repo,
iomodo62da94a2025-07-28 19:01:55 +040045 logger: config.Logger,
iomodo1d173602025-07-26 15:35:57 +040046 }
47}
48
49// GitHub API response types
50type githubPullRequest struct {
51 ID int `json:"id"`
52 Number int `json:"number"`
53 Title string `json:"title"`
54 Body string `json:"body"`
55 State string `json:"state"`
56 User githubUser `json:"user"`
57 CreatedAt time.Time `json:"created_at"`
58 UpdatedAt time.Time `json:"updated_at"`
59 Base githubBranch `json:"base"`
60 Head githubBranch `json:"head"`
61 Labels []githubLabel `json:"labels"`
62 Assignees []githubUser `json:"assignees"`
63 RequestedReviewers []githubUser `json:"requested_reviewers"`
64 CommitsURL string `json:"commits_url"`
65 CommentsURL string `json:"comments_url"`
66}
67
68type githubUser struct {
69 Login string `json:"login"`
70 ID int `json:"id"`
71 Type string `json:"type"`
72}
73
74type githubBranch struct {
75 Ref string `json:"ref"`
76 SHA string `json:"sha"`
77 Repo githubRepo `json:"repo"`
78}
79
80type githubRepo struct {
81 FullName string `json:"full_name"`
82}
83
84type githubLabel struct {
85 Name string `json:"name"`
86 Color string `json:"color"`
87}
88
89type githubCreatePRRequest struct {
90 Title string `json:"title"`
91 Body string `json:"body"`
92 Head string `json:"head"`
93 Base string `json:"base"`
94 Labels []string `json:"labels,omitempty"`
95 Assignees []string `json:"assignees,omitempty"`
96 Reviewers []string `json:"reviewers,omitempty"`
97 Draft bool `json:"draft,omitempty"`
98}
99
100type githubUpdatePRRequest struct {
101 Title string `json:"title,omitempty"`
102 Body string `json:"body,omitempty"`
103 State string `json:"state,omitempty"`
104 Base string `json:"base,omitempty"`
105 Labels []string `json:"labels,omitempty"`
106 Assignees []string `json:"assignees,omitempty"`
107}
108
109type githubMergePRRequest struct {
110 CommitTitle string `json:"commit_title,omitempty"`
111 CommitMsg string `json:"commit_message,omitempty"`
112 MergeMethod string `json:"merge_method,omitempty"`
113}
114
iomodo8acd08d2025-07-31 16:22:08 +0400115// GitHub webhook API types
116type githubWebhookRequest struct {
iomodo13a10fc2025-07-31 17:47:06 +0400117 Name string `json:"name"`
118 Active bool `json:"active"`
119 Events []string `json:"events"`
120 Config githubWebhookConfig `json:"config"`
iomodo8acd08d2025-07-31 16:22:08 +0400121}
122
123type githubWebhookConfig struct {
124 URL string `json:"url"`
125 ContentType string `json:"content_type"`
126 Secret string `json:"secret"`
127}
128
129type GitHubWebhookResponse struct {
130 ID int `json:"id"`
131 Name string `json:"name"`
132 Active bool `json:"active"`
133 Events []string `json:"events"`
134 Config githubWebhookConfig `json:"config"`
135 URL string `json:"url"`
136}
137
iomodo1d173602025-07-26 15:35:57 +0400138// CreatePullRequest creates a new pull request on GitHub
139func (g *GitHubPullRequestProvider) CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error) {
140 reqBody := githubCreatePRRequest{
141 Title: options.Title,
142 Body: options.Description,
143 Head: options.HeadBranch,
144 Base: options.BaseBranch,
145 Labels: options.Labels,
146 Assignees: options.Assignees,
147 Reviewers: options.Reviewers,
148 Draft: options.Draft,
149 }
150
151 jsonBody, err := json.Marshal(reqBody)
152 if err != nil {
153 return nil, fmt.Errorf("failed to marshal request body: %w", err)
154 }
155
iomodo62da94a2025-07-28 19:01:55 +0400156 // Log PR creation with structured data
157 g.logger.Info("Creating GitHub PR",
158 slog.String("url", fmt.Sprintf("%s/repos/%s/%s/pulls", g.config.BaseURL, g.owner, g.repo)),
159 slog.String("title", options.Title),
160 slog.String("head_branch", options.HeadBranch),
161 slog.String("base_branch", options.BaseBranch),
162 slog.Any("labels", options.Labels))
iomodo43ec6ae2025-07-28 17:40:12 +0400163
iomodo1d173602025-07-26 15:35:57 +0400164 url := fmt.Sprintf("%s/repos/%s/%s/pulls", g.config.BaseURL, g.owner, g.repo)
iomodoa53240a2025-07-30 17:33:35 +0400165
iomodo1d173602025-07-26 15:35:57 +0400166 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
167 if err != nil {
168 return nil, fmt.Errorf("failed to create request: %w", err)
169 }
170
171 req.Header.Set("Authorization", "token "+g.config.Token)
172 req.Header.Set("Content-Type", "application/json")
173 req.Header.Set("Accept", "application/vnd.github.v3+json")
174
175 resp, err := g.config.HTTPClient.Do(req)
176 if err != nil {
177 return nil, fmt.Errorf("failed to make request: %w", err)
178 }
179 defer resp.Body.Close()
180
181 if resp.StatusCode != http.StatusCreated {
iomodo43ec6ae2025-07-28 17:40:12 +0400182 // Read the error response body for detailed error information
183 var errorBody bytes.Buffer
184 _, _ = errorBody.ReadFrom(resp.Body)
185 return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, errorBody.String())
iomodo1d173602025-07-26 15:35:57 +0400186 }
187
188 var githubPR githubPullRequest
189 if err := json.NewDecoder(resp.Body).Decode(&githubPR); err != nil {
190 return nil, fmt.Errorf("failed to decode response: %w", err)
191 }
192
193 return g.convertGitHubPR(githubPR), nil
194}
195
196// GetPullRequest retrieves a pull request by number
197func (g *GitHubPullRequestProvider) GetPullRequest(ctx context.Context, id string) (*PullRequest, error) {
198 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.config.BaseURL, g.owner, g.repo, id)
199 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
200 if err != nil {
201 return nil, fmt.Errorf("failed to create request: %w", err)
202 }
203
204 req.Header.Set("Authorization", "token "+g.config.Token)
205 req.Header.Set("Accept", "application/vnd.github.v3+json")
206
207 resp, err := g.config.HTTPClient.Do(req)
208 if err != nil {
209 return nil, fmt.Errorf("failed to make request: %w", err)
210 }
211 defer resp.Body.Close()
212
213 if resp.StatusCode != http.StatusOK {
214 return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode)
215 }
216
217 var githubPR githubPullRequest
218 if err := json.NewDecoder(resp.Body).Decode(&githubPR); err != nil {
219 return nil, fmt.Errorf("failed to decode response: %w", err)
220 }
221
222 return g.convertGitHubPR(githubPR), nil
223}
224
225// ListPullRequests lists pull requests
226func (g *GitHubPullRequestProvider) ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error) {
227 url := fmt.Sprintf("%s/repos/%s/%s/pulls", g.config.BaseURL, g.owner, g.repo)
228 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
229 if err != nil {
230 return nil, fmt.Errorf("failed to create request: %w", err)
231 }
232
233 // Add query parameters
234 q := req.URL.Query()
235 if options.State != "" {
236 q.Add("state", options.State)
237 }
238 if options.Author != "" {
239 q.Add("author", options.Author)
240 }
241 if options.Assignee != "" {
242 q.Add("assignee", options.Assignee)
243 }
244 if options.BaseBranch != "" {
245 q.Add("base", options.BaseBranch)
246 }
247 if options.HeadBranch != "" {
248 q.Add("head", options.HeadBranch)
249 }
250 if options.Limit > 0 {
251 q.Add("per_page", fmt.Sprintf("%d", options.Limit))
252 }
253 req.URL.RawQuery = q.Encode()
254
255 req.Header.Set("Authorization", "token "+g.config.Token)
256 req.Header.Set("Accept", "application/vnd.github.v3+json")
257
258 resp, err := g.config.HTTPClient.Do(req)
259 if err != nil {
260 return nil, fmt.Errorf("failed to make request: %w", err)
261 }
262 defer resp.Body.Close()
263
264 if resp.StatusCode != http.StatusOK {
265 return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode)
266 }
267
268 var githubPRs []githubPullRequest
269 if err := json.NewDecoder(resp.Body).Decode(&githubPRs); err != nil {
270 return nil, fmt.Errorf("failed to decode response: %w", err)
271 }
272
273 prs := make([]PullRequest, len(githubPRs))
274 for i, githubPR := range githubPRs {
275 prs[i] = *g.convertGitHubPR(githubPR)
276 }
277
278 return prs, nil
279}
280
281// UpdatePullRequest updates a pull request
282func (g *GitHubPullRequestProvider) UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error) {
283 reqBody := githubUpdatePRRequest{
284 Title: options.Title,
285 Body: options.Description,
286 Base: options.BaseBranch,
287 Labels: options.Labels,
288 Assignees: options.Assignees,
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/repos/%s/%s/pulls/%s", g.config.BaseURL, g.owner, g.repo, id)
297 req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewBuffer(jsonBody))
298 if err != nil {
299 return nil, fmt.Errorf("failed to create request: %w", err)
300 }
301
302 req.Header.Set("Authorization", "token "+g.config.Token)
303 req.Header.Set("Content-Type", "application/json")
304 req.Header.Set("Accept", "application/vnd.github.v3+json")
305
306 resp, err := g.config.HTTPClient.Do(req)
307 if err != nil {
308 return nil, fmt.Errorf("failed to make request: %w", err)
309 }
310 defer resp.Body.Close()
311
312 if resp.StatusCode != http.StatusOK {
313 return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode)
314 }
315
316 var githubPR githubPullRequest
317 if err := json.NewDecoder(resp.Body).Decode(&githubPR); err != nil {
318 return nil, fmt.Errorf("failed to decode response: %w", err)
319 }
320
321 return g.convertGitHubPR(githubPR), nil
322}
323
324// ClosePullRequest closes a pull request
325func (g *GitHubPullRequestProvider) ClosePullRequest(ctx context.Context, id string) error {
326 reqBody := githubUpdatePRRequest{
327 State: "closed",
328 }
329
330 jsonBody, err := json.Marshal(reqBody)
331 if err != nil {
332 return fmt.Errorf("failed to marshal request body: %w", err)
333 }
334
335 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.config.BaseURL, g.owner, g.repo, id)
336 req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewBuffer(jsonBody))
337 if err != nil {
338 return fmt.Errorf("failed to create request: %w", err)
339 }
340
341 req.Header.Set("Authorization", "token "+g.config.Token)
342 req.Header.Set("Content-Type", "application/json")
343 req.Header.Set("Accept", "application/vnd.github.v3+json")
344
345 resp, err := g.config.HTTPClient.Do(req)
346 if err != nil {
347 return fmt.Errorf("failed to make request: %w", err)
348 }
349 defer resp.Body.Close()
350
351 if resp.StatusCode != http.StatusOK {
352 return fmt.Errorf("GitHub API error: %d", resp.StatusCode)
353 }
354
355 return nil
356}
357
358// MergePullRequest merges a pull request
359func (g *GitHubPullRequestProvider) MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error {
360 reqBody := githubMergePRRequest{
361 CommitTitle: options.CommitTitle,
362 CommitMsg: options.CommitMsg,
363 MergeMethod: options.MergeMethod,
364 }
365
366 jsonBody, err := json.Marshal(reqBody)
367 if err != nil {
368 return fmt.Errorf("failed to marshal request body: %w", err)
369 }
370
371 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s/merge", g.config.BaseURL, g.owner, g.repo, id)
372 req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewBuffer(jsonBody))
373 if err != nil {
374 return fmt.Errorf("failed to create request: %w", err)
375 }
376
377 req.Header.Set("Authorization", "token "+g.config.Token)
378 req.Header.Set("Content-Type", "application/json")
379 req.Header.Set("Accept", "application/vnd.github.v3+json")
380
381 resp, err := g.config.HTTPClient.Do(req)
382 if err != nil {
383 return fmt.Errorf("failed to make request: %w", err)
384 }
385 defer resp.Body.Close()
386
387 if resp.StatusCode != http.StatusOK {
388 return fmt.Errorf("GitHub API error: %d", resp.StatusCode)
389 }
390
391 return nil
392}
393
394// convertGitHubPR converts a GitHub pull request to our PullRequest type
395func (g *GitHubPullRequestProvider) convertGitHubPR(githubPR githubPullRequest) *PullRequest {
396 labels := make([]string, len(githubPR.Labels))
397 for i, label := range githubPR.Labels {
398 labels[i] = label.Name
399 }
400
401 assignees := make([]Author, len(githubPR.Assignees))
402 for i, assignee := range githubPR.Assignees {
403 assignees[i] = Author{
404 Name: assignee.Login,
405 Email: "", // GitHub API doesn't provide email in this context
406 }
407 }
408
409 reviewers := make([]Author, len(githubPR.RequestedReviewers))
410 for i, reviewer := range githubPR.RequestedReviewers {
411 reviewers[i] = Author{
412 Name: reviewer.Login,
413 Email: "", // GitHub API doesn't provide email in this context
414 }
415 }
416
417 return &PullRequest{
418 ID: fmt.Sprintf("%d", githubPR.ID),
419 Number: githubPR.Number,
420 Title: githubPR.Title,
421 Description: githubPR.Body,
422 State: githubPR.State,
423 Author: Author{
424 Name: githubPR.User.Login,
425 Email: "", // GitHub API doesn't provide email in this context
426 },
427 CreatedAt: githubPR.CreatedAt,
428 UpdatedAt: githubPR.UpdatedAt,
429 BaseBranch: githubPR.Base.Ref,
430 HeadBranch: githubPR.Head.Ref,
431 BaseRepo: githubPR.Base.Repo.FullName,
432 HeadRepo: githubPR.Head.Repo.FullName,
433 Labels: labels,
434 Assignees: assignees,
435 Reviewers: reviewers,
436 Commits: []Commit{}, // Would need additional API call to populate
437 Comments: []PullRequestComment{}, // Would need additional API call to populate
iomodoa53240a2025-07-30 17:33:35 +0400438 URL: fmt.Sprintf("https://github.com/%s/%s/pull/%d", g.owner, g.repo, githubPR.Number),
iomodo1d173602025-07-26 15:35:57 +0400439 }
440}
iomodo8acd08d2025-07-31 16:22:08 +0400441
442// ListWebhooks lists existing webhooks for the repository
443func (g *GitHubPullRequestProvider) ListWebhooks(ctx context.Context) ([]GitHubWebhookResponse, error) {
444 url := fmt.Sprintf("%s/repos/%s/%s/hooks", g.config.BaseURL, g.owner, g.repo)
445 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
446 if err != nil {
447 return nil, fmt.Errorf("failed to create request: %w", err)
448 }
449
450 req.Header.Set("Authorization", "token "+g.config.Token)
451 req.Header.Set("Accept", "application/vnd.github.v3+json")
452
453 resp, err := g.config.HTTPClient.Do(req)
454 if err != nil {
455 return nil, fmt.Errorf("failed to make request: %w", err)
456 }
457 defer resp.Body.Close()
458
459 if resp.StatusCode != http.StatusOK {
460 var errorBody bytes.Buffer
461 _, _ = errorBody.ReadFrom(resp.Body)
462 return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, errorBody.String())
463 }
464
465 var webhooks []GitHubWebhookResponse
466 if err := json.NewDecoder(resp.Body).Decode(&webhooks); err != nil {
467 return nil, fmt.Errorf("failed to decode response: %w", err)
468 }
469
470 return webhooks, nil
471}
472
473// CreateWebhook creates a new webhook for the repository
474func (g *GitHubPullRequestProvider) CreateWebhook(ctx context.Context, webhookURL, secret string) (*GitHubWebhookResponse, error) {
475 reqBody := githubWebhookRequest{
476 Name: "web",
477 Active: true,
478 Events: []string{"pull_request"},
479 Config: githubWebhookConfig{
480 URL: webhookURL,
481 ContentType: "json",
482 Secret: secret,
483 },
484 }
485
486 jsonBody, err := json.Marshal(reqBody)
487 if err != nil {
488 return nil, fmt.Errorf("failed to marshal request body: %w", err)
489 }
490
491 g.logger.Info("Creating GitHub webhook",
492 slog.String("url", fmt.Sprintf("%s/repos/%s/%s/hooks", g.config.BaseURL, g.owner, g.repo)),
493 slog.String("webhook_url", webhookURL),
494 slog.Any("events", reqBody.Events))
495
496 url := fmt.Sprintf("%s/repos/%s/%s/hooks", g.config.BaseURL, g.owner, g.repo)
497 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
498 if err != nil {
499 return nil, fmt.Errorf("failed to create request: %w", err)
500 }
501
502 req.Header.Set("Authorization", "token "+g.config.Token)
503 req.Header.Set("Content-Type", "application/json")
504 req.Header.Set("Accept", "application/vnd.github.v3+json")
505
506 resp, err := g.config.HTTPClient.Do(req)
507 if err != nil {
508 return nil, fmt.Errorf("failed to make request: %w", err)
509 }
510 defer resp.Body.Close()
511
512 if resp.StatusCode != http.StatusCreated {
513 var errorBody bytes.Buffer
514 _, _ = errorBody.ReadFrom(resp.Body)
515 return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, errorBody.String())
516 }
517
518 var webhook GitHubWebhookResponse
519 if err := json.NewDecoder(resp.Body).Decode(&webhook); err != nil {
520 return nil, fmt.Errorf("failed to decode response: %w", err)
521 }
522
523 return &webhook, nil
524}
525
526// UpdateWebhook updates an existing webhook
527func (g *GitHubPullRequestProvider) UpdateWebhook(ctx context.Context, webhookID int, webhookURL, secret string) (*GitHubWebhookResponse, error) {
528 reqBody := githubWebhookRequest{
529 Name: "web",
530 Active: true,
531 Events: []string{"pull_request"},
532 Config: githubWebhookConfig{
533 URL: webhookURL,
534 ContentType: "json",
535 Secret: secret,
536 },
537 }
538
539 jsonBody, err := json.Marshal(reqBody)
540 if err != nil {
541 return nil, fmt.Errorf("failed to marshal request body: %w", err)
542 }
543
544 g.logger.Info("Updating GitHub webhook",
545 slog.Int("webhook_id", webhookID),
546 slog.String("webhook_url", webhookURL),
547 slog.Any("events", reqBody.Events))
548
549 url := fmt.Sprintf("%s/repos/%s/%s/hooks/%d", g.config.BaseURL, g.owner, g.repo, webhookID)
550 req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewBuffer(jsonBody))
551 if err != nil {
552 return nil, fmt.Errorf("failed to create request: %w", err)
553 }
554
555 req.Header.Set("Authorization", "token "+g.config.Token)
556 req.Header.Set("Content-Type", "application/json")
557 req.Header.Set("Accept", "application/vnd.github.v3+json")
558
559 resp, err := g.config.HTTPClient.Do(req)
560 if err != nil {
561 return nil, fmt.Errorf("failed to make request: %w", err)
562 }
563 defer resp.Body.Close()
564
565 if resp.StatusCode != http.StatusOK {
566 var errorBody bytes.Buffer
567 _, _ = errorBody.ReadFrom(resp.Body)
568 return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, errorBody.String())
569 }
570
571 var webhook GitHubWebhookResponse
572 if err := json.NewDecoder(resp.Body).Decode(&webhook); err != nil {
573 return nil, fmt.Errorf("failed to decode response: %w", err)
574 }
575
576 return &webhook, nil
577}