Skip to content

Commit ab00752

Browse files
authored
Merge pull request #20 from wrapbook/tcsullens/sticky-comments
2 parents 2ac7aaf + 2df2d3b commit ab00752

5 files changed

Lines changed: 260 additions & 11 deletions

File tree

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,35 @@ The use of this plugin requires that clusters are being used and that the secret
1111

1212
## 👩‍💻 Usage
1313

14+
>The plugin expects at least one of `BUILDKITE_STEP_KEY` or `BUILDKITE_LABEL` to be set for proper usage.
15+
1416
Add the following to your `pipeline.yml`:
1517

1618
```yaml
1719
steps:
20+
key: approval-comment
1821
command: echo "~~~ :github: Add approval comment Pull Request"
1922
plugins:
20-
- pr-commenter#v0.1.0:
23+
- pr-commenter#v0.3.0:
2124
message: "LGTM!"
2225
secret-name: GITHUB_TOKEN
2326
```
2427
28+
### Enabling "Sticky" comments
29+
30+
Set `allow-repeats: false` in order to post and update a single comment. This configuration relies on `BUILDKITE_STEP_KEY` or `BUILDKITE_LABEL` being set _**and unique to the step**_.
31+
32+
```yaml
33+
steps:
34+
key: approval-comment
35+
command: echo "~~~ :github: Add approval comment Pull Request"
36+
plugins:
37+
- pr-commenter#v0.3.0:
38+
message: "LGTM!"
39+
secret-name: GITHUB_TOKEN
40+
allow-repeats: false
41+
```
42+
2543
## 📒 Options
2644

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

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

55+
### `allow-repeats` (optional, boolean)
56+
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.
57+
58+
Default: `true`
59+
60+
### `message-id` (optional, string)
61+
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_.
62+
63+
Default: `null`
64+
3765
## Compatibility
3866

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

internal/issue/comment/comment.go

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,117 @@ 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)
1325
}
1426

15-
func Post(ctx context.Context, client GitHubClient, owner string, repo string, number string, body string) error {
27+
func NewCommenter(client GitHubClient) (*Commenter, error) {
28+
// Create a unique "id" embedded in the comment that identifies the comment generated by this pipeline+step
29+
// The generated "id" is unique to the specific pipeline+step, with the plugin message-id param allowing further uniqueness
30+
stepKey, found := os.LookupEnv("BUILDKITE_STEP_KEY")
31+
if !found {
32+
stepKey, found = os.LookupEnv("BUILDKITE_LABEL")
33+
if !found {
34+
return nil, errors.New("At least one of BUILDKITE_STEP_KEY or BUILDKITE_LABEL must be set.")
35+
}
36+
}
37+
38+
messageId := fmt.Sprintf("%s:%s:pr-commenter-buildkite-plugin", os.Getenv("BUILDKITE_PIPELINE_SLUG"), stepKey)
39+
uniqueId, found := os.LookupEnv(common.PluginPrefix + "MESSAGE_ID")
40+
if found {
41+
messageId = fmt.Sprintf("%s:%s", messageId, uniqueId)
42+
}
43+
44+
return &Commenter{
45+
client: client,
46+
messageId: messageId,
47+
}, nil
48+
}
49+
50+
func (c *Commenter) formatBody(message string) string {
51+
return fmt.Sprintf("%s\n\n<!-- %s -->", message, c.messageId)
52+
}
53+
54+
func (c *Commenter) Post(ctx context.Context, owner string, repo string, number string, message string) error {
1655
numberConverted, err := strconv.Atoi(number)
1756
if err != nil {
1857
return err
1958
}
2059

21-
if body == "" {
22-
return errors.New("no body provided for comment")
60+
if message == "" {
61+
return errors.New("no message provided for comment")
62+
}
63+
body := c.formatBody(message)
64+
65+
comment := &github.IssueComment{
66+
Body: &body,
2367
}
2468

69+
_, _, err = c.client.CreateComment(ctx, owner, repo, numberConverted, comment)
70+
return err
71+
}
72+
73+
func (c *Commenter) UpdateComment(ctx context.Context, owner string, repo string, message string, commentId int64) error {
74+
if message == "" {
75+
return errors.New("no message provided for comment")
76+
}
77+
body := c.formatBody(message)
2578
comment := &github.IssueComment{
2679
Body: &body,
2780
}
2881

29-
_, _, err = client.CreateComment(ctx, owner, repo, numberConverted, comment)
82+
_, _, err := c.client.EditComment(ctx, owner, repo, commentId, comment)
3083
return err
3184
}
85+
86+
func (c *Commenter) FindExistingComment(ctx context.Context, owner string, repo string, number string) (*github.IssueComment, error) {
87+
numberConverted, err := strconv.Atoi(number)
88+
if err != nil {
89+
return nil, err
90+
}
91+
92+
opt := &github.IssueListCommentsOptions{
93+
ListOptions: github.ListOptions{PerPage: 100},
94+
}
95+
for {
96+
comments, resp, err := c.client.ListComments(ctx, owner, repo, numberConverted, opt)
97+
if err != nil {
98+
return nil, err
99+
}
100+
for _, comment := range comments {
101+
if comment.Body != nil && strings.Contains(*comment.Body, c.messageId) {
102+
return comment, nil
103+
}
104+
}
105+
if resp.NextPage == 0 {
106+
break
107+
}
108+
opt.Page = resp.NextPage
109+
}
110+
return nil, nil
111+
}
112+
113+
func (c *Commenter) MatchBody(ctx context.Context, comment *github.IssueComment, message string) bool {
114+
// Match for exact body content
115+
if comment == nil || comment.Body == nil {
116+
return false
117+
}
118+
return c.formatBody(message) == *comment.Body
119+
}

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_STEP_KEY", "test")
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: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_STEP_KEY", "test")
66+
t.Setenv(common.PluginPrefix+"MESSAGE_ID", "1")
67+
68+
expectedID := int64(123)
69+
expectedBody := "Test comment\n\n<!-- test-pipeline:test: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_STEP_KEY", "test")
104+
t.Setenv(common.PluginPrefix+"MESSAGE_ID", "1")
105+
106+
commentID := int64(456)
107+
expectedBody := "Updated comment\n\n<!-- test-pipeline:test: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: 44 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,55 @@ func run() exitCode {
5960
fmt.Fprintf(os.Stderr, "Error creating GitHub client: %s\n", err)
6061
return exitError
6162
}
63+
commenter, err := comment.NewCommenter(client)
64+
if err != nil {
65+
fmt.Fprintf(os.Stderr, "Error configuring commenter: %s\n", err)
66+
return exitError
67+
}
6268

6369
message, found := os.LookupEnv(common.PluginPrefix + "MESSAGE")
6470
if !found {
6571
fullStepURL := fmt.Sprintf("%s#%s", os.Getenv("BUILDKITE_BUILD_URL"), os.Getenv("BUILDKITE_JOB_ID"))
6672
message = fmt.Sprintf("[%s](%s) exited with code %s", fullStepURL, fullStepURL, os.Getenv("BUILDKITE_COMMAND_EXIT_STATUS"))
6773
}
6874

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

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)