ebdd25f725
The docs claimed every later head/base move restarts the conflict window. arm() returns early once armed, so only the first move sets armedAt and every move after it is a no-op. State the real trade-off instead: repeated moves do not extend the debounce, so a conflict can be confirmed while the newest recompute is younger than the window. Also narrow the --json stderr claim to the notices watchpr writes itself (cobra's terminal Error: line is plain text), rename the test to what it covers, and stop the unknown-mergeability baseline implying a false answer arms the rule.
314 lines
11 KiB
Go
314 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.unkin.net/unkin/agent-tools/internal/agent"
|
|
)
|
|
|
|
// A bad PR reference must fail the command (so main exits non-zero) rather than
|
|
// return nil. Parsing rejects the ref before any Vault/Gitea call, so this stays
|
|
// hermetic.
|
|
func TestExecuteBadRefErrors(t *testing.T) {
|
|
cmd := newRootCmd()
|
|
cmd.SetArgs([]string{"--once", "not-a-ref"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
if err := cmd.Execute(); err == nil {
|
|
t.Fatal("Execute() = nil, want error for a bad PR reference")
|
|
}
|
|
}
|
|
|
|
// No arguments is also an error (nothing to watch).
|
|
func TestExecuteNoArgsErrors(t *testing.T) {
|
|
cmd := newRootCmd()
|
|
cmd.SetArgs(nil)
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
if err := cmd.Execute(); err == nil {
|
|
t.Fatal("Execute() = nil, want error when no PR references are given")
|
|
}
|
|
}
|
|
|
|
// Watching a public repo with no credentials available must work: the failed
|
|
// mint is a warning, the poll goes out unauthenticated, and the command exits 0.
|
|
func TestOnceRunsAnonymouslyWhenNoTokenIsAvailable(t *testing.T) {
|
|
vault := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
defer vault.Close()
|
|
|
|
authHeaders := 0
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/pulls/7", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "" {
|
|
authHeaders++
|
|
}
|
|
_, _ = io.WriteString(w, `{"number":7,"state":"open","mergeable":true,"head":{"sha":"cafebabe"}}`)
|
|
})
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/commits/cafebabe/status", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `{"state":"success"}`)
|
|
})
|
|
mux.HandleFunc("/api/v1/repos/unkin/repo/issues/7/comments", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, `[]`)
|
|
})
|
|
gitea := httptest.NewServer(mux)
|
|
defer gitea.Close()
|
|
|
|
t.Setenv("VAULT_ADDR", vault.URL)
|
|
t.Setenv("GITEA_URL", gitea.URL)
|
|
|
|
cmd := newRootCmd()
|
|
cmd.SetArgs([]string{"--once", "unkin/repo#7"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("anonymous --once should succeed without a token: %v", err)
|
|
}
|
|
if authHeaders != 0 {
|
|
t.Errorf("sent %d Authorization headers, want none", authHeaders)
|
|
}
|
|
}
|
|
|
|
// A bare integer interval means seconds and must survive flag parsing: the
|
|
// command should fail on the missing PR reference, not on the flag value.
|
|
func TestExecuteBareIntervalIsSeconds(t *testing.T) {
|
|
cmd := newRootCmd()
|
|
cmd.SetArgs([]string{"--interval", "15"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("Execute() = nil, want the no-references error")
|
|
}
|
|
if !strings.Contains(err.Error(), "no PR references given") {
|
|
t.Fatalf("Execute() error = %v, want the no-references error", err)
|
|
}
|
|
}
|
|
|
|
// An unparseable interval is rejected before any Vault/Gitea call, with an
|
|
// error naming the flag and showing valid forms.
|
|
func TestExecuteBadIntervalErrors(t *testing.T) {
|
|
cmd := newRootCmd()
|
|
cmd.SetArgs([]string{"--interval", "soon", "unkin/repo#1"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("Execute() = nil, want error for an unparseable --interval")
|
|
}
|
|
for _, want := range []string{"--interval", "30s"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("Execute() error %q does not mention %q", err, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// failingVault serves an AppRole login that never issues a token, so the
|
|
// command falls back to anonymous polling exactly as it does when Vault is
|
|
// unreachable.
|
|
func failingVault(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// An anonymous run against a repo that is not public must exit non-zero saying
|
|
// no token was available — not claim a token expired, and not keep going.
|
|
func TestOnceAnonymousRejectionNamesTheMissingToken(t *testing.T) {
|
|
vault := failingVault(t)
|
|
|
|
requests := 0
|
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = io.WriteString(w, `{"message":"invalid username, password or token"}`)
|
|
}))
|
|
defer gitea.Close()
|
|
|
|
t.Setenv("VAULT_ADDR", vault.URL)
|
|
t.Setenv("GITEA_URL", gitea.URL)
|
|
|
|
cmd := newRootCmd()
|
|
cmd.SetArgs([]string{"--once", "unkin/repo#7"})
|
|
cmd.SetOut(io.Discard)
|
|
cmd.SetErr(io.Discard)
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("Execute() = nil, want a non-zero exit when the poll is rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "no token could be minted") {
|
|
t.Errorf("Execute() error = %q, want it to name the missing token", err)
|
|
}
|
|
if requests != 1 {
|
|
t.Errorf("gitea requests = %d, want 1 (no replay without a credential)", requests)
|
|
}
|
|
}
|
|
|
|
// describeFailure must tell the four terminal causes apart: each one sends the
|
|
// reader somewhere different, and a watcher that stops without saying why is
|
|
// the failure this names.
|
|
func TestDescribeFailureNamesTheCause(t *testing.T) {
|
|
rejected := &agent.APIError{Method: "GET", Path: "/p", StatusCode: 401, Body: `{"message":"invalid username, password or token"}`}
|
|
forbidden := &agent.APIError{Method: "GET", Path: "/p", StatusCode: 403, Body: `{"message":"Forbidden"}`}
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{"anonymous", fmt.Errorf("%w: %w", agent.ErrNoCredential, rejected), "no token could be minted"},
|
|
{"permission boundary", error(forbidden), "denied access"},
|
|
{"rejected token", error(rejected), "re-minting did not recover it"},
|
|
{"other", errors.New("dial tcp: timeout"), "dial tcp: timeout"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := describeFailure(tt.err)
|
|
if got == nil || !strings.Contains(got.Error(), tt.want) {
|
|
t.Errorf("describeFailure = %v, want it to mention %q", got, tt.want)
|
|
}
|
|
if !errors.Is(got, tt.err) {
|
|
t.Errorf("describeFailure dropped the underlying error %v", tt.err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A watch started against an already conflicted or CI-red PR sends no alert for
|
|
// either, so the baseline line has to say that is what it is doing: before this,
|
|
// such a run printed nothing in either direction.
|
|
func TestBaselineLineNamesSuppressedConditions(t *testing.T) {
|
|
st := agent.PRState{
|
|
Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7},
|
|
State: "open",
|
|
Mergeable: agent.MergeNo,
|
|
CIStatus: "failure",
|
|
HeadSHA: "cafebabecafebabe",
|
|
BaseSHA: "deadbeefdeadbeef",
|
|
}
|
|
got := baselineLine(st)
|
|
for _, want := range []string{
|
|
"unkin/repo#7", "mergeable=false", "ci=failure", "head=cafebabe", "base=deadbeef",
|
|
"already non-mergeable", "CI already failure", "not alerting",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("baselineLine = %q, want it to mention %q", got, want)
|
|
}
|
|
}
|
|
|
|
clean := st
|
|
clean.Mergeable = agent.MergeYes
|
|
clean.CIStatus = "success"
|
|
if got := baselineLine(clean); strings.Contains(got, "not alerting") {
|
|
t.Errorf("baselineLine = %q, want no suppression note for a clean baseline", got)
|
|
}
|
|
}
|
|
|
|
// --json is the mode automation uses, and automation is exactly who needs to be
|
|
// told the watch started against an already-conflicted PR -- the one case that
|
|
// will never produce an alert. It must therefore be emitted in JSON mode too,
|
|
// on stderr, where it cannot corrupt the result record on stdout.
|
|
func TestBaselineIsEmittedInJSONMode(t *testing.T) {
|
|
st := agent.PRState{
|
|
Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7},
|
|
State: "open",
|
|
Mergeable: agent.MergeNo,
|
|
CIStatus: "failure",
|
|
HeadSHA: "cafebabecafebabe",
|
|
}
|
|
var buf bytes.Buffer
|
|
emitBaselines(&buf, []agent.PRState{st}, 30*time.Second, true)
|
|
|
|
var got baselineRecord
|
|
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
|
t.Fatalf("baseline is not a JSON record (%v); got %q", err, buf.String())
|
|
}
|
|
if !got.Baseline {
|
|
t.Error("record does not mark itself as the baseline")
|
|
}
|
|
if got.State.Ref != st.Ref || got.State.Mergeable != agent.MergeNo {
|
|
t.Errorf("record state = %+v, want the conflicted snapshot the watch started from", got.State)
|
|
}
|
|
for _, want := range []string{"already non-mergeable", "CI already failure"} {
|
|
if !strings.Contains(got.Suppressed, want) {
|
|
t.Errorf("suppressed = %q, want it to mention %q", got.Suppressed, want)
|
|
}
|
|
}
|
|
|
|
buf.Reset()
|
|
clean := st
|
|
clean.Mergeable = agent.MergeYes
|
|
clean.CIStatus = "success"
|
|
emitBaselines(&buf, []agent.PRState{clean}, 30*time.Second, true)
|
|
if strings.Contains(buf.String(), "suppressed") {
|
|
t.Errorf("clean baseline = %q, want no suppression field", buf.String())
|
|
}
|
|
}
|
|
|
|
// Under --json stderr is the baseline and warning channel, so every notice
|
|
// watchpr writes there has to be one shape: a caller parsing it line by line
|
|
// must never meet a bare `warning:` line between two NDJSON records. Cobra's
|
|
// terminal `Error: ...` line is not covered here -- SilenceErrors stays off, so
|
|
// it is plain text on stderr alongside a non-zero exit.
|
|
func TestJSONModeWarningsAndBaselineAreRecords(t *testing.T) {
|
|
st := agent.PRState{
|
|
Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7},
|
|
State: "open",
|
|
Mergeable: agent.MergeNo,
|
|
}
|
|
var buf bytes.Buffer
|
|
warn(&buf, true, "no Gitea token (%v); polling anonymously", errors.New("vault refused"))
|
|
emitBaselines(&buf, []agent.PRState{st}, 30*time.Second, true)
|
|
warn(&buf, true, "polling %s: %v", st.Ref.String(), errors.New("HTTP 502"))
|
|
|
|
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
|
if len(lines) != 3 {
|
|
t.Fatalf("stderr = %q, want 3 records", buf.String())
|
|
}
|
|
for _, line := range lines {
|
|
var rec map[string]any
|
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
|
t.Errorf("stderr line %q is not a JSON record: %v", line, err)
|
|
}
|
|
}
|
|
var first warnRecord
|
|
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil || !strings.Contains(first.Warning, "vault refused") {
|
|
t.Errorf("first record = %q, want the token warning", lines[0])
|
|
}
|
|
|
|
buf.Reset()
|
|
warn(&buf, false, "polling %s: %v", st.Ref.String(), errors.New("HTTP 502"))
|
|
if got := buf.String(); !strings.HasPrefix(got, "warning: ") {
|
|
t.Errorf("plain-mode warning = %q, want the warning: prefix", got)
|
|
}
|
|
}
|
|
|
|
// An unknown mergeability disarms the conflict rule exactly as a non-mergeable
|
|
// baseline does, so the baseline has to name it too.
|
|
func TestBaselineNamesAnUnknownMergeability(t *testing.T) {
|
|
st := agent.PRState{
|
|
Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 7},
|
|
State: "open",
|
|
Mergeable: agent.MergeUnknown,
|
|
CIStatus: "success",
|
|
}
|
|
if got := suppressedAtBaseline(st); !strings.Contains(got, "mergeability unknown") {
|
|
t.Errorf("suppressed = %q, want it to name the unknown mergeability", got)
|
|
}
|
|
if got := baselineLine(st); !strings.Contains(got, "not alerting") {
|
|
t.Errorf("baselineLine = %q, want the suppression note", got)
|
|
}
|
|
}
|