blob: 63424f128a8cc136890685ed246b31b7a8d2a0ca [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 "time"
10)
11
12// GitHubConfig holds configuration for GitHub operations
13type GitHubConfig struct {
14 Token string
15 BaseURL string // Default: https://api.github.com
16 HTTPClient *http.Client
17}
18
19// GitHubPullRequestProvider implements PullRequestProvider for GitHub
20type GitHubPullRequestProvider struct {
21 config GitHubConfig
22 owner string
23 repo string
24}
25
26// NewGitHubPullRequestProvider creates a new GitHub pull request provider
27func NewGitHubPullRequestProvider(owner, repo string, config GitHubConfig) PullRequestProvider {
28 if config.BaseURL == "" {
29 config.BaseURL = "https://api.github.com"
30 }
31 if config.HTTPClient == nil {
32 config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
33 }
34
35 return &GitHubPullRequestProvider{
36 config: config,
37 owner: owner,
38 repo: repo,
39 }
40}
41
42// GitHub API response types
43type githubPullRequest struct {
44 ID int `json:"id"`
45 Number int `json:"number"`
46 Title string `json:"title"`
47 Body string `json:"body"`
48 State string `json:"state"`
49 User githubUser `json:"user"`
50 CreatedAt time.Time `json:"created_at"`
51 UpdatedAt time.Time `json:"updated_at"`
52 Base githubBranch `json:"base"`
53 Head githubBranch `json:"head"`
54 Labels []githubLabel `json:"labels"`
55 Assignees []githubUser `json:"assignees"`
56 RequestedReviewers []githubUser `json:"requested_reviewers"`
57 CommitsURL string `json:"commits_url"`
58 CommentsURL string `json:"comments_url"`
59}
60
61type githubUser struct {
62 Login string `json:"login"`
63 ID int `json:"id"`
64 Type string `json:"type"`
65}
66
67type githubBranch struct {
68 Ref string `json:"ref"`
69 SHA string `json:"sha"`
70 Repo githubRepo `json:"repo"`
71}
72
73type githubRepo struct {
74 FullName string `json:"full_name"`
75}
76
77type githubLabel struct {
78 Name string `json:"name"`
79 Color string `json:"color"`
80}
81
82type githubCreatePRRequest struct {
83 Title string `json:"title"`
84 Body string `json:"body"`
85 Head string `json:"head"`
86 Base string `json:"base"`
87 Labels []string `json:"labels,omitempty"`
88 Assignees []string `json:"assignees,omitempty"`
89 Reviewers []string `json:"reviewers,omitempty"`
90 Draft bool `json:"draft,omitempty"`
91}
92
93type githubUpdatePRRequest struct {
94 Title string `json:"title,omitempty"`
95 Body string `json:"body,omitempty"`
96 State string `json:"state,omitempty"`
97 Base string `json:"base,omitempty"`
98 Labels []string `json:"labels,omitempty"`
99 Assignees []string `json:"assignees,omitempty"`
100}
101
102type githubMergePRRequest struct {
103 CommitTitle string `json:"commit_title,omitempty"`
104 CommitMsg string `json:"commit_message,omitempty"`
105 MergeMethod string `json:"merge_method,omitempty"`
106}
107
108// CreatePullRequest creates a new pull request on GitHub
109func (g *GitHubPullRequestProvider) CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error) {
110 reqBody := githubCreatePRRequest{
111 Title: options.Title,
112 Body: options.Description,
113 Head: options.HeadBranch,
114 Base: options.BaseBranch,
115 Labels: options.Labels,
116 Assignees: options.Assignees,
117 Reviewers: options.Reviewers,
118 Draft: options.Draft,
119 }
120
121 jsonBody, err := json.Marshal(reqBody)
122 if err != nil {
123 return nil, fmt.Errorf("failed to marshal request body: %w", err)
124 }
125
iomodo43ec6ae2025-07-28 17:40:12 +0400126 // Debug logging for request data
127 fmt.Printf("DEBUG: Creating PR with data: %s\n", string(jsonBody))
128
iomodo1d173602025-07-26 15:35:57 +0400129 url := fmt.Sprintf("%s/repos/%s/%s/pulls", g.config.BaseURL, g.owner, g.repo)
iomodo43ec6ae2025-07-28 17:40:12 +0400130 fmt.Printf("DEBUG: POST URL: %s\n", url)
131
iomodo1d173602025-07-26 15:35:57 +0400132 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
133 if err != nil {
134 return nil, fmt.Errorf("failed to create request: %w", err)
135 }
136
137 req.Header.Set("Authorization", "token "+g.config.Token)
138 req.Header.Set("Content-Type", "application/json")
139 req.Header.Set("Accept", "application/vnd.github.v3+json")
140
141 resp, err := g.config.HTTPClient.Do(req)
142 if err != nil {
143 return nil, fmt.Errorf("failed to make request: %w", err)
144 }
145 defer resp.Body.Close()
146
147 if resp.StatusCode != http.StatusCreated {
iomodo43ec6ae2025-07-28 17:40:12 +0400148 // Read the error response body for detailed error information
149 var errorBody bytes.Buffer
150 _, _ = errorBody.ReadFrom(resp.Body)
151 return nil, fmt.Errorf("GitHub API error: %d - %s", resp.StatusCode, errorBody.String())
iomodo1d173602025-07-26 15:35:57 +0400152 }
153
154 var githubPR githubPullRequest
155 if err := json.NewDecoder(resp.Body).Decode(&githubPR); err != nil {
156 return nil, fmt.Errorf("failed to decode response: %w", err)
157 }
158
159 return g.convertGitHubPR(githubPR), nil
160}
161
162// GetPullRequest retrieves a pull request by number
163func (g *GitHubPullRequestProvider) GetPullRequest(ctx context.Context, id string) (*PullRequest, error) {
164 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.config.BaseURL, g.owner, g.repo, id)
165 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
166 if err != nil {
167 return nil, fmt.Errorf("failed to create request: %w", err)
168 }
169
170 req.Header.Set("Authorization", "token "+g.config.Token)
171 req.Header.Set("Accept", "application/vnd.github.v3+json")
172
173 resp, err := g.config.HTTPClient.Do(req)
174 if err != nil {
175 return nil, fmt.Errorf("failed to make request: %w", err)
176 }
177 defer resp.Body.Close()
178
179 if resp.StatusCode != http.StatusOK {
180 return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode)
181 }
182
183 var githubPR githubPullRequest
184 if err := json.NewDecoder(resp.Body).Decode(&githubPR); err != nil {
185 return nil, fmt.Errorf("failed to decode response: %w", err)
186 }
187
188 return g.convertGitHubPR(githubPR), nil
189}
190
191// ListPullRequests lists pull requests
192func (g *GitHubPullRequestProvider) ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error) {
193 url := fmt.Sprintf("%s/repos/%s/%s/pulls", g.config.BaseURL, g.owner, g.repo)
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 // Add query parameters
200 q := req.URL.Query()
201 if options.State != "" {
202 q.Add("state", options.State)
203 }
204 if options.Author != "" {
205 q.Add("author", options.Author)
206 }
207 if options.Assignee != "" {
208 q.Add("assignee", options.Assignee)
209 }
210 if options.BaseBranch != "" {
211 q.Add("base", options.BaseBranch)
212 }
213 if options.HeadBranch != "" {
214 q.Add("head", options.HeadBranch)
215 }
216 if options.Limit > 0 {
217 q.Add("per_page", fmt.Sprintf("%d", options.Limit))
218 }
219 req.URL.RawQuery = q.Encode()
220
221 req.Header.Set("Authorization", "token "+g.config.Token)
222 req.Header.Set("Accept", "application/vnd.github.v3+json")
223
224 resp, err := g.config.HTTPClient.Do(req)
225 if err != nil {
226 return nil, fmt.Errorf("failed to make request: %w", err)
227 }
228 defer resp.Body.Close()
229
230 if resp.StatusCode != http.StatusOK {
231 return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode)
232 }
233
234 var githubPRs []githubPullRequest
235 if err := json.NewDecoder(resp.Body).Decode(&githubPRs); err != nil {
236 return nil, fmt.Errorf("failed to decode response: %w", err)
237 }
238
239 prs := make([]PullRequest, len(githubPRs))
240 for i, githubPR := range githubPRs {
241 prs[i] = *g.convertGitHubPR(githubPR)
242 }
243
244 return prs, nil
245}
246
247// UpdatePullRequest updates a pull request
248func (g *GitHubPullRequestProvider) UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error) {
249 reqBody := githubUpdatePRRequest{
250 Title: options.Title,
251 Body: options.Description,
252 Base: options.BaseBranch,
253 Labels: options.Labels,
254 Assignees: options.Assignees,
255 }
256
257 jsonBody, err := json.Marshal(reqBody)
258 if err != nil {
259 return nil, fmt.Errorf("failed to marshal request body: %w", err)
260 }
261
262 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.config.BaseURL, g.owner, g.repo, id)
263 req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewBuffer(jsonBody))
264 if err != nil {
265 return nil, fmt.Errorf("failed to create request: %w", err)
266 }
267
268 req.Header.Set("Authorization", "token "+g.config.Token)
269 req.Header.Set("Content-Type", "application/json")
270 req.Header.Set("Accept", "application/vnd.github.v3+json")
271
272 resp, err := g.config.HTTPClient.Do(req)
273 if err != nil {
274 return nil, fmt.Errorf("failed to make request: %w", err)
275 }
276 defer resp.Body.Close()
277
278 if resp.StatusCode != http.StatusOK {
279 return nil, fmt.Errorf("GitHub API error: %d", resp.StatusCode)
280 }
281
282 var githubPR githubPullRequest
283 if err := json.NewDecoder(resp.Body).Decode(&githubPR); err != nil {
284 return nil, fmt.Errorf("failed to decode response: %w", err)
285 }
286
287 return g.convertGitHubPR(githubPR), nil
288}
289
290// ClosePullRequest closes a pull request
291func (g *GitHubPullRequestProvider) ClosePullRequest(ctx context.Context, id string) error {
292 reqBody := githubUpdatePRRequest{
293 State: "closed",
294 }
295
296 jsonBody, err := json.Marshal(reqBody)
297 if err != nil {
298 return fmt.Errorf("failed to marshal request body: %w", err)
299 }
300
301 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s", g.config.BaseURL, g.owner, g.repo, id)
302 req, err := http.NewRequestWithContext(ctx, "PATCH", url, bytes.NewBuffer(jsonBody))
303 if err != nil {
304 return fmt.Errorf("failed to create request: %w", err)
305 }
306
307 req.Header.Set("Authorization", "token "+g.config.Token)
308 req.Header.Set("Content-Type", "application/json")
309 req.Header.Set("Accept", "application/vnd.github.v3+json")
310
311 resp, err := g.config.HTTPClient.Do(req)
312 if err != nil {
313 return fmt.Errorf("failed to make request: %w", err)
314 }
315 defer resp.Body.Close()
316
317 if resp.StatusCode != http.StatusOK {
318 return fmt.Errorf("GitHub API error: %d", resp.StatusCode)
319 }
320
321 return nil
322}
323
324// MergePullRequest merges a pull request
325func (g *GitHubPullRequestProvider) MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error {
326 reqBody := githubMergePRRequest{
327 CommitTitle: options.CommitTitle,
328 CommitMsg: options.CommitMsg,
329 MergeMethod: options.MergeMethod,
330 }
331
332 jsonBody, err := json.Marshal(reqBody)
333 if err != nil {
334 return fmt.Errorf("failed to marshal request body: %w", err)
335 }
336
337 url := fmt.Sprintf("%s/repos/%s/%s/pulls/%s/merge", g.config.BaseURL, g.owner, g.repo, id)
338 req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewBuffer(jsonBody))
339 if err != nil {
340 return fmt.Errorf("failed to create request: %w", err)
341 }
342
343 req.Header.Set("Authorization", "token "+g.config.Token)
344 req.Header.Set("Content-Type", "application/json")
345 req.Header.Set("Accept", "application/vnd.github.v3+json")
346
347 resp, err := g.config.HTTPClient.Do(req)
348 if err != nil {
349 return fmt.Errorf("failed to make request: %w", err)
350 }
351 defer resp.Body.Close()
352
353 if resp.StatusCode != http.StatusOK {
354 return fmt.Errorf("GitHub API error: %d", resp.StatusCode)
355 }
356
357 return nil
358}
359
360// convertGitHubPR converts a GitHub pull request to our PullRequest type
361func (g *GitHubPullRequestProvider) convertGitHubPR(githubPR githubPullRequest) *PullRequest {
362 labels := make([]string, len(githubPR.Labels))
363 for i, label := range githubPR.Labels {
364 labels[i] = label.Name
365 }
366
367 assignees := make([]Author, len(githubPR.Assignees))
368 for i, assignee := range githubPR.Assignees {
369 assignees[i] = Author{
370 Name: assignee.Login,
371 Email: "", // GitHub API doesn't provide email in this context
372 }
373 }
374
375 reviewers := make([]Author, len(githubPR.RequestedReviewers))
376 for i, reviewer := range githubPR.RequestedReviewers {
377 reviewers[i] = Author{
378 Name: reviewer.Login,
379 Email: "", // GitHub API doesn't provide email in this context
380 }
381 }
382
383 return &PullRequest{
384 ID: fmt.Sprintf("%d", githubPR.ID),
385 Number: githubPR.Number,
386 Title: githubPR.Title,
387 Description: githubPR.Body,
388 State: githubPR.State,
389 Author: Author{
390 Name: githubPR.User.Login,
391 Email: "", // GitHub API doesn't provide email in this context
392 },
393 CreatedAt: githubPR.CreatedAt,
394 UpdatedAt: githubPR.UpdatedAt,
395 BaseBranch: githubPR.Base.Ref,
396 HeadBranch: githubPR.Head.Ref,
397 BaseRepo: githubPR.Base.Repo.FullName,
398 HeadRepo: githubPR.Head.Repo.FullName,
399 Labels: labels,
400 Assignees: assignees,
401 Reviewers: reviewers,
402 Commits: []Commit{}, // Would need additional API call to populate
403 Comments: []PullRequestComment{}, // Would need additional API call to populate
404 }
405}