Skip to content

Commit e7adddd

Browse files
authored
Summary casdiff comment (#1198)
In addition to per-transition inline comments, post a single overall comment on `modules/sync/state.json` showing the end-to-end change (base → head latest reference) for each module that changed — giving reviewers a quick summary of what BSR main will see after sync without reading every intermediate step. - Per-transition comments are collapsed in a `<details>` block and labeled **Intermediate transition** - The overall comment is posted on the `latest_reference` line in the global state file and labeled **Global transition** - Fixed broken markdown rendering when a diffed file (e.g. `buf.md`) contains triple-backtick sequences inside its own diff
1 parent 34e7108 commit e7adddd

7 files changed

Lines changed: 153 additions & 32 deletions

File tree

.github/workflows/auto-casdiff-comment.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ on:
66
branches:
77
- main
88
paths:
9+
- modules/sync/state.json
910
- modules/sync/*/*/state.json
1011

1112
permissions:

cmd/commentprcasdiff/casdiff_runner.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,18 @@ func runCASDiff(ctx context.Context, transition stateTransition) casDiffResult {
4747
return result
4848
}
4949

50-
result.output = fmt.Sprintf(
51-
"```sh\n$ casdiff %s %s --format=markdown\n```\n\n%s",
52-
transition.fromRef,
53-
transition.toRef,
54-
mdiff.String(bufcasdiff.ManifestDiffOutputFormatMarkdown),
55-
)
50+
cmd := fmt.Sprintf("```sh\n$ casdiff %s \\\n %s \\\n --format=markdown\n```", transition.fromRef, transition.toRef)
51+
diffOutput := mdiff.String(bufcasdiff.ManifestDiffOutputFormatMarkdown)
52+
if transition.isOverallTransition {
53+
result.output = "### Overall transition\n\n" + cmd + "\n\n" + diffOutput
54+
} else {
55+
result.output = fmt.Sprintf(
56+
"**Intermediate transition**\n\n%s\n<details><summary>%s</summary>\n<p>\n\n%s\n</p>\n</details>",
57+
cmd,
58+
mdiff.Summary(),
59+
diffOutput,
60+
)
61+
}
5662
return result
5763
}
5864

cmd/commentprcasdiff/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ func run(ctx context.Context, flags *flags) error {
147147
}
148148
}
149149

