watchpr: add --max-wait, exit 3 on timeout
This commit is contained in:
+84
-5
@@ -19,10 +19,12 @@
|
||||
// watchpr --once --json owner/repo#12
|
||||
// watchpr --interval 30s owner/repo#12
|
||||
// watchpr --interval 30 owner/repo#12
|
||||
// watchpr --max-wait 110m owner/repo#12 # exits 3 if nothing changed
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -36,10 +38,19 @@ import (
|
||||
|
||||
var version = "dev"
|
||||
|
||||
// exitTimedOut is the exit status when --max-wait elapses with no change, so a
|
||||
// caller can tell a deliberate timeout from a crash and restart the watch.
|
||||
const exitTimedOut = 3
|
||||
|
||||
var errTimedOut = errors.New("max-wait elapsed with no change")
|
||||
|
||||
func main() {
|
||||
// cobra prints the error itself (SilenceErrors stays off); we only need to
|
||||
// turn any command error into a non-zero exit.
|
||||
if err := newRootCmd().Execute(); err != nil {
|
||||
if errors.Is(err, errTimedOut) {
|
||||
os.Exit(exitTimedOut)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +59,7 @@ func main() {
|
||||
// tests can invoke Execute and assert the exit behaviour without spawning a
|
||||
// process.
|
||||
func newRootCmd() *cobra.Command {
|
||||
var intervalFlag string
|
||||
var intervalFlag, maxWaitFlag string
|
||||
var once, jsonMode bool
|
||||
|
||||
root := &cobra.Command{
|
||||
@@ -57,7 +68,9 @@ func newRootCmd() *cobra.Command {
|
||||
Long: "watchpr polls each PR every --interval and exits (reporting what changed)\n" +
|
||||
"when a PR merges/closes, gets a new non-agent comment, its CI fails, or it\n" +
|
||||
"loses mergeability after the baseline. Conditions already true at the\n" +
|
||||
"baseline are printed, not alerted on. Refs take owner/repo#N or owner/repo:N.",
|
||||
"baseline are printed, not alerted on. Refs take owner/repo#N or owner/repo:N.\n\n" +
|
||||
"Exit status: 0 on a change (or --once), 3 when --max-wait elapses with no\n" +
|
||||
"change (current states are printed), 1 on any error.",
|
||||
Version: version,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
SilenceUsage: true,
|
||||
@@ -69,6 +82,10 @@ func newRootCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxWait, err := parseMaxWait(maxWaitFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refs := make([]agent.PRRef, 0, len(args))
|
||||
for _, a := range args {
|
||||
ref, err := agent.ParsePRRef(a)
|
||||
@@ -81,13 +98,18 @@ func newRootCmd() *cobra.Command {
|
||||
if once {
|
||||
return runOnce(c, refs, jsonMode)
|
||||
}
|
||||
return runWatch(c, refs, interval, jsonMode)
|
||||
err = runWatch(c, refs, interval, maxWait, jsonMode)
|
||||
if errors.Is(err, errTimedOut) {
|
||||
cmd.SilenceErrors = true
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
root.SetVersionTemplate("{{.Version}}\n")
|
||||
|
||||
f := root.Flags()
|
||||
f.StringVar(&intervalFlag, "interval", "60s", "Polling interval: a duration (30s, 2m, 1h30m) or a bare number of seconds")
|
||||
f.StringVar(&maxWaitFlag, "max-wait", "0", "Give up after this long with no change and exit 3 (same forms as --interval; 0 = unlimited)")
|
||||
f.BoolVar(&once, "once", false, "Check once, print current state, and exit")
|
||||
f.BoolVar(&jsonMode, "json", false, "Emit JSON")
|
||||
|
||||
@@ -154,11 +176,17 @@ func runOnce(c *agent.GiteaClient, refs []agent.PRRef, jsonMode bool) error {
|
||||
|
||||
// runWatch establishes a baseline then polls until a tracked PR changes
|
||||
// meaningfully, at which point it reports the change and returns.
|
||||
func runWatch(c *agent.GiteaClient, refs []agent.PRRef, interval time.Duration, jsonMode bool) error {
|
||||
func runWatch(c *agent.GiteaClient, refs []agent.PRRef, interval, maxWait time.Duration, jsonMode bool) error {
|
||||
login := agent.AgentLogin()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
var deadline <-chan time.Time
|
||||
if maxWait > 0 {
|
||||
timer := time.NewTimer(maxWait)
|
||||
defer timer.Stop()
|
||||
deadline = timer.C
|
||||
}
|
||||
|
||||
onBaseline := func(states []agent.PRState) {
|
||||
emitBaselines(os.Stderr, states, interval, jsonMode)
|
||||
@@ -167,14 +195,65 @@ func runWatch(c *agent.GiteaClient, refs []agent.PRRef, interval time.Duration,
|
||||
warn(os.Stderr, jsonMode, "polling %s: %v", ref.String(), err)
|
||||
}
|
||||
|
||||
res, err := agent.Watch(c, refs, login, ticker.C, onBaseline, onError)
|
||||
res, err := agent.Watch(c, refs, login, untilDeadline(ticker.C, deadline), onBaseline, onError)
|
||||
if err != nil {
|
||||
return describeFailure(err)
|
||||
}
|
||||
if res.TimedOut {
|
||||
reportTimeout(os.Stdout, maxWait, res.States, jsonMode)
|
||||
return errTimedOut
|
||||
}
|
||||
report(res.Ref.String(), res.Reason, res.State, jsonMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseMaxWait reads --max-wait: 0 means unlimited, anything else parses like
|
||||
// --interval.
|
||||
func parseMaxWait(v string) (time.Duration, error) {
|
||||
if d, err := time.ParseDuration(strings.TrimSpace(v)); err == nil && d == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return agent.ParseDurationFlag("max-wait", v)
|
||||
}
|
||||
|
||||
// untilDeadline forwards ticks until deadline fires, then closes, which ends
|
||||
// Watch without waiting out the rest of an interval. A nil deadline never fires.
|
||||
func untilDeadline(ticks, deadline <-chan time.Time) <-chan time.Time {
|
||||
out := make(chan time.Time)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
return
|
||||
case t := <-ticks:
|
||||
select {
|
||||
case out <- t:
|
||||
case <-deadline:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
// reportTimeout emits each PR's current state when --max-wait elapses.
|
||||
func reportTimeout(w io.Writer, maxWait time.Duration, states []agent.PRState, jsonMode bool) {
|
||||
if jsonMode {
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Timeout bool `json:"timeout"`
|
||||
MaxWait string `json:"max_wait"`
|
||||
States []agent.PRState `json:"states"`
|
||||
}{true, maxWait.String(), states})
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "timeout: no change within %s\n", maxWait)
|
||||
for _, st := range states {
|
||||
_, _ = fmt.Fprintln(w, stateLine(st))
|
||||
}
|
||||
}
|
||||
|
||||
// baselineRecord is the --json form of baselineLine. Both go to stderr, leaving
|
||||
// the stdout contract a single result record: a caller automating watchpr is
|
||||
// precisely the one who needs to be told the watch started against a PR that is
|
||||
|
||||
@@ -311,3 +311,66 @@ func TestBaselineNamesAnUnknownMergeability(t *testing.T) {
|
||||
t.Errorf("baselineLine = %q, want the suppression note", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaxWait(t *testing.T) {
|
||||
for in, want := range map[string]time.Duration{"0": 0, "0s": 0, "30s": 30 * time.Second, "1h55m": 115 * time.Minute, "90": 90 * time.Second} {
|
||||
got, err := parseMaxWait(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("parseMaxWait(%q) = %v, %v; want %v", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"soon", "-1m"} {
|
||||
if _, err := parseMaxWait(in); err == nil || !strings.Contains(err.Error(), "--max-wait") {
|
||||
t.Errorf("parseMaxWait(%q) err = %v, want a --max-wait error", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteBadMaxWaitErrors(t *testing.T) {
|
||||
cmd := newRootCmd()
|
||||
cmd.SetArgs([]string{"--max-wait", "soon", "unkin/repo#1"})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
if err := cmd.Execute(); err == nil || errors.Is(err, errTimedOut) {
|
||||
t.Fatalf("Execute() = %v, want a parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ticks pass through until the deadline fires; then the channel closes without
|
||||
// waiting for another tick.
|
||||
func TestUntilDeadline(t *testing.T) {
|
||||
ticks := make(chan time.Time)
|
||||
deadline := make(chan time.Time)
|
||||
out := untilDeadline(ticks, deadline)
|
||||
|
||||
now := time.Now()
|
||||
ticks <- now
|
||||
if got := <-out; !got.Equal(now) {
|
||||
t.Fatalf("forwarded %v, want %v", got, now)
|
||||
}
|
||||
close(deadline)
|
||||
if _, ok := <-out; ok {
|
||||
t.Fatal("channel still open after the deadline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportTimeout(t *testing.T) {
|
||||
st := agent.PRState{Ref: agent.PRRef{Owner: "unkin", Repo: "repo", Number: 3}, State: "open", Mergeable: agent.MergeYes, CIStatus: "pending"}
|
||||
|
||||
var buf bytes.Buffer
|
||||
reportTimeout(&buf, 110*time.Minute, []agent.PRState{st}, false)
|
||||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||
if len(lines) != 2 || lines[0] != "timeout: no change within 1h50m0s" || lines[1] != stateLine(st) {
|
||||
t.Errorf("text output = %q", buf.String())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
reportTimeout(&buf, time.Minute, []agent.PRState{st}, true)
|
||||
var rec struct {
|
||||
Timeout bool `json:"timeout"`
|
||||
States []agent.PRState `json:"states"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil || !rec.Timeout || len(rec.States) != 1 {
|
||||
t.Errorf("json output = %q (err %v)", buf.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user