Skip to content

Commit 8db326f

Browse files
authored
Automate CAS Diff comments (#1178)
Set up a GH workflow to automate `casdiff` manual comments for git reference transitions like [this one](#1172 (comment)). - Recognize if a PR targetting `main` changes any module's state file. - Calculate all digest transitions in those state files diffs. - For each digest transition, run the `casdiff` command. - Post a comment in the `"digest":` PR line with the result of the `casdiff` command. - If there are new commits in the PR, update the existing comments instead of posting new ones. Test comment: #1178 (comment)
1 parent f1e31b3 commit 8db326f

File tree

11 files changed

+1074
-3
lines changed

11 files changed

+1074
-3
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Auto-comment PR with casdiff
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize]
6+
branches:
7+
- main
8+
paths:
9+
- modules/sync/*/*/state.json
10+
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
15+
jobs:
16+
comment-changes:
17+
runs-on: ubuntu-latest
18+
if: github.repository == 'bufbuild/modules'
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@v6
22+
with:
23+
fetch-depth: 0 # Need full history to compare branches
24+
25+
- name: Install Go
26+
uses: actions/setup-go@v6
27+
with:
28+
go-version: 1.26.x
29+
check-latest: true
30+
cache: true
31+
32+
- name: Run commentprcasdiff
33+
run: go run ./cmd/commentprcasdiff
34+
env:
35+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36+
PR_NUMBER: ${{ github.event.pull_request.number }}
37+
BASE_REF: ${{ github.event.pull_request.base.sha }}
38+
HEAD_REF: ${{ github.event.pull_request.head.sha }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
/.tmp/
2+
/.claude/

.golangci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ linters:
3737
- T any
3838
- i int
3939
- wg sync.WaitGroup
40+
- tc testCase
4041
exclusions:
4142
generated: lax
4243
presets:

cmd/commentprcasdiff/README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# commentprcasdiff
2+
3+
Automates posting CAS diff comments on PRs when module digest changes are detected.
4+
5+
## What it does
6+
7+
This tool runs in GitHub Actions when a PR is created against the `fetch-modules` branch. It:
8+
9+
1. Identifies which `state.json` files changed in the PR
10+
2. Compares old and new state to find digest transitions (hash changes)
11+
3. Runs `casdiff` for each transition to show what changed
12+
4. Posts the output as inline review comments at the line where each new digest appears
13+
14+
## How it works
15+
16+
### State Analysis
17+
18+
Each module's `state.json` file is append-only and contains references with their content digests:
19+
20+
```json
21+
{
22+
"references": [
23+
{"name": "v1.0.0", "digest": "aaa..."},
24+
{"name": "v1.1.0", "digest": "bbb..."}
25+
]
26+
}
27+
```
28+
29+
The tool:
30+
- Reads the full JSON from both the base branch (`main`) and head branch (`fetch-modules`)
31+
- Identifies newly appended references
32+
- Detects when the digest changes between consecutive references
33+
- For each digest change, runs: `casdiff <old_ref> <new_ref> --format=markdown`
34+
35+
### Comment Posting
36+
37+
Comments are posted as PR review comments on the specific line where the new digest first appears in the diff, similar to manual code review comments.
38+
39+
Example: If digest changes from `aaa` to `bbb` at reference `v1.1.0`, a comment is posted at the line containing `"digest": "bbb"` in the state.json diff.
40+
41+
## Local Testing
42+
43+
To test the command locally:
44+
45+
```bash
46+
# Set required environment variables
47+
export PR_NUMBER=1234
48+
export BASE_REF=main
49+
export HEAD_REF=fetch-modules
50+
export GITHUB_TOKEN=<your_token>
51+
52+
# Run the command
53+
go run ./cmd/commentprcasdiff
54+
```
55+
56+
**Note:** The command expects to be run from the repository root and requires:
57+
- Git repository with the specified refs
58+
- GitHub CLI (`gh`) installed and authenticated
59+
- Access to post PR comments via GitHub API
60+
61+
## Architecture
62+
63+
- **main.go**: Entry point, orchestrates the workflow
64+
- **module_finder.go**: Finds changed `state.json` files using git diff
65+
- **state_analyzer.go**: Compares JSON arrays to detect digest transitions
66+
- **casdiff_runner.go**: Executes casdiff commands in parallel
67+
- **comment_poster.go**: Posts review comments via GitHub API
68+
69+
## Error Handling
70+
71+
- If casdiff fails for a transition, the error is logged but doesn't stop the workflow
72+
- Successful transitions still get commented
73+
- All failures are summarized at the end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright 2021-2025 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"fmt"
21+
"os"
22+
"os/exec"
23+
"path/filepath"
24+
"sync"
25+
)
26+
27+
// casDiffResult contains the result of running casdiff for a transition.
28+
type casDiffResult struct {
29+
transition stateTransition
30+
output string // Markdown output from casdiff
31+
err error
32+
}
33+
34+
// runCASDiff executes casdiff command in the module directory.
35+
func runCASDiff(ctx context.Context, transition stateTransition) casDiffResult {
36+
result := casDiffResult{
37+
transition: transition,
38+
}
39+
40+
repoRoot, err := os.Getwd()
41+
if err != nil {
42+
result.err = fmt.Errorf("get working directory: %w", err)
43+
return result
44+
}
45+
46+
// Run casdiff in the module directory. casdiff reads state.json from "." so it must run from the
47+
// module directory. We use an absolute path to the package to avoid path resolution issues when
48+
// cmd.Dir is set.
49+
cmd := exec.CommandContext( //nolint:gosec
50+
ctx,
51+
"go", "run", filepath.Join(repoRoot, "cmd", "casdiff"),
52+
transition.fromRef,
53+
transition.toRef,
54+
"--format=markdown",
55+
)
56+
cmd.Dir = filepath.Join(repoRoot, transition.modulePath)
57+
58+
var stdout, stderr bytes.Buffer
59+
cmd.Stdout = &stdout
60+
cmd.Stderr = &stderr
61+
62+
if err := cmd.Run(); err != nil {
63+
result.err = fmt.Errorf("casdiff failed: %w (stderr: %s)", err, stderr.String())
64+
return result
65+
}
66+
67+
result.output = fmt.Sprintf(
68+
"```sh\n$ casdiff %s %s --format=markdown\n```\n\n%s",
69+
transition.fromRef,
70+
transition.toRef,
71+
stdout.String(),
72+
)
73+
return result
74+
}
75+
76+
// runCASDiffs runs multiple casdiff commands concurrently.
77+
func runCASDiffs(ctx context.Context, transitions []stateTransition) []casDiffResult {
78+
results := make([]casDiffResult, len(transitions))
79+
var wg sync.WaitGroup
80+
81+
for i, transition := range transitions {
82+
wg.Go(func() {
83+
results[i] = runCASDiff(ctx, transition)
84+
})
85+
}
86+
87+
wg.Wait()
88+
return results
89+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright 2021-2025 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"os"
22+
"time"
23+
24+
"github.com/bufbuild/modules/internal/githubutil"
25+
"github.com/google/go-github/v64/github"
26+
)
27+
28+
// prReviewComment represents a comment to be posted or patched on a specific line in a PR.
29+
type prReviewComment struct {
30+
filePath string // File path in the PR (e.g., "modules/sync/bufbuild/protovalidate/state.json")
31+
lineNumber int // Line number in the diff
32+
body string // Comment body (casdiff output)
33+
}
34+
35+
// commentKey uniquely identifies a comment by file and line.
36+
type commentKey struct {
37+
path string
38+
line int
39+
}
40+
41+
// postReviewComments posts review comments to specific lines in the PR diff. If a bot comment
42+
// already exists at the same file/line, it is updated instead of creating a duplicate.
43+
func postReviewComments(ctx context.Context, prNumber int, gitCommitID string, comments ...prReviewComment) error {
44+
client := githubutil.NewClient(ctx)
45+
46+
existingPRComments, err := listExistingBotComments(ctx, client, prNumber)
47+
if err != nil {
48+
return fmt.Errorf("list existing bot comments: %w", err)
49+
}
50+
51+
var errsPosting []error
52+
for _, comment := range comments {
53+
key := commentKey{path: comment.filePath, line: comment.lineNumber}
54+
if prCommentID, alreadyPosted := existingPRComments[key]; alreadyPosted {
55+
if err := updateReviewComment(ctx, client, prCommentID, comment.body); err != nil {
56+
errsPosting = append(errsPosting, fmt.Errorf("updating existing comment in %v: %w", key, err))
57+
}
58+
} else {
59+
if err := postSingleReviewComment(ctx, client, prNumber, gitCommitID, comment); err != nil {
60+
errsPosting = append(errsPosting, fmt.Errorf("posting new comment in %v: %w", key, err))
61+
}
62+
}
63+
}
64+
return errors.Join(errsPosting...)
65+
}
66+
67+
// listExistingBotComments returns a map of (path, line) → commentID for all comments left by
68+
// github-actions[bot] on the PR. Sorted ascending by creation time so that when multiple bot
69+
// comments exist at the same file+line, the highest-ID (most recently created) one wins.
70+
func listExistingBotComments(ctx context.Context, client *githubutil.Client, prNumber int) (map[commentKey]int64, error) {
71+
const githubActionsBotUsername = "github-actions[bot]"
72+
result := make(map[commentKey]int64)
73+
opts := &github.PullRequestListCommentsOptions{
74+
Sort: "created",
75+
Direction: "asc",
76+
ListOptions: github.ListOptions{PerPage: 100},
77+
}
78+
for {
79+
comments, resp, err := client.GitHub.PullRequests.ListComments(
80+
ctx,
81+
string(githubutil.GithubOwnerBufbuild),
82+
string(githubutil.GithubRepoModules),
83+
prNumber,
84+
opts,
85+
)
86+
if err != nil {
87+
return nil, fmt.Errorf("list PR comments: %w", err)
88+
}
89+
for _, comment := range comments {
90+
if comment.GetUser().GetLogin() == githubActionsBotUsername {
91+
key := commentKey{path: comment.GetPath(), line: comment.GetLine()}
92+
result[key] = comment.GetID()
93+
}
94+
}
95+
if resp.NextPage == 0 {
96+
break
97+
}
98+
opts.Page = resp.NextPage
99+
}
100+
return result, nil
101+
}
102+
103+
func postSingleReviewComment(ctx context.Context, client *githubutil.Client, prNumber int, gitCommitID string, comment prReviewComment) error {
104+
body := fmt.Sprintf("_[Posted at %s]_\n\n%s", time.Now().Format(time.RFC3339), comment.body)
105+
created, _, err := client.GitHub.PullRequests.CreateComment(
106+
ctx,
107+
string(githubutil.GithubOwnerBufbuild),
108+
string(githubutil.GithubRepoModules),
109+
prNumber,
110+
&github.PullRequestComment{
111+
CommitID: &gitCommitID,
112+
Path: &comment.filePath,
113+
Line: &comment.lineNumber,
114+
Body: &body,
115+
},
116+
)
117+
if err != nil {
118+
return fmt.Errorf("create PR comment: %w", err)
119+
}
120+
fmt.Fprintf(os.Stdout, "Posted comment: %s\n", created.GetHTMLURL())
121+
return nil
122+
}
123+
124+
func updateReviewComment(ctx context.Context, client *githubutil.Client, prCommentID int64, body string) error {
125+
body = fmt.Sprintf("_[Updated at %s]_\n\n%s", time.Now().Format(time.RFC3339), body)
126+
updated, _, err := client.GitHub.PullRequests.EditComment(
127+
ctx,
128+
string(githubutil.GithubOwnerBufbuild),
129+
string(githubutil.GithubRepoModules),
130+
prCommentID,
131+
&github.PullRequestComment{
132+
Body: &body,
133+
},
134+
)
135+
if err != nil {
136+
return fmt.Errorf("edit PR comment: %w", err)
137+
}
138+
fmt.Fprintf(os.Stdout, "Updated comment: %s\n", updated.GetHTMLURL())
139+
return nil
140+
}

0 commit comments

Comments
 (0)