diff --git a/.github/workflows/cr.yml b/.github/workflows/cr.yml new file mode 100644 index 0000000..240ba7c --- /dev/null +++ b/.github/workflows/cr.yml @@ -0,0 +1,19 @@ +name: Code Review + +permissions: + contents: read + pull-requests: write + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + test: +# if: ${{ contains(github.event.*.labels.*.name, 'gpt review') }} # Optional; to run only when a label is attached + runs-on: ubuntu-latest + steps: + - uses: anc95/ChatGPT-CodeReview@main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} \ No newline at end of file diff --git a/README.md b/README.md index b995ecd..87a772b 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,69 @@ -# Pull Request Description Generator +# GPT-PullRequest-Updater -This Go program automates the process of generating GitHub pull request descriptions based on the changes made in each file. It uses OpenAI's GPT-3.5-turbo model to generate the descriptions and Jira issue links based on the PR title. +This repository contains a tool for updating and reviewing GitHub pull requests using OpenAI's GPT language model. The project has two commands: `description` and `review`. The `description` command updates the pull request description with a high-level summary of the changes made. The `review` command creates individual comments for each file and an overall review summary comment. + +## Requirements + +- GitHub token with access to the desired repository +- OpenAI API token ## Installation -To install the program, clone the repository and build the binary: +1. Clone the repository: -```sh -git clone https://github.com/your-repo/pull-request-description-generator.git -cd pull-request-description-generator -go build -``` + ``` + git clone https://github.com/ravilushqa/gpt-pullrequest-updater.git + ``` + +2. Navigate to the project root: + + ``` + cd gpt-pullrequest-updater + ``` + +3. Build the commands: + + ``` + go build -o description ./cmd/description + go build -o review ./cmd/review + ``` ## Usage -Before running the program, you'll need to set the following environment variables: +Before running the commands, make sure you have set the following environment variables: + +- `GITHUB_TOKEN`: Your GitHub token +- `OPENAI_TOKEN`: Your OpenAI API token +- `OWNER`: The owner of the GitHub repository +- `REPO`: The name of the GitHub repository +- `PR_NUMBER`: The pull request number you want to update or review -- `GITHUB_TOKEN`: Your GitHub access token. -- `OPENAI_TOKEN`: Your OpenAI access token. -- `OWNER`: The GitHub owner (username or organization) of the repository. -- `REPO`: The GitHub repository name. -- `PR_NUMBER`: The pull request number. +### Description Command -You can also use flags to provide the required information: +The `description` command updates the pull request description with a high-level summary of the changes made. To run the command, execute: ``` -./pull-request-description-generator --gh-token --openai-token --owner --repo --pr-number +./description ``` -Optional flags: +### Review Command -- `--test`: Test mode. The generated description will be printed to the console without updating the pull request. -- `--skip-files`: Comma-separated list of files to skip when generating the description (default: "go.mod,go.sum,.pb.go"). +The `review` command creates individual comments for each file and an overall review summary comment. To run the command, execute: -After running the program, the pull request description will be updated with the generated content. +``` +./review +``` -## Dependencies +### Test Mode -- [go-flags](https://github.com/jessevdk/go-flags): A Go library for command line flag parsing. -- [go-openai](https://github.com/sashabaranov/go-openai): A Go client for the OpenAI API. +Both commands support a test mode that prints the generated content to the console instead of updating the pull request. To enable test mode, set the `TEST` environment variable to `true`: -## Functions +``` +export TEST=true +``` -- `getDiffContent`: Fetches the diff content from the GitHub API. -- `parseGitDiffAndSplitPerFile`: Parses the git diff and splits it into a slice of FileDiff. -- `getFilenameFromDiffHeader`: Extracts the filename from a diff header. -- `generatePRDescription`: Generates the pull request description using the OpenAI API. -- `getPullRequestTitle`: Fetches the pull request title from the GitHub API. -- `generateJiraLinkByTitle`: Generates a Jira issue link based on the PR title. -- `updatePullRequestDescription`: Updates the pull request description on GitHub. +Then, run the desired command as described above. The generated content will be printed to the console. ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License. \ No newline at end of file diff --git a/cmd/description/README.md b/cmd/description/README.md new file mode 100644 index 0000000..4ac0517 --- /dev/null +++ b/cmd/description/README.md @@ -0,0 +1,64 @@ +# GPT Pull Request Updater + +GPT Pull Request Updater is a command line tool that uses OpenAI's GPT-4 model to generate a comprehensive description of a GitHub pull request, including a summary of code changes. It then updates the pull request description with the generated summary. + +## Prerequisites + +To use the GPT Pull Request Updater, you need the following: +- A GitHub token with repository access +- An OpenAI API token + +## Installation + +1. Clone the repository: + +```sh +git clone https://github.com/ravilushqa/gpt-pullrequest-updater.git +``` + +1. Change to the repository directory: + +```sh +cd gpt-pullrequest-updater +``` + +1. Build the binary: + +```sh +go build -o gpt-pr-updater +``` + +1. Add the binary to your PATH: + +```sh +export PATH=$PATH:$(pwd) +``` + +## Usage + +Run the GPT Pull Request Updater with the following command, providing the required flags: + +```sh +gpt-pr-updater --gh-token GITHUB_TOKEN --openai-token OPENAI_TOKEN --owner OWNER --repo REPO --pr-number PR_NUMBER +``` + +### Flags + +- `--gh-token` (required): Your GitHub token. Can also be set with the `GITHUB_TOKEN` environment variable. +- `--openai-token` (required): Your OpenAI API token. Can also be set with the `OPENAI_TOKEN` environment variable. +- `--owner` (required): GitHub repository owner. Can also be set with the `OWNER` environment variable. +- `--repo` (required): GitHub repository name. Can also be set with the `REPO` environment variable. +- `--pr-number` (required): The number of the pull request to update. Can also be set with the `PR_NUMBER` environment variable. +- `--test` (optional): If set, the tool will print the generated description to the console without updating the pull request. Can also be set with the `TEST` environment variable. + +## Example + +```sh +gpt-pr-updater --gh-token your_github_token --openai-token your_openai_token --owner ravilushqa --repo myrepo --pr-number 42 +``` + +This command will fetch the pull request #42 from the `myrepo` repository, generate a summary of code changes using GPT-4, and update the pull request description with the generated summary. + +## License + +This project is released under the [MIT License](LICENSE). \ No newline at end of file diff --git a/cmd/description/main.go b/cmd/description/main.go new file mode 100644 index 0000000..c3c5ba3 --- /dev/null +++ b/cmd/description/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/google/go-github/v51/github" + "github.com/jessevdk/go-flags" + "github.com/sashabaranov/go-openai" + + ghClient "github.com/ravilushqa/gpt-pullrequest-updater/github" + oAIClient "github.com/ravilushqa/gpt-pullrequest-updater/openai" +) + +var opts struct { + GithubToken string `long:"gh-token" env:"GITHUB_TOKEN" description:"GitHub token" required:"true"` + OpenAIToken string `long:"openai-token" env:"OPENAI_TOKEN" description:"OpenAI token" required:"true"` + Owner string `long:"owner" env:"OWNER" description:"GitHub owner" required:"true"` + Repo string `long:"repo" env:"REPO" description:"GitHub repo" required:"true"` + PRNumber int `long:"pr-number" env:"PR_NUMBER" description:"Pull request number" required:"true"` + Test bool `long:"test" env:"TEST" description:"Test mode"` +} + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if _, err := flags.Parse(&opts); err != nil { + if err.(*flags.Error).Type != flags.ErrHelp { + fmt.Printf("Error parsing flags: %v \n", err) + } + os.Exit(0) + } + + if err := run(ctx); err != nil { + panic(err) + } +} + +func run(ctx context.Context) error { + openAIClient := oAIClient.NewClient(opts.OpenAIToken) + githubClient := ghClient.NewClient(ctx, opts.GithubToken) + + pr, err := githubClient.GetPullRequest(ctx, opts.Owner, opts.Repo, opts.PRNumber) + if err != nil { + return fmt.Errorf("error getting pull request: %w", err) + } + + diff, err := githubClient.CompareCommits(ctx, opts.Owner, opts.Repo, pr.GetBase().GetSHA(), pr.GetHead().GetSHA()) + if err != nil { + return fmt.Errorf("error getting commits: %w", err) + } + + var OverallDescribeCompletion string + OverallDescribeCompletion += fmt.Sprintf("Pull request title: %s, body: %s\n\n", pr.GetTitle(), pr.GetBody()) + for _, file := range diff.Files { + prompt := fmt.Sprintf(oAIClient.PromptDescribeChanges, *file.Patch) + + if len(prompt) > 4096 { + prompt = fmt.Sprintf("%s...", prompt[:4093]) + } + + completion, err := openAIClient.ChatCompletion(ctx, []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleUser, + Content: prompt, + }, + }) + if err != nil { + return fmt.Errorf("error getting review: %w", err) + } + OverallDescribeCompletion += fmt.Sprintf("File: %s \nDescription: %s \n\n", file.GetFilename(), completion) + } + + overallCompletion, err := openAIClient.ChatCompletion(ctx, []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleUser, + Content: fmt.Sprintf(oAIClient.PromptOverallDescribe, OverallDescribeCompletion), + }, + }) + if err != nil { + return fmt.Errorf("error getting overall review: %w", err) + } + + if opts.Test { + fmt.Println(OverallDescribeCompletion) + fmt.Println("=====================================") + fmt.Println(overallCompletion) + + return nil + } + + // Update the pull request description + updatePr := &github.PullRequest{Body: github.String(overallCompletion)} + if _, err = githubClient.UpdatePullRequest(ctx, opts.Owner, opts.Repo, opts.PRNumber, updatePr); err != nil { + return fmt.Errorf("error updating pull request: %w", err) + } + + return nil +} diff --git a/cmd/review/main.go b/cmd/review/main.go new file mode 100644 index 0000000..0f7a948 --- /dev/null +++ b/cmd/review/main.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/google/go-github/v51/github" + "github.com/jessevdk/go-flags" + "github.com/sashabaranov/go-openai" + + ghClient "github.com/ravilushqa/gpt-pullrequest-updater/github" + oAIClient "github.com/ravilushqa/gpt-pullrequest-updater/openai" +) + +var opts struct { + GithubToken string `long:"gh-token" env:"GITHUB_TOKEN" description:"GitHub token" required:"true"` + OpenAIToken string `long:"openai-token" env:"OPENAI_TOKEN" description:"OpenAI token" required:"true"` + Owner string `long:"owner" env:"OWNER" description:"GitHub owner" required:"true"` + Repo string `long:"repo" env:"REPO" description:"GitHub repo" required:"true"` + PRNumber int `long:"pr-number" env:"PR_NUMBER" description:"Pull request number" required:"true"` + Test bool `long:"test" env:"TEST" description:"Test mode"` +} + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if _, err := flags.Parse(&opts); err != nil { + if err.(*flags.Error).Type != flags.ErrHelp { + fmt.Printf("Error parsing flags: %v \n", err) + } + os.Exit(0) + } + + if err := run(ctx); err != nil { + panic(err) + } +} + +func run(ctx context.Context) error { + openAIClient := oAIClient.NewClient(opts.OpenAIToken) + githubClient := ghClient.NewClient(ctx, opts.GithubToken) + + pr, err := githubClient.GetPullRequest(ctx, opts.Owner, opts.Repo, opts.PRNumber) + if err != nil { + return fmt.Errorf("error getting pull request: %w", err) + } + + diff, err := githubClient.CompareCommits(ctx, opts.Owner, opts.Repo, pr.GetBase().GetSHA(), pr.GetHead().GetSHA()) + if err != nil { + return fmt.Errorf("error getting commits: %w", err) + } + + var OverallReviewCompletion string + for _, file := range diff.Files { + if file.GetStatus() == "removed" || file.GetStatus() == "renamed" { + continue + } + + prompt := fmt.Sprintf(oAIClient.PromptReview, *file.Patch) + + if len(prompt) > 4096 { + prompt = fmt.Sprintf("%s...", prompt[:4093]) + } + + completion, err := openAIClient.ChatCompletion(ctx, []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleUser, + Content: prompt, + }, + }) + if err != nil { + return fmt.Errorf("error getting review: %w", err) + } + OverallReviewCompletion += fmt.Sprintf("File: %s \nReview: %s \n\n", file.GetFilename(), completion) + + position := len(strings.Split(*file.Patch, "\n")) - 1 + + comment := &github.PullRequestComment{ + CommitID: diff.Commits[len(diff.Commits)-1].SHA, + Path: file.Filename, + Body: &completion, + Position: &position, + } + + if opts.Test { + continue + } + + if _, err := githubClient.CreatePullRequestComment(ctx, opts.Owner, opts.Repo, opts.PRNumber, comment); err != nil { + return fmt.Errorf("error creating comment: %w", err) + } + } + + overallCompletion, err := openAIClient.ChatCompletion(ctx, []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleUser, + Content: fmt.Sprintf(oAIClient.PromptOverallReview, OverallReviewCompletion), + }, + }) + if err != nil { + return fmt.Errorf("error getting overall review: %w", err) + } + + if opts.Test { + fmt.Println(OverallReviewCompletion) + fmt.Println("=====================================") + fmt.Println(overallCompletion) + + return nil + } + + comment := &github.PullRequestReviewRequest{Body: &overallCompletion} + if _, err = githubClient.CreateReview(ctx, opts.Owner, opts.Repo, opts.PRNumber, comment); err != nil { + return fmt.Errorf("error creating comment: %w", err) + } + + return nil +} diff --git a/github/github.go b/github/github.go new file mode 100644 index 0000000..e65691f --- /dev/null +++ b/github/github.go @@ -0,0 +1,56 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v51/github" + "golang.org/x/oauth2" +) + +type Client struct { + client *github.Client +} + +func NewClient(ctx context.Context, token string) *Client { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(ctx, ts) + + return &Client{github.NewClient(tc)} +} + +func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, prNumber int) ([]*github.CommitFile, error) { + files, _, err := c.client.PullRequests.ListFiles(ctx, owner, repo, prNumber, nil) + return files, err +} + +func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*github.PullRequest, error) { + pr, _, err := c.client.PullRequests.Get(ctx, owner, repo, number) + return pr, err +} + +func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) { + diff, _, err := c.client.PullRequests.GetRaw(ctx, owner, repo, number, github.RawOptions{Type: github.Diff}) + return diff, err +} + +func (c *Client) UpdatePullRequest(ctx context.Context, owner, repo string, number int, pr *github.PullRequest) (*github.PullRequest, error) { + updatedPR, _, err := c.client.PullRequests.Edit(ctx, owner, repo, number, pr) + return updatedPR, err +} + +func (c *Client) CreatePullRequestComment(ctx context.Context, owner, repo string, number int, comment *github.PullRequestComment) (*github.PullRequestComment, error) { + createdComment, _, err := c.client.PullRequests.CreateComment(ctx, owner, repo, number, comment) + return createdComment, err +} + +func (c *Client) CreateReview(ctx context.Context, owner, repo string, number int, comment *github.PullRequestReviewRequest) (*github.PullRequestReview, error) { + createdReview, _, err := c.client.PullRequests.CreateReview(ctx, owner, repo, number, comment) + return createdReview, err +} + +func (c *Client) CompareCommits(ctx context.Context, owner, repo, base, head string) (*github.CommitsComparison, error) { + comp, _, err := c.client.Repositories.CompareCommits(ctx, owner, repo, base, head, nil) + return comp, err +} diff --git a/go.mod b/go.mod index 4fc11d7..3bd10c8 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,20 @@ module github.com/ravilushqa/gpt-pullrequest-updater go 1.19 require ( + github.com/google/go-github/v51 v51.0.0 github.com/jessevdk/go-flags v1.5.0 github.com/sashabaranov/go-openai v1.7.0 ) -require golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 // indirect +require ( + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/cloudflare/circl v1.1.0 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/oauth2 v0.6.0 // indirect + golang.org/x/sys v0.6.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.28.0 // indirect +) diff --git a/go.sum b/go.sum index 34ee409..a4812bc 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,49 @@ +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-github/v51 v51.0.0 h1:KCjsbgPV28VoRftdP+K2mQL16jniUsLAJknsOVKwHyU= +github.com/google/go-github/v51 v51.0.0/go.mod h1:kZj/rn/c1lSUbr/PFWl2hhusPV7a5XNYKcwPrd5L3Us= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/sashabaranov/go-openai v1.7.0 h1:D1dBXoZhtf/aKNu6WFf0c7Ah2NM30PZ/3Mqly6cZ7fk= github.com/sashabaranov/go-openai v1.7.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4 h1:EZ2mChiOa8udjfp6rRmswTbtZN/QzUQp4ptM4rnjHvc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= diff --git a/main.go b/main.go deleted file mode 100644 index 15f80ff..0000000 --- a/main.go +++ /dev/null @@ -1,276 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - - "github.com/jessevdk/go-flags" - "github.com/sashabaranov/go-openai" -) - -var opts struct { - GithubToken string `long:"gh-token" env:"GITHUB_TOKEN" description:"GitHub token" required:"true"` - OpenAIToken string `long:"openai-token" env:"OPENAI_TOKEN" description:"OpenAI token" required:"true"` - Owner string `long:"owner" env:"OWNER" description:"GitHub owner" required:"true"` - Repo string `long:"repo" env:"REPO" description:"GitHub repo" required:"true"` - PRNumber int `long:"pr-number" env:"PR_NUMBER" description:"Pull request number" required:"true"` - Test bool `long:"test" env:"TEST" description:"Test mode"` - SkipFiles string `long:"skip-files" env:"SKIP_FILES" description:"Skip files. Comma separated list" default:"go.mod,go.sum,.pb.go"` -} - -// FileDiff represents a single file diff. -type FileDiff struct { - Header string - Diff string -} - -func main() { - if _, err := flags.Parse(&opts); err != nil { - if err.(*flags.Error).Type != flags.ErrHelp { - fmt.Printf("Error parsing flags: %v \n", err) - } - os.Exit(0) - } - openaiClient := openai.NewClient(opts.OpenAIToken) - - diff, err := getDiffContent(opts.GithubToken, opts.Owner, opts.Repo, opts.PRNumber) - if err != nil { - fmt.Printf("Error fetching diff content: %v\n", err) - return - } - filesDiff, err := parseGitDiffAndSplitPerFile(diff) - if err != nil { - return - } - - var messages []openai.ChatCompletionMessage - prompt := fmt.Sprintf( - "Generate a GitHub pull request description based on the following changes " + - "without basic prefix in markdown format with ###Description and ###Changes blocks:\n", - ) - messages = append(messages, openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleUser, - Content: prompt, - }) - for _, fileDiff := range filesDiff { - fileName := getFilenameFromDiffHeader(fileDiff.Header) - - isSkipped := false - for _, skipFile := range strings.Split(opts.SkipFiles, ",") { - if strings.Contains(fileName, skipFile) { - isSkipped = true - break - } - } - if isSkipped { - continue - } - - prompt := fmt.Sprintf("File %s:\n%s\n%s\n", fileName, fileDiff.Header, fileDiff.Diff) - messages = append(messages, openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleUser, - Content: prompt, - }) - } - chatGPTDescription, err := generatePRDescription(openaiClient, messages) - if err != nil { - fmt.Printf("Error generating pull request description: %v\n", err) - return - } - - title, err := getPullRequestTitle(opts.GithubToken, opts.Owner, opts.Repo, opts.PRNumber) - if err != nil { - return - } - - jiraLink := generateJiraLinkByTitle(title) - - description := fmt.Sprintf("### Jira\n%s\n%s", jiraLink, chatGPTDescription) - if opts.Test { - fmt.Println(description) - os.Exit(0) - } - // Update the pull request with the generated description - err = updatePullRequestDescription(opts.GithubToken, opts.Owner, opts.Repo, opts.PRNumber, description) - if err != nil { - fmt.Printf("Error updating pull request description: %v\n", err) - return - } - - fmt.Println("Pull request description updated successfully") -} - -func getDiffContent(token, owner, repo string, prNumber int) (string, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls/%d", owner, repo, prNumber) - method := "GET" - - client := &http.Client{} - req, err := http.NewRequest(method, url, nil) - - if err != nil { - fmt.Println(err) - return "", err - } - req.Header.Add("Accept", "application/vnd.github.v3.diff") - req.Header.Add("Authorization", fmt.Sprintf("token %s", token)) - //req.Header.Add("Cookie", "logged_in=no") - - res, err := client.Do(req) - if err != nil { - return "", err - } - defer res.Body.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - return "", err - } - - return string(body), nil -} - -// parseGitDiffAndSplitPerFile parses a git diff and splits it into a slice of FileDiff. -func parseGitDiffAndSplitPerFile(diff string) ([]FileDiff, error) { - lines := strings.Split(diff, "\n") - var fileDiffs []FileDiff - - inFileDiff := false - var currentFileDiff FileDiff - for _, line := range lines { - if strings.HasPrefix(line, "diff --git") { - if inFileDiff { - fileDiffs = append(fileDiffs, currentFileDiff) - } - currentFileDiff = FileDiff{Header: line} - inFileDiff = true - } else if inFileDiff { - currentFileDiff.Diff += line + "\n" - } - } - if inFileDiff { - fileDiffs = append(fileDiffs, currentFileDiff) - } - - return fileDiffs, nil -} - -func getFilenameFromDiffHeader(diffHeader string) string { - // Split the diff header into individual lines - lines := strings.Split(diffHeader, "\n") - - // Extract the filename from the "diff --git" line - gitDiffLine := lines[0] - parts := strings.Split(gitDiffLine, " ") - oldFileName := strings.TrimPrefix(parts[2], "a/") - newFileName := strings.TrimPrefix(parts[3], "b/") - - // Return the new filename if it exists, otherwise return the old filename - if newFileName != "/dev/null" { - return newFileName - } else { - return oldFileName - } -} - -func generatePRDescription(client *openai.Client, messages []openai.ChatCompletionMessage) (string, error) { - resp, err := client.CreateChatCompletion( - context.Background(), - openai.ChatCompletionRequest{ - Model: openai.GPT3Dot5Turbo, - Messages: messages, - }, - ) - - if err != nil { - fmt.Printf("ChatCompletion error: %v\n", err) - return "", err - } - - return resp.Choices[0].Message.Content, nil -} - -func getPullRequestTitle(token, owner, repo string, prNumber int) (string, error) { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls/%d", owner, repo, prNumber) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "", err - } - - req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) - req.Header.Set("Accept", "application/vnd.github.v3+json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("Failed to fetch pull request details. Status code: %d", resp.StatusCode) - } - - var pr struct { - Title string `json:"title"` - } - - err = json.NewDecoder(resp.Body).Decode(&pr) - if err != nil { - return "", err - } - - return pr.Title, nil -} - -func generateJiraLinkByTitle(title string) string { - //NCR-1234 - issueKey := strings.ToUpper(strings.Split(title, " ")[0]) - if !strings.HasPrefix(issueKey, "NCR-") { - return "" - } - jiraBaseURL := "https://jira.deliveryhero.com/browse/" - - return fmt.Sprintf("[%s](%s%s)", issueKey, jiraBaseURL, issueKey) -} - -func updatePullRequestDescription(token string, o string, r string, number int, description string) error { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls/%d", o, r, number) - - data := map[string]string{ - "body": description, - } - - payload, err := json.Marshal(data) - if err != nil { - return err - } - - req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(payload)) - if err != nil { - return err - } - - req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) - req.Header.Set("Accept", "application/vnd.github.v3+json") - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to update pull request description. Status code: %d", resp.StatusCode) - } - - return nil -} diff --git a/openai/openai.go b/openai/openai.go new file mode 100644 index 0000000..4ec4d85 --- /dev/null +++ b/openai/openai.go @@ -0,0 +1,42 @@ +package openai + +import ( + "context" + "fmt" + + "github.com/sashabaranov/go-openai" +) + +const ( + PromptDescribeChanges = "Bellow is the code patch, Generate a GitHub pull request description based on the following comments without basic prefix\n%s\n" + PromptOverallDescribe = "Bellow comments are generated by AI, Generate a GitHub pull request description based on the following comments without basic prefix in markdown format with ### Description and ### Changes blocks:\n%s\n" + PromptReview = "Bellow is the code patch, please help me do a brief code review,Answer me in English, if any bug risk and improvement suggestion are welcome\n%s\n" + PromptOverallReview = "Bellow comments are generated by AI, please help me do a brief code review,Answer me in English, if any bug risk and improvement suggestion are welcome\n%s\n" +) + +type Client struct { + client *openai.Client +} + +func NewClient(token string) *Client { + return &Client{ + client: openai.NewClient(token), + } +} + +func (o *Client) ChatCompletion(ctx context.Context, messages []openai.ChatCompletionMessage) (string, error) { + resp, err := o.client.CreateChatCompletion( + ctx, + openai.ChatCompletionRequest{ + Model: openai.GPT3Dot5Turbo, + Messages: messages, + }, + ) + + if err != nil { + fmt.Printf("ChatCompletion error: %v\n", err) + return "", err + } + + return resp.Choices[0].Message.Content, nil +}