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
This commit is contained in:
+132
-29
@@ -6,6 +6,9 @@
|
||||
// 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
|
||||
|
||||
@@ -34,14 +37,14 @@ func main() {
|
||||
func newRootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "agentpr",
|
||||
Short: "Manage Gitea PRs and comments as an agent user.",
|
||||
Long: "agentpr manages Gitea pull requests and comments as an agent user, using a\nGitea 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.",
|
||||
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(), newWhoamiCmd(), newVersionCmd())
|
||||
root.AddCommand(newPRCmd(), newIssueCmd(), newWhoamiCmd(), newVersionCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -59,7 +62,16 @@ func newPRCmd() *cobra.Command {
|
||||
Use: "pr",
|
||||
Short: "Create and edit PRs, and post PR comments",
|
||||
}
|
||||
cmd.AddCommand(newPRCreateCmd(), newPRCommentCmd(), newPREditCmd())
|
||||
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
|
||||
}
|
||||
|
||||
@@ -104,20 +116,24 @@ func newPRCreateCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPRCommentCmd() *cobra.Command {
|
||||
// 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 pr int
|
||||
var number int
|
||||
cmd := &cobra.Command{
|
||||
Use: "comment",
|
||||
Short: "Post a comment on a pull request",
|
||||
Short: short,
|
||||
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 number <= 0 {
|
||||
return fmt.Errorf("--%s must be a positive %s number", numFlag, noun)
|
||||
}
|
||||
if body == "" {
|
||||
return fmt.Errorf("--body is required")
|
||||
@@ -126,20 +142,20 @@ func newPRCommentCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cm, err := c.CreateComment(owner+"/"+name, pr, body)
|
||||
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, pr)
|
||||
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(&pr, "pr", 0, "PR number (required)")
|
||||
f.IntVar(&number, numFlag, 0, noun+" number (required)")
|
||||
f.StringVar(&body, "body", "", "Comment body (required)")
|
||||
_ = cmd.MarkFlagRequired("repo")
|
||||
_ = cmd.MarkFlagRequired("pr")
|
||||
_ = cmd.MarkFlagRequired(numFlag)
|
||||
_ = cmd.MarkFlagRequired("body")
|
||||
return cmd
|
||||
}
|
||||
@@ -159,22 +175,9 @@ func newPREditCmd() *cobra.Command {
|
||||
if pr <= 0 {
|
||||
return fmt.Errorf("--pr must be a positive PR number")
|
||||
}
|
||||
// Only the flags actually given are sent: omitting --title must
|
||||
// leave the title as it is, not blank it.
|
||||
var opts agent.EditPROptions
|
||||
if cmd.Flags().Changed("title") {
|
||||
// Gitea ignores an empty title, so sending one would report
|
||||
// success while changing nothing.
|
||||
if title == "" {
|
||||
return 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 fmt.Errorf("at least one of --title or --body is required")
|
||||
opts, err := editOptions(cmd, title, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
@@ -198,6 +201,106 @@ func newPREditCmd() *cobra.Command {
|
||||
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",
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// A malformed --repo must fail the command (so main exits non-zero). ParseRepo
|
||||
@@ -49,3 +51,101 @@ func TestPREditRejectsEmptyTitle(t *testing.T) {
|
||||
t.Errorf("error = %q, want it to reject the empty title", err)
|
||||
}
|
||||
}
|
||||
|
||||
// execute runs the command tree with args, discarding output, so tests assert
|
||||
// on the error alone. Every case here fails before any Vault/Gitea call.
|
||||
func execute(args ...string) error {
|
||||
cmd := newRootCmd()
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
// An issue needs a title; Gitea rejects an empty one, so the command must too.
|
||||
func TestIssueCreateRequiresTitle(t *testing.T) {
|
||||
err := execute("issue", "create", "--repo", "unkin/repo", "--body", "b")
|
||||
if err == nil {
|
||||
t.Fatal("Execute() = nil, want an error when --title is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--title is required") {
|
||||
t.Errorf("error = %q, want it to name the missing flag", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --repo is required, and cobra must reject its absence before anything reaches
|
||||
// for a token.
|
||||
func TestIssueCreateRequiresRepo(t *testing.T) {
|
||||
err := execute("issue", "create", "--title", "t")
|
||||
if err == nil {
|
||||
t.Fatal("Execute() = nil, want an error when --repo is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "repo") {
|
||||
t.Errorf("error = %q, want it to name the missing flag", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCreateBadRepoErrors(t *testing.T) {
|
||||
if err := execute("issue", "create", "--repo", "not-a-repo", "--title", "t"); err == nil {
|
||||
t.Fatal("Execute() = nil, want error for a malformed --repo")
|
||||
}
|
||||
}
|
||||
|
||||
// `issue comment` addresses the issue by --issue, not --pr, and needs it.
|
||||
func TestIssueCommentRequiresIssueNumber(t *testing.T) {
|
||||
err := execute("issue", "comment", "--repo", "unkin/repo", "--body", "hi")
|
||||
if err == nil {
|
||||
t.Fatal("Execute() = nil, want an error when --issue is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "issue") {
|
||||
t.Errorf("error = %q, want it to name the missing --issue flag", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueEditRequiresTitleOrBody(t *testing.T) {
|
||||
err := execute("issue", "edit", "--repo", "unkin/repo", "--issue", "12")
|
||||
if err == nil {
|
||||
t.Fatal("Execute() = nil, want an error when neither --title nor --body is given")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--title or --body") {
|
||||
t.Errorf("error = %q, want it to name the missing flags", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueEditRejectsEmptyTitle(t *testing.T) {
|
||||
err := execute("issue", "edit", "--repo", "unkin/repo", "--issue", "12", "--title", "")
|
||||
if err == nil {
|
||||
t.Fatal("Execute() = nil, want an error for an empty --title")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--title cannot be empty") {
|
||||
t.Errorf("error = %q, want it to reject the empty title", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PRs and issues share Gitea's comment endpoint, so both comment commands are
|
||||
// built from one constructor: they must stay identical apart from the flag
|
||||
// naming the number.
|
||||
func TestCommentCommandsStayInStep(t *testing.T) {
|
||||
find := func(group string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd, _, err := newRootCmd().Find([]string{group, "comment"})
|
||||
if err != nil || cmd.Name() != "comment" {
|
||||
t.Fatalf("%s comment not found: %v", group, err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
has := func(cmd *cobra.Command, name string) bool { return cmd.Flags().Lookup(name) != nil }
|
||||
|
||||
prCmd, issueCmd := find("pr"), find("issue")
|
||||
for _, name := range []string{"repo", "body"} {
|
||||
if !has(prCmd, name) || !has(issueCmd, name) {
|
||||
t.Errorf("--%s missing: pr=%t issue=%t", name, has(prCmd, name), has(issueCmd, name))
|
||||
}
|
||||
}
|
||||
if !has(prCmd, "pr") || has(prCmd, "issue") {
|
||||
t.Error("pr comment must take --pr and only --pr")
|
||||
}
|
||||
if !has(issueCmd, "issue") || has(issueCmd, "pr") {
|
||||
t.Error("issue comment must take --issue and only --issue")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user