blob: a636785abbffc9aeda591060adae8f925b1a161b [file]
// Copyright 2026 The Pigweed Authors
//
// 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
//
// https://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 pw_ghish
import (
"context"
"net/http"
"strings"
"testing"
"github.com/andygrunwald/go-gerrit"
)
func TestFindAutoSubmitLabel_MatchesHostSpellings(t *testing.T) {
for _, tt := range []struct {
name string
labels map[string]gerrit.LabelInfo
want LabelVote
}{
{
name: "pigweed",
labels: map[string]gerrit.LabelInfo{
"Code-Review": {},
"Commit-Queue": {},
"Pigweed-Auto-Submit": {},
},
want: LabelVote{Name: "Pigweed-Auto-Submit", Value: 1},
},
{
name: "fuchsia",
labels: map[string]gerrit.LabelInfo{
"Fuchsia-Auto-Submit": {},
},
want: LabelVote{Name: "Fuchsia-Auto-Submit", Value: 1},
},
{
name: "chromium",
labels: map[string]gerrit.LabelInfo{
"Auto-Submit": {},
},
want: LabelVote{Name: "Auto-Submit", Value: 1},
},
{
name: "unhyphenated",
labels: map[string]gerrit.LabelInfo{
"Autosubmit": {},
},
want: LabelVote{Name: "Autosubmit", Value: 1},
},
{
name: "underscored and lowercased",
labels: map[string]gerrit.LabelInfo{
"auto_submit": {},
},
want: LabelVote{Name: "auto_submit", Value: 1},
},
{
name: "votes the label maximum, not a fixed +1",
labels: map[string]gerrit.LabelInfo{
"Auto-Submit": {Values: map[string]string{" 0": "No", "+1": "Yes", "+2": "Yes, really"}},
},
want: LabelVote{Name: "Auto-Submit", Value: 2},
},
} {
t.Run(tt.name, func(t *testing.T) {
got, ok, err := FindAutoSubmitLabel(tt.labels)
if err != nil {
t.Fatalf("FindAutoSubmitLabel(%v) returned error: %v", tt.labels, err)
}
if !ok {
t.Fatalf("FindAutoSubmitLabel(%v) reported no match, want %+v", tt.labels, tt.want)
}
if got != tt.want {
t.Errorf("FindAutoSubmitLabel() = %+v, want %+v", got, tt.want)
}
})
}
}
func TestFindAutoSubmitLabel_NoMatch(t *testing.T) {
labels := map[string]gerrit.LabelInfo{
"Code-Review": {},
"Commit-Queue": {},
}
got, ok, err := FindAutoSubmitLabel(labels)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Errorf("FindAutoSubmitLabel() = %+v, true; want no match", got)
}
if got, ok, err := FindAutoSubmitLabel(nil); ok || err != nil {
t.Errorf("FindAutoSubmitLabel(nil) = %+v, %v, %v; want no match and no error", got, ok, err)
}
}
// Only the separators label names actually use may join the two words. A
// looser pattern would vote an unrelated label as though it submitted the
// change.
func TestFindAutoSubmitLabel_RejectsNonSeparators(t *testing.T) {
for _, name := range []string{
"autoXsubmit",
"Auto+Submit",
"Auto.Submit",
"Auto/Submit",
} {
t.Run(name, func(t *testing.T) {
labels := map[string]gerrit.LabelInfo{name: {}}
got, ok, err := FindAutoSubmitLabel(labels)
if err != nil {
t.Fatalf("FindAutoSubmitLabel(%q) returned error: %v", name, err)
}
if ok {
t.Errorf("FindAutoSubmitLabel(%q) = %+v, true; want no match", name, got)
}
})
}
}
// Two labels that both look like auto-submit are not interchangeable. Picking
// one would vote a label that may not gate submission at all, and the change
// would sit there looking handed off, so this has to fail loudly.
func TestFindAutoSubmitLabel_AmbiguousIsAnError(t *testing.T) {
labels := map[string]gerrit.LabelInfo{
"Pigweed-Auto-Submit": {},
"Auto-Submit": {},
"Code-Review": {},
}
got, ok, err := FindAutoSubmitLabel(labels)
if err == nil {
t.Fatalf("FindAutoSubmitLabel() = %+v, %v, nil; want an error for ambiguous labels", got, ok)
}
if ok || got != (LabelVote{}) {
t.Errorf("FindAutoSubmitLabel() = %+v, %v; want no label alongside the error", got, ok)
}
// The error has to name the candidates, or the user cannot act on it.
for _, want := range []string{"Auto-Submit", "Pigweed-Auto-Submit", "--add-label"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err, want)
}
}
if strings.Contains(err.Error(), "Code-Review") {
t.Errorf("error %q should only list auto-submit candidates", err)
}
}
func TestDecideAutoSubmit_UsesTheHostsLabel(t *testing.T) {
labels := map[string]gerrit.LabelInfo{
"Code-Review": {},
"Auto-Submit": {},
}
got, err := DecideAutoSubmit(labels, "change 1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Unsupported != nil {
t.Fatalf("unexpected Unsupported: %v", got.Unsupported)
}
if got.Vote.Name != "Auto-Submit" || got.Vote.Value != 1 {
t.Errorf("DecideAutoSubmit() voted %+v, want Auto-Submit=1", got.Vote)
}
}
// --auto asks for the change to be submitted. A host with no auto-submit label
// is not going to do that, so saying nothing and voting something adjacent
// would leave the user believing their change was handed off.
func TestDecideAutoSubmit_NoLabelReportsCommitQueueDryRunAndFails(t *testing.T) {
labels := map[string]gerrit.LabelInfo{
"Code-Review": {},
"Commit-Queue": {Values: map[string]string{" 0": "no", "+1": "dry run", "+2": "submit"}},
}
got, err := DecideAutoSubmit(labels, "change 1")
if err != nil {
t.Fatalf("unexpected hard error: %v", err)
}
if got.Unsupported == nil {
t.Fatal("DecideAutoSubmit() reported success for a host with no auto-submit label")
}
// +1 is the dry run. +2 would submit the change, which is more than the
// user asked pw_ghish to arrange and more than it could confirm.
if got.Vote.Name != "Commit-Queue" || got.Vote.Value != 1 {
t.Errorf("DecideAutoSubmit() voted %+v, want Commit-Queue=1", got.Vote)
}
for _, want := range []string{"no auto-submit label", "Commit-Queue+1", "--cq", "Code-Review, Commit-Queue"} {
if !strings.Contains(got.Unsupported.Error(), want) {
t.Errorf("error %q does not mention %q", got.Unsupported, want)
}
}
}
// With nothing to vote there is no reason to let the caller act first, so this
// is a plain error rather than a decision carrying one.
func TestDecideAutoSubmit_NoLabelAndNoCommitQueueIsAHardError(t *testing.T) {
labels := map[string]gerrit.LabelInfo{
"Code-Review": {},
"Verified": {},
}
got, err := DecideAutoSubmit(labels, "change 1")
if err == nil {
t.Fatalf("DecideAutoSubmit() = %+v, nil; want an error", got)
}
if got.Vote != (LabelVote{}) {
t.Errorf("DecideAutoSubmit() voted %+v, want nothing", got.Vote)
}
for _, want := range []string{"no auto-submit label", "Code-Review, Verified"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err, want)
}
}
}
// A Commit Queue that cannot be voted +1 is no use as a dry run, so this is
// the same as having none.
func TestDecideAutoSubmit_CommitQueueWithoutDryRunIsNotOffered(t *testing.T) {
labels := map[string]gerrit.LabelInfo{
"Commit-Queue": {Values: map[string]string{" 0": "no"}},
}
got, err := DecideAutoSubmit(labels, "change 1")
if err == nil {
t.Fatalf("DecideAutoSubmit() = %+v, nil; want an error", got)
}
if got.Vote != (LabelVote{}) {
t.Errorf("DecideAutoSubmit() voted %+v, want nothing", got.Vote)
}
}
// Labels are how the host says what it supports. Without them there is nothing
// to reason from, and the profile is not consulted: guessing is what this whole
// path exists to stop.
func TestDecideAutoSubmit_NoLabelsAtAllIsAnError(t *testing.T) {
if got, err := DecideAutoSubmit(nil, "change 1"); err == nil {
t.Errorf("DecideAutoSubmit(nil) = %+v, nil; want an error", got)
}
}
func TestDecideAutoSubmit_AmbiguousIsAHardError(t *testing.T) {
labels := map[string]gerrit.LabelInfo{
"Auto-Submit": {},
"Pigweed-Auto-Submit": {},
"Commit-Queue": {},
}
got, err := DecideAutoSubmit(labels, "change 1")
if err == nil {
t.Fatalf("DecideAutoSubmit() = %+v, nil; want an error", got)
}
// Ambiguity is not a host that lacks the feature, so it must not quietly
// turn into a Commit-Queue dry run.
if got.Vote != (LabelVote{}) {
t.Errorf("DecideAutoSubmit() voted %+v, want nothing", got.Vote)
}
}
func TestFormatLabelNames(t *testing.T) {
labels := map[string]gerrit.LabelInfo{
"Commit-Queue": {},
"Code-Review": {},
}
if got, want := FormatLabelNames(labels), "Code-Review, Commit-Queue"; got != want {
t.Errorf("FormatLabelNames() = %q, want %q", got, want)
}
if got, want := FormatLabelNames(nil), "none reported"; got != want {
t.Errorf("FormatLabelNames(nil) = %q, want %q", got, want)
}
}
func TestGerritProjectFromRemote(t *testing.T) {
for _, tt := range []struct {
remote string
want string
}{
{remote: "https://pigweed.googlesource.com/pigweed/pigweed", want: "pigweed/pigweed"},
{remote: "https://pigweed.googlesource.com/pigweed/pigweed.git", want: "pigweed/pigweed"},
{remote: "https://pigweed-review.googlesource.com/a/pigweed/pigweed", want: "pigweed/pigweed"},
{remote: "sso://chrome-internal/infradata/config", want: "infradata/config"},
{remote: "sso://pigweed/pigweed", want: "pigweed"},
{remote: "git@host.example.com:team/repo.git", want: "team/repo"},
{remote: "https://host.example.com/", want: ""},
{remote: "https://host.example.com/a", want: ""},
{remote: "https://host.example.com/a/", want: ""},
{remote: "", want: ""},
} {
if got := GerritProjectFromRemote(tt.remote); got != tt.want {
t.Errorf("GerritProjectFromRemote(%q) = %q, want %q", tt.remote, got, tt.want)
}
}
}
func TestListProjectLabels(t *testing.T) {
server := NewMockGerritServer(t)
// The mock server routes on the *decoded* path, so match a glob and assert
// the escaping separately below.
server.OnJSON("GET", "/projects/*", http.StatusOK, []map[string]any{
{"name": "Code-Review", "values": map[string]string{"-2": "no", "+2": "yes"}},
{"name": "Auto-Submit", "values": map[string]string{" 0": "no", "+1": "yes"}},
})
defs, err := ListProjectLabels(context.Background(), server.Client(), "pigweed/pigweed")
if err != nil {
t.Fatalf("ListProjectLabels() failed: %v", err)
}
if len(defs) != 2 {
t.Fatalf("ListProjectLabels() returned %d labels, want 2", len(defs))
}
// The project name contains a slash; a real Gerrit requires it escaped,
// otherwise it sees "projects/pigweed/pigweed/labels/" and 404s.
req := server.LastRequest()
if req == nil {
t.Fatal("no request was recorded")
}
if got, want := req.URL.EscapedPath(), "/projects/pigweed%2Fpigweed/labels/"; got != want {
t.Errorf("requested path = %q, want %q", got, want)
}
}
func TestDecideAutoSubmitForNewChange(t *testing.T) {
ctx := context.Background()
t.Run("uses the project's label", func(t *testing.T) {
server := NewMockGerritServer(t)
server.OnJSON("GET", "/projects/*", http.StatusOK, []map[string]any{
{"name": "Code-Review"},
{"name": "Auto-Submit"},
})
got, err := DecideAutoSubmitForNewChange(ctx, server.Client(), "some/project")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Unsupported != nil {
t.Fatalf("unexpected Unsupported: %v", got.Unsupported)
}
if got.Vote.Name != "Auto-Submit" || got.Vote.Value != 1 {
t.Errorf("got %+v, want Auto-Submit=1", got.Vote)
}
})
t.Run("refuses to choose between two candidates", func(t *testing.T) {
server := NewMockGerritServer(t)
server.OnJSON("GET", "/projects/*", http.StatusOK, []map[string]any{
{"name": "Auto-Submit"},
{"name": "Owners-Auto-Submit"},
})
_, err := DecideAutoSubmitForNewChange(ctx, server.Client(), "some/project")
if err == nil {
t.Fatal("expected an error for two auto-submit labels, got nil")
}
for _, want := range []string{"Auto-Submit", "Owners-Auto-Submit", "some/project"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err, want)
}
}
})
// A label that cannot be read cannot be confirmed, and pw_ghish no longer
// has a compiled-in guess to reach for.
t.Run("errors when the lookup fails", func(t *testing.T) {
server := NewMockGerritServer(t)
server.OnString("GET", "/projects/*", http.StatusForbidden, "text/plain", "forbidden")
_, err := DecideAutoSubmitForNewChange(ctx, server.Client(), "some/project")
if err == nil {
t.Fatal("expected an error when the labels cannot be read, got nil")
}
if !strings.Contains(err.Error(), "-o l=<Label>+1") {
t.Errorf("expected the error to offer a manual remedy, got: %v", err)
}
})
t.Run("errors when the project defines no auto-submit label", func(t *testing.T) {
server := NewMockGerritServer(t)
server.OnJSON("GET", "/projects/*", http.StatusOK, []map[string]any{
{"name": "Code-Review"},
{"name": "Verified"},
})
_, err := DecideAutoSubmitForNewChange(ctx, server.Client(), "some/project")
if err == nil {
t.Fatal("expected an error when the project has no auto-submit label, got nil")
}
if !strings.Contains(err.Error(), "Code-Review, Verified") {
t.Errorf("expected the error to list the project's labels, got: %v", err)
}
})
// A Commit Queue is not an auto-submit label, so the push still fails --
// but the dry run is worth starting on the way out.
t.Run("offers a Commit-Queue dry run and still fails", func(t *testing.T) {
server := NewMockGerritServer(t)
server.OnJSON("GET", "/projects/*", http.StatusOK, []map[string]any{
{"name": "Code-Review"},
{"name": "Commit-Queue", "values": map[string]string{" 0": "no", "+1": "dry run", "+2": "submit"}},
})
got, err := DecideAutoSubmitForNewChange(ctx, server.Client(), "some/project")
if err != nil {
t.Fatalf("unexpected hard error: %v", err)
}
if got.Unsupported == nil {
t.Fatal("expected the project's lack of an auto-submit label to be reported")
}
if got.Vote.Name != "Commit-Queue" || got.Vote.Value != 1 {
t.Errorf("got %+v, want Commit-Queue=1", got.Vote)
}
})
}