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