mirror of
https://github.com/Pull-Pal/pull-pal.git
synced 2024-11-12 23:37:02 -05:00
726e34d093
Only comments in the configured "users to listen to" are listed Still need to add additional filtering in the future
341 lines
8.3 KiB
Go
341 lines
8.3 KiB
Go
package vc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"go/format"
|
|
"io/ioutil"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mobyvb/pull-pal/llm"
|
|
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/config"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
|
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
"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
|
|
|
|
worktree *git.Worktree
|
|
}
|
|
|
|
// 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)
|
|
|
|
// clone provided repository to local path
|
|
if repo.LocalPath == "" {
|
|
return nil, errors.New("local path to clone repository not provided")
|
|
}
|
|
|
|
if repo.LocalPath != "" {
|
|
// remove local repo if it exists already
|
|
err := os.RemoveAll(repo.LocalPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
log.Info("Cloning repository locally...", zap.String("local repo path", repo.LocalPath), zap.String("url", repo.SSH()))
|
|
// 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.Info("failed")
|
|
return nil, err
|
|
}
|
|
repo.localRepo = localRepo
|
|
|
|
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) (id, url string, err error) {
|
|
// TODO handle gc.ctx canceled
|
|
|
|
title := req.Subject
|
|
if title == "" {
|
|
title = "update files"
|
|
}
|
|
branchName := randomBranchName()
|
|
branchRefName := plumbing.NewBranchReferenceName(branchName)
|
|
baseBranch := "main"
|
|
remoteName := "origin"
|
|
body := res.Notes
|
|
body += fmt.Sprintf("\n\nResolves #%s", req.IssueID)
|
|
|
|
// Create new local branch
|
|
headRef, err := gc.repo.localRepo.Head()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
err = gc.repo.localRepo.CreateBranch(&config.Branch{
|
|
Name: branchName,
|
|
Remote: remoteName,
|
|
Merge: branchRefName,
|
|
})
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
// Update the branch to point to the new commit
|
|
err = gc.repo.localRepo.Storer.SetReference(plumbing.NewHashReference(branchRefName, headRef.Hash()))
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
// Push the new branch to the remote repository
|
|
remote, err := gc.repo.localRepo.Remote(remoteName)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
err = remote.Push(&git.PushOptions{
|
|
RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("%s:refs/heads/%s", branchRefName, branchName))},
|
|
Auth: &http.BasicAuth{
|
|
Username: gc.self.Handle,
|
|
Password: gc.self.Token,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
// 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: &branchName,
|
|
Base: &baseBranch,
|
|
Body: &body,
|
|
})
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
url = pr.GetHTMLURL()
|
|
id = strconv.Itoa(int(pr.GetID()))
|
|
|
|
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(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{
|
|
ID: strconv.Itoa(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
|
|
}
|
|
|
|
// ListOpenComments lists unresolved comments in the Github repository.
|
|
func (gc *GithubClient) ListOpenComments(options ListCommentOptions) ([]Comment, error) {
|
|
toReturn := []Comment{}
|
|
prNumber, err := strconv.Atoi(options.ChangeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
comments, _, err := gc.client.PullRequests.ListComments(gc.ctx, gc.repo.Owner.Handle, gc.repo.Name, prNumber, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// TODO: filter out comments that "self" has already replied to
|
|
// TODO: ignore resolved comments
|
|
for _, c := range comments {
|
|
commentUser := c.GetUser().GetLogin()
|
|
allowedUser := false
|
|
for _, u := range options.Handles {
|
|
if commentUser == u {
|
|
allowedUser = true
|
|
break
|
|
}
|
|
}
|
|
if !allowedUser {
|
|
continue
|
|
}
|
|
|
|
nextComment := Comment{
|
|
ID: strconv.FormatInt(c.GetID(), 10),
|
|
ChangeID: options.ChangeID,
|
|
URL: c.GetHTMLURL(),
|
|
Author: Author{
|
|
Email: c.GetUser().GetEmail(),
|
|
Handle: c.GetUser().GetLogin(),
|
|
},
|
|
Body: c.GetBody(),
|
|
Position: c.GetPosition(),
|
|
DiffHunk: c.GetDiffHunk(),
|
|
}
|
|
toReturn = append(toReturn, nextComment)
|
|
}
|
|
|
|
return toReturn, nil
|
|
}
|
|
|
|
// GetLocalFile gets the current representation of the file at the provided path from the local git repo.
|
|
func (gc *GithubClient) GetLocalFile(path string) (llm.File, error) {
|
|
fullPath := filepath.Join(gc.repo.LocalPath, path)
|
|
|
|
data, err := ioutil.ReadFile(fullPath)
|
|
if err != nil {
|
|
// if file doesn't exist, just return an empty file
|
|
// this means we want to prompt the llm to populate it for the first time
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return llm.File{
|
|
Path: path,
|
|
Contents: "",
|
|
}, nil
|
|
}
|
|
return llm.File{}, err
|
|
}
|
|
|
|
return llm.File{
|
|
Path: path,
|
|
Contents: string(data),
|
|
}, nil
|
|
}
|
|
|
|
// StartCommit creates a new worktree associated with this Github client.
|
|
func (gc *GithubClient) StartCommit() error {
|
|
if gc.worktree != nil {
|
|
return errors.New("worktree is not nil - cannot start a new commit")
|
|
}
|
|
|
|
worktree, err := gc.repo.localRepo.Worktree()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
gc.worktree = worktree
|
|
|
|
return nil
|
|
}
|
|
|
|
// ReplaceOrAddLocalFile updates or adds a file in the locally cloned repo, and applies these changes to the current git worktree.
|
|
func (gc *GithubClient) ReplaceOrAddLocalFile(newFile llm.File) error {
|
|
if gc.worktree == nil {
|
|
return errors.New("worktree is nil - StartCommit must be called")
|
|
}
|
|
|
|
// TODO format non-go files as well
|
|
if strings.HasSuffix(newFile.Path, ".go") {
|
|
newContents, err := format.Source([]byte(newFile.Contents))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newFile.Contents = string(newContents)
|
|
}
|
|
|
|
fullPath := filepath.Join(gc.repo.LocalPath, newFile.Path)
|
|
|
|
err := ioutil.WriteFile(fullPath, []byte(newFile.Contents), 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = gc.worktree.Add(newFile.Path)
|
|
|
|
return err
|
|
}
|
|
|
|
// FinishCommit completes a commit, after which a code change request can be opened or updated.
|
|
func (gc *GithubClient) FinishCommit(message string) error {
|
|
if gc.worktree == nil {
|
|
return errors.New("worktree is nil - StartCommit must be called")
|
|
}
|
|
_, err := gc.worktree.Commit(message, &git.CommitOptions{
|
|
Author: &object.Signature{
|
|
Name: gc.self.Handle,
|
|
Email: gc.self.Email,
|
|
When: time.Now(),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// set worktree to nil so a new commit can be started
|
|
gc.worktree = nil
|
|
|
|
return nil
|
|
}
|