diff --git a/README.md b/README.md index d8bbc84..66cf399 100644 --- a/README.md +++ b/README.md @@ -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()) + } + } +} +``` diff --git a/cmd/commenter/main.go b/cmd/commenter/main.go deleted file mode 100644 index db6ccca..0000000 --- a/cmd/commenter/main.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - commenter "github.com/owenrumney/go-github-pr-commenter" - "os" -) - -func main() { - - token := os.Getenv("GITHUB_TOKEN") - - c, err := commenter.NewCommenter(token, "tfsec", "tfsec-example-project", 8) - if err != nil { - panic(err) - } - - if c.CheckCommentRelevant(".travis.yml", 5) { - c.WriteComment(&commenter.CommentBlock{ - CommitFileInfo: , - StartLine: 0, - EndLine: 0, - Comment: "", - }) - } -} diff --git a/commentBlock.go b/commentBlock.go deleted file mode 100644 index cb317b9..0000000 --- a/commentBlock.go +++ /dev/null @@ -1,13 +0,0 @@ -package go_github_pr_commenter - -type CommentBlock struct { - FileName string - StartLine int - EndLine int - Comment string -} - -func (cb *CommentBlock) CalculatePosition() *int { - position := cb.StartLine - cb.CommitFileInfo.hunkStart - return &position -} diff --git a/comment_block_stage_test.go b/comment_block_stage_test.go deleted file mode 100644 index a6649df..0000000 --- a/comment_block_stage_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package go_github_pr_commenter - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -type commentBlockTest struct { - t *testing.T - c *CommentBlock -} - -func (t *commentBlockTest) a_new_comment_block_for_file_with_comments_(filename, comment string) { - t.c = NewCommentBlock(filename, comment, 1, 10) -} - -func (t *commentBlockTest) the_no_start_or_end_line_are_applied() { - // do nothing -} - -func (t *commentBlockTest) has_filename(expected string) *commentBlockTest { - assert.Equal(t.t, t.c.fileName, expected) - return t -} - -func (t *commentBlockTest) has_comment(expected string) *commentBlockTest { - assert.Equal(t.t, t.c.comment, expected) - return t -} - -func (t *commentBlockTest) has_start_line(expected int) *commentBlockTest { - assert.Equal(t.t, t.c.startLine, expected) - return t -} - -func (t *commentBlockTest) has_end_line(expected int) *commentBlockTest { - assert.Equal(t.t, t.c.endLine, expected) - return t -} - -func (t *commentBlockTest) and() *commentBlockTest { - return t -} - -func newCommentBlockTest(t *testing.T) (*commentBlockTest, *commentBlockTest, *commentBlockTest) { - cbt := &commentBlockTest{ - t: t, - } - return cbt, cbt, cbt -} diff --git a/comment_block_test.go b/comment_block_test.go deleted file mode 100644 index 55d9a25..0000000 --- a/comment_block_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package go_github_pr_commenter - -import ( - "testing" -) - -func Test_comment_block_created_ok(t *testing.T) { - given, when, then := newCommentBlockTest(t) - - given.a_new_comment_block_for_file_with_comments_("test.file", "this code is really broken") - - when.the_no_start_or_end_line_are_applied() - - then.has_filename("test.file"). - and().has_comment("this code is really broken"). - and().has_start_line(-1). - and().has_end_line(-1) -} diff --git a/commenter.go b/commenter.go index bb0d73e..08bada9 100644 --- a/commenter.go +++ b/commenter.go @@ -1,28 +1,19 @@ package go_github_pr_commenter import ( - "context" "errors" "fmt" "github.com/google/go-github/v32/github" - "golang.org/x/oauth2" "regexp" "strconv" "strings" ) type commenter struct { - connector *github.Client - owner string - repo string - prNumber int + pr *connector existingComments []*existingComment files []*CommitFileInfo -} - -type existingComment struct { - filename *string - comment *string + loaded bool } var patchRegex *regexp.Regexp @@ -46,123 +37,146 @@ func NewCommenter(token, owner, repo 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) - c := &commenter{ - connector: client, - owner: owner, - repo: repo, - prNumber: prNumber, + if !connector.prExists() { + return nil, newPrDoesNotExistError(connector) } - err = c.loadPr() - if err != nil { - return nil, err + c := &commenter{ + pr: connector, } return c, nil } -// GetCommitFileInfo get file info for files in the commit -func (gc *commenter) getCommitFileInfo() error { - prFiles, err := gc.getFilesForPr() - if err != nil { - return err - } - var errs []string - for _, file := range prFiles { - info, err := getCommitInfo(file) +// 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 { - errs = append(errs, err.Error()) - continue + return err } - gc.files = append(gc.files, info) } - if len(errs) > 0 { - return errors.New(fmt.Sprintf("there were errors processing the PR files.\n%s", strings.Join(errs, "\n"))) + + if !c.checkCommentRelevant(file, startLine) { + return newCommentNotValidError(file, startLine) } - return nil -} -func (gc *commenter) WriteComment(block *CommentBlock) error { - connector := gc.connector - ctx := context.Background() + if startLine == endLine { + return c.WriteLineComment(file, comment, endLine) + } - var _, _, err = connector.PullRequests.CreateComment(ctx, gc.owner, gc.repo, gc.prNumber, buildComment(block)) + info, err := c.getFileInfo(file, endLine) if err != nil { return err } - return nil + prComment := buildComment(file, comment, endLine, *info) + prComment.StartLine = &startLine + return c.writeCommentIfRequired(prComment) } -func buildComment(block *CommentBlock) *github.PullRequestComment { - comment := &github.PullRequestComment{ - Line: &block.StartLine, - Path: &block.CommitFileInfo.FileName, - CommitID: &block.CommitFileInfo.sha, - Body: &block.Comment, - Position: block.CalculatePosition(), - } - if block.StartLine != block.EndLine { - comment.StartLine = &block.StartLine - comment.Line = &block.EndLine +// 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 + } } - return comment -} -func (gc *commenter) getFilesForPr() ([]*github.CommitFile, error) { - connector := gc.connector + if !c.checkCommentRelevant(file, line) { + return newCommentNotValidError(file, line) + } - files, _, err := connector.PullRequests.ListFiles(context.Background(), gc.owner, gc.repo, gc.prNumber, nil) + info, err := c.getFileInfo(file, line) if err != nil { - return nil, err + return err } - var commitFiles []*github.CommitFile - for _, file := range files { - if *file.Status != "deleted" { - commitFiles = append(commitFiles, file) + 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 commitFiles, nil + return c.pr.writeReviewComment(prComment) } -func (gc *commenter) getExistingComments() error { - connector := gc.connector - ctx := context.Background() - - comments, _, err := connector.PullRequests.ListComments(ctx, gc.owner, gc.repo, gc.prNumber, &github.PullRequestListCommentsOptions{}) +func (c *commenter) getCommitFileInfo() error { + prFiles, err := c.pr.getFilesForPr() if err != nil { return err } - for _, comment := range comments { - gc.existingComments = append(gc.existingComments, &existingComment{ - filename: comment.Path, - comment: comment.Body, - }) + var errs []string + for _, file := range prFiles { + info, err := getCommitInfo(file) + if err != nil { + errs = append(errs, err.Error()) + continue + } + c.files = append(c.files, info) + } + if len(errs) > 0 { + return errors.New(fmt.Sprintf("there were errors processing the PR files.\n%s", strings.Join(errs, "\n"))) } return nil } -func (gc *commenter) loadPr() error { - err := gc.getCommitFileInfo() +func (c *commenter) loadPr() error { + err := c.getCommitFileInfo() if err != nil { return err } - err = gc.getExistingComments() + c.existingComments, err = c.pr.getExistingComments() if err != nil { return err } - + c.loaded = true return nil } -func createClient(token string) *github.Client { - ctx := context.Background() - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) - tc := oauth2.NewClient(ctx, ts) +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 false +} + +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 + } + } + } + return nil, newNotPartOfPrError(file) +} - return github.NewClient(tc) +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), + } } func getCommitInfo(file *github.CommitFile) (*CommitFileInfo, error) { @@ -186,14 +200,3 @@ func getCommitInfo(file *github.CommitFile) (*CommitFileInfo, error) { sha: sha, }, nil } - -func (gc *commenter) CheckCommentRelevant(filename string, line int) bool { - for _, file := range gc.files { - if file.FileName == filename { - if line > file.hunkStart && line < file.hunkEnd { - return true - } - } - } - return false -} diff --git a/commitFileInfo.go b/commitFileInfo.go index 79dc59e..8a9c2f4 100644 --- a/commitFileInfo.go +++ b/commitFileInfo.go @@ -10,3 +10,8 @@ type CommitFileInfo struct { 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 +} diff --git a/connector.go b/connector.go new file mode 100644 index 0000000..fbfd533 --- /dev/null +++ b/connector.go @@ -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 +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..633030e --- /dev/null +++ b/errors.go @@ -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) +} diff --git a/go.mod b/go.mod index b0d579a..4e2fcf1 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 3bdafc4..6e6ff96 100644 --- a/go.sum +++ b/go.sum @@ -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=