blob: 6555b69a03f5ccc2f6d35085d34a2915c7cb1318 [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
115// CreatePullRequest creates a new pull request on GitHub
116func (g *GitHubPullRequestProvider) CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error) {
117 reqBody := githubCreatePRRequest{
118 Title: options.Title,
119 Body: options.Description,
120 Head: options.HeadBranch,
121 Base: options.BaseBranch,
122 Labels: options.Labels,
123 Assignees: options.Assignees,
124 Reviewers: options.Reviewers,
125 Draft: options.Draft,
126 }
127
128 jsonBody, err := json.Marshal(reqBody)
129 if err != nil {
130 return nil, fmt.Errorf("failed to marshal request body: %w", err)
131 }
132
iomodo62da94a2025-07-28 19:01:55 +0400133 // Log PR creation with structured data
134 g.logger.Info("Creating GitHub PR",
135 slog.String("url", fmt.Sprintf("%s/repos/%s/%s/pulls", g.config.BaseURL, g.owner, g.repo)),
136 slog.String("title", options.Title),
137 slog.String("head_branch", options.HeadBranch),
138 slog.String("base_branch", options.BaseBranch),
139 slog.Any("labels", options.Labels))
iomodo43ec6ae2025-07-28 17:40:12 +0400140
iomodo1d173602025-07-26 15:35:57 +0400141 url := fmt.Sprintf("%s/repos/%s/%s/pulls", g.config.BaseURL, g.owner, g.repo)
iomodo43ec6ae2025-07-28 17:40:12 +0400142
iomodo1d173602025-07-26 15:35:57 +0400143 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
144 if err != nil {
145 return nil, fmt.Errorf("failed to create request: %w", err)
146 }
147
148 req.Header.Set("Authorization", "token "+g.config.Token)
149 req.Header.Set("Content-Type", "application/json")
150 req.Header.Set("Accept", "application/vnd.github.v3+json")
151
152 resp, err := g.config.HTTPClient.Do(req)
153 if err != nil {
154 return nil, fmt.Errorf("failed to make request: %w", err)
155 }
156 defer resp.Body.Close()
157
158 if resp.StatusCode != http.StatusCreated {
iomodo43ec6ae2025-07-28 17:40:12 +0400159 // Read the error response body for detailed error information
160 var errorBody bytes.Buffer
161 _, _ = errorBody.ReadFrom(resp.Body)
162 return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, errorBody.String())
iomodo1d173602025-07-26 15:35:57 +0400163 }
164
165 var githubPR githubPullRequest
166 if err := json.NewDecoder(resp.Body).Decode(&githubPR); err != nil {
167 return nil, fmt.Errorf("failed to decode response: %w", err)
168 }
169
170 return g.convertGitHubPR(githubPR), nil
171}
172
173// GetPullRequest retrieves a pull request by number
174func (g *GitHubPullRequestProvider) GetPullRequest(ctx context.Context, id string) (*PullRequest, error) {
175 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.config.BaseURL, g.owner, g.repo, id)
176 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
177 if err != nil {
178 return nil, fmt.Errorf("failed to create request: %w", err)
179 }
180
181 req.Header.Set("Authorization", "token "+g.config.Token)
182 req.Header.Set("Accept", "application/vnd.github.v3+json")
183
184 resp, err := g.config.HTTPClient.Do(req)
185 if err != nil {
186 return nil, fmt.Errorf("failed to make request: %w", err)
187 }
188 defer resp.Body.Close()
189
190 if resp.StatusCode != http.StatusOK {
191 return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode)
192 }
193
194 var githubPR githubPullRequest
195 if err := json.NewDecoder(resp.Body).Decode(&githubPR); err != nil {
196 return nil, fmt.Errorf("failed to decode response: %w", err)
197 }
198
199 return g.convertGitHubPR(githubPR), nil
200}
201
202// ListPullRequests lists pull requests
203func (g *GitHubPullRequestProvider) ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error) {
204 url := fmt.Sprintf("%s/repos/%s/%s/pulls", g.config.BaseURL, g.owner, g.repo)
205 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
206 if err != nil {
207 return nil, fmt.Errorf("failed to create request: %w", err)
208 }
209
210 // Add query parameters
211 q := req.URL.Query()
212 if options.State != "" {
213 q.Add("state", options.State)
214 }
215 if options.Author != "" {
216 q.Add("author", options.Author)
217 }
218 if options.Assignee != "" {
219 q.Add("assignee", options.Assignee)
220 }
221 if options.BaseBranch != "" {
222 q.Add("base", options.BaseBranch)
223 }
224 if options.HeadBranch != "" {
225 q.Add("head", options.HeadBranch)
226 }
227 if options.Limit > 0 {
228 q.Add("per_page", fmt.Sprintf("%d", options.Limit))
229 }
230 req.URL.RawQuery = q.Encode()
231
232 req.Header.Set("Authorization", "token "+g.config.Token)
233 req.Header.Set("Accept", "application/vnd.github.v3+json")
234
235 resp, err := g.config.HTTPClient.Do(req)
236 if err != nil {
237 return nil, fmt.Errorf("failed to make request: %w", err)
238 }
239 defer resp.Body.Close()
240
241 if resp.StatusCode != http.StatusOK {
242 return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode)
243 }
244
245 var githubPRs []githubPullRequest
246 if err := json.NewDecoder(resp.Body).Decode(&githubPRs); err != nil {
247 return nil, fmt.Errorf("failed to decode response: %w", err)
248 }
249
250 prs := make([]PullRequest, len(githubPRs))
251 for i, githubPR := range githubPRs {
252 prs[i] = *g.convertGitHubPR(githubPR)
253 }
254
255 return prs, nil
256}
257
258// UpdatePullRequest updates a pull request
259func (g *GitHubPullRequestProvider) UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error) {
260 reqBody := githubUpdatePRRequest{
261 Title: options.Title,
262 Body: options.Description,
263 Base: options.BaseBranch,
264 Labels: options.Labels,
265 Assignees: options.Assignees,
266 }
267
268 jsonBody, err := json.Marshal(reqBody)
269 if err != nil {
270 return nil, fmt.Errorf("failed to marshal request body: %w", err)
271 }
272
273 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.config.BaseURL, g.owner, g.repo, id)
274 req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewBuffer(jsonBody))
275 if err != nil {
276 return nil, fmt.Errorf("failed to create request: %w", err)
277 }
278
279 req.Header.Set("Authorization", "token "+g.config.Token)
280 req.Header.Set("Content-Type", "application/json")
281 req.Header.Set("Accept", "application/vnd.github.v3+json")
282
283 resp, err := g.config.HTTPClient.Do(req)
284 if err != nil {
285 return nil, fmt.Errorf("failed to make request: %w", err)
286 }
287 defer resp.Body.Close()
288
289 if resp.StatusCode != http.StatusOK {
290 return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode)
291 }
292
293 var githubPR githubPullRequest
294 if err := json.NewDecoder(resp.Body).Decode(&githubPR); err != nil {
295 return nil, fmt.Errorf("failed to decode response: %w", err)
296 }
297
298 return g.convertGitHubPR(githubPR), nil
299}
300
301// ClosePullRequest closes a pull request
302func (g *GitHubPullRequestProvider) ClosePullRequest(ctx context.Context, id string) error {
303 reqBody := githubUpdatePRRequest{
304 State: "closed",
305 }
306
307 jsonBody, err := json.Marshal(reqBody)
308 if err != nil {
309 return fmt.Errorf("failed to marshal request body: %w", err)
310 }
311
312 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.config.BaseURL, g.owner, g.repo, id)
313 req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewBuffer(jsonBody))
314 if err != nil {
315 return fmt.Errorf("failed to create request: %w", err)
316 }
317
318 req.Header.Set("Authorization", "token "+g.config.Token)
319 req.Header.Set("Content-Type", "application/json")
320 req.Header.Set("Accept", "application/vnd.github.v3+json")
321
322 resp, err := g.config.HTTPClient.Do(req)
323 if err != nil {
324 return fmt.Errorf("failed to make request: %w", err)
325 }
326 defer resp.Body.Close()
327
328 if resp.StatusCode != http.StatusOK {
329 return fmt.Errorf("GitHub API error: %d", resp.StatusCode)
330 }
331
332 return nil
333}
334
335// MergePullRequest merges a pull request
336func (g *GitHubPullRequestProvider) MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error {
337 reqBody := githubMergePRRequest{
338 CommitTitle: options.CommitTitle,
339 CommitMsg: options.CommitMsg,
340 MergeMethod: options.MergeMethod,
341 }
342
343 jsonBody, err := json.Marshal(reqBody)
344 if err != nil {
345 return fmt.Errorf("failed to marshal request body: %w", err)
346 }
347
348 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s/merge", g.config.BaseURL, g.owner, g.repo, id)
349 req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewBuffer(jsonBody))
350 if err != nil {
351 return fmt.Errorf("failed to create request: %w", err)
352 }
353
354 req.Header.Set("Authorization", "token "+g.config.Token)
355 req.Header.Set("Content-Type", "application/json")
356 req.Header.Set("Accept", "application/vnd.github.v3+json")
357
358 resp, err := g.config.HTTPClient.Do(req)
359 if err != nil {
360 return fmt.Errorf("failed to make request: %w", err)
361 }
362 defer resp.Body.Close()
363
364 if resp.StatusCode != http.StatusOK {
365 return fmt.Errorf("GitHub API error: %d", resp.StatusCode)
366 }
367
368 return nil
369}
370
371// convertGitHubPR converts a GitHub pull request to our PullRequest type
372func (g *GitHubPullRequestProvider) convertGitHubPR(githubPR githubPullRequest) *PullRequest {
373 labels := make([]string, len(githubPR.Labels))
374 for i, label := range githubPR.Labels {
375 labels[i] = label.Name
376 }
377
378 assignees := make([]Author, len(githubPR.Assignees))
379 for i, assignee := range githubPR.Assignees {
380 assignees[i] = Author{
381 Name: assignee.Login,
382 Email: "", // GitHub API doesn't provide email in this context
383 }
384 }
385
386 reviewers := make([]Author, len(githubPR.RequestedReviewers))
387 for i, reviewer := range githubPR.RequestedReviewers {
388 reviewers[i] = Author{
389 Name: reviewer.Login,
390 Email: "", // GitHub API doesn't provide email in this context
391 }
392 }
393
394 return &PullRequest{
395 ID: fmt.Sprintf("%d", githubPR.ID),
396 Number: githubPR.Number,
397 Title: githubPR.Title,
398 Description: githubPR.Body,
399 State: githubPR.State,
400 Author: Author{
401 Name: githubPR.User.Login,
402 Email: "", // GitHub API doesn't provide email in this context
403 },
404 CreatedAt: githubPR.CreatedAt,
405 UpdatedAt: githubPR.UpdatedAt,
406 BaseBranch: githubPR.Base.Ref,
407 HeadBranch: githubPR.Head.Ref,
408 BaseRepo: githubPR.Base.Repo.FullName,
409 HeadRepo: githubPR.Head.Repo.FullName,
410 Labels: labels,
411 Assignees: assignees,
412 Reviewers: reviewers,
413 Commits: []Commit{}, // Would need additional API call to populate
414 Comments: []PullRequestComment{}, // Would need additional API call to populate
415 }
416}