// Command watchpr polls one or more Gitea pull requests and exits when a // tracked PR changes in a way worth alerting on: it merges or closes, gets a // new comment from someone other than the agent, its CI fails, or it loses // mergeability after the baseline. Benign transitions (CI pending→success, the // agent's own pushes and comments) are ignored, and so is any condition that was // already true at the baseline -- which watchpr prints on stderr (as a JSON // record under --json), so a run started against a conflicted or CI-red PR says // so. One case is never reported at all: a conflict introduced by the push // immediately before the watch started, on a PR whose head and base then never // move, stays silent for the whole run, because nothing in Gitea's payload // separates it from a merge check still in flight. The baseline line is the // only notice of it. // // watchpr owner/repo#12 owner/repo:15 // watchpr --once --json owner/repo#12 // watchpr --interval 30s owner/repo#12 // watchpr --interval 30 owner/repo#12 package main import ( "encoding/json" "fmt" "io" "os" "strings" "time" "git.unkin.net/unkin/agent-tools/internal/agent" "github.com/spf13/cobra" ) var version = "dev" 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 { os.Exit(1) } } // newRootCmd builds the watchpr command tree. It is separated from main so // tests can invoke Execute and assert the exit behaviour without spawning a // process. func newRootCmd() *cobra.Command { var intervalFlag string var once, jsonMode bool root := &cobra.Command{ Use: "watchpr [flags] owner/repo#N [owner/repo#N ...]", Short: "Poll Gitea PRs and exit when one changes meaningfully.", 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.", Version: version, Args: cobra.ArbitraryArgs, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return fmt.Errorf("no PR references given (e.g. owner/repo#12)") } interval, err := agent.ParseDurationFlag("interval", intervalFlag) if err != nil { return err } refs := make([]agent.PRRef, 0, len(args)) for _, a := range args { ref, err := agent.ParsePRRef(a) if err != nil { return err } refs = append(refs, ref) } c := clientFor() if once { return runOnce(c, refs, jsonMode) } return runWatch(c, refs, interval, jsonMode) }, } 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.BoolVar(&once, "once", false, "Check once, print current state, and exit") f.BoolVar(&jsonMode, "json", false, "Emit JSON") root.AddCommand(&cobra.Command{ Use: "version", Short: "Print the version", Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) }, SilenceUsage: true, }) return root } // clientFor builds the Gitea client. Watching public repos works anonymously, // so an unavailable token is a warning, not a failure; a poll that is actually // rejected re-mints then. func clientFor() *agent.GiteaClient { token, err := agent.GiteaToken() if err != nil { fmt.Fprintf(os.Stderr, "warning: no Gitea token (%v); polling anonymously\n", err) token = "" } return agent.NewGiteaClient(token) } // runOnce fetches and prints the current state of each PR, then exits 0. func runOnce(c *agent.GiteaClient, refs []agent.PRRef, jsonMode bool) error { login := agent.AgentLogin() states := make([]agent.PRState, 0, len(refs)) for _, ref := range refs { st, err := agent.FetchState(c, ref, login) if err != nil { return describeFailure(err) } states = append(states, st) } if jsonMode { return json.NewEncoder(os.Stdout).Encode(states) } for _, st := range states { printState(st) } return nil } // 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 { login := agent.AgentLogin() ticker := time.NewTicker(interval) defer ticker.Stop() onBaseline := func(states []agent.PRState) { emitBaselines(os.Stderr, states, interval, jsonMode) } onError := func(ref agent.PRRef, err error) { fmt.Fprintf(os.Stderr, "warning: polling %s: %v\n", ref.String(), err) } res, err := agent.Watch(c, refs, login, ticker.C, onBaseline, onError) if err != nil { return describeFailure(err) } report(res.Ref.String(), res.Reason, res.State, jsonMode) return nil } // 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 // already conflicted, since no alert will ever follow for it. type baselineRecord struct { Baseline bool `json:"baseline"` Suppressed string `json:"suppressed,omitempty"` State agent.PRState `json:"state"` } // emitBaselines writes the state each watch started from, in whichever form the // caller asked for. func emitBaselines(w io.Writer, states []agent.PRState, interval time.Duration, jsonMode bool) { if jsonMode { enc := json.NewEncoder(w) for _, st := range states { _ = enc.Encode(baselineRecord{Baseline: true, Suppressed: suppressedAtBaseline(st), State: st}) } return } _, _ = fmt.Fprintf(w, "watching %d PR(s) every %s; baseline established\n", len(states), interval) for _, st := range states { _, _ = fmt.Fprintln(w, baselineLine(st)) } } // describeFailure names the cause of a terminal failure so a watcher that stops // says why. An anonymous rejection, a permission boundary and a token that // outlived its Vault lease are three different problems and only the last is // fixed by a fresh token. func describeFailure(err error) error { switch { case agent.IsNoCredential(err): return fmt.Errorf("gitea requires authentication and no token could be minted, aborted: %w", err) case agent.IsPermissionDenied(err): return fmt.Errorf("gitea denied access to %s (a fresh token will not help), aborted: %w", agent.AgentLogin(), err) case agent.IsAuthError(err): return fmt.Errorf("gitea rejected the token and re-minting did not recover it, aborted: %w", err) case agent.IsPRGone(err): return fmt.Errorf("PR no longer visible (repo deleted, renamed, or made private), aborted: %w", err) } return err } // report emits the change that ended the watch. func report(key, reason string, st agent.PRState, jsonMode bool) { if jsonMode { _ = json.NewEncoder(os.Stdout).Encode(struct { Changed bool `json:"changed"` Reason string `json:"reason"` State agent.PRState `json:"state"` }{true, reason, st}) return } fmt.Printf("%s changed: %s\n", key, reason) printState(st) } func printState(st agent.PRState) { fmt.Println(stateLine(st)) } func stateLine(st agent.PRState) string { return fmt.Sprintf("%s state=%s merged=%t mergeable=%s ci=%s head=%s base=%s comments(non-agent)=%d", st.Ref.String(), st.State, st.Merged, st.Mergeable, ciOrNone(st.CIStatus), shortSHA(st.HeadSHA), shortSHA(st.BaseSHA), st.NonAgentComments) } // baselineLine describes the state a watch started from, naming the conditions // it will deliberately stay silent about: watchpr alerts on changes, so a PR // that is already conflicted or already CI-red produces no alert for either, and // that suppression has to be visible to whoever started the watch. func baselineLine(st agent.PRState) string { line := " baseline " + stateLine(st) if s := suppressedAtBaseline(st); s != "" { return line + " -- " + s + "; not alerting on a pre-existing condition" } return line } func suppressedAtBaseline(st agent.PRState) string { var conds []string if st.Mergeable == agent.MergeNo { conds = append(conds, "already non-mergeable (a real conflict, or Gitea still recomputing)") } if st.CIStatus == "failure" || st.CIStatus == "error" { conds = append(conds, "CI already "+st.CIStatus) } return strings.Join(conds, ", ") } func ciOrNone(s string) string { if s == "" { return "none" } return s } func shortSHA(s string) string { if len(s) > 8 { return s[:8] } if s == "" { return "-" } return s }