Skip to content

Commit

Permalink
Merge pull request #1 from owenrumney/initial-commit
Browse files Browse the repository at this point in the history
Initial commit
owenrumney authored Nov 7, 2020
2 parents 839d7db + 39fe984 commit 9f81078
Showing 10 changed files with 373 additions and 212 deletions.
77 changes: 76 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,77 @@
# go-github-pr-commenter
Library for adding comments to github PRs

## What is it?

A convenience libary that wraps the [go-github](https://github.com/google/go-github) library and allows you to quickly add comments to the lines of changes in a comment.

The intention is this is used with CI tools to automatically comment on new Github pull requests when static analysis checks are failing.

For an example of this in use, see the [tfsec-pr-commenter-action](https://github.com/tfsec/tfsec-pr-commenter-action). This Github action will run against your Terraform and report any security issues that are present in the code before it is committed.

## How do I use it?

The intention is to keep the interface as clean as possible; steps are

- create a commenter for a repo and PR
- write comments to the commenter
- comments which exist will not be written
- comments that aren't appropriate (not part of the PR) will not be written

### Expected Errors

The following errors can be handled - I hope these are self explanatory

```go
type PrDoesNotExistError struct {
owner string
repo string
prNumber int
}

type NotPartOfPrError struct {
filepath string
}

type CommentAlreadyWrittenError struct {
filepath string
comment string
}

type CommentNotValidError struct {
filepath string
lineNo int
}
```

### Basic Usage Example

```go
package main

import (
commenter "github.com/owenrumney/go-github-pr-commenter"
log "github.com/sirupsen/logrus"
"os"
)


// Create the commenter
token := os.Getenv("GITHUB_TOKEN")

c, err := commenter.NewCommenter(token, "tfsec", "tfsec-example-project", 8)
if err != nil {
fmt.Println(err.Error())
}

// process whatever static analysis results you've gathered
for _, result := myResults {
err = c.WriteMultiLineComment(result.Path, result.Comment, result.StartLine, result.EndLine)
if err != nil {
if errors.Is(err, commenter.CommentNotValidError{}) {
log.Debugf("result not relevant for commit. %s", err.Error())
} else {
log.Errorf("an error occurred writing the comment: %s", err.Error())
}
}
}
```
27 changes: 0 additions & 27 deletions commentBlock.go

This file was deleted.

60 changes: 0 additions & 60 deletions comment_block_stage_test.go

This file was deleted.

58 changes: 0 additions & 58 deletions comment_block_test.go

This file was deleted.

187 changes: 122 additions & 65 deletions commenter.go
Original file line number Diff line number Diff line change
@@ -1,38 +1,26 @@
package go_github_pr_commenter

import (
"context"
"errors"
"fmt"
"github.com/google/go-github/v32/github"
"golang.org/x/oauth2"
"regexp"
"strconv"
"strings"
)

type githubConnector struct {
client *github.Client
owner string
repo string
prNumber int
}

type commenter struct {
connector *githubConnector
}

type CommitFileInfo struct {
fileName string
hunkStart int
hunkEnd int
sha string
pr *connector
existingComments []*existingComment
files []*CommitFileInfo
loaded bool
}

var patchRegex *regexp.Regexp
var commitRefRegex *regexp.Regexp

func New(owner, repo, token string, prNumber int) (*commenter, error) {
// NewCommenter creates a commenter for updating PR with comments
func NewCommenter(token, owner, repo string, prNumber int) (*commenter, error) {
regex, err := regexp.Compile("^@@.*\\+(\\d+),(\\d+).+?@@")
if err != nil {
return nil, err
@@ -49,77 +37,146 @@ func New(owner, repo, token string, prNumber int) (*commenter, error) {
return nil, errors.New("the INPUT_GITHUB_TOKEN has not been set")
}

client := createClient(token)
connector := createConnector(token, owner, repo, prNumber)

return &commenter{
connector: &githubConnector{
client: client,
owner: owner,
repo: repo,
prNumber: prNumber,
},
}, nil
if !connector.prExists() {
return nil, newPrDoesNotExistError(connector)
}

c := &commenter{
pr: connector,
}
return c, nil
}

func (gc *commenter) GetCommitFileInfo() ([]*CommitFileInfo, error) {
prFiles, err := gc.getFilesForPr()
// WriteMultiLineComment writes a multiline review on a file in the github PR
func (c *commenter) WriteMultiLineComment(file, comment string, startLine, endLine int) error {
if !c.loaded {
err := c.loadPr()
if err != nil {
return err
}
}

if !c.checkCommentRelevant(file, startLine) {
return newCommentNotValidError(file, startLine)
}

if startLine == endLine {
return c.WriteLineComment(file, comment, endLine)
}

info, err := c.getFileInfo(file, endLine)
if err != nil {
return nil, err
return err
}

prComment := buildComment(file, comment, endLine, *info)
prComment.StartLine = &startLine
return c.writeCommentIfRequired(prComment)
}

// WriteLineComment writes a single review line on a file of the github PR
func (c *commenter) WriteLineComment(file, comment string, line int) error {
if !c.loaded {
err := c.loadPr()
if err != nil {
return err
}
}

if !c.checkCommentRelevant(file, line) {
return newCommentNotValidError(file, line)
}

info, err := c.getFileInfo(file, line)
if err != nil {
return err
}
prComment := buildComment(file, comment, line, *info)
return c.writeCommentIfRequired(prComment)
}

func (c *commenter) writeCommentIfRequired(prComment *github.PullRequestComment) error {
for _, existing := range c.existingComments {
err := func(ec *existingComment) error {
if *ec.filename == *prComment.Path && *ec.comment == *prComment.Body {
return newCommentAlreadyWrittenError(*existing.filename, *existing.comment)
}
return nil
}(existing)
if err != nil {
return err
}

}
return c.pr.writeReviewComment(prComment)
}

func (c *commenter) getCommitFileInfo() error {
prFiles, err := c.pr.getFilesForPr()
if err != nil {
return err
}
var commitFileInfos []*CommitFileInfo
var errs []string
for _, file := range prFiles {
info, err := getCommitInfo(file)
if err != nil {
errs = append(errs, err.Error())
continue
}
commitFileInfos = append(commitFileInfos, info)
c.files = append(c.files, info)
}
if len(errs) > 0 {
return commitFileInfos, errors.New(fmt.Sprintf("there were errors processing the PR files.\n%s", strings.Join(errs, "\n")))
return errors.New(fmt.Sprintf("there were errors processing the PR files.\n%s", strings.Join(errs, "\n")))
}
return commitFileInfos, nil
}

func createClient(token string) *github.Client {
ctx := context.Background()
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
tc := oauth2.NewClient(ctx, ts)

return github.NewClient(tc)
return nil
}

func (gc *commenter) getFilesForPr() ([]*github.CommitFile, error) {
connector := gc.connector
ctx := context.Background()
func (c *commenter) loadPr() error {
err := c.getCommitFileInfo()
if err != nil {
return err
}

files, _, err := connector.client.PullRequests.ListFiles(ctx, connector.owner, connector.repo, connector.prNumber, nil)
c.existingComments, err = c.pr.getExistingComments()
if err != nil {
return nil, err
return err
}
var commitFiles []*github.CommitFile
for _, file := range files {
if *file.Status != "deleted" {
commitFiles = append(commitFiles, file)
c.loaded = true
return nil
}

func (c *commenter) checkCommentRelevant(filename string, line int) bool {
for _, file := range c.files {
if file.FileName == filename {
if line > file.hunkStart && line < file.hunkEnd {
return true
}
}
}
return commitFiles, nil
return false
}

func (gc *commenter) getExistingComments() ([]string, error) {
connector := gc.connector
ctx := context.Background()

var bodies []string
comments, _, err := connector.client.PullRequests.ListComments(ctx, connector.owner, connector.repo, connector.prNumber, &github.PullRequestListCommentsOptions{})
if err != nil {
return nil, err
func (c *commenter) getFileInfo(file string, line int) (*CommitFileInfo, error) {
for _, info := range c.files {
if info.FileName == file {
if line > info.hunkStart && line < info.hunkEnd {
return info, nil
}
}
}
for _, comment := range comments {
bodies = append(bodies, comment.GetBody())
return nil, newNotPartOfPrError(file)
}

func buildComment(file, comment string, line int, info CommitFileInfo) *github.PullRequestComment {
return &github.PullRequestComment{
Line: &line,
Path: &file,
CommitID: &info.sha,
Body: &comment,
Position: info.CalculatePosition(line),
}
return bodies, nil
}

func getCommitInfo(file *github.CommitFile) (*CommitFileInfo, error) {
@@ -137,9 +194,9 @@ func getCommitInfo(file *github.CommitFile) (*CommitFileInfo, error) {
sha := shaGroups[0][1]

return &CommitFileInfo{
fileName: *file.Filename,
FileName: *file.Filename,
hunkStart: hunkStart,
hunkEnd: hunkEnd,
hunkEnd: hunkStart + (hunkEnd - 1),
sha: sha,
}, nil
}
17 changes: 17 additions & 0 deletions commitFileInfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package go_github_pr_commenter

type CommitFileInfo struct {
FileName string
hunkStart int
hunkEnd int
sha string
}

func (cfi CommitFileInfo) CommentRequired(filename string, startLine int) bool {
return filename == cfi.FileName && startLine > cfi.hunkStart && startLine < cfi.hunkEnd
}

func (cfi CommitFileInfo) CalculatePosition(line int) *int {
position := line - cfi.hunkStart
return &position
}
84 changes: 84 additions & 0 deletions connector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package go_github_pr_commenter

import (
"context"
"github.com/google/go-github/v32/github"
"golang.org/x/oauth2"
)

type connector struct {
prs *github.PullRequestsService
owner string
repo string
prNumber int
}

type existingComment struct {
filename *string
comment *string
}

func createConnector(token, owner, repo string, prNumber int) *connector {
ctx := context.Background()
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
tc := oauth2.NewClient(ctx, ts)

client := github.NewClient(tc)

return &connector{
prs: client.PullRequests,
owner: owner,
repo: repo,
prNumber: prNumber,
}
}

func (c *connector) writeReviewComment(block *github.PullRequestComment) error {
ctx := context.Background()

var _, _, err = c.prs.CreateComment(ctx, c.owner, c.repo, c.prNumber, block)
if err != nil {
return err
}

return nil
}

func (c *connector) getFilesForPr() ([]*github.CommitFile, error) {
files, _, err := c.prs.ListFiles(context.Background(), c.owner, c.repo, c.prNumber, nil)
if err != nil {
return nil, err
}
var commitFiles []*github.CommitFile
for _, file := range files {
if *file.Status != "deleted" {
commitFiles = append(commitFiles, file)
}
}
return commitFiles, nil
}

func (c *connector) getExistingComments() ([]*existingComment, error) {
ctx := context.Background()

comments, _, err := c.prs.ListComments(ctx, c.owner, c.repo, c.prNumber, &github.PullRequestListCommentsOptions{})
if err != nil {
return nil, err
}

var existingComments []*existingComment
for _, comment := range comments {
existingComments = append(existingComments, &existingComment{
filename: comment.Path,
comment: comment.Body,
})
}
return existingComments, nil
}

func (c *connector) prExists() bool {
ctx := context.Background()

_, _, err := c.prs.Get(ctx, c.owner, c.repo, c.prNumber)
return err == nil
}
67 changes: 67 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package go_github_pr_commenter

import "fmt"

type NotPartOfPrError struct {
filepath string
}

type CommentAlreadyWrittenError struct {
filepath string
comment string
}

type CommentNotValidError struct {
filepath string
lineNo int
}

type PrDoesNotExistError struct {
owner string
repo string
prNumber int
}

func newNotPartOfPrError(filepath string) NotPartOfPrError {
return NotPartOfPrError{
filepath: filepath,
}
}

func (e NotPartOfPrError) Error() string {
return fmt.Sprintf("The file [%s] provided is not part of the PR", e.filepath)
}

func newCommentAlreadyWrittenError(filepath, comment string) CommentAlreadyWrittenError {
return CommentAlreadyWrittenError{
filepath: filepath,
comment: comment,
}
}

func (e CommentAlreadyWrittenError) Error() string {
return fmt.Sprintf("The file [%s] already has the comment written [%s]", e.filepath, e.comment)
}

func newCommentNotValidError(filepath string, line int) CommentNotValidError {
return CommentNotValidError{
filepath: filepath,
lineNo: line,
}
}

func (e CommentNotValidError) Error() string {
return fmt.Sprintf("There is nothing to comment on at line [%d] in file [%s]", e.lineNo, e.filepath)
}

func newPrDoesNotExistError(c *connector) PrDoesNotExistError {
return PrDoesNotExistError{
owner: c.owner,
repo: c.repo,
prNumber: c.prNumber,
}
}

func (e PrDoesNotExistError) Error() string {
return fmt.Sprintf("PR number [%d] not found for %s/%s", e.prNumber, e.owner, e.repo)
}
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -4,6 +4,5 @@ go 1.15

require (
github.com/google/go-github/v32 v32.1.0
github.com/stretchr/testify v1.6.1
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be
)
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=
@@ -8,7 +10,10 @@ github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASu
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
@@ -18,6 +23,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=

0 comments on commit 9f81078

Please sign in to comment.