Files
agent-tools/cmd/agentpr/main.go
T
unkin-agent 3de9d35699
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add agentpr issue create/comment/edit
Filing an issue as unkin-agent means hand-rolling a POST to the issues
API with a manually minted token, the exact credential handling agentpr
exists to encapsulate.

- add `issue create`, `issue comment` and `issue edit`, mirroring the pr
  command group's flags, output and errors
- add CreateIssue/EditIssue to the Gitea client, sharing EditOptions with
  EditPR
- build both comment commands from one constructor so pr and issue
  comments cannot drift off Gitea's shared endpoint
- cover create, edit, a missing repo, an API error and missing flags
2026-09-19 16:11:42 +10:00

332 lines
9.5 KiB
Go

// Command agentpr manages Gitea pull requests and comments as an agent user. It
// obtains a scoped Gitea token from Vault (AppRole login, then reads
// gitea/creds/<AGENT_LOGIN>, or GITEA_CREDS_PATH when set) so actions are
// attributed to that 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 pr edit --repo owner/repo --pr 12 --title T --body B
// agentpr issue create --repo owner/repo --title T --body B
// agentpr issue comment --repo owner/repo --issue 12 --body "..."
// agentpr issue edit --repo owner/repo --issue 12 --title T --body B
// 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, issues and comments as an agent user.",
Long: "agentpr manages Gitea pull requests, issues and comments as an agent user,\nusing a Gitea token minted from Vault (AppRole login + gitea/creds/<AGENT_LOGIN>).\nSet AGENT_LOGIN to act as another agent identity, or GITEA_CREDS_PATH to name\nthe Vault creds path outright.",
Version: version,
SilenceUsage: true,
}
root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(newPRCmd(), newIssueCmd(), 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 and edit PRs, and post PR comments",
}
cmd.AddCommand(newPRCreateCmd(), newCommentCmd("pr", "PR", "Post a comment on a pull request"), newPREditCmd())
return cmd
}
func newIssueCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "issue",
Short: "File and edit issues, and post issue comments",
}
cmd.AddCommand(newIssueCreateCmd(), newCommentCmd("issue", "issue", "Post a comment on an issue"), newIssueEditCmd())
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
}
// newCommentCmd builds a comment command whose number flag is named numFlag.
// Gitea backs every PR with an issue of the same number and serves comments
// from the issue endpoint, so `pr comment` and `issue comment` are one command
// under two flag names rather than two implementations that could drift.
func newCommentCmd(numFlag, noun, short string) *cobra.Command {
var repo, body string
var number int
cmd := &cobra.Command{
Use: "comment",
Short: short,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
owner, name, err := agent.ParseRepo(repo)
if err != nil {
return err
}
if number <= 0 {
return fmt.Errorf("--%s must be a positive %s number", numFlag, noun)
}
if body == "" {
return fmt.Errorf("--body is required")
}
c, err := client()
if err != nil {
return err
}
cm, err := c.CreateComment(owner+"/"+name, number, body)
if err != nil {
return err
}
fmt.Printf("comment %d posted on %s/%s#%d\n", cm.ID, owner, name, number)
return nil
},
}
f := cmd.Flags()
f.StringVar(&repo, "repo", "", "Repository as owner/repo (required)")
f.IntVar(&number, numFlag, 0, noun+" number (required)")
f.StringVar(&body, "body", "", "Comment body (required)")
_ = cmd.MarkFlagRequired("repo")
_ = cmd.MarkFlagRequired(numFlag)
_ = cmd.MarkFlagRequired("body")
return cmd
}
func newPREditCmd() *cobra.Command {
var repo, title, body string
var pr int
cmd := &cobra.Command{
Use: "edit",
Short: "Edit a pull request's title and/or body",
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")
}
opts, err := editOptions(cmd, title, body)
if err != nil {
return err
}
c, err := client()
if err != nil {
return err
}
updated, err := c.EditPR(owner+"/"+name, pr, opts)
if err != nil {
return err
}
fmt.Printf("#%d %s\n", updated.Number, updated.HTMLURL)
return nil
},
}
f := cmd.Flags()
f.StringVar(&repo, "repo", "", "Repository as owner/repo (required)")
f.IntVar(&pr, "pr", 0, "PR number (required)")
f.StringVar(&title, "title", "", "New PR title (unchanged when omitted)")
f.StringVar(&body, "body", "", "New PR body (unchanged when omitted)")
_ = cmd.MarkFlagRequired("repo")
_ = cmd.MarkFlagRequired("pr")
return cmd
}
// editOptions turns the --title/--body flags actually given into an edit
// payload. Only the flags present are sent: omitting --title must leave the
// title as it is, not blank it.
func editOptions(cmd *cobra.Command, title, body string) (agent.EditOptions, error) {
var opts agent.EditOptions
if cmd.Flags().Changed("title") {
// Gitea ignores an empty title, so sending one would report success
// while changing nothing.
if title == "" {
return opts, fmt.Errorf("--title cannot be empty: a title can be set but not cleared")
}
opts.Title = &title
}
if cmd.Flags().Changed("body") {
opts.Body = &body
}
if opts.Title == nil && opts.Body == nil {
return opts, fmt.Errorf("at least one of --title or --body is required")
}
return opts, nil
}
func newIssueCreateCmd() *cobra.Command {
var repo, title, body string
cmd := &cobra.Command{
Use: "create",
Short: "File an issue",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
owner, name, err := agent.ParseRepo(repo)
if err != nil {
return err
}
if title == "" {
return fmt.Errorf("--title is required")
}
c, err := client()
if err != nil {
return err
}
issue, err := c.CreateIssue(owner+"/"+name, agent.CreateIssueOptions{
Title: title,
Body: body,
})
if err != nil {
return err
}
fmt.Printf("#%d %s\n", issue.Number, issue.HTMLURL)
return nil
},
}
f := cmd.Flags()
f.StringVar(&repo, "repo", "", "Repository as owner/repo (required)")
f.StringVar(&title, "title", "", "Issue title (required)")
f.StringVar(&body, "body", "", "Issue body")
_ = cmd.MarkFlagRequired("repo")
return cmd
}
func newIssueEditCmd() *cobra.Command {
var repo, title, body string
var issue int
cmd := &cobra.Command{
Use: "edit",
Short: "Edit an issue's title and/or body",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
owner, name, err := agent.ParseRepo(repo)
if err != nil {
return err
}
if issue <= 0 {
return fmt.Errorf("--issue must be a positive issue number")
}
opts, err := editOptions(cmd, title, body)
if err != nil {
return err
}
c, err := client()
if err != nil {
return err
}
updated, err := c.EditIssue(owner+"/"+name, issue, opts)
if err != nil {
return err
}
fmt.Printf("#%d %s\n", updated.Number, updated.HTMLURL)
return nil
},
}
f := cmd.Flags()
f.StringVar(&repo, "repo", "", "Repository as owner/repo (required)")
f.IntVar(&issue, "issue", 0, "Issue number (required)")
f.StringVar(&title, "title", "", "New issue title (unchanged when omitted)")
f.StringVar(&body, "body", "", "New issue body (unchanged when omitted)")
_ = cmd.MarkFlagRequired("repo")
_ = cmd.MarkFlagRequired("issue")
return cmd
}
func newWhoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Print the authenticated Gitea login (the identity PRs are opened as)",
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,
}
}