pull-pal/vc/github.go

236 lines
5.7 KiB
Go

package vc
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/mobyvb/pull-pal/llm"
"github.com/google/go-github/github"
"go.uber.org/zap"
"golang.org/x/oauth2"
)
// GithubClient implements the VCClient interface.
type GithubClient struct {
ctx context.Context
log *zap.Logger
client *github.Client
self Author
repo Repository
}
// NewGithubClient initializes a Github client and checks out a repository locally.
func NewGithubClient(ctx context.Context, log *zap.Logger, self Author, repo Repository) (*GithubClient, error) {
log.Info("Creating new Github client...")
if self.Token == "" {
return nil, errors.New("Github access token not provided")
}
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: self.Token},
)
// oauth client is used to list issues, open pull requests, etc...
tc := oauth2.NewClient(ctx, ts)
log.Info("Success. Github client set up.")
return &GithubClient{
ctx: ctx,
log: log,
client: github.NewClient(tc),
self: self,
repo: repo,
}, nil
}
// OpenCodeChangeRequest pushes to a new remote branch and opens a PR on Github.
func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm.CodeChangeResponse, fromBranch string) (id, url string, err error) {
// TODO handle gc.ctx canceled
title := req.Subject
if title == "" {
title = "update files"
}
body := res.Notes
body += fmt.Sprintf("\n\nResolves #%d", req.IssueNumber)
// Finally, open a pull request from the new branch.
pr, _, err := gc.client.PullRequests.Create(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, &github.NewPullRequest{
Title: &title,
Head: &fromBranch,
Base: &req.BaseBranch,
Body: &body,
})
if err != nil {
return "", "", err
}
url = pr.GetHTMLURL()
id = strconv.Itoa(int(pr.GetID()))
return id, url, nil
}
// ListOpenIssues lists unresolved issues in the Github repository.
func (gc *GithubClient) ListOpenIssues(options ListIssueOptions) ([]Issue, error) {
// List and parse GitHub issues
opt := &github.IssueListByRepoOptions{
Labels: options.Labels,
}
issues, _, err := gc.client.Issues.ListByRepo(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, opt)
if err != nil {
return nil, err
}
toReturn := []Issue{}
for _, issue := range issues {
issueUser := issue.GetUser().GetLogin()
allowedUser := false
for _, u := range options.Handles {
if issueUser == u {
allowedUser = true
break
}
}
if !allowedUser {
continue
}
nextIssue := Issue{
Number: issue.GetNumber(),
Subject: issue.GetTitle(),
Body: issue.GetBody(),
URL: issue.GetHTMLURL(),
Author: Author{
Email: issue.GetUser().GetEmail(),
Handle: issue.GetUser().GetLogin(),
},
}
toReturn = append(toReturn, nextIssue)
}
return toReturn, nil
}
// CommentOnIssue adds a comment to the issue provided.
func (gc *GithubClient) CommentOnIssue(issueNumber int, comment string) error {
ghComment := &github.IssueComment{
Body: github.String(comment),
}
_, _, err := gc.client.Issues.CreateComment(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, issueNumber, ghComment)
return err
}
// RemoveLabelFromIssue removes the provided label from an issue if that label is applied.
func (gc *GithubClient) RemoveLabelFromIssue(issueNumber int, label string) error {
hasLabel := false
labels, _, err := gc.client.Issues.ListLabelsByIssue(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, issueNumber, nil)
if err != nil {
return err
}
for _, l := range labels {
if l.GetName() == label {
hasLabel = true
break
}
}
if hasLabel {
_, err = gc.client.Issues.RemoveLabelForIssue(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, issueNumber, label)
return err
}
return nil
}
// ListOpenComments lists unresolved comments in the Github repository.
func (gc *GithubClient) ListOpenComments(options ListCommentOptions) ([]Comment, error) {
prs, _, err := gc.client.PullRequests.List(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, nil)
if err != nil {
return nil, err
}
allComments := []Comment{}
repliedTo := make(map[int64]bool)
for _, pr := range prs {
if pr.GetUser().GetLogin() != gc.self.Handle {
continue
}
branch := ""
if pr.Head != nil {
branch = pr.Head.GetLabel()
if strings.Contains(branch, ":") {
branch = strings.Split(branch, ":")[1]
}
}
comments, _, err := gc.client.PullRequests.ListComments(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, pr.GetNumber(), nil)
if err != nil {
return nil, err
}
for _, c := range comments {
commentUser := c.GetUser().GetLogin()
if commentUser == gc.self.Handle {
repliedTo[c.GetInReplyTo()] = true
}
allowedUser := false
for _, u := range options.Handles {
if commentUser == u {
allowedUser = true
break
}
}
if !allowedUser {
continue
}
nextComment := Comment{
ID: c.GetID(),
ChangeID: strconv.Itoa(pr.GetNumber()),
URL: c.GetHTMLURL(),
Author: Author{
Email: c.GetUser().GetEmail(),
Handle: c.GetUser().GetLogin(),
},
Body: c.GetBody(),
FilePath: c.GetPath(),
Position: c.GetPosition(),
DiffHunk: c.GetDiffHunk(),
Branch: branch,
PRNumber: pr.GetNumber(),
}
allComments = append(allComments, nextComment)
}
}
// remove any comments that bot has replied to already from the list
toReturn := []Comment{}
for _, c := range allComments {
if !repliedTo[c.ID] {
toReturn = append(toReturn, c)
}
}
return toReturn, nil
}
// RespondToComment adds a comment to the provided thread.
func (gc *GithubClient) RespondToComment(prNumber int, commentID int64, comment string) error {
_, _, err := gc.client.PullRequests.CreateCommentInReplyTo(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, prNumber, comment, commentID)
if err != nil {
return err
}
return err
}