| // Copyright 2026 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package task |
| |
| import ( |
| "context" |
| "path" |
| "reflect" |
| "strings" |
| "testing" |
| |
| "golang.org/x/build/gerrit" |
| wf "golang.org/x/build/internal/workflow" |
| "golang.org/x/build/relmeta" |
| "golang.org/x/vulndb/report" |
| yaml "gopkg.in/yaml.v3" |
| ) |
| |
| func TestStartsWithASCII(t *testing.T) { |
| tests := []struct { |
| in string |
| want bool |
| }{ |
| {"hello", true}, |
| {"Hello", true}, |
| {"zoo", true}, |
| {"Zoo", true}, |
| {"aBC", true}, |
| {"ZBC", true}, |
| {"1abc", false}, |
| {" abc", false}, |
| {"", false}, |
| {"日本語", false}, |
| {"{bad", false}, |
| {"@at", false}, |
| {"[bracket", false}, |
| } |
| for _, tt := range tests { |
| if got := startsWithASCII(tt.in); got != tt.want { |
| t.Errorf("startsWithAscii(%q) = %v, want %v", tt.in, got, tt.want) |
| } |
| } |
| } |
| |
| func TestSubject(t *testing.T) { |
| reports := []*report.Report{ |
| {ID: "GO-2026-0001"}, |
| {ID: "GO-2026-0002"}, |
| } |
| got := Subject(reports) |
| if !strings.Contains(got, "add 2 first party reports") { |
| t.Errorf("subject missing report count:\n%s", got) |
| } |
| if !strings.Contains(got, "Fixes golang/vulndb#0001") { |
| t.Errorf("subject missing first fix line:\n%s", got) |
| } |
| if !strings.Contains(got, "Fixes golang/vulndb#0002") { |
| t.Errorf("subject missing second fix line:\n%s", got) |
| } |
| } |
| |
| func TestStdVulnReportVersions(t *testing.T) { |
| tests := []struct { |
| name string |
| targets []string |
| want report.Versions |
| wantErr bool |
| }{ |
| { |
| name: "single", |
| targets: []string{"go1.1.0"}, |
| want: report.Versions{report.Fixed("1.1.0")}, |
| }, |
| { |
| name: "two versions", |
| targets: []string{"go1.24.1", "go1.23.5"}, |
| want: report.Versions{ |
| report.Fixed("1.23.5"), |
| report.Introduced("1.24.0-0"), |
| report.Fixed("1.24.1"), |
| }, |
| }, |
| { |
| name: "empty", |
| targets: nil, |
| wantErr: true, |
| }, |
| { |
| name: "invalid semver", |
| targets: []string{"not-a-version"}, |
| wantErr: true, |
| }, |
| { |
| name: "bare semver rejected", |
| targets: []string{"1.24.1"}, |
| wantErr: true, |
| }, |
| { |
| name: "two component rejected", |
| targets: []string{"go1.26"}, |
| wantErr: true, |
| }, |
| { |
| name: "one component rejected", |
| targets: []string{"go1"}, |
| wantErr: true, |
| }, |
| { |
| name: "non-numeric minor rejected", |
| targets: []string{"go1.2a.3"}, |
| wantErr: true, |
| }, |
| } |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| got, err := stdVulnReportVersions(tt.targets) |
| if (err != nil) != tt.wantErr { |
| t.Fatalf("stdVulnReportVersions(%v): err = %v, wantErr = %v", tt.targets, err, tt.wantErr) |
| } |
| if err != nil { |
| return |
| } |
| if !reflect.DeepEqual(got, tt.want) { |
| t.Errorf("stdVulnReportVersions(%v):\ngot %v\nwant %v", tt.targets, got, tt.want) |
| } |
| }) |
| } |
| } |
| |
| func TestVulnReport(t *testing.T) { |
| mod := VulnModuleInfo{Module: "golang.org/x/net", Versions: report.Versions{report.Fixed("1.1.0")}, VulnerableAt: report.VulnerableAt("1.0.0")} |
| const announceURL = "https://groups.google.com/g/golang-announce/c/test" |
| |
| t.Run("valid", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| GitHubIssueID: 12345, |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "net/http2: bad things happen.\n\nDetails about the bad things.", |
| CVE: "CVE-2026-0001", |
| CWE: "CWE-400", |
| Credits: []string{"Alice"}, |
| VulnReportID: "GO-2026-0001", |
| } |
| r, err := VulnReport(p, mod, announceURL) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if r.ID != "GO-2026-0001" { |
| t.Errorf("ID = %q, want GO-2026-0001", r.ID) |
| } |
| if got := string(r.Summary); got != "Bad things happen in net/http2" { |
| t.Errorf("Summary = %q", got) |
| } |
| if got := string(r.Description); got != "Details about the bad things." { |
| t.Errorf("Description = %q", got) |
| } |
| if r.CVEMetadata.ID != "CVE-2026-0001" { |
| t.Errorf("CVE = %q", r.CVEMetadata.ID) |
| } |
| if len(r.Modules) != 1 || r.Modules[0].Module != "golang.org/x/net" { |
| t.Errorf("Module = %v", r.Modules) |
| } |
| if got := r.Modules[0].VulnerableAt; got == nil || got.Version != "1.0.0" { |
| t.Errorf("VulnerableAt = %v, want 1.0.0", got) |
| } |
| if r.Modules[0].Packages[0].Package != "golang.org/x/net/http2" { |
| t.Errorf("Package = %q", r.Modules[0].Packages[0].Package) |
| } |
| if r.ReviewStatus != report.Reviewed { |
| t.Errorf("ReviewStatus = %v", r.ReviewStatus) |
| } |
| }) |
| |
| t.Run("dotted identifier preserves interior periods", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "net/http", |
| GitHubIssueID: 12345, |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "net/http: TLS 1.3 handshake panics.\n\nDetails about the panic.", |
| CVE: "CVE-2026-0002", |
| CWE: "CWE-400", |
| Credits: []string{"Bob"}, |
| VulnReportID: "GO-2026-0002", |
| } |
| stdMod := VulnModuleInfo{Module: "std", Versions: report.Versions{report.Fixed("1.1.0")}, VulnerableAt: report.VulnerableAt("1.0.0")} |
| r, err := VulnReport(p, stdMod, announceURL) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if got := string(r.Summary); got != "TLS 1.3 handshake panics in net/http" { |
| t.Errorf("Summary = %q, want %q", got, "TLS 1.3 handshake panics in net/http") |
| } |
| }) |
| |
| t.Run("VulnReportDesc override", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| GitHubIssueID: 12345, |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "net/http2: bad things happen.\n\nOriginal description.", |
| VulnReportDesc: "Overridden description.", |
| VulnReportID: "GO-2026-0001", |
| CVE: "CVE-2026-0001", |
| } |
| r, err := VulnReport(p, mod, announceURL) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if got := string(r.Description); got != "Overridden description." { |
| t.Errorf("Description = %q, want overridden", got) |
| } |
| }) |
| |
| t.Run("missing versions", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| GitHubIssueID: 12345, |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "net/http2: bad.\n\nDetails.", |
| } |
| if _, err := VulnReport(p, VulnModuleInfo{Module: "golang.org/x/net"}, announceURL); err == nil { |
| t.Fatal("expected error for missing versions") |
| } |
| }) |
| |
| t.Run("missing github issue", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "net/http2: bad.\n\nDetails.", |
| } |
| if _, err := VulnReport(p, mod, announceURL); err == nil { |
| t.Fatal("expected error for missing github issue") |
| } |
| }) |
| |
| t.Run("missing changelists", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| GitHubIssueID: 12345, |
| ReleaseNote: "net/http2: bad.\n\nDetails.", |
| } |
| if _, err := VulnReport(p, mod, announceURL); err == nil { |
| t.Fatal("expected error for missing changelists") |
| } |
| }) |
| |
| t.Run("missing announce URL", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| GitHubIssueID: 12345, |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "net/http2: bad.\n\nDetails.", |
| } |
| if _, err := VulnReport(p, mod, ""); err == nil { |
| t.Fatal("expected error for missing announce URL") |
| } |
| }) |
| |
| t.Run("malformed release note no newline", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| GitHubIssueID: 12345, |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "no newline here", |
| } |
| if _, err := VulnReport(p, mod, announceURL); err == nil { |
| t.Fatal("expected error for malformed release note") |
| } |
| }) |
| |
| t.Run("malformed release note no colon", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| GitHubIssueID: 12345, |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "no colon in subject\n\nDetails.", |
| } |
| if _, err := VulnReport(p, mod, announceURL); err == nil { |
| t.Fatal("expected error for malformed subject") |
| } |
| }) |
| |
| t.Run("empty summary after trim", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| GitHubIssueID: 12345, |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "net/http2: .\n\nDetails.", |
| } |
| if _, err := VulnReport(p, mod, announceURL); err == nil { |
| t.Fatal("expected error for empty summary after trim") |
| } |
| }) |
| |
| t.Run("non-ascii summary", func(t *testing.T) { |
| p := &relmeta.SecurityPatch{ |
| Package: "golang.org/x/net/http2", |
| GitHubIssueID: 12345, |
| Changelists: []string{"https://go.dev/cl/111"}, |
| ReleaseNote: "net/http2: 日本語\n\nDetails.", |
| } |
| if _, err := VulnReport(p, mod, announceURL); err == nil { |
| t.Fatal("expected error for non-ascii summary") |
| } |
| }) |
| } |
| |
| func TestVulnModule(t *testing.T) { |
| tests := []struct { |
| pkg string |
| want string |
| }{ |
| {"net/http", "std"}, |
| {"crypto/tls", "std"}, |
| {"cmd/go", "cmd"}, |
| {"cmd", "cmd"}, |
| {"cmd/compile/internal/ssa", "cmd"}, |
| {"golang.org/x/net/http2", "golang.org/x/net"}, |
| {"golang.org/x/crypto/ssh", "golang.org/x/crypto"}, |
| {"golang.org/x/net", "golang.org/x/net"}, |
| {"", "std"}, |
| } |
| for _, tc := range tests { |
| t.Run(tc.pkg, func(t *testing.T) { |
| if got := VulnModule(tc.pkg); got != tc.want { |
| t.Errorf("got %q, want %q", got, tc.want) |
| } |
| }) |
| } |
| } |
| |
| func TestStdVulnerableAt(t *testing.T) { |
| tests := []struct { |
| name string |
| targets []string |
| wantVer string |
| wantErr bool |
| }{ |
| { |
| name: "two release lines", |
| targets: []string{"go1.25.10", "go1.26.3"}, |
| wantVer: "1.26.2", |
| }, |
| { |
| name: "single release", |
| targets: []string{"go1.26.3"}, |
| wantVer: "1.26.2", |
| }, |
| { |
| name: "result patch zero", |
| targets: []string{"go1.24.1", "go1.25.1"}, |
| wantVer: "1.25.0", |
| }, |
| { |
| name: "empty input", |
| targets: []string{}, |
| wantErr: true, |
| }, |
| { |
| name: "patch zero", |
| targets: []string{"go1.26.0"}, |
| wantErr: true, |
| }, |
| { |
| name: "invalid semver", |
| targets: []string{"not.a.version"}, |
| wantErr: true, |
| }, |
| { |
| name: "two component version", |
| targets: []string{"go1.26"}, |
| wantErr: true, |
| }, |
| { |
| name: "bare semver rejected", |
| targets: []string{"1.26.3"}, |
| wantErr: true, |
| }, |
| { |
| name: "one component rejected", |
| targets: []string{"go1"}, |
| wantErr: true, |
| }, |
| { |
| name: "non-numeric minor rejected", |
| targets: []string{"go1.2a.3"}, |
| wantErr: true, |
| }, |
| } |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| got, err := stdVulnerableAt(tt.targets) |
| if (err != nil) != tt.wantErr { |
| t.Fatalf("got %v, want %v", err, tt.wantErr) |
| } |
| if err != nil { |
| return |
| } |
| if got.Version != tt.wantVer { |
| t.Errorf("got %q, want %q", got.Version, tt.wantVer) |
| } |
| }) |
| } |
| } |
| |
| func TestStdVulnerableAtPrerelease(t *testing.T) { |
| // A pre-release suffix like "1.26.3-rc1" passes semver.IsValid |
| // but makes the patch extraction fail (strconv.Atoi on "3-rc1"). |
| _, err := stdVulnerableAt([]string{"go1.26.3-rc1"}) |
| if err == nil { |
| t.Fatal("expected error for pre-release target, got nil") |
| } |
| if !strings.Contains(err.Error(), "non-numeric patch") { |
| t.Errorf("unexpected error: %v", err) |
| } |
| } |
| |
| func TestStdVulnModuleInfo(t *testing.T) { |
| tests := []struct { |
| name string |
| p *relmeta.SecurityPatch |
| wantMod string |
| wantVer string |
| wantVers report.Versions |
| wantErr bool |
| }{ |
| { |
| name: "std package", |
| p: &relmeta.SecurityPatch{ |
| Package: "net/http", |
| TargetReleases: []string{"go1.25.10", "go1.26.3"}, |
| }, |
| wantMod: "std", |
| wantVer: "1.26.2", |
| wantVers: report.Versions{report.Fixed("1.25.10"), report.Introduced("1.26.0-0"), report.Fixed("1.26.3")}, |
| }, |
| { |
| name: "cmd package", |
| p: &relmeta.SecurityPatch{ |
| Package: "cmd/go", |
| TargetReleases: []string{"go1.26.3"}, |
| }, |
| wantMod: "cmd", |
| wantVer: "1.26.2", |
| wantVers: report.Versions{report.Fixed("1.26.3")}, |
| }, |
| { |
| name: "invalid target releases", |
| p: &relmeta.SecurityPatch{ |
| Package: "net/http", |
| TargetReleases: []string{"not-a-version"}, |
| }, |
| wantErr: true, |
| }, |
| { |
| name: "empty target releases", |
| p: &relmeta.SecurityPatch{ |
| Package: "net/http", |
| TargetReleases: nil, |
| }, |
| wantErr: true, |
| }, |
| } |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| mod, err := StdVulnModuleInfo(tt.p) |
| if (err != nil) != tt.wantErr { |
| t.Fatalf("got %v, want %v", err, tt.wantErr) |
| } |
| if err != nil { |
| return |
| } |
| if mod.Module != tt.wantMod { |
| t.Errorf("Module = %q, want %q", mod.Module, tt.wantMod) |
| } |
| if mod.VulnerableAt == nil || mod.VulnerableAt.Version != tt.wantVer { |
| t.Errorf("VulnerableAt = %v, want %q", mod.VulnerableAt, tt.wantVer) |
| } |
| if !reflect.DeepEqual(mod.Versions, tt.wantVers) { |
| t.Errorf("Versions = %v, want %v", mod.Versions, tt.wantVers) |
| } |
| }) |
| } |
| } |
| |
| type fakeVulnGerrit struct { |
| *FakeGerrit |
| |
| gotInput gerrit.ChangeInput |
| gotReviewers []string |
| gotFiles map[string]string |
| } |
| |
| func (g *fakeVulnGerrit) CreateAutoSubmitChange(ctx *wf.TaskContext, input gerrit.ChangeInput, reviewers []string, files map[string]string) (string, error) { |
| g.gotInput = input |
| g.gotReviewers = reviewers |
| g.gotFiles = files |
| return g.FakeGerrit.CreateAutoSubmitChange(ctx, input, reviewers, files) |
| } |
| |
| func TestMailVulnReports(t *testing.T) { |
| t.Run("empty reports", func(t *testing.T) { |
| ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}} |
| changeID, err := MailVulnReports(ctx, nil, nil, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if changeID != "" { |
| t.Errorf("got change ID %q, want empty", changeID) |
| } |
| }) |
| |
| t.Run("happy path", func(t *testing.T) { |
| vulnRepo := NewFakeRepo(t, "vulndb") |
| gc := &fakeVulnGerrit{ |
| FakeGerrit: NewFakeGerrit(t, vulnRepo), |
| } |
| ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}} |
| |
| reports := []*report.Report{ |
| {ID: "GO-2026-0001"}, |
| {ID: "GO-2026-0002"}, |
| } |
| wantReviewers := []string{"reviewer-a@google.com", "reviewer-b@google.com"} |
| changeID, err := MailVulnReports(ctx, gc, reports, wantReviewers) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if changeID == "" { |
| t.Fatal("expected non-empty change ID") |
| } |
| if gc.gotInput.Project != "vulndb" { |
| t.Errorf("project = %q, want vulndb", gc.gotInput.Project) |
| } |
| if gc.gotInput.Branch != "master" { |
| t.Errorf("branch = %q, want master", gc.gotInput.Branch) |
| } |
| if !reflect.DeepEqual(gc.gotReviewers, wantReviewers) { |
| t.Errorf("reviewers = %v, want %v", gc.gotReviewers, wantReviewers) |
| } |
| for _, id := range []string{"GO-2026-0001", "GO-2026-0002"} { |
| key := path.Join("data", "reports", id+".yaml") |
| content, ok := gc.gotFiles[key] |
| if !ok { |
| t.Errorf("missing file %q in submitted files", key) |
| continue |
| } |
| var got report.Report |
| if err := yaml.Unmarshal([]byte(content), &got); err != nil { |
| t.Errorf("unmarshal %q: %v", key, err) |
| continue |
| } |
| if got.ID != id { |
| t.Errorf("file %q: ID = %q, want %q", key, got.ID, id) |
| } |
| } |
| }) |
| |
| t.Run("open CL exists", func(t *testing.T) { |
| vulnRepo := NewFakeRepo(t, "vulndb") |
| fg := NewFakeGerrit(t, vulnRepo) |
| gc := &fakeVulnGerrit{FakeGerrit: fg} |
| |
| reports := []*report.Report{ |
| {ID: "GO-2026-0001"}, |
| {ID: "GO-2026-0002"}, |
| } |
| |
| fg.AddChange("vulndb", "existing-cl", &gerrit.ChangeInfo{ |
| ID: "existing-cl", |
| Status: "NEW", |
| Branch: "master", |
| }, Subject(reports)) |
| |
| ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}} |
| changeID, err := MailVulnReports(ctx, gc, reports, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if changeID != "existing-cl" { |
| t.Errorf("got change ID %q, want %q", changeID, "existing-cl") |
| } |
| if gc.gotFiles != nil { |
| t.Error("expected no CL to be created when open CL exists") |
| } |
| }) |
| } |
| |
| func TestConvertInternalChangelists(t *testing.T) { |
| const milestoneYAML = `id: 77770001 |
| security_patches: |
| - id: 1 |
| package: golang.org/x/net/http2 |
| track: PRIVATE |
| changelists: |
| - https://go-internal-review.git.corp.google.com/c/net/+/1111 |
| - https://go-internal-review.git.corp.google.com/c/net/+/2222 |
| - id: 2 |
| package: golang.org/x/net/html |
| track: PUBLIC |
| changelists: |
| - https://go.dev/cl/3333 |
| ` |
| milestonePath := path.Join("data", "milestones", "77770001.yaml") |
| newGerrit := func(t *testing.T) *FakeGerrit { |
| smRepo := NewFakeRepo(t, "security-metadata") |
| smRepo.Branch("main", smRepo.History()[0]) |
| smRepo.CommitOnBranch("main", map[string]string{milestonePath: milestoneYAML}) |
| return NewFakeGerrit(t, smRepo) |
| } |
| readMilestone := func(t *testing.T, ctx context.Context, gc *FakeGerrit) string { |
| head, err := gc.ReadBranchHead(ctx, "security-metadata", "main") |
| if err != nil { |
| t.Fatal(err) |
| } |
| b, err := gc.ReadFile(ctx, "security-metadata", head, milestonePath) |
| if err != nil { |
| t.Fatal(err) |
| } |
| return string(b) |
| } |
| external := map[string]string{ |
| "https://go-internal-review.git.corp.google.com/c/net/+/1111": "https://go.dev/cl/558675", |
| "https://go-internal-review.git.corp.google.com/c/net/+/2222": "https://go.dev/cl/558676", |
| } |
| wantReviewers := []string{"reviewer-a@google.com"} |
| |
| t.Run("rewrites in place and refetches", func(t *testing.T) { |
| ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}} |
| gc := newGerrit(t) |
| rm, err := ConvertInternalChangelists(ctx, gc, "77770001", external, wantReviewers) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if got, want := rm.Patches[0].Changelists, []string{"https://go.dev/cl/558675", "https://go.dev/cl/558676"}; !reflect.DeepEqual(got, want) { |
| t.Errorf("private patch changelists = %v, want %v", got, want) |
| } |
| if got, want := rm.Patches[1].Changelists, []string{"https://go.dev/cl/3333"}; !reflect.DeepEqual(got, want) { |
| t.Errorf("public patch changelists = %v, want %v", got, want) |
| } |
| if !reflect.DeepEqual(gc.LastReviewers, wantReviewers) { |
| t.Errorf("reviewers = %v, want %v", gc.LastReviewers, wantReviewers) |
| } |
| if got := readMilestone(t, ctx, gc); strings.Contains(got, "go-internal-review") { |
| t.Errorf("milestone at head still has private links:\n%s", got) |
| } |
| |
| gc.LastReviewers = nil |
| again, err := ConvertInternalChangelists(ctx, gc, "77770001", external, wantReviewers) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if gc.LastReviewers != nil { |
| t.Errorf("second call mailed a change with reviewers %v", gc.LastReviewers) |
| } |
| if !reflect.DeepEqual(again, rm) { |
| t.Errorf("second call milestone = %+v, want %+v", again, rm) |
| } |
| }) |
| |
| t.Run("nothing to convert", func(t *testing.T) { |
| ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}} |
| gc := newGerrit(t) |
| before := readMilestone(t, ctx, gc) |
| if _, err := ConvertInternalChangelists(ctx, gc, "77770001", nil, wantReviewers); err != nil { |
| t.Fatal(err) |
| } |
| if gc.LastReviewers != nil { |
| t.Errorf("mailed a change with reviewers %v", gc.LastReviewers) |
| } |
| if after := readMilestone(t, ctx, gc); after != before { |
| t.Errorf("milestone changed without external changelists:\ngot %s\nwant %s", after, before) |
| } |
| }) |
| } |