Skip to content

Commit 3912b00

Browse files
author
Tyler Sullens
committed
feat: add comment update functionality
1 parent 111f96c commit 3912b00

5 files changed

Lines changed: 231 additions & 11 deletions

File tree

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,24 @@ Add the following to your `pipeline.yml`:
1717
steps:
1818
command: echo "~~~ :github: Add approval comment Pull Request"
1919
plugins:
20-
- pr-commenter#v0.1.0:
20+
- pr-commenter#v0.3.0:
2121
message: "LGTM!"
2222
secret-name: GITHUB_TOKEN
2323
```
2424
25+
### Enabling "Sticky" comments
26+
27+
Set `allow-repeats: false` in order to post and update a single comment.
28+
```yaml
29+
steps:
30+
command: echo "~~~ :github: Add approval comment Pull Request"
31+
plugins:
32+
- pr-commenter#v0.3.0:
33+
message: "LGTM!"
34+
secret-name: GITHUB_TOKEN
35+
allow-repeats: false
36+
```
37+
2538
## 📒 Options
2639

2740
### `secret-name` (optional, string)
@@ -34,6 +47,16 @@ The message which should be posted to the PR. This can be a dynamic value, such
3447

3548
Default: `[${BUILDKITE_BUILD_URL}#${BUILDKITE_JOB_ID}](${BUILDKITE_BUILD_URL}#${BUILDKITE_JOB_ID}) exited with code ${BUILDKITE_COMMAND_EXIT_STATUS}`
3649

50+
### `allow-repeats` (optional, boolean)
51+
Whether to Allow identical comments to be posted every time the plugin is run. Disabling this (`allow-repeats: false`) will cause the plugin to post a single "sticky" comment, which will be updated on subsequent runs if the message changes.
52+
53+
Default: `true`
54+
55+
### `message-id` (optional, string)
56+
An additional unique identifier for the comment generated by the plugin instance; useful if using "sticky" comments (`allow-repeats: false`) and using the plugin multiple times in a single _step_.
57+
58+
Default: `null`
59+
3760
## Compatibility
3861

3962
| Elastic Stack | Agent Stack K8s | Hosted (Mac) | Hosted (Linux) | Notes |

internal/issue/comment/comment.go

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,97 @@ package comment
33
import (
44
"context"
55
"errors"
6+
"fmt"
7+
"os"
68
"strconv"
9+
"strings"
10+
11+
"prcommenter/internal/common"
712

813
"github.com/google/go-github/github"
914
)
1015

16+
type Commenter struct {
17+
client GitHubClient
18+
messageId string
19+
}
20+
1121
type GitHubClient interface {
1222
CreateComment(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
23+
ListComments(ctx context.Context, owner string, repo string, number int, opts *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
24+
EditComment(ctx context.Context, owner, repo string, commentID int64, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
25+
}
26+
27+
func NewCommenter(client GitHubClient) *Commenter {
28+
// Create a unique "id" embedded in the comment that identifies the comment generated by this pipeline+job
29+
// The generated "id" is unique to the specific pipeline+job, with the plugin message-id param allowing further uniqueness
30+
messageId := fmt.Sprintf("%s:%s:pr-commenter-buildkite-plugin", os.Getenv("BUILDKITE_PIPELINE_SLUG"), os.Getenv("BUILDKITE_LABEL"))
31+
uniqueId, found := os.LookupEnv(common.PluginPrefix + "MESSAGE_ID")
32+
if found {
33+
messageId = fmt.Sprintf("%s:%s", messageId, uniqueId)
34+
}
35+
36+
return &Commenter{
37+
client: client,
38+
messageId: messageId,
39+
}
1340
}
1441

15-
func Post(ctx context.Context, client GitHubClient, owner string, repo string, number string, body string) error {
42+
func (c *Commenter) formatBody(message string) string {
43+
return fmt.Sprintf("%s\n\n<!-- %s -->", message, c.messageId)
44+
}
45+
46+
func (c *Commenter) Post(ctx context.Context, owner string, repo string, number string, message string) error {
1647
numberConverted, err := strconv.Atoi(number)
1748
if err != nil {
1849
return err
1950
}
2051

21-
if body == "" {
22-
return errors.New("no body provided for comment")
52+
if message == "" {
53+
return errors.New("no message provided for comment")
2354
}
55+
body := c.formatBody(message)
2456

2557
comment := &github.IssueComment{
2658
Body: &body,
2759
}
2860

29-
_, _, err = client.CreateComment(ctx, owner, repo, numberConverted, comment)
61+
_, _, err = c.client.CreateComment(ctx, owner, repo, numberConverted, comment)
3062
return err
3163
}
64+
65+
func (c *Commenter) UpdateComment(ctx context.Context, owner string, repo string, message string, commentId int64) error {
66+
if message == "" {
67+
return errors.New("no message provided for comment")
68+
}
69+
body := c.formatBody(message)
70+
comment := &github.IssueComment{
71+
Body: &body,
72+
}
73+
74+
_, _, err := c.client.EditComment(ctx, owner, repo, commentId, comment)
75+
return err
76+
}
77+
78+
func (c *Commenter) FindExistingComment(ctx context.Context, owner string, repo string, number string) (*github.IssueComment, error) {
79+
numberConverted, err := strconv.Atoi(number)
80+
if err != nil {
81+
return nil, err
82+
}
83+
84+
comments, _, err := c.client.ListComments(ctx, owner, repo, numberConverted, nil)
85+
if err != nil {
86+
return nil, err
87+
}
88+
for _, comment := range comments {
89+
if comment.Body != nil && strings.Contains(*comment.Body, c.messageId) {
90+
return comment, nil
91+
}
92+
}
93+
return nil, nil
94+
}
95+
96+
func (c *Commenter) MatchBody(ctx context.Context, comment *github.IssueComment, message string) bool {
97+
// Match for exact body content
98+
return c.formatBody(message) == *comment.Body
99+
}

internal/issue/comment/comment_test.go

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,124 @@ import (
44
"context"
55
"testing"
66

7+
"prcommenter/internal/common"
78
"prcommenter/internal/issue/comment"
89

910
"github.com/google/go-github/github"
1011
)
1112

1213
type mockGitHubClient struct {
1314
createComment func(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
15+
listComments func(ctx context.Context, owner string, repo string, number int, opts *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error)
16+
editComment func(ctx context.Context, owner, repo string, commentID int64, comment *github.IssueComment) (*github.IssueComment, *github.Response, error)
1417
}
1518

1619
func (m *mockGitHubClient) CreateComment(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
1720
return m.createComment(ctx, owner, repo, number, comment)
1821
}
1922

23+
func (m *mockGitHubClient) ListComments(ctx context.Context, owner string, repo string, number int, opts *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) {
24+
return m.listComments(ctx, owner, repo, number, opts)
25+
}
26+
27+
func (m *mockGitHubClient) EditComment(ctx context.Context, owner, repo string, commentID int64, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
28+
return m.editComment(ctx, owner, repo, commentID, comment)
29+
}
30+
2031
func TestPost(t *testing.T) {
32+
t.Setenv("BUILDKITE_PIPELINE_SLUG", "test-pipeline")
33+
t.Setenv("BUILDKITE_LABEL", "test-label")
34+
t.Setenv(common.PluginPrefix+"MESSAGE_ID", "1")
35+
2136
mockClient := &mockGitHubClient{
2237
createComment: func(ctx context.Context, owner, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
23-
if owner != "testdev" || repo != "hello" || number != 420 || *comment.Body != "Test comment" {
24-
t.Errorf("Unexpected arguments to CreateComment")
38+
if owner != "testdev" || repo != "hello" || number != 420 || *comment.Body != "Test comment\n\n<!-- test-pipeline:test-label:pr-commenter-buildkite-plugin:1 -->" {
39+
t.Errorf("Unexpected arguments: owner=%s, repo=%s, number=%d, body=%s", owner, repo, number, *comment.Body)
2540
}
2641
return nil, nil, nil
2742
},
2843
}
2944

30-
err := comment.Post(context.Background(), mockClient, "testdev", "hello", "420", "Test comment")
45+
commenter := comment.NewCommenter(mockClient)
46+
47+
err := commenter.Post(context.Background(), "testdev", "hello", "420", "Test comment")
3148
if err != nil {
3249
t.Fatalf("error posting comment: %s", err)
3350
}
3451
}
3552

3653
func TestPostCommentEmptyBody(t *testing.T) {
3754
mockClient := &mockGitHubClient{}
55+
commenter := comment.NewCommenter(mockClient)
3856

39-
err := comment.Post(context.Background(), mockClient, "testdev", "hello", "69", "")
57+
err := commenter.Post(context.Background(), "testdev", "hello", "69", "")
4058
if err == nil {
4159
t.Fatalf("error expected due to empty body")
4260
}
4361
}
62+
63+
func TestFindExistingComment_Found(t *testing.T) {
64+
t.Setenv("BUILDKITE_PIPELINE_SLUG", "test-pipeline")
65+
t.Setenv("BUILDKITE_LABEL", "test-label")
66+
t.Setenv(common.PluginPrefix+"MESSAGE_ID", "1")
67+
68+
expectedID := int64(123)
69+
expectedBody := "Test comment\n\n<!-- test-pipeline:test-label:pr-commenter-buildkite-plugin:1 -->"
70+
expectedURL := "https://github.com/test/repo/pull/1#issuecomment-123"
71+
72+
mockClient := &mockGitHubClient{
73+
listComments: func(ctx context.Context, owner, repo string, number int, opts *github.IssueListCommentsOptions) ([]*github.IssueComment, *github.Response, error) {
74+
if owner != "testdev" || repo != "hello" || number != 320 {
75+
t.Errorf("Unexpected arguments: owner=%s, repo=%s, number=%d", owner, repo, number)
76+
}
77+
return []*github.IssueComment{
78+
{
79+
ID: &expectedID,
80+
Body: &expectedBody,
81+
HTMLURL: &expectedURL,
82+
},
83+
}, nil, nil
84+
},
85+
}
86+
87+
commenter := comment.NewCommenter(mockClient)
88+
result, err := commenter.FindExistingComment(context.Background(), "testdev", "hello", "320")
89+
90+
if err != nil {
91+
t.Fatalf("unexpected error: %s", err)
92+
}
93+
if result == nil {
94+
t.Fatal("expected comment to be found, got nil")
95+
}
96+
if *result.ID != expectedID {
97+
t.Errorf("expected ID %d, got %d", expectedID, *result.ID)
98+
}
99+
}
100+
101+
func TestUpdateComment_Success(t *testing.T) {
102+
t.Setenv("BUILDKITE_PIPELINE_SLUG", "test-pipeline")
103+
t.Setenv("BUILDKITE_LABEL", "test-label")
104+
t.Setenv(common.PluginPrefix+"MESSAGE_ID", "1")
105+
106+
commentID := int64(456)
107+
expectedBody := "Updated comment\n\n<!-- test-pipeline:test-label:pr-commenter-buildkite-plugin:1 -->"
108+
109+
mockClient := &mockGitHubClient{
110+
editComment: func(ctx context.Context, owner, repo string, id int64, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) {
111+
if owner != "testdev" || repo != "hello" || id != commentID {
112+
t.Errorf("Unexpected arguments: owner=%s, repo=%s, commentID=%d", owner, repo, id)
113+
}
114+
if *comment.Body != expectedBody {
115+
t.Errorf("Expected body=%s, got %s", expectedBody, *comment.Body)
116+
}
117+
return comment, nil, nil
118+
},
119+
}
120+
121+
commenter := comment.NewCommenter(mockClient)
122+
err := commenter.UpdateComment(context.Background(), "testdev", "hello", "Updated comment", commentID)
123+
124+
if err != nil {
125+
t.Fatalf("unexpected error: %s", err)
126+
}
127+
}

main.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"strconv"
78

89
"prcommenter/internal/common"
910
"prcommenter/internal/github"
@@ -39,7 +40,7 @@ func run() exitCode {
3940

4041
prNumber := os.Getenv("BUILDKITE_PULL_REQUEST")
4142
if prNumber == "false" {
42-
fmt.Fprintf(os.Stdout, "Not a pull request. Exiting gracefully.\n")
43+
_, _ = fmt.Fprintf(os.Stdout, "Not a pull request. Exiting gracefully.\n")
4344
return exitOK
4445
}
4546

@@ -59,14 +60,51 @@ func run() exitCode {
5960
fmt.Fprintf(os.Stderr, "Error creating GitHub client: %s\n", err)
6061
return exitError
6162
}
63+
commenter := comment.NewCommenter(client)
6264

6365
message, found := os.LookupEnv(common.PluginPrefix + "MESSAGE")
6466
if !found {
6567
fullStepURL := fmt.Sprintf("%s#%s", os.Getenv("BUILDKITE_BUILD_URL"), os.Getenv("BUILDKITE_JOB_ID"))
6668
message = fmt.Sprintf("[%s](%s) exited with code %s", fullStepURL, fullStepURL, os.Getenv("BUILDKITE_COMMAND_EXIT_STATUS"))
6769
}
6870

69-
err = comment.Post(ctx, client, owner, repo, prNumber, message)
71+
var allowRepeats = true
72+
// Allow for setting a "allow-repeats: false" plugin option to prevent duplicate comments
73+
allowRepeatsVal, found := os.LookupEnv(common.PluginPrefix + "ALLOW_REPEATS")
74+
if found {
75+
// if this fails, allowRepeats val will just be the default (true)
76+
allowRepeats, _ = strconv.ParseBool(allowRepeatsVal)
77+
}
78+
79+
// Check for existing comment using the internal "message id", and update body if necessary
80+
if !allowRepeats {
81+
comment, err := commenter.FindExistingComment(ctx, owner, repo, prNumber)
82+
if err != nil {
83+
fmt.Fprintf(os.Stderr, "Error fetching existing comments: %s\n", err)
84+
return exitError
85+
}
86+
if comment != nil {
87+
// existing comment found, check comment body for exact match
88+
// and update if body/message has changed
89+
if commenter.MatchBody(ctx, comment, message) {
90+
// Comment body/message unchanged, no action needed
91+
_, _ = fmt.Fprintf(os.Stdout, "Found matching comment: %s\n", *comment.HTMLURL)
92+
} else {
93+
// Body does not match, update comment
94+
err = commenter.UpdateComment(ctx, owner, repo, message, *comment.ID)
95+
if err != nil {
96+
fmt.Fprintf(os.Stderr, "Error updating existing comment %s: %s\n", *comment.HTMLURL, err)
97+
return exitError
98+
}
99+
_, _ = fmt.Fprintf(os.Stdout, "Updated matching comment: %s\n", *comment.HTMLURL)
100+
}
101+
return exitOK
102+
}
103+
}
104+
105+
// If we're here, we didn't find an existing comment or allowRepeats is true (ie. post duplicate comments)
106+
// Post a new comment
107+
err = commenter.Post(ctx, owner, repo, prNumber, message)
70108
if err != nil {
71109
fmt.Fprintf(os.Stderr, "Error posting comment: %s\n", err)
72110
}

plugin.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,12 @@ configuration:
1010
type: string
1111
secret-name:
1212
type: string
13+
allow-repeats:
14+
type: boolean
15+
description: "Allow identical comments to be posted every time the plugin is run"
16+
default: true
17+
message-id:
18+
type: string
19+
description: "Unique ID of the message to be posted, used to identify existing comments (allow-repeats: false). Use if plugin is used more than once in same job."
1320
required:
1421
- secret-name

0 commit comments

Comments
 (0)