Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
d6a6d66
Diff commenter
unmultimedio Mar 17, 2026
870975d
lint
unmultimedio Mar 17, 2026
03b780b
Allow on any PR.
unmultimedio Mar 17, 2026
6a16eae
Remove some state changes
unmultimedio Mar 17, 2026
c0df650
Merge commit '6a16eae7a861b394ff70323ced502349d2e156c8' into jfiguero…
unmultimedio Mar 17, 2026
918c3f0
Revert "Remove some state changes"
unmultimedio Mar 17, 2026
931a568
Newer go
unmultimedio Mar 17, 2026
70353b3
nits
unmultimedio Mar 17, 2026
e2e0336
add dry-run
unmultimedio Mar 17, 2026
1f27022
lint
unmultimedio Mar 17, 2026
be9ed5d
nits
unmultimedio Mar 17, 2026
832b817
Remove some state changes
unmultimedio Mar 17, 2026
3ccf934
Reapply "Remove some state changes"
unmultimedio Mar 17, 2026
87c102a
Merge branch 'jfigueroa/mock-old-main' into jfigueroa/automate-cas-co…
unmultimedio Mar 17, 2026
610b51b
Revert "Remove some state changes"
unmultimedio Mar 17, 2026
e176380
nit
unmultimedio Mar 17, 2026
35d09bd
Dedupe GH PR comments
unmultimedio Mar 18, 2026
127c28b
Manual nits
unmultimedio Mar 18, 2026
9dc5c6d
time format
unmultimedio Mar 18, 2026
425d58b
Reorganize comments poster
unmultimedio Mar 18, 2026
2030053
Use proto schema
unmultimedio Mar 18, 2026
4842e46
Test and comment nits
unmultimedio Mar 18, 2026
2edf93b
lint
unmultimedio Mar 18, 2026
a41d8fc
Reapply "Remove some state changes"
unmultimedio Mar 18, 2026
faedd0c
Enable workflow
unmultimedio Mar 18, 2026
7eb6345
Revert "Reapply "Remove some state changes""
unmultimedio Mar 18, 2026
e19a2e8
Simplify test
unmultimedio Mar 18, 2026
2c57e46
Paths filter
unmultimedio Mar 18, 2026
ebd2529
lint
unmultimedio Mar 18, 2026
b1832d2
Merge branch 'main' into jfigueroa/automate-cas-comment
unmultimedio Mar 18, 2026
b145d81
Use wg.Go
unmultimedio Mar 18, 2026
60c54f0
Use gh dependency instead of shelling out to gh CLI
unmultimedio Mar 18, 2026
bc23dbe
Use appcmd
unmultimedio Mar 18, 2026
89a8f9b
Remove some state syncs
unmultimedio Mar 18, 2026
ae2ef2f
Merge branch 'jfigueroa/mock-old-main' into jfigueroa/automate-cas-co…
unmultimedio Mar 18, 2026
9d758d7
Revert "Remove some state syncs"
unmultimedio Mar 18, 2026
5e42e6f
lint
unmultimedio Mar 18, 2026
6d12ab5
Run workflow in all PRs
unmultimedio Mar 18, 2026
ed1a13c
Log comment URLs
unmultimedio Mar 18, 2026
1ed486b
Revert "Run workflow in all PRs"
unmultimedio Mar 18, 2026
9c72750
Fix off-by-one refs indexes
unmultimedio Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/auto-casdiff-comment.yaml
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 }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/.tmp/
/.claude/
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ linters:
- T any
- i int
- wg sync.WaitGroup
- tc testCase
exclusions:
generated: lax
presets:
Expand Down
73 changes: 73 additions & 0 deletions cmd/commentprcasdiff/README.md
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
89 changes: 89 additions & 0 deletions cmd/commentprcasdiff/casdiff_runner.go
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
Copy link
Member

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.

Copy link
Member Author

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:

The core problem is that casdiff is package main — everything is unexported and unimportable. On top of that, printMarkdown() writes directly to os.Stdout, so even if we could import it, we'd need an io.Writer-based API to capture the output as a string.

The cleanest path, following the pattern already set by private/bufpkg/bufstate, is to extract the core logic into a new package — something like private/bufpkg/bufcasdiff. That package would expose something like:

// DiffMarkdown computes the CAS diff for two refs in a module directory and
// writes the result in markdown format to w.
func DiffMarkdown(ctx context.Context, moduleDir string, from, to string, w io.Writer) error

Then:

  • cmd/casdiff becomes a thin wrapper that calls bufcasdiff.DiffMarkdown writing to os.Stdout
  • cmd/commentprcasdiff calls it writing to a strings.Builder, capturing the output directly

It's a real refactor — you'd be moving buildManifestDiff, manifestDiff, calculateDiffFromCASDirectory, and the formatting logic out of cmd/casdiff/main into the new package, and updating cmd/casdiff to use it. The calculateFileNodeDiff logic has the heaviest dependencies (cas, storage, diff from buf), but those are already dependencies of this module.

Happy to take that as a follow up!

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
}
140 changes: 140 additions & 0 deletions cmd/commentprcasdiff/comment_poster.go
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
}
Loading
Loading