pull-pal/vc/github.go

139 lines
3.8 KiB
Go
Raw Normal View History

package vc
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strconv"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/google/go-github/github"
"github.com/mobyvb/pull-pal/llm"
"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.Debug("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)
// clone provided repository to local path
if repo.LocalPath == "" {
return nil, errors.New("local path to clone repository not provided")
}
log.Debug("Cloning repository locally...")
// TODO this can be done in-memory - see https://pkg.go.dev/github.com/go-git/go-git/v5#readme-in-memory-example
localRepo, err := git.PlainClone(repo.LocalPath, false, &git.CloneOptions{
URL: repo.SSH(),
// URL: repo.HTTPS(),
Auth: &http.BasicAuth{
Username: self.Handle,
Password: self.Token,
},
})
if err != nil {
log.Error("Failed to clone local repo.", zap.Error(err))
return nil, err
}
repo.localRepo = localRepo
log.Debug("Success. Github client set up.")
return &GithubClient{
ctx: ctx,
log: log,
client: github.NewClient(tc),
self: self,
repo: repo,
}, nil
}
// OpenCodeChangeRequest opens a new PR on Github based on the provided LLM request and response.
func (gc *GithubClient) OpenCodeChangeRequest(req llm.CodeChangeRequest, res llm.CodeChangeResponse) (id, url string, err error) {
// TODO handle gc.ctx canceled
gc.log.Debug("Creating a new pull request...")
title := req.Subject
branchName := randomBranchName()
baseBranch := "main"
body := res.Notes
body += fmt.Sprintf("\n\nResolves #%s", req.IssueID)
issue, err := strconv.Atoi(req.IssueID)
if err != nil {
gc.log.Error("Failed to parse issue ID from code change request as integer", zap.String("provided issue ID", req.IssueID), zap.Error(err))
return "", "", err
}
pr, _, err := gc.client.PullRequests.Create(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, &github.NewPullRequest{
Title: &title,
Head: &branchName,
Base: &baseBranch,
Body: &body,
Issue: &issue,
})
if err != nil {
gc.log.Error("Failed to create pull request", zap.Error(err))
return "", "", err
}
url = pr.GetHTMLURL()
id = strconv.Itoa(int(pr.GetID()))
gc.log.Info("Successfully created pull request.", zap.String("ID", id), zap.String("URL", url))
return id, url, nil
}
func randomBranchName() string {
bytes := make([]byte, 4)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// ListOpenIssues lists unresolved issues in the Github repository.
func (gc *GithubClient) ListOpenIssues() ([]Issue, error) {
// List and parse GitHub issues
issues, _, err := gc.client.Issues.ListByRepo(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, nil)
if err != nil {
gc.log.Error("Failed to list issues", zap.Error(err))
return nil, err
}
toReturn := make([]Issue, len(issues))
for _, issue := range issues {
nextIssue := Issue{
ID: strconv.Itoa(int(issue.GetID())),
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
}