diff --git a/.github/workflows/rdformat.yml b/.github/workflows/rdformat.yml index 2e174b2a..7214fd91 100644 --- a/.github/workflows/rdformat.yml +++ b/.github/workflows/rdformat.yml @@ -1,4 +1,4 @@ -name: RDFormat +name: Proto on: [push,pull_request] jobs: build: @@ -7,7 +7,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Build - working-directory: proto/rdf/ + working-directory: proto/ run: ./update.sh - name: Check diff run: git diff --exit-code diff --git a/CHANGELOG.md b/CHANGELOG.md index f1431cfe..83d32912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ... ### :bug: Fixes -- ... +- [#1792](https://github.com/reviewdog/reviewdog/pull/1792) Introduce `__reviewdog__` HTML comment metadata and fingerprint for identifying existing posted comments for `github-pr-review` reporter. This resolve duplicated comments issue with related location feature. ### :rotating_light: Breaking changes - ... diff --git a/proto/rdf/Dockerfile b/proto/Dockerfile similarity index 100% rename from proto/rdf/Dockerfile rename to proto/Dockerfile diff --git a/proto/entrypoint.sh b/proto/entrypoint.sh new file mode 100755 index 00000000..a48ec457 --- /dev/null +++ b/proto/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/bash +protoc --proto_path=./rdf --go_out=./rdf --go_opt=paths=source_relative --jsonschema_out=./rdf/jsonschema ./rdf/reviewdog.proto +protoc --proto_path=./metacomment --go_out=./metacomment --go_opt=paths=source_relative ./metacomment/metacomment.proto diff --git a/proto/metacomment/metacomment.pb.go b/proto/metacomment/metacomment.pb.go new file mode 100644 index 00000000..2fb9b8f6 --- /dev/null +++ b/proto/metacomment/metacomment.pb.go @@ -0,0 +1,166 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.2 +// protoc v5.27.1 +// source: metacomment.proto + +package metacomment + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Represents a metadata of a diagnostic result. +// It's expected to be base64 encoded and included into reporter comments such +// as GitHub Pull Request Review comment. +// +// This metadata allow reviewdog to identify the same existing comment and +// avoid posting duplicated comments. It can also be used for resolving or +// deleting existing comments. +type MetaComment struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // An unique identity, or "fingerprint", of the diagnostic result. + Fingerprint string `protobuf:"bytes,1,opt,name=fingerprint,proto3" json:"fingerprint,omitempty"` + // Source (tool) name of the diagnostic result. + // It's important to have source name so that reviewdog can handle existing + // comments with the same source properly. + SourceName string `protobuf:"bytes,2,opt,name=source_name,json=sourceName,proto3" json:"source_name,omitempty"` +} + +func (x *MetaComment) Reset() { + *x = MetaComment{} + if protoimpl.UnsafeEnabled { + mi := &file_metacomment_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MetaComment) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MetaComment) ProtoMessage() {} + +func (x *MetaComment) ProtoReflect() protoreflect.Message { + mi := &file_metacomment_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MetaComment.ProtoReflect.Descriptor instead. +func (*MetaComment) Descriptor() ([]byte, []int) { + return file_metacomment_proto_rawDescGZIP(), []int{0} +} + +func (x *MetaComment) GetFingerprint() string { + if x != nil { + return x.Fingerprint + } + return "" +} + +func (x *MetaComment) GetSourceName() string { + if x != nil { + return x.SourceName + } + return "" +} + +var File_metacomment_proto protoreflect.FileDescriptor + +var file_metacomment_proto_rawDesc = []byte{ + 0x0a, 0x11, 0x6d, 0x65, 0x74, 0x61, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2e, 0x72, + 0x64, 0x66, 0x22, 0x50, 0x0a, 0x0b, 0x4d, 0x65, 0x74, 0x61, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, + 0x74, 0x12, 0x20, 0x0a, 0x0b, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x66, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, + 0x69, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, + 0x6f, 0x6d, 0x2f, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2f, 0x72, 0x65, 0x76, + 0x69, 0x65, 0x77, 0x64, 0x6f, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6d, 0x65, 0x74, + 0x61, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_metacomment_proto_rawDescOnce sync.Once + file_metacomment_proto_rawDescData = file_metacomment_proto_rawDesc +) + +func file_metacomment_proto_rawDescGZIP() []byte { + file_metacomment_proto_rawDescOnce.Do(func() { + file_metacomment_proto_rawDescData = protoimpl.X.CompressGZIP(file_metacomment_proto_rawDescData) + }) + return file_metacomment_proto_rawDescData +} + +var file_metacomment_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_metacomment_proto_goTypes = []any{ + (*MetaComment)(nil), // 0: reviewdog.rdf.MetaComment +} +var file_metacomment_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_metacomment_proto_init() } +func file_metacomment_proto_init() { + if File_metacomment_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_metacomment_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*MetaComment); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_metacomment_proto_rawDesc, + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_metacomment_proto_goTypes, + DependencyIndexes: file_metacomment_proto_depIdxs, + MessageInfos: file_metacomment_proto_msgTypes, + }.Build() + File_metacomment_proto = out.File + file_metacomment_proto_rawDesc = nil + file_metacomment_proto_goTypes = nil + file_metacomment_proto_depIdxs = nil +} diff --git a/proto/metacomment/metacomment.proto b/proto/metacomment/metacomment.proto new file mode 100644 index 00000000..26ac2201 --- /dev/null +++ b/proto/metacomment/metacomment.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; +package reviewdog.rdf; + +option go_package = "github.com/reviewdog/reviewdog/proto/metacomment"; + +// Represents a metadata of a diagnostic result. +// It's expected to be base64 encoded and included into reporter comments such +// as GitHub Pull Request Review comment. +// +// This metadata allow reviewdog to identify the same existing comment and +// avoid posting duplicated comments. It can also be used for resolving or +// deleting existing comments. +message MetaComment { + // An unique identity, or "fingerprint", of the diagnostic result. + string fingerprint = 1; + + // Source (tool) name of the diagnostic result. + // It's important to have source name so that reviewdog can handle existing + // comments with the same source properly. + string source_name = 2; +} diff --git a/proto/rdf/entrypoint.sh b/proto/rdf/entrypoint.sh deleted file mode 100755 index 9c5718ec..00000000 --- a/proto/rdf/entrypoint.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -protoc --proto_path=. --go_out=. --go_opt=paths=source_relative --jsonschema_out=./jsonschema ./reviewdog.proto diff --git a/proto/rdf/update.sh b/proto/update.sh similarity index 100% rename from proto/rdf/update.sh rename to proto/update.sh diff --git a/scripts/decode-metacomment/main.go b/scripts/decode-metacomment/main.go new file mode 100644 index 00000000..f75edb69 --- /dev/null +++ b/scripts/decode-metacomment/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/reviewdog/reviewdog/service/github" +) + +func main() { + if len(os.Args) == 1 { + log.Fatal("require one argument") + } + meta, err := github.DecodeMetaComment(os.Args[1]) + if err != nil { + log.Fatalf("failed to decode meta comment: %v", err) + } + fmt.Printf("%v\n", meta) +} diff --git a/service/commentutil/commentutil.go b/service/commentutil/commentutil.go index 0393c4a2..d2db28bb 100644 --- a/service/commentutil/commentutil.go +++ b/service/commentutil/commentutil.go @@ -9,13 +9,13 @@ import ( "github.com/reviewdog/reviewdog/proto/rdf" ) -// `path` to `position`(Lnum for new file) to comment `body`s +// `path` to `position`(Lnum for new file) to comment `body` or `finterprint` type PostedComments map[string]map[int][]string // IsPosted returns true if a given comment has been posted in code review service already, // otherwise returns false. It sees comments with same path, same position, // and same body as same comments. -func (p PostedComments) IsPosted(c *reviewdog.Comment, lineNum int, body string) bool { +func (p PostedComments) IsPosted(c *reviewdog.Comment, lineNum int, bodyOrFingerprint string) bool { path := c.Result.Diagnostic.GetLocation().GetPath() if _, ok := p[path]; !ok { return false @@ -25,7 +25,7 @@ func (p PostedComments) IsPosted(c *reviewdog.Comment, lineNum int, body string) return false } for _, b := range bodies { - if b == body { + if b == bodyOrFingerprint { return true } } @@ -33,14 +33,14 @@ func (p PostedComments) IsPosted(c *reviewdog.Comment, lineNum int, body string) } // AddPostedComment adds a posted comment. -func (p PostedComments) AddPostedComment(path string, lineNum int, body string) { +func (p PostedComments) AddPostedComment(path string, lineNum int, bodyOrFingerprint string) { if _, ok := p[path]; !ok { p[path] = make(map[int][]string) } if _, ok := p[path][lineNum]; !ok { p[path][lineNum] = make([]string, 0) } - p[path][lineNum] = append(p[path][lineNum], body) + p[path][lineNum] = append(p[path][lineNum], bodyOrFingerprint) } // DebugLog outputs posted comments as log for debugging. diff --git a/service/github/github.go b/service/github/github.go index fe40c76c..a7ddaaa6 100644 --- a/service/github/github.go +++ b/service/github/github.go @@ -2,8 +2,10 @@ package github import ( "context" + "encoding/base64" "errors" "fmt" + "hash/fnv" "log" "net/http" "os" @@ -13,10 +15,12 @@ import ( "sync" "github.com/google/go-github/v60/github" + "google.golang.org/protobuf/proto" "github.com/reviewdog/reviewdog" "github.com/reviewdog/reviewdog/cienv" "github.com/reviewdog/reviewdog/pathutil" + "github.com/reviewdog/reviewdog/proto/metacomment" "github.com/reviewdog/reviewdog/proto/rdf" "github.com/reviewdog/reviewdog/service/commentutil" "github.com/reviewdog/reviewdog/service/github/githubutils" @@ -154,8 +158,12 @@ func (g *PullRequest) postAsReviewComment(ctx context.Context) error { } repoBaseHTMLURLForRelatedLoc = repo.GetHTMLURL() + "/blob/" + g.sha } - body := buildBody(c, repoBaseHTMLURLForRelatedLoc, rootPath) - if g.postedcs.IsPosted(c, githubCommentLine(c), body) { + fprint, err := fingerprint(c.Result.Diagnostic) + if err != nil { + return err + } + body := buildBody(c, repoBaseHTMLURLForRelatedLoc, rootPath, fprint) + if g.postedcs.IsPosted(c, githubCommentLine(c), fprint) { // it's already posted. skip it. continue } @@ -307,11 +315,42 @@ func (g *PullRequest) setPostedComment(ctx context.Context) error { if c.GetSubjectType() == "line" { line = c.GetLine() } - g.postedcs.AddPostedComment(c.GetPath(), line, c.GetBody()) + if meta := extractMetaComment(c.GetBody()); meta != nil { + g.postedcs.AddPostedComment(c.GetPath(), line, meta.GetFingerprint()) + } + } + return nil +} + +func extractMetaComment(body string) *metacomment.MetaComment { + prefix := ""); foundSuffix { + meta, err := DecodeMetaComment(metastring) + if err != nil { + log.Printf("failed to decode MetaComment: %v", err) + continue + } + return meta + } + } } return nil } +func DecodeMetaComment(metaBase64 string) (*metacomment.MetaComment, error) { + b, err := base64.StdEncoding.DecodeString(metaBase64) + if err != nil { + return nil, err + } + meta := &metacomment.MetaComment{} + if err := proto.Unmarshal(b, meta); err != nil { + return nil, err + } + return meta, nil +} + // Diff returns a diff of PullRequest. func (g *PullRequest) Diff(ctx context.Context) ([]byte, error) { opt := github.RawOptions{Type: github.Diff} @@ -403,7 +442,7 @@ func listAllPullRequestsComments(ctx context.Context, cli *github.Client, return append(comments, restComments...), nil } -func buildBody(c *reviewdog.Comment, baseRelatedLocURL string, gitRootPath string) string { +func buildBody(c *reviewdog.Comment, baseRelatedLocURL string, gitRootPath string, fprint string) string { cbody := commentutil.MarkdownComment(c) if suggestion := buildSuggestions(c); suggestion != "" { cbody += "\n" + suggestion @@ -420,9 +459,20 @@ func buildBody(c *reviewdog.Comment, baseRelatedLocURL string, gitRootPath strin } cbody += "\n
\n\n" + relatedLoc.GetMessage() + "\n" + relatedURL } + cbody += fmt.Sprintf("\n\n", buildMetaComment(fprint, c.ToolName)) return cbody } +func buildMetaComment(fprint string, toolName string) string { + b, _ := proto.Marshal( + &metacomment.MetaComment{ + Fingerprint: fprint, + SourceName: toolName, + }, + ) + return base64.StdEncoding.EncodeToString(b) +} + func buildSuggestions(c *reviewdog.Comment) string { var sb strings.Builder for _, s := range c.Result.Diagnostic.GetSuggestions() { @@ -505,3 +555,21 @@ func getSourceLine(sourceLines map[int]string, line int) (string, error) { } return lineContent, nil } + +func fingerprint(d *rdf.Diagnostic) (string, error) { + h := fnv.New64a() + // Ideally, we should not use proto.Marshal since Proto Serialization Is Not + // Canonical. + // https://protobuf.dev/programming-guides/serialization-not-canonical/ + // + // However, I left it as-is for now considering the same reviewdog binary + // should re-calculate and compare fingerprint for almost all cases. + data, err := proto.Marshal(d) + if err != nil { + return "", err + } + if _, err := h.Write(data); err != nil { + return "", err + } + return fmt.Sprintf("%x", h.Sum64()), nil +} diff --git a/service/github/github_test.go b/service/github/github_test.go index 3be394cb..51626fe4 100644 --- a/service/github/github_test.go +++ b/service/github/github_test.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "testing" @@ -183,7 +184,7 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { { Path: github.String("reviewdog.go"), Line: github.Int(2), - Body: github.String(commentutil.BodyPrefix + "already commented"), + Body: github.String(commentutil.BodyPrefix + "already commented" + "\n\n"), SubjectType: github.String("line"), }, } @@ -196,21 +197,21 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { { Path: github.String("reviewdog.go"), Line: github.Int(15), - Body: github.String(commentutil.BodyPrefix + "already commented 2"), + Body: github.String(commentutil.BodyPrefix + "already commented 2" + "\n\n"), SubjectType: github.String("line"), }, { Path: github.String("reviewdog.go"), StartLine: github.Int(15), Line: github.Int(16), - Body: github.String(commentutil.BodyPrefix + "multiline existing comment"), + Body: github.String(commentutil.BodyPrefix + "multiline existing comment" + "\n\n"), SubjectType: github.String("line"), }, { Path: github.String("reviewdog.go"), StartLine: github.Int(15), Line: github.Int(17), - Body: github.String(commentutil.BodyPrefix + "multiline existing comment (line-break)"), + Body: github.String(commentutil.BodyPrefix + "multiline existing comment (line-break)" + "\n\n"), SubjectType: github.String("line"), }, { @@ -257,7 +258,7 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { Path: github.String("reviewdog.go"), Side: github.String("RIGHT"), Line: github.Int(15), - Body: github.String(commentutil.BodyPrefix + "new comment"), + Body: github.String(commentutil.BodyPrefix + "new comment" + "\n\n"), }, { Path: github.String("reviewdog.go"), @@ -265,7 +266,7 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { StartSide: github.String("RIGHT"), StartLine: github.Int(15), Line: github.Int(16), - Body: github.String(commentutil.BodyPrefix + "multiline new comment"), + Body: github.String(commentutil.BodyPrefix + "multiline new comment" + "\n\n"), }, { Path: github.String("reviewdog.go"), @@ -280,6 +281,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "line2", "line3", "```", + "", + "", }, "\n") + "\n"), }, { @@ -292,6 +295,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "line1", "line2", "```", + "", + "", }, "\n") + "\n"), }, { @@ -303,6 +308,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { Body: github.String(commentutil.BodyPrefix + strings.Join([]string{ "invalid lines suggestion comment", invalidSuggestionPre + "GitHub comment range and suggestion line range must be same. L15-L16 v.s. L16-L17" + invalidSuggestionPost, + "", + "", }, "\n") + "\n"), }, { @@ -318,6 +325,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "line2", "line3", "```", + "", + "", }, "\n") + "\n"), }, { @@ -334,6 +343,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "line3", "```", invalidSuggestionPre + "GitHub comment range and suggestion line range must be same. L14-L16 v.s. L14-L14" + invalidSuggestionPost, + "", + "", }, "\n") + "\n"), }, { @@ -345,6 +356,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { Body: github.String(commentutil.BodyPrefix + strings.Join([]string{ "non-line based suggestion comment (no source lines)", invalidSuggestionPre + "source lines are not available" + invalidSuggestionPost, + "", + "", }, "\n") + "\n"), }, { @@ -356,6 +369,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "```suggestion", "haya14busa", "```", + "", + "", }, "\n") + "\n"), }, { @@ -369,6 +384,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "```suggestion", "haya14busa (multi-line)", "```", + "", + "", }, "\n") + "\n"), }, { @@ -382,6 +399,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "```suggestion", "line 15 (content at line 15)", "```", + "", + "", }, "\n") + "\n"), }, { @@ -393,6 +412,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "```suggestion", "haya14busa", "```", + "", + "", }, "\n") + "\n"), }, { @@ -410,6 +431,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "```suggestion", "haya14busa", "```", + "", + "", }, "\n") + "\n"), }, { @@ -421,6 +444,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "```suggestion", "haya14busa", "```", + "", + "", }, "\n") + "\n"), }, { @@ -436,6 +461,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "some code", "```", "````", + "", + "", }, "\n") + "\n"), }, { @@ -449,6 +476,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "some code", "```", "````", + "", + "", }, "\n") + "\n"), }, { @@ -463,6 +492,8 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "```", "`````", "``````", + "", + "", }, "\n") + "\n"), }, { @@ -481,9 +512,18 @@ func TestGitHubPullRequest_Post_Flush_review_api(t *testing.T) { "", "related loc test (2)", "https://test/repo/path/blob/sha/service/github/reviewdog2.go#L14", + "", + "", }, "\n")), }, } + // Replace __reviewdog__ comment so that the test pass regardless of environments. + // Proto serialization is not canonical, and test could break unless + // replacing the metacomment string. + for i := 0; i < len(req.Comments); i++ { + metaCommentRe := regexp.MustCompile(`__reviewdog__:\S+`) + req.Comments[i].Body = github.String(metaCommentRe.ReplaceAllString(*req.Comments[i].Body, `__reviewdog__:xxxxxxxxxx`)) + } if diff := pretty.Compare(want, req.Comments); diff != "" { t.Errorf("req.Comments diff: (-got +want)\n%s", diff) }