// 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. Benign transitions (CI pending→success, the agent's own // comments) are ignored. // // watchpr owner/repo#12 owner/repo:15 // watchpr --once --json owner/repo#12 // watchpr --interval 30s owner/repo#12 package main import ( "encoding/json" "fmt" "os" "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 interval time.Duration 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. Accepts refs as 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)") } 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, err := clientFor() if err != nil { return err } if once { return runOnce(c, refs, jsonMode) } return runWatch(c, refs, interval, jsonMode) }, } root.SetVersionTemplate("{{.Version}}\n") f := root.Flags() f.DurationVar(&interval, "interval", 60*time.Second, "Polling interval") 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 } func clientFor() (*agent.GiteaClient, error) { token, err := agent.GiteaToken() if err != nil { return nil, err } return agent.NewGiteaClient(token), nil } // 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 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() { if !jsonMode { fmt.Fprintf(os.Stderr, "watching %d PR(s) every %s; baseline established\n", len(refs), interval) } } 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 err } report(res.Ref.String(), res.Reason, res.State, jsonMode) return nil } // 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.Printf("%s state=%s merged=%t mergeable=%t ci=%s head=%s comments(non-agent)=%d\n", st.Ref.String(), st.State, st.Merged, st.Mergeable, ciOrNone(st.CIStatus), shortSHA(st.HeadSHA), st.NonAgentComments) } 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 }