| |
| |
| package actions |
|
|
| import ( |
| "bytes" |
| "context" |
| "encoding/base64" |
| "encoding/json" |
| "fmt" |
| "io" |
| "net/http" |
| "os" |
| "strings" |
| "time" |
| ) |
|
|
| type GitHubConfig struct { |
| Token string |
| Repo string |
| } |
|
|
| func GitHubFromEnv() GitHubConfig { |
| return GitHubConfig{ |
| Token: os.Getenv("GITHUB_TOKEN"), |
| Repo: os.Getenv("GITHUB_REPO"), |
| } |
| } |
|
|
| func (c GitHubConfig) Enabled() bool { |
| return c.Token != "" && c.Repo != "" |
| } |
|
|
| func (c GitHubConfig) do(ctx context.Context, method, path string, body any) ([]byte, int, error) { |
| var rdr io.Reader |
| if body != nil { |
| b, _ := json.Marshal(body) |
| rdr = bytes.NewReader(b) |
| } |
| req, err := http.NewRequestWithContext(ctx, method, "https://api.github.com"+path, rdr) |
| if err != nil { |
| return nil, 0, err |
| } |
| req.Header.Set("Authorization", "Bearer "+c.Token) |
| req.Header.Set("Accept", "application/vnd.github+json") |
| if body != nil { |
| req.Header.Set("Content-Type", "application/json") |
| } |
| resp, err := http.DefaultClient.Do(req) |
| if err != nil { |
| return nil, 0, fmt.Errorf("github request: %w", err) |
| } |
| defer resp.Body.Close() |
| rb, _ := io.ReadAll(resp.Body) |
| return rb, resp.StatusCode, nil |
| } |
|
|
| type IssueResult struct { |
| Number int `json:"number"` |
| URL string `json:"html_url"` |
| } |
|
|
| func CreateIssue(ctx context.Context, cfg GitHubConfig, title, body string, labels []string) (IssueResult, error) { |
| b, status, err := cfg.do(ctx, http.MethodPost, |
| fmt.Sprintf("/repos/%s/issues", cfg.Repo), |
| map[string]any{"title": title, "body": body, "labels": labels}) |
| if err != nil { |
| return IssueResult{}, err |
| } |
| if status >= 300 { |
| return IssueResult{}, fmt.Errorf("create issue %d: %s", status, string(b)) |
| } |
| var r IssueResult |
| if err := json.Unmarshal(b, &r); err != nil { |
| return IssueResult{}, fmt.Errorf("parse issue response: %w", err) |
| } |
| return r, nil |
| } |
|
|
| |
| func FetchPRDiff(ctx context.Context, cfg GitHubConfig, prNumber int) (string, error) { |
| b, status, err := cfg.do(ctx, http.MethodGet, |
| fmt.Sprintf("/repos/%s/pulls/%d/files", cfg.Repo, prNumber), nil) |
| if err != nil { |
| return "", err |
| } |
| if status >= 300 { |
| return "", fmt.Errorf("fetch pr #%d files %d: %s", prNumber, status, string(b)) |
| } |
| var files []struct { |
| Filename string `json:"filename"` |
| Status string `json:"status"` |
| Additions int `json:"additions"` |
| Deletions int `json:"deletions"` |
| Patch string `json:"patch"` |
| } |
| if err := json.Unmarshal(b, &files); err != nil { |
| return "", fmt.Errorf("parse pr files: %w", err) |
| } |
| if len(files) == 0 { |
| return "", fmt.Errorf("pr #%d has no changed files", prNumber) |
| } |
| var sb strings.Builder |
| for _, f := range files { |
| fmt.Fprintf(&sb, "FILE: %s (%s, +%d -%d)\n%s\n\n", f.Filename, f.Status, f.Additions, f.Deletions, f.Patch) |
| } |
| return sb.String(), nil |
| } |
|
|
| type FileContent struct { |
| Path string |
| Content string |
| SHA string |
| } |
|
|
| |
| func FetchFile(ctx context.Context, cfg GitHubConfig, path string) (FileContent, error) { |
| b, status, err := cfg.do(ctx, http.MethodGet, |
| fmt.Sprintf("/repos/%s/contents/%s", cfg.Repo, path), nil) |
| if err != nil { |
| return FileContent{}, err |
| } |
| if status >= 300 { |
| return FileContent{}, fmt.Errorf("fetch file %s %d: %s", path, status, string(b)) |
| } |
| var resp struct { |
| Content string `json:"content"` |
| SHA string `json:"sha"` |
| } |
| if err := json.Unmarshal(b, &resp); err != nil { |
| return FileContent{}, fmt.Errorf("parse file response: %w", err) |
| } |
| decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(resp.Content, "\n", "")) |
| if err != nil { |
| return FileContent{}, fmt.Errorf("decode file content: %w", err) |
| } |
| return FileContent{Path: path, Content: string(decoded), SHA: resp.SHA}, nil |
| } |
|
|
| type PRSummary struct { |
| Number int `json:"number"` |
| Title string `json:"title"` |
| MergedAt time.Time `json:"merged_at"` |
| } |
|
|
| |
| func ListRecentPRs(ctx context.Context, cfg GitHubConfig, since, until time.Time) ([]PRSummary, error) { |
| b, status, err := cfg.do(ctx, http.MethodGet, |
| fmt.Sprintf("/repos/%s/pulls?state=closed&sort=updated&direction=desc&per_page=50", cfg.Repo), nil) |
| if err != nil { |
| return nil, err |
| } |
| if status >= 300 { |
| return nil, fmt.Errorf("list prs %d: %s", status, string(b)) |
| } |
| var raw []struct { |
| Number int `json:"number"` |
| Title string `json:"title"` |
| MergedAt *string `json:"merged_at"` |
| } |
| if err := json.Unmarshal(b, &raw); err != nil { |
| return nil, fmt.Errorf("parse pr list: %w", err) |
| } |
| var out []PRSummary |
| for _, p := range raw { |
| if p.MergedAt == nil { |
| continue |
| } |
| t, err := time.Parse(time.RFC3339, *p.MergedAt) |
| if err != nil || t.Before(since) || t.After(until) { |
| continue |
| } |
| out = append(out, PRSummary{Number: p.Number, Title: p.Title, MergedAt: t}) |
| } |
| return out, nil |
| } |
|
|
| type FixPROptions struct { |
| Path string |
| NewContent string |
| Title string |
| Body string |
| } |
|
|
| |
| |
| func OpenFixPR(ctx context.Context, cfg GitHubConfig, opts FixPROptions) (IssueResult, error) { |
| |
| b, status, err := cfg.do(ctx, http.MethodGet, |
| fmt.Sprintf("/repos/%s/git/ref/heads/main", cfg.Repo), nil) |
| if err != nil { |
| return IssueResult{}, err |
| } |
| if status >= 300 { |
| return IssueResult{}, fmt.Errorf("get main ref %d: %s", status, string(b)) |
| } |
| var ref struct { |
| Object struct { |
| SHA string `json:"sha"` |
| } `json:"object"` |
| } |
| if err := json.Unmarshal(b, &ref); err != nil || ref.Object.SHA == "" { |
| return IssueResult{}, fmt.Errorf("resolve main sha: %v", err) |
| } |
|
|
| |
| branch := fmt.Sprintf("llmtrace/fix-%d", time.Now().Unix()) |
| b, status, err = cfg.do(ctx, http.MethodPost, |
| fmt.Sprintf("/repos/%s/git/refs", cfg.Repo), |
| map[string]string{"ref": "refs/heads/" + branch, "sha": ref.Object.SHA}) |
| if err != nil { |
| return IssueResult{}, err |
| } |
| if status >= 300 { |
| return IssueResult{}, fmt.Errorf("create branch %d: %s", status, string(b)) |
| } |
|
|
| |
| cur, err := FetchFile(ctx, cfg, opts.Path) |
| if err != nil { |
| return IssueResult{}, fmt.Errorf("fetch current file: %w", err) |
| } |
| b, status, err = cfg.do(ctx, http.MethodPut, |
| fmt.Sprintf("/repos/%s/contents/%s", cfg.Repo, opts.Path), |
| map[string]string{ |
| "message": "fix: " + opts.Title, |
| "content": base64.StdEncoding.EncodeToString([]byte(opts.NewContent)), |
| "sha": cur.SHA, |
| "branch": branch, |
| }) |
| if err != nil { |
| return IssueResult{}, err |
| } |
| if status >= 300 { |
| return IssueResult{}, fmt.Errorf("commit fix %d: %s", status, string(b)) |
| } |
|
|
| |
| b, status, err = cfg.do(ctx, http.MethodPost, |
| fmt.Sprintf("/repos/%s/pulls", cfg.Repo), |
| map[string]string{"title": opts.Title, "body": opts.Body, "head": branch, "base": "main"}) |
| if err != nil { |
| return IssueResult{}, err |
| } |
| if status >= 300 { |
| return IssueResult{}, fmt.Errorf("open pr %d: %s", status, string(b)) |
| } |
| var pr IssueResult |
| if err := json.Unmarshal(b, &pr); err != nil { |
| return IssueResult{}, fmt.Errorf("parse pr response: %w", err) |
| } |
| return pr, nil |
| } |
|
|