-
Notifications
You must be signed in to change notification settings - Fork 2
Automate CAS Diff comments #1178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
41 commits
Select commit
Hold shift + click to select a range
d6a6d66
Diff commenter
unmultimedio 870975d
lint
unmultimedio 03b780b
Allow on any PR.
unmultimedio 6a16eae
Remove some state changes
unmultimedio c0df650
Merge commit '6a16eae7a861b394ff70323ced502349d2e156c8' into jfiguero…
unmultimedio 918c3f0
Revert "Remove some state changes"
unmultimedio 931a568
Newer go
unmultimedio 70353b3
nits
unmultimedio e2e0336
add dry-run
unmultimedio 1f27022
lint
unmultimedio be9ed5d
nits
unmultimedio 832b817
Remove some state changes
unmultimedio 3ccf934
Reapply "Remove some state changes"
unmultimedio 87c102a
Merge branch 'jfigueroa/mock-old-main' into jfigueroa/automate-cas-co…
unmultimedio 610b51b
Revert "Remove some state changes"
unmultimedio e176380
nit
unmultimedio 35d09bd
Dedupe GH PR comments
unmultimedio 127c28b
Manual nits
unmultimedio 9dc5c6d
time format
unmultimedio 425d58b
Reorganize comments poster
unmultimedio 2030053
Use proto schema
unmultimedio 4842e46
Test and comment nits
unmultimedio 2edf93b
lint
unmultimedio a41d8fc
Reapply "Remove some state changes"
unmultimedio faedd0c
Enable workflow
unmultimedio 7eb6345
Revert "Reapply "Remove some state changes""
unmultimedio e19a2e8
Simplify test
unmultimedio 2c57e46
Paths filter
unmultimedio ebd2529
lint
unmultimedio b1832d2
Merge branch 'main' into jfigueroa/automate-cas-comment
unmultimedio b145d81
Use wg.Go
unmultimedio 60c54f0
Use gh dependency instead of shelling out to gh CLI
unmultimedio bc23dbe
Use appcmd
unmultimedio 89a8f9b
Remove some state syncs
unmultimedio ae2ef2f
Merge branch 'jfigueroa/mock-old-main' into jfigueroa/automate-cas-co…
unmultimedio 9d758d7
Revert "Remove some state syncs"
unmultimedio 5e42e6f
lint
unmultimedio 6d12ab5
Run workflow in all PRs
unmultimedio ed1a13c
Log comment URLs
unmultimedio 1ed486b
Revert "Run workflow in all PRs"
unmultimedio 9c72750
Fix off-by-one refs indexes
unmultimedio File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| name: Auto-comment PR with casdiff | ||
|
|
||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize] | ||
| branches: | ||
| - main | ||
| paths: | ||
| - modules/sync/*/*/state.json | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| comment-changes: | ||
| runs-on: ubuntu-latest | ||
| if: github.repository == 'bufbuild/modules' | ||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| fetch-depth: 0 # Need full history to compare branches | ||
|
|
||
| - name: Install Go | ||
| uses: actions/setup-go@v6 | ||
| with: | ||
| go-version: 1.26.x | ||
| check-latest: true | ||
| cache: true | ||
|
|
||
| - name: Run commentprcasdiff | ||
| run: go run ./cmd/commentprcasdiff | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| BASE_REF: ${{ github.event.pull_request.base.sha }} | ||
| HEAD_REF: ${{ github.event.pull_request.head.sha }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| /.tmp/ | ||
| /.claude/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ linters: | |
| - T any | ||
| - i int | ||
| - wg sync.WaitGroup | ||
| - tc testCase | ||
| exclusions: | ||
| generated: lax | ||
| presets: | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| # commentprcasdiff | ||
|
|
||
| Automates posting CAS diff comments on PRs when module digest changes are detected. | ||
|
|
||
| ## What it does | ||
|
|
||
| This tool runs in GitHub Actions when a PR is created against the `fetch-modules` branch. It: | ||
|
|
||
| 1. Identifies which `state.json` files changed in the PR | ||
| 2. Compares old and new state to find digest transitions (hash changes) | ||
| 3. Runs `casdiff` for each transition to show what changed | ||
| 4. Posts the output as inline review comments at the line where each new digest appears | ||
|
|
||
| ## How it works | ||
|
|
||
| ### State Analysis | ||
|
|
||
| Each module's `state.json` file is append-only and contains references with their content digests: | ||
|
|
||
| ```json | ||
| { | ||
| "references": [ | ||
| {"name": "v1.0.0", "digest": "aaa..."}, | ||
| {"name": "v1.1.0", "digest": "bbb..."} | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| The tool: | ||
| - Reads the full JSON from both the base branch (`main`) and head branch (`fetch-modules`) | ||
| - Identifies newly appended references | ||
| - Detects when the digest changes between consecutive references | ||
| - For each digest change, runs: `casdiff <old_ref> <new_ref> --format=markdown` | ||
|
|
||
| ### Comment Posting | ||
|
|
||
| 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. | ||
|
|
||
| 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. | ||
|
|
||
| ## Local Testing | ||
|
|
||
| To test the command locally: | ||
|
|
||
| ```bash | ||
| # Set required environment variables | ||
| export PR_NUMBER=1234 | ||
| export BASE_REF=main | ||
| export HEAD_REF=fetch-modules | ||
| export GITHUB_TOKEN=<your_token> | ||
|
|
||
| # Run the command | ||
| go run ./cmd/commentprcasdiff | ||
| ``` | ||
|
|
||
| **Note:** The command expects to be run from the repository root and requires: | ||
| - Git repository with the specified refs | ||
| - GitHub CLI (`gh`) installed and authenticated | ||
| - Access to post PR comments via GitHub API | ||
|
|
||
| ## Architecture | ||
|
|
||
| - **main.go**: Entry point, orchestrates the workflow | ||
| - **module_finder.go**: Finds changed `state.json` files using git diff | ||
| - **state_analyzer.go**: Compares JSON arrays to detect digest transitions | ||
| - **casdiff_runner.go**: Executes casdiff commands in parallel | ||
| - **comment_poster.go**: Posts review comments via GitHub API | ||
|
|
||
| ## Error Handling | ||
|
|
||
| - If casdiff fails for a transition, the error is logged but doesn't stop the workflow | ||
| - Successful transitions still get commented | ||
| - All failures are summarized at the end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| // Copyright 2021-2025 Buf Technologies, Inc. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package main | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "fmt" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "sync" | ||
| ) | ||
|
|
||
| // casDiffResult contains the result of running casdiff for a transition. | ||
| type casDiffResult struct { | ||
| transition stateTransition | ||
| output string // Markdown output from casdiff | ||
| err error | ||
| } | ||
|
|
||
| // runCASDiff executes casdiff command in the module directory. | ||
| func runCASDiff(ctx context.Context, transition stateTransition) casDiffResult { | ||
| result := casDiffResult{ | ||
| transition: transition, | ||
| } | ||
|
|
||
| repoRoot, err := os.Getwd() | ||
| if err != nil { | ||
| result.err = fmt.Errorf("get working directory: %w", err) | ||
| return result | ||
| } | ||
|
|
||
| // Run casdiff in the module directory. casdiff reads state.json from "." so it must run from the | ||
| // module directory. We use an absolute path to the package to avoid path resolution issues when | ||
| // cmd.Dir is set. | ||
| cmd := exec.CommandContext( //nolint:gosec | ||
| ctx, | ||
| "go", "run", filepath.Join(repoRoot, "cmd", "casdiff"), | ||
| transition.fromRef, | ||
| transition.toRef, | ||
| "--format=markdown", | ||
| ) | ||
| cmd.Dir = filepath.Join(repoRoot, transition.modulePath) | ||
|
|
||
| var stdout, stderr bytes.Buffer | ||
| cmd.Stdout = &stdout | ||
| cmd.Stderr = &stderr | ||
|
|
||
| if err := cmd.Run(); err != nil { | ||
| result.err = fmt.Errorf("casdiff failed: %w (stderr: %s)", err, stderr.String()) | ||
| return result | ||
| } | ||
|
|
||
| result.output = fmt.Sprintf( | ||
| "```sh\n$ casdiff %s %s --format=markdown\n```\n\n%s", | ||
| transition.fromRef, | ||
| transition.toRef, | ||
| stdout.String(), | ||
| ) | ||
| return result | ||
| } | ||
|
|
||
| // runCASDiffs runs multiple casdiff commands concurrently. | ||
| func runCASDiffs(ctx context.Context, transitions []stateTransition) []casDiffResult { | ||
| results := make([]casDiffResult, len(transitions)) | ||
| var wg sync.WaitGroup | ||
|
|
||
| for i, transition := range transitions { | ||
| wg.Go(func() { | ||
| results[i] = runCASDiff(ctx, transition) | ||
| }) | ||
| } | ||
|
|
||
| wg.Wait() | ||
| return results | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| // Copyright 2021-2025 Buf Technologies, Inc. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "time" | ||
|
|
||
| "github.com/bufbuild/modules/internal/githubutil" | ||
| "github.com/google/go-github/v64/github" | ||
| ) | ||
|
|
||
| // prReviewComment represents a comment to be posted or patched on a specific line in a PR. | ||
| type prReviewComment struct { | ||
| filePath string // File path in the PR (e.g., "modules/sync/bufbuild/protovalidate/state.json") | ||
| lineNumber int // Line number in the diff | ||
| body string // Comment body (casdiff output) | ||
| } | ||
|
|
||
| // commentKey uniquely identifies a comment by file and line. | ||
| type commentKey struct { | ||
| path string | ||
| line int | ||
| } | ||
|
|
||
| // postReviewComments posts review comments to specific lines in the PR diff. If a bot comment | ||
| // already exists at the same file/line, it is updated instead of creating a duplicate. | ||
| func postReviewComments(ctx context.Context, prNumber int, gitCommitID string, comments ...prReviewComment) error { | ||
| client := githubutil.NewClient(ctx) | ||
|
|
||
| existingPRComments, err := listExistingBotComments(ctx, client, prNumber) | ||
| if err != nil { | ||
| return fmt.Errorf("list existing bot comments: %w", err) | ||
| } | ||
|
|
||
| var errsPosting []error | ||
| for _, comment := range comments { | ||
| key := commentKey{path: comment.filePath, line: comment.lineNumber} | ||
| if prCommentID, alreadyPosted := existingPRComments[key]; alreadyPosted { | ||
| if err := updateReviewComment(ctx, client, prCommentID, comment.body); err != nil { | ||
| errsPosting = append(errsPosting, fmt.Errorf("updating existing comment in %v: %w", key, err)) | ||
| } | ||
| } else { | ||
| if err := postSingleReviewComment(ctx, client, prNumber, gitCommitID, comment); err != nil { | ||
| errsPosting = append(errsPosting, fmt.Errorf("posting new comment in %v: %w", key, err)) | ||
| } | ||
| } | ||
| } | ||
| return errors.Join(errsPosting...) | ||
| } | ||
|
|
||
| // listExistingBotComments returns a map of (path, line) → commentID for all comments left by | ||
| // github-actions[bot] on the PR. Sorted ascending by creation time so that when multiple bot | ||
| // comments exist at the same file+line, the highest-ID (most recently created) one wins. | ||
| func listExistingBotComments(ctx context.Context, client *githubutil.Client, prNumber int) (map[commentKey]int64, error) { | ||
| const githubActionsBotUsername = "github-actions[bot]" | ||
| result := make(map[commentKey]int64) | ||
| opts := &github.PullRequestListCommentsOptions{ | ||
| Sort: "created", | ||
| Direction: "asc", | ||
| ListOptions: github.ListOptions{PerPage: 100}, | ||
| } | ||
| for { | ||
| comments, resp, err := client.GitHub.PullRequests.ListComments( | ||
| ctx, | ||
| string(githubutil.GithubOwnerBufbuild), | ||
| string(githubutil.GithubRepoModules), | ||
| prNumber, | ||
| opts, | ||
| ) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("list PR comments: %w", err) | ||
| } | ||
| for _, comment := range comments { | ||
| if comment.GetUser().GetLogin() == githubActionsBotUsername { | ||
| key := commentKey{path: comment.GetPath(), line: comment.GetLine()} | ||
| result[key] = comment.GetID() | ||
| } | ||
| } | ||
| if resp.NextPage == 0 { | ||
| break | ||
| } | ||
| opts.Page = resp.NextPage | ||
| } | ||
| return result, nil | ||
| } | ||
|
|
||
| func postSingleReviewComment(ctx context.Context, client *githubutil.Client, prNumber int, gitCommitID string, comment prReviewComment) error { | ||
| body := fmt.Sprintf("_[Posted at %s]_\n\n%s", time.Now().Format(time.RFC3339), comment.body) | ||
| created, _, err := client.GitHub.PullRequests.CreateComment( | ||
| ctx, | ||
| string(githubutil.GithubOwnerBufbuild), | ||
| string(githubutil.GithubRepoModules), | ||
| prNumber, | ||
| &github.PullRequestComment{ | ||
| CommitID: &gitCommitID, | ||
| Path: &comment.filePath, | ||
| Line: &comment.lineNumber, | ||
| Body: &body, | ||
| }, | ||
| ) | ||
| if err != nil { | ||
| return fmt.Errorf("create PR comment: %w", err) | ||
| } | ||
| fmt.Fprintf(os.Stdout, "Posted comment: %s\n", created.GetHTMLURL()) | ||
| return nil | ||
| } | ||
|
|
||
| func updateReviewComment(ctx context.Context, client *githubutil.Client, prCommentID int64, body string) error { | ||
| body = fmt.Sprintf("_[Updated at %s]_\n\n%s", time.Now().Format(time.RFC3339), body) | ||
| updated, _, err := client.GitHub.PullRequests.EditComment( | ||
| ctx, | ||
| string(githubutil.GithubOwnerBufbuild), | ||
| string(githubutil.GithubRepoModules), | ||
| prCommentID, | ||
| &github.PullRequestComment{ | ||
| Body: &body, | ||
| }, | ||
| ) | ||
| if err != nil { | ||
| return fmt.Errorf("edit PR comment: %w", err) | ||
| } | ||
| fmt.Fprintf(os.Stdout, "Updated comment: %s\n", updated.GetHTMLURL()) | ||
| return nil | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of shelling out to casdiff, it would be nice if it could just call the main logic here as an API. This works for now though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had Claude analyze this:
Happy to take that as a follow up!