Files
agent-tools/cmd/agentpr/main.go
T
unkin-agent 05594113a2
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Fix non-zero exit on error and debounce transient mergeable=false
agentpr/watchpr already propagated command errors to a non-zero exit, but
that behaviour had no regression coverage and the root command was not
constructible outside main(). watchpr also fired a spurious conflict alert
because Gitea computes mergeability asynchronously and can briefly report
mergeable=false right after a push. The docs additionally printed the
AppRole role_id literal UUID.

- Extract newRootCmd() in both cmd/agentpr and cmd/watchpr so main() only
  runs Execute and exits non-zero on error; add tests asserting Execute
  returns an error for a bad PR ref / malformed --repo / no args.
- Debounce mergeability loss in MeaningfulChange: only alert when
  mergeable=false persists across two consecutive polls (both prev and cur
  false, still open); update the table test for one-poll-false (benign),
  false-persisting (alert), and recovered false->true (benign).
- Refer to AGENT_APPROLE_ROLE_ID by env var in README.md/AGENTS.md without
  printing the literal role_id; keep the code default and env override.
2026-08-12 22:17:10 +10:00

174 lines
4.6 KiB
Go

// Command agentpr manages Gitea pull requests and comments as the unkin-agent
// user. It obtains a scoped Gitea token from Vault (AppRole login, then reads
// gitea/creds/unkin-agent) so actions are attributed to the agent rather than
// to whoever runs the tool.
//
// agentpr pr create --repo owner/repo --base main --head feature --title T --body B
// agentpr pr comment --repo owner/repo --pr 12 --body "..."
// agentpr whoami
package main
import (
"fmt"
"os"
"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 agentpr 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 {
root := &cobra.Command{
Use: "agentpr",
Short: "Manage Gitea PRs and comments as the unkin-agent user.",
Long: "agentpr manages Gitea pull requests and comments as unkin-agent, using a\nGitea token minted from Vault (AppRole login + gitea/creds/unkin-agent).",
Version: version,
SilenceUsage: true,
}
root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(newPRCmd(), newWhoamiCmd(), newVersionCmd())
return root
}
// client mints a Gitea token via Vault and returns a ready client.
func client() (*agent.GiteaClient, error) {
token, err := agent.GiteaToken()
if err != nil {
return nil, err
}
return agent.NewGiteaClient(token), nil
}
func newPRCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "pr",
Short: "Create PRs and post PR comments",
}
cmd.AddCommand(newPRCreateCmd(), newPRCommentCmd())
return cmd
}
func newPRCreateCmd() *cobra.Command {
var repo, base, head, title, body string
cmd := &cobra.Command{
Use: "create",
Short: "Open a pull request",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
owner, name, err := agent.ParseRepo(repo)
if err != nil {
return err
}
if base == "" || head == "" || title == "" {
return fmt.Errorf("--base, --head and --title are required")
}
c, err := client()
if err != nil {
return err
}
pr, err := c.CreatePR(owner+"/"+name, agent.CreatePROptions{
Base: base,
Head: head,
Title: title,
Body: body,
})
if err != nil {
return err
}
fmt.Printf("#%d %s\n", pr.Number, pr.HTMLURL)
return nil
},
}
f := cmd.Flags()
f.StringVar(&repo, "repo", "", "Repository as owner/repo (required)")
f.StringVar(&base, "base", "", "Base branch (required)")
f.StringVar(&head, "head", "", "Head branch (required)")
f.StringVar(&title, "title", "", "PR title (required)")
f.StringVar(&body, "body", "", "PR body")
_ = cmd.MarkFlagRequired("repo")
return cmd
}
func newPRCommentCmd() *cobra.Command {
var repo, body string
var pr int
cmd := &cobra.Command{
Use: "comment",
Short: "Post a comment on a pull request",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
owner, name, err := agent.ParseRepo(repo)
if err != nil {
return err
}
if pr <= 0 {
return fmt.Errorf("--pr must be a positive PR number")
}
if body == "" {
return fmt.Errorf("--body is required")
}
c, err := client()
if err != nil {
return err
}
cm, err := c.CreateComment(owner+"/"+name, pr, body)
if err != nil {
return err
}
fmt.Printf("comment %d posted on %s/%s#%d\n", cm.ID, owner, name, pr)
return nil
},
}
f := cmd.Flags()
f.StringVar(&repo, "repo", "", "Repository as owner/repo (required)")
f.IntVar(&pr, "pr", 0, "PR number (required)")
f.StringVar(&body, "body", "", "Comment body (required)")
_ = cmd.MarkFlagRequired("repo")
_ = cmd.MarkFlagRequired("pr")
_ = cmd.MarkFlagRequired("body")
return cmd
}
func newWhoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Print the authenticated Gitea login (should be unkin-agent)",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
u, err := c.Whoami()
if err != nil {
return err
}
fmt.Println(u.Login)
return nil
},
}
}
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(cmd *cobra.Command, args []string) { fmt.Println(version) },
SilenceUsage: true,
}
}