150+
overallTransitions, err := getOverallTransitions(ctx, stateRW, baseRef, headRef)
151+
if err != nil {
152+
fmt.Fprintf(os.Stderr, "Warning: failed to get overall transitions from global state: %v\n", err)
153+
} else if len(overallTransitions) > 0 {
154+
fmt.Fprintf(os.Stdout, "Found %d overall transition(s) in global state:\n", len(overallTransitions))
155+
for _, t := range overallTransitions {
156+
fmt.Fprintf(os.Stdout, " %s: %s -> %s\n", strings.TrimPrefix(t.modulePath, "modules/sync/"), t.fromRef, t.toRef)
157+
}
158+
allTransitions = append(allTransitions, overallTransitions...)
159+
}
160+
150161
if len(allTransitions) == 0 {
151162
fmt.Fprintf(os.Stdout, "No digest transitions found\n")
152163
return nil

cmd/commentprcasdiff/state_analyzer.go

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ import (
3030

3131
// stateTransition represents a digest change in a module's state.json file.
3232
type stateTransition struct {
33-
modulePath string // e.g., "modules/sync/bufbuild/protovalidate"
34-
filePath string // e.g., "modules/sync/bufbuild/protovalidate/state.json"
35-
fromRef string // Last reference with old digest (e.g., "v1.1.0")
36-
toRef string // First reference with new digest (e.g., "v1.2.0")
37-
fromDigest string // Old digest
38-
toDigest string // New digest
39-
lineNumber int // Line in diff where new digest first appears
33+
modulePath string // e.g., "modules/sync/bufbuild/protovalidate"
34+
filePath string // The module where the transition happened, can be an individual module like "modules/sync/bufbuild/protovalidate/state.json" or the global state file "modules/sync/state.json"
35+
fromRef string // Old git reference (e.g., "v1.1.0")
36+
toRef string // New git reference (e.g., "v1.2.0")
37+
fromDigest string // Old digest
38+
toDigest string // New digest
39+
lineNumber int // Line in diff where the new reference or digest appears.
40+
isOverallTransition bool // True for overall transitions on the global state.json file.
4041
}
4142

4243
// getStateFileTransitions reads state.json from base and head branches, compares the JSON arrays to
@@ -99,13 +100,14 @@ func getStateFileTransitions(
99100
lineNumber = lineNumbers[i]
100101
}
101102
transitions = append(transitions, stateTransition{
102-
modulePath: modulePath,
103-
filePath: filePath,
104-
fromRef: currentRef,
105-
toRef: appendedRef.GetName(),
106-
fromDigest: currentDigest,
107-
toDigest: appendedRef.GetDigest(),
108-
lineNumber: lineNumber,
103+
modulePath: modulePath,
104+
filePath: filePath,
105+
fromRef: currentRef,
106+
toRef: appendedRef.GetName(),
107+
fromDigest: currentDigest,
108+
toDigest: appendedRef.GetDigest(),
109+
lineNumber: lineNumber,
110+
isOverallTransition: false,
109111
})
110112
currentDigest = appendedRef.GetDigest()
111113
}
@@ -151,6 +153,90 @@ func resolveAppendedRefs(
151153
return baseRefs[len(baseRefs)-1], headRefs[len(baseRefs):]
152154
}
153155

156+
// getOverallTransitions reads modules/sync/state.json from both base and head, compares the
157+
// two, and returns one stateTransition per module whose latest_reference changed. Modules that were
158+
// added or removed between base and head are ignored.
159+
func getOverallTransitions(
160+
ctx context.Context,
161+
stateRW *bufstate.ReadWriter,
162+
baseRef string,
163+
headRef string,
164+
) ([]stateTransition, error) {
165+
const globalStatePath = "modules/sync/state.json"
166+
167+
baseContent, err := readFileAtRef(ctx, globalStatePath, baseRef)
168+
if err != nil {
169+
return nil, fmt.Errorf("read base global state: %w", err)
170+
}
171+
headContent, err := readFileAtRef(ctx, globalStatePath, headRef)
172+
if err != nil {
173+
return nil, fmt.Errorf("read head global state: %w", err)
174+
}
175+
176+
baseGlobalState, err := stateRW.ReadGlobalState(io.NopCloser(bytes.NewReader(baseContent)))
177+
if err != nil {
178+
return nil, fmt.Errorf("parse base global state: %w", err)
179+
}
180+
headGlobalState, err := stateRW.ReadGlobalState(io.NopCloser(bytes.NewReader(headContent)))
181+
if err != nil {
182+
return nil, fmt.Errorf("parse head global state: %w", err)
183+
}
184+
185+
baseLatestRefs := make(map[string]string, len(baseGlobalState.GetModules()))
186+
for _, mod := range baseGlobalState.GetModules() {
187+
baseLatestRefs[mod.GetModuleName()] = mod.GetLatestReference()
188+
}
189+
190+
var transitions []stateTransition
191+
for _, mod := range headGlobalState.GetModules() {
192+
moduleName := mod.GetModuleName()
193+
toRef := mod.GetLatestReference()
194+
fromRef, existsInBase := baseLatestRefs[moduleName]
195+
if !existsInBase || fromRef == toRef {
196+
continue // it is a new module, or the reference did not change.
197+
}
198+
lineNumber, err := findLatestReferenceLineInGlobalState(headContent, moduleName)
199+
if err != nil {
200+
return nil, fmt.Errorf("find line number for %q: %w", moduleName, err)
201+
}
202+
transitions = append(transitions, stateTransition{
203+
modulePath: "modules/sync/" + moduleName,
204+
filePath: globalStatePath,
205+
fromRef: fromRef,
206+
toRef: toRef,
207+
lineNumber: lineNumber,
208+
isOverallTransition: true,
209+
})
210+
}
211+
return transitions, nil
212+
}
213+
214+
// findLatestReferenceLineInGlobalState scans the raw JSON of modules/sync/state.json and returns
215+
// the 1-based line number of the "latest_reference" field for the given module.
216+
func findLatestReferenceLineInGlobalState(content []byte, moduleName string) (int, error) {
217+
quotedName := `"` + moduleName + `"`
218+
scanner := bufio.NewScanner(bytes.NewReader(content))
219+
var (
220+
lineNum int
221+
foundModule bool
222+
)
223+
for scanner.Scan() {
224+
lineNum++
225+
line := scanner.Text()
226+
if !foundModule {
227+
if strings.Contains(line, `"module_name"`) && strings.Contains(line, quotedName) {
228+
foundModule = true
229+
}
230+
} else if strings.Contains(line, `"latest_reference"`) {
231+
return lineNum, nil
232+
}
233+
}
234+
if err := scanner.Err(); err != nil {
235+
return 0, fmt.Errorf("scan global state: %w", err)
236+
}
237+
return 0, fmt.Errorf("latest_reference for module %q not found in global state", moduleName)
238+
}
239+
154240
// readFileAtRef reads a file's content at a specific git ref using git show.
155241
func readFileAtRef(ctx context.Context, filePath string, ref string) ([]byte, error) {
156242
cmd := exec.CommandContext(ctx, "git", "show", fmt.Sprintf("%s:%s", ref, filePath)) //nolint:gosec

internal/bufcasdiff/manifest_diff.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"context"
2020
"encoding/hex"
2121
"fmt"
22+
"strings"
2223

2324
"buf.build/go/standard/xslices"
2425
"github.com/bufbuild/buf/private/pkg/cas"
@@ -139,6 +140,20 @@ func buildManifestDiff(
139140
return diff, nil
140141
}
141142

143+
// Summary returns a manifest diff summary in the shape of:
144+
//
145+
// %d files changed: %d removed, %d renamed, %d added, %d changed content.
146+
func (d *ManifestDiff) Summary() string {
147+
return fmt.Sprintf(
148+
"%d files changed: %d removed, %d renamed, %d added, %d changed content.",
149+
len(d.pathsRemoved)+len(d.pathsRenamed)+len(d.pathsAdded)+len(d.pathsChangedContent),
150+
len(d.pathsRemoved),
151+
len(d.pathsRenamed),
152+
len(d.pathsAdded),
153+
len(d.pathsChangedContent),
154+
)
155+
}
156+
142157
// String returns the diff output in the given format. On invalid or unknown format, this function
143158
// defaults to ManifestDiffOutputFormatText.
144159
func (d *ManifestDiff) String(format ManifestDiffOutputFormat) string {
@@ -147,15 +162,7 @@ func (d *ManifestDiff) String(format ManifestDiffOutputFormat) string {
147162
if isMarkdown {
148163
b.WriteString("> ")
149164
}
150-
fmt.Fprintf(
151-
&b,
152-
"%d files changed: %d removed, %d renamed, %d added, %d changed content\n",
153-
len(d.pathsRemoved)+len(d.pathsRenamed)+len(d.pathsAdded)+len(d.pathsChangedContent),
154-
len(d.pathsRemoved),
155-
len(d.pathsRenamed),
156-
len(d.pathsAdded),
157-
len(d.pathsChangedContent),
158-
)
165+
b.WriteString(d.Summary() + "\n")
159166
if len(d.pathsRemoved) > 0 {
160167
b.WriteString("\n")
161168
if isMarkdown {
@@ -219,7 +226,7 @@ func (d *ManifestDiff) String(format ManifestDiffOutputFormat) string {
219226
b.WriteString(fdiff.from.Path() + ":\n")
220227
}
221228
if isMarkdown {
222-
b.WriteString("```diff\n" + fdiff.diff + "\n```\n")
229+
b.WriteString(markdownFencedDiff(fdiff.diff))
223230
} else {
224231
b.WriteString(fdiff.diff + "\n")
225232
}
@@ -228,6 +235,16 @@ func (d *ManifestDiff) String(format ManifestDiffOutputFormat) string {
228235
return b.String()
229236
}
230237

238+
// markdownFencedDiff wraps content in a ```diff code fence, using a longer fence if the content
239+
// itself contains backtick runs that would break the fence.
240+
func markdownFencedDiff(content string) string {
241+
fence := "```"
242+
for strings.Contains(content, fence) {
243+
fence += "`"
244+
}
245+
return fence + "diff\n" + content + "\n" + fence + "\n"
246+
}
247+
231248
func calculateFileNodeDiff(
232249
ctx context.Context,
233250
from cas.FileNode,

internal/bufcasdiff/testdata/manifest_diff/markdown.golden.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
> 9 files changed: 1 removed, 6 renamed, 1 added, 1 changed content
1+
> 9 files changed: 1 removed, 6 renamed, 1 added, 1 changed content.
22
33
# Files removed:
44

internal/bufcasdiff/testdata/manifest_diff/text.golden.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
9 files changed: 1 removed, 6 renamed, 1 added, 1 changed content
1+
9 files changed: 1 removed, 6 renamed, 1 added, 1 changed content.
22

33
Files removed:
44

0 commit comments

Comments
 (0)