diff --git a/.github/workflows/auto-casdiff-comment.yaml b/.github/workflows/auto-casdiff-comment.yaml new file mode 100644 index 00000000..96e83864 --- /dev/null +++ b/.github/workflows/auto-casdiff-comment.yaml @@ -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 }} diff --git a/.gitignore b/.gitignore index a5fc3962..dfcdbb86 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /.tmp/ +/.claude/ diff --git a/.golangci.yaml b/.golangci.yaml index 5f17f0ec..720b2e12 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -37,6 +37,7 @@ linters: - T any - i int - wg sync.WaitGroup + - tc testCase exclusions: generated: lax presets: diff --git a/cmd/commentprcasdiff/README.md b/cmd/commentprcasdiff/README.md new file mode 100644 index 00000000..a8ac5f12 --- /dev/null +++ b/cmd/commentprcasdiff/README.md @@ -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 --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= + +# 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 diff --git a/cmd/commentprcasdiff/casdiff_runner.go b/cmd/commentprcasdiff/casdiff_runner.go new file mode 100644 index 00000000..af666ac1 --- /dev/null +++ b/cmd/commentprcasdiff/casdiff_runner.go @@ -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 +} diff --git a/cmd/commentprcasdiff/comment_poster.go b/cmd/commentprcasdiff/comment_poster.go new file mode 100644 index 00000000..3c0a16f4 --- /dev/null +++ b/cmd/commentprcasdiff/comment_poster.go @@ -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 +} diff --git a/cmd/commentprcasdiff/main.go b/cmd/commentprcasdiff/main.go new file mode 100644 index 00000000..15fccf44 --- /dev/null +++ b/cmd/commentprcasdiff/main.go @@ -0,0 +1,213 @@ +// 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 ( + "cmp" + "context" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "buf.build/go/app/appcmd" + "buf.build/go/app/appext" + "buf.build/go/standard/xslices" + "github.com/bufbuild/buf/private/pkg/slogapp" + "github.com/bufbuild/modules/private/bufpkg/bufstate" + "github.com/spf13/pflag" +) + +const rootCmdName = "commentprcasdiff" + +func main() { + appcmd.Main(context.Background(), newCommand(rootCmdName)) +} + +func newCommand(name string) *appcmd.Command { + builder := appext.NewBuilder( + name, + appext.BuilderWithLoggerProvider(slogapp.LoggerProvider), + ) + flags := newFlags() + return &appcmd.Command{ + Use: name, + Short: "Post CAS diff comments on PRs when module digests change.", + BindFlags: flags.bind, + Run: builder.NewRunFunc( + func(ctx context.Context, _ appext.Container) error { + return run(ctx, flags) + }, + ), + } +} + +type flags struct { + dryRun bool +} + +func newFlags() *flags { + return &flags{} +} + +func (f *flags) bind(flagSet *pflag.FlagSet) { + flagSet.BoolVar(&f.dryRun, "dry-run", false, "print comments to stdout instead of posting to GitHub") +} + +func run(ctx context.Context, flags *flags) error { + baseRef := os.Getenv("BASE_REF") + headRef := os.Getenv("HEAD_REF") + prNumberString := os.Getenv("PR_NUMBER") + var prNumber int + + if baseRef == "" { + return errors.New("BASE_REF environment variable is required") + } + if headRef == "" { + return errors.New("HEAD_REF environment variable is required") + } + if !flags.dryRun { + if os.Getenv("GITHUB_TOKEN") == "" { + return errors.New("GITHUB_TOKEN environment variable is required when not a dry-run") + } + if prNumberString == "" { + return errors.New("PR_NUMBER environment variable is required when not a dry-run") + } + var err error + prNumber, err = strconv.Atoi(prNumberString) + if err != nil { + return fmt.Errorf("parse PR_NUMBER: %w", err) + } + } + + fmt.Fprintf( + os.Stdout, + "Processing PR #%s (base: %s, head: %s)\n", + cmp.Or(prNumberString, "dry-run"), + baseRef, + headRef, + ) + + moduleStatePaths, err := changedModuleStateFiles(ctx, baseRef, headRef) + if err != nil { + return fmt.Errorf("find changed modules: %w", err) + } + + if len(moduleStatePaths) == 0 { + fmt.Fprintf( + os.Stdout, + "No module state.json files changed in between %s..%s refs\n", + baseRef, + headRef, + ) + return nil + } + moduleStatePathsSorted := xslices.MapKeysToSortedSlice(moduleStatePaths) + + fmt.Fprintf( + os.Stdout, + "Found %d changed module(s):\n%s\n", + len(moduleStatePaths), + strings.Join(moduleStatePathsSorted, "\n"), + ) + + stateRW, err := bufstate.NewReadWriter() + if err != nil { + return fmt.Errorf("new state read writer: %w", err) + } + + var allTransitions []stateTransition + for _, moduleStatePath := range moduleStatePathsSorted { + fmt.Fprintf(os.Stdout, "Analyzing %s...\n", moduleStatePath) + + transitions, err := getStateFileTransitions(ctx, stateRW, moduleStatePath, baseRef, headRef) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to analyze %s: %v\n", moduleStatePath, err) + continue + } + + if len(transitions) > 0 { + fmt.Fprintf(os.Stdout, " Found %d digest transition(s)\n", len(transitions)) + allTransitions = append(allTransitions, transitions...) + } else { + fmt.Fprintf(os.Stdout, " No digest changes\n") + } + } + + if len(allTransitions) == 0 { + fmt.Fprintf(os.Stdout, "No digest transitions found\n") + return nil + } + + fmt.Fprintf(os.Stdout, "\nRunning casdiff for %d transition(s)...\n", len(allTransitions)) + results := runCASDiffs(ctx, allTransitions) + + var ( + casResults []casDiffResult + errsToReturn []error + ) + + for _, result := range results { + if result.err != nil { + errsToReturn = append( + errsToReturn, + fmt.Errorf( + "casdiff failed for %s %s->%s: %w", + result.transition.modulePath, + result.transition.fromRef, + result.transition.toRef, + result.err, + ), + ) + } else { + casResults = append(casResults, result) + } + } + + if len(casResults) > 0 { + if flags.dryRun { + fmt.Fprintf(os.Stdout, "\n[dry-run] %d comment(s) would be posted:\n", len(casResults)) + for _, result := range casResults { + fmt.Fprintf(os.Stdout, "\n--- %s (line %d: %s -> %s) ---\n%s\n", + result.transition.filePath, + result.transition.lineNumber, + result.transition.fromRef, + result.transition.toRef, + result.output, + ) + } + } else { + fmt.Fprintf(os.Stdout, "\nPosting %d comment(s) to PR...\n", len(casResults)) + comments := make([]prReviewComment, len(casResults)) + for i, result := range casResults { + comments[i] = prReviewComment{ + filePath: result.transition.filePath, + lineNumber: result.transition.lineNumber, + body: result.output, + } + } + if err := postReviewComments(ctx, prNumber, headRef, comments...); err != nil { + errsToReturn = append(errsToReturn, fmt.Errorf("post review comments: %w", err)) + } + } + } + + if len(errsToReturn) > 0 { + return errors.Join(errsToReturn...) + } + fmt.Fprintf(os.Stdout, "\nDone!\n") + return nil +} diff --git a/cmd/commentprcasdiff/module_finder.go b/cmd/commentprcasdiff/module_finder.go new file mode 100644 index 00000000..fa97b56f --- /dev/null +++ b/cmd/commentprcasdiff/module_finder.go @@ -0,0 +1,50 @@ +// 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" + "fmt" + "os/exec" + "strings" +) + +// changedModuleStateFiles returns modules' state files that had *any change* between the passed +// base and head refs. +func changedModuleStateFiles(ctx context.Context, baseRef string, headRef string) (map[string]struct{}, error) { + // Get list of changed files in the PR + cmd := exec.CommandContext(ctx, "git", "diff", "--name-only", baseRef, headRef) //nolint:gosec + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git diff: %w", err) + } + + moduleStatePaths := make(map[string]struct{}) + for line := range strings.SplitSeq(string(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if line == "modules/sync/state.json" { + continue // exclude the global modules' state.json + } + // Look for "modules/sync///state.json" files + if strings.HasPrefix(line, "modules/sync/") && strings.HasSuffix(line, "/state.json") { + moduleStatePaths[line] = struct{}{} + } + } + + return moduleStatePaths, nil +} diff --git a/cmd/commentprcasdiff/state_analyzer.go b/cmd/commentprcasdiff/state_analyzer.go new file mode 100644 index 00000000..b9ee220e --- /dev/null +++ b/cmd/commentprcasdiff/state_analyzer.go @@ -0,0 +1,228 @@ +// 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 ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os/exec" + "path/filepath" + "strings" + + "github.com/bufbuild/modules/private/bufpkg/bufstate" + statev1alpha1 "github.com/bufbuild/modules/private/gen/modules/state/v1alpha1" +) + +// stateTransition represents a digest change in a module's state.json file. +type stateTransition struct { + modulePath string // e.g., "modules/sync/bufbuild/protovalidate" + filePath string // e.g., "modules/sync/bufbuild/protovalidate/state.json" + fromRef string // Last reference with old digest (e.g., "v1.1.0") + toRef string // First reference with new digest (e.g., "v1.2.0") + fromDigest string // Old digest + toDigest string // New digest + lineNumber int // Line in diff where new digest first appears +} + +// getStateFileTransitions reads state.json from base and head branches, compares the JSON arrays to +// find appended references, and detects digest transitions. +func getStateFileTransitions( + ctx context.Context, + stateRW *bufstate.ReadWriter, + filePath string, + baseRef string, + headRef string, +) ([]stateTransition, error) { + // Read state.json from both branches + baseContent, err := readFileAtRef(ctx, filePath, baseRef) + if err != nil { + return nil, fmt.Errorf("read base state: %w", err) + } + headContent, err := readFileAtRef(ctx, filePath, headRef) + if err != nil { + return nil, fmt.Errorf("read head state: %w", err) + } + + baseState, err := stateRW.ReadModStateFile(io.NopCloser(bytes.NewReader(baseContent))) + if err != nil { + return nil, fmt.Errorf("parse base state: %w", err) + } + headState, err := stateRW.ReadModStateFile(io.NopCloser(bytes.NewReader(headContent))) + if err != nil { + return nil, fmt.Errorf("parse head state: %w", err) + } + + // Identify appended references. + // + // This only works for diffs that append references in the state file, not for diffs that + // update/remove existing refs in base. + baseRefs := baseState.GetReferences() + headRefs := headState.GetReferences() + current, appendedRefs := resolveAppendedRefs(baseRefs, headRefs) + if current == nil { + return nil, nil + } + + // Get line number mapping for the appended references + lineNumbers, err := getLineNumbersForAppendedRefs(ctx, filePath, baseRef, headRef, len(baseRefs), len(headRefs)) + if err != nil { + return nil, fmt.Errorf("get line numbers: %w", err) + } + + // Detect digest transitions + var ( + modulePath = filepath.Dir(filePath) + currentRef = current.GetName() + currentDigest = current.GetDigest() + transitions []stateTransition + ) + for i, appendedRef := range appendedRefs { + if appendedRef.GetDigest() != currentDigest { + // Digest changed! Record transition + var lineNumber int + if i < len(lineNumbers) { + lineNumber = lineNumbers[i] + } + transitions = append(transitions, stateTransition{ + modulePath: modulePath, + filePath: filePath, + fromRef: currentRef, + toRef: appendedRef.GetName(), + fromDigest: currentDigest, + toDigest: appendedRef.GetDigest(), + lineNumber: lineNumber, + }) + currentDigest = appendedRef.GetDigest() + } + currentRef = appendedRef.GetName() + } + + return transitions, nil +} + +// resolveAppendedRefs identifies the refs from headRefs that are new, considering new by +// index-behavior, not its content. Meaning if base has 3 refs and head has 5, we assume the first 3 +// in head are the same 3 in base, and the latest 2 are "appended". That is the use case for the +// fetch-modules PR, and that is for now the only supported behavior. +// +// Returns (nil, nil) when the head refs is empty. The current ref is the latest in the base refs. +// If base is empty, the first ref in head is returned as the current, and all the rest are returned +// as appended. +func resolveAppendedRefs( + baseRefs []*statev1alpha1.ModuleReference, + headRefs []*statev1alpha1.ModuleReference, +) (*statev1alpha1.ModuleReference, []*statev1alpha1.ModuleReference) { + if len(baseRefs) == 0 && len(headRefs) == 0 { + // both empty + // - current: none + // - appended: none + return nil, nil + } + if len(baseRefs) == 0 { + // base empty + // - current: first in head + // - appended: the rest from head (if any) + return headRefs[0], headRefs[1:] + } + if len(headRefs) <= len(baseRefs) { + // head has the same amount or less refs than base + // - current: last in base + // - appended: none + return baseRefs[len(baseRefs)-1], nil + } + // head has more refs than base (expected scenario most of the times) + // - current: last in base + // - appended: the index-base appended in head + return baseRefs[len(baseRefs)-1], headRefs[len(baseRefs):] +} + +// readFileAtRef reads a file's content at a specific git ref using git show. +func readFileAtRef(ctx context.Context, filePath string, ref string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "git", "show", fmt.Sprintf("%s:%s", ref, filePath)) //nolint:gosec + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git show %s:%s: %w", ref, filePath, err) + } + return output, nil +} + +// getLineNumbersForAppendedRefs calculates the line numbers in the diff where each appended +// reference's digest appears. +func getLineNumbersForAppendedRefs( + ctx context.Context, + filePath string, + baseRef string, + headRef string, + baseCount int, + headCount int, +) ([]int, error) { + // Get the unified diff, with zero lines of context up or down. + cmd := exec.CommandContext(ctx, "git", "diff", "-U0", baseRef, headRef, "--", filePath) //nolint:gosec + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git diff: %w", err) + } + return parseLineNumbersFromDiff(string(output), headCount-baseCount) +} + +// parseLineNumbersFromDiff parses a git diff output and extracts line numbers where "digest" fields +// appear in added lines. +func parseLineNumbersFromDiff(diffOutput string, expectedCount int) ([]int, error) { + lineNumbers := make([]int, expectedCount) + scanner := bufio.NewScanner(strings.NewReader(diffOutput)) + + var ( + currentLine int + refIndex = 0 + inAddedSection = false + ) + for scanner.Scan() { + line := scanner.Text() + // Parse hunk headers like: @@ -275,0 +276,12 @@ + if strings.HasPrefix(line, "@@") { + parts := strings.Split(line, " ") + if len(parts) >= 3 { + // Extract new file line number from +276,12 + newRange := strings.TrimPrefix(parts[2], "+") + newRange = strings.Split(newRange, ",")[0] + if _, err := fmt.Sscanf(newRange, "%d", ¤tLine); err != nil { + return nil, fmt.Errorf("parse hunk header line number %q: %w", newRange, err) + } + inAddedSection = true + continue + } + } + if !inAddedSection { + continue + } + // Look for added lines (starting with +) with the string `"digest":` + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + if strings.Contains(line, `"digest":`) { + if refIndex < len(lineNumbers) { + lineNumbers[refIndex] = currentLine + refIndex++ + } + } + currentLine++ + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan diff: %w", err) + } + return lineNumbers, nil +} diff --git a/cmd/commentprcasdiff/state_analyzer_test.go b/cmd/commentprcasdiff/state_analyzer_test.go new file mode 100644 index 00000000..f9191823 --- /dev/null +++ b/cmd/commentprcasdiff/state_analyzer_test.go @@ -0,0 +1,238 @@ +// 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 ( + "testing" + + statev1alpha1 "github.com/bufbuild/modules/private/gen/modules/state/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveAppendedRefs(t *testing.T) { + t.Parallel() + ref := func(name, digest string) *statev1alpha1.ModuleReference { + return statev1alpha1.ModuleReference_builder{Name: name, Digest: digest}.Build() + } + type testCase struct { + name string + baseRefs []*statev1alpha1.ModuleReference + headRefs []*statev1alpha1.ModuleReference + wantCurrent *statev1alpha1.ModuleReference + wantAppended []*statev1alpha1.ModuleReference + } + testCases := []testCase{ + { + // base=0, head=0 → nothing to do + name: "both_empty", + baseRefs: nil, + headRefs: nil, + wantCurrent: nil, + wantAppended: nil, + }, + { + // base=0, head=1 → head[0] is baseline, nothing appended + name: "base_empty_single_ref_in_head", + baseRefs: nil, + headRefs: []*statev1alpha1.ModuleReference{ref("v1.0.0", "d1")}, + wantCurrent: ref("v1.0.0", "d1"), + wantAppended: []*statev1alpha1.ModuleReference{}, + }, + { + // base=0, head=3 → head[0] is baseline, head[1:] are appended + name: "base_empty_multiple_refs", + baseRefs: nil, + headRefs: []*statev1alpha1.ModuleReference{ + ref("v1.0.0", "d1"), + ref("v2.0.0", "d2"), + ref("v3.0.0", "d2"), + }, + wantCurrent: ref("v1.0.0", "d1"), + wantAppended: []*statev1alpha1.ModuleReference{ + ref("v2.0.0", "d2"), + ref("v3.0.0", "d2"), + }, + }, + { + // base=2, head=4 → base[latest] is baseline, head[2:] are appended + name: "base_non_empty_some_appends", + baseRefs: []*statev1alpha1.ModuleReference{ + ref("v1.0.0", "d1"), + ref("v1.1.0", "d1"), + }, + headRefs: []*statev1alpha1.ModuleReference{ + ref("v1.0.0", "d1"), + ref("v1.1.0", "d1"), + ref("v2.0.0", "d2"), + ref("v2.1.0", "d2"), + }, + wantCurrent: ref("v1.1.0", "d1"), + wantAppended: []*statev1alpha1.ModuleReference{ + ref("v2.0.0", "d2"), + ref("v2.1.0", "d2"), + }, + }, + { + // base=1, head=1 → base[latest] is baseline, no appends + name: "head_count_equals_base_count_not_supported", + baseRefs: []*statev1alpha1.ModuleReference{ref("v1.0.0", "d1")}, + headRefs: []*statev1alpha1.ModuleReference{ref("v1.0.0", "d1")}, + wantCurrent: ref("v1.0.0", "d1"), + wantAppended: nil, + }, + { + // base=2, head=1 → base[latest] is baseline, no appends + name: "head_shorter_than_base_not_supported", + baseRefs: []*statev1alpha1.ModuleReference{ + ref("v1.0.0", "d1"), + ref("v2.0.0", "d2"), + }, + headRefs: []*statev1alpha1.ModuleReference{ref("v1.0.0", "d1")}, + wantCurrent: ref("v2.0.0", "d2"), + wantAppended: nil, + }, + { + // base=3, head=5, but the digests don't match. The function only looks at counts, not + // content: base[latest] is baseline, head[2:] are appended. + name: "existing_ref_modified_not_supported", + baseRefs: []*statev1alpha1.ModuleReference{ + ref("v1.0.0", "d1"), + ref("v2.0.0", "d2"), + ref("v3.0.0", "d3"), + }, + headRefs: []*statev1alpha1.ModuleReference{ + ref("v1.0.0", "d1-modified"), + ref("v2.0.0", "d2-modified"), + ref("v3.0.0", "d3-modified"), + ref("v4.0.0", "d4"), + ref("v5.0.0", "d5"), + }, + wantCurrent: ref("v3.0.0", "d3"), // the one from base, not from head + wantAppended: []*statev1alpha1.ModuleReference{ + ref("v4.0.0", "d4"), + ref("v5.0.0", "d5"), + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotCurrent, gotAppended := resolveAppendedRefs(tc.baseRefs, tc.headRefs) + assert.Equal(t, tc.wantCurrent, gotCurrent) + assert.Equal(t, tc.wantAppended, gotAppended) + }) + } +} + +func TestParseLineNumbersFromDiff(t *testing.T) { + t.Parallel() + type testCase struct { + name string + diffOutput string + expectedCount int + want []int + } + testCases := []testCase{ + { + name: "single_digest_change", + diffOutput: `@@ -275,0 +276,6 @@ ++ }, ++ { ++ "name": "v1.1.0", ++ "digest": "aaa" ++ } ++ ]`, + expectedCount: 1, + want: []int{279}, + }, + { + name: "multiple_digest_changes", + diffOutput: `@@ -100,0 +101,12 @@ ++ }, ++ { ++ "name": "v1.1.0", ++ "digest": "aaa" ++ }, ++ { ++ "name": "v1.2.0", ++ "digest": "bbb" ++ }, ++ { ++ "name": "v1.3.0", ++ "digest": "ccc"`, + expectedCount: 3, + want: []int{104, 108, 112}, + }, + { + name: "multiple_hunks", + diffOutput: `@@ -50,0 +51,4 @@ ++ { ++ "name": "v2.0.0", ++ "digest": "xxx" ++ } +@@ -100,0 +105,8 @@ ++ { ++ "name": "v3.0.0", ++ "digest": "yyy" ++ }, ++ { ++ "name": "v4.0.0", ++ "digest": "zzz" ++ }`, + expectedCount: 3, + want: []int{53, 107, 111}, + }, + { + name: "no_digest_lines", + diffOutput: `@@ -10,0 +11,4 @@ ++ { ++ "name": "v1.0.0", ++ "other": "field" ++ }`, + expectedCount: 1, + want: []int{0}, + }, + { + name: "empty_diff", + diffOutput: "", + expectedCount: 1, + want: []int{0}, + }, + { + name: "digest_with_formatting", + diffOutput: `@@ -200,0 +201,8 @@ ++{ ++"name": "v0.1.0", ++"digest": "abc123" ++}, ++ { ++ "name": "v0.2.0", ++ "digest": "def456" ++ }`, + expectedCount: 2, + want: []int{203, 207}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := parseLineNumbersFromDiff(tc.diffOutput, tc.expectedCount) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/cmd/release/main.go b/cmd/release/main.go index f66dc25f..051bcc9e 100644 --- a/cmd/release/main.go +++ b/cmd/release/main.go @@ -306,9 +306,9 @@ func createRelease( githubutil.GithubOwnerBufbuild, githubutil.GithubRepoModules, &github.RepositoryRelease{ - TagName: new(releaseName), - Name: new(releaseName), - Body: new(releaseBody), + TagName: &releaseName, + Name: &releaseName, + Body: &releaseBody, Draft: new(true), // Start release as a draft until all assets are uploaded }) if err != nil {