fix: seed issue create from --template, edit and delete inline review comments, explain workflow run refs (follow gh)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 18:58:28 +02:00
1 parent 0db9ed463e
commit 2a221d0a81
9 files changed
+121 -20

No files matched your search

+2 -1
View File
@@ -425,7 +425,8 @@ archive, or run `make docs`.
(unless it approves), team creation needs a permission, migrations from (unless it approves), team creation needs a permission, migrations from
GitHub, Gitea, GitLab and Gogs need an access token. `workflow run` uses the GitHub, Gitea, GitLab and Gogs need an access token. `workflow run` uses the
checked-out branch (which must be on the server) or the default branch when checked-out branch (which must be on the server) or the default branch when
there is no `--ref`, and refuses unknown inputs, missing required inputs and there is no `--ref`; the workflow must exist on the default branch even with
`--ref`, as in `gh`. It also refuses unknown inputs, missing required inputs and
values that a choice, boolean or number input does not accept. values that a choice, boolean or number input does not accept.
- `pr merge` merges the head it read and refuses when the branch moved in the - `pr merge` merges the head it read and refuses when the branch moved in the
meantime; run it again to merge the new head. meantime; run it again to merge the new head.
+8
View File
@@ -169,6 +169,10 @@ Inputs are checked against the inputs that the workflow declares: unknown inputs
required inputs and values that a choice or boolean input does not accept are refused. In a required inputs and values that a choice or boolean input does not accept are refused. In a
terminal, tea asks for missing required inputs, or for every input when none is given. terminal, tea asks for missing required inputs, or for every input when none is given.
The workflow is looked up among those the server lists from the default branch, so it must exist
there; --ref only selects the branch or tag whose version of it runs, as in gh. List them with
tea workflow list.
Without --ref, the workflow runs on the checked-out branch, which must be on the server, or on Without --ref, the workflow runs on the checked-out branch, which must be on the server, or on
the default branch when the repository does not come from the working directory.`, the default branch when the repository does not come from the working directory.`,
Example: `# run a workflow on the default or current branch Example: `# run a workflow on the default or current branch
@@ -196,6 +200,10 @@ $ tea workflow run nightly --follow`,
} }
service := services.Actions{Client: t.client, Repository: t.Repo} service := services.Actions{Client: t.client, Repository: t.Repo}
workflow, err := service.FindWorkflow(cmd.Context(), args[0]) workflow, err := service.FindWorkflow(cmd.Context(), args[0])
if notFound, ok := errors.AsType[*services.NotFoundError](err); ok && notFound.Kind == "workflow" {
// Like gh, the workflow comes from the server's list, so --ref cannot add one.
return fmt.Errorf("%w; workflows are listed from the default branch and must exist there, even with --ref; see tea workflow list%s", err, a.suggestedRepo(t))
}
if err != nil { if err != nil {
return err return err
} }
+8
View File
@@ -109,6 +109,14 @@ func TestWorkflowRunChecksInputsBeforeDispatching(t *testing.T) {
"workflow nightly.yaml is disabled; enable it with: tea workflow enable nightly.yaml -R alice/project") "workflow nightly.yaml is disabled; enable it with: tea workflow enable nightly.yaml -R alice/project")
} }
func TestWorkflowRunExplainsWorkflowsComeFromTheDefaultBranch(t *testing.T) {
server, requests := actionsAPI(t, map[string]string{"GET actions/workflows": workflowsFixture})
deps, _, _ := actionsDeps(t, server.URL, false)
err := Execute(context.Background(), deps, []string{"workflow", "run", "branch-only", "-R", "alice/project", "--ref", "feature"})
require.EqualError(t, err, `workflow "branch-only" not found in alice/project; workflows are listed from the default branch and must exist there, even with --ref; see tea workflow list -R alice/project`)
require.Equal(t, []string{"GET actions/workflows"}, *requests)
}
// dispatchingPrompter answers workflow inputs: selections with their first choice, confirmations // dispatchingPrompter answers workflow inputs: selections with their first choice, confirmations
// with yes, and text by title. // with yes, and text by title.
type dispatchingPrompter struct { type dispatchingPrompter struct {
+14 -5
View File
@@ -61,6 +61,10 @@ func issueCommandPath(cmd *cobra.Command) string {
// commentName names a comment in messages: "comment 81 on alice/x#1". // commentName names a comment in messages: "comment 81 on alice/x#1".
func commentName(t target, comment services.Comment) string { func commentName(t target, comment services.Comment) string {
if comment.IssueNumber == 0 {
// Inline review comments cannot be fetched, so their discussion is unknown.
return fmt.Sprintf("comment %d on %s", comment.ID, t.Repo)
}
return fmt.Sprintf("comment %d on %s#%d", comment.ID, t.Repo, comment.IssueNumber) return fmt.Sprintf("comment %d on %s#%d", comment.ID, t.Repo, comment.IssueNumber)
} }
@@ -159,8 +163,10 @@ func lastComment(cmd *cobra.Command, t target, n int64) (services.Comment, error
} }
// comment returns the comment that the ID argument names, which a command of kind may change: pr // comment returns the comment that the ID argument names, which a command of kind may change: pr
// commands refuse comments on issues. // commands refuse comments on issues. With changing set, as for edit and delete, a comment that
func (a *application) comment(cmd *cobra.Command, arg string, kind refKind) (target, services.Comment, error) { // cannot be fetched, such as an inline review comment, is returned by its ID alone: the edit and
// delete endpoints accept it.
func (a *application) comment(cmd *cobra.Command, arg string, kind refKind, changing bool) (target, services.Comment, error) {
id, err := number(arg) id, err := number(arg)
if err != nil { if err != nil {
return target{}, services.Comment{}, err return target{}, services.Comment{}, err
@@ -170,6 +176,9 @@ func (a *application) comment(cmd *cobra.Command, arg string, kind refKind) (tar
return t, services.Comment{}, err return t, services.Comment{}, err
} }
current, err := (services.Comments{Client: t.client, Repository: t.Repo}).View(cmd.Context(), id) current, err := (services.Comments{Client: t.client, Repository: t.Repo}).View(cmd.Context(), id)
if notPlain := (services.NotPlainCommentError{}); changing && errors.As(err, &notPlain) {
return t, services.Comment{ID: id}, nil
}
if err == nil && kind == pullRef && !current.Pull { if err == nil && kind == pullRef && !current.Pull {
err = fmt.Errorf("comment %d in %s is on an issue, not a pull request; use %s %d", id, t.Repo, issueCommandPath(cmd), id) err = fmt.Errorf("comment %d in %s is on an issue, not a pull request; use %s %d", id, t.Repo, issueCommandPath(cmd), id)
} }
@@ -338,7 +347,7 @@ $ tea %[1]s comment list %[2]s --json id,author,body`, words.group, words.exampl
list.ValidArgsFunction = a.completeDiscussions(kind) list.ValidArgsFunction = a.completeDiscussions(kind)
view := &cobra.Command{Use: "view <comment-id>", Short: "View a comment", Example: fmt.Sprintf("$ tea %s comment view 81", words.group), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { view := &cobra.Command{Use: "view <comment-id>", Short: "View a comment", Example: fmt.Sprintf("$ tea %s comment view 81", words.group), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
t, comment, err := a.comment(cmd, args[0], kind) t, comment, err := a.comment(cmd, args[0], kind, false)
if err != nil { if err != nil {
return err return err
} }
@@ -366,7 +375,7 @@ $ tea %[1]s comment edit 81`, words.group), Args: argsExact(1), RunE: func(cmd *
if err := editFlags.validate(cmd); err != nil { if err := editFlags.validate(cmd); err != nil {
return err return err
} }
t, comment, err := a.comment(cmd, args[0], kind) t, comment, err := a.comment(cmd, args[0], kind, true)
if err != nil { if err != nil {
return err return err
} }
@@ -375,7 +384,7 @@ $ tea %[1]s comment edit 81`, words.group), Args: argsExact(1), RunE: func(cmd *
editFlags.register(edit) editFlags.register(edit)
remove := &cobra.Command{Use: "delete <comment-id>", Short: "Delete a comment", Example: fmt.Sprintf("$ tea %s comment delete 81 --yes", words.group), Annotations: map[string]string{"tea.text": "comment deletion does not return a resource", "tea.confirm": "true"}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { remove := &cobra.Command{Use: "delete <comment-id>", Short: "Delete a comment", Example: fmt.Sprintf("$ tea %s comment delete 81 --yes", words.group), Annotations: map[string]string{"tea.text": "comment deletion does not return a resource", "tea.confirm": "true"}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
t, comment, err := a.comment(cmd, args[0], kind) t, comment, err := a.comment(cmd, args[0], kind, true)
if err != nil { if err != nil {
return err return err
} }
+30
View File
@@ -192,3 +192,33 @@ func TestViewCommentsCannotBeCombinedWithJSON(t *testing.T) {
require.ErrorContains(t, err, "tea pr review list <number> --json") require.ErrorContains(t, err, "tea pr review list <number> --json")
require.Empty(t, out.String()) require.Empty(t, out.String())
} }
func TestCommentEditAndDeleteReachInlineReviewComments(t *testing.T) {
var calls []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch r.Method + " " + r.URL.Path {
case "GET /api/v1/repos/alice/project/issues/comments/40":
w.WriteHeader(http.StatusNoContent) // Gitea's answer for inline review comments
case "PATCH /api/v1/repos/alice/project/issues/comments/40":
require.Equal(t, "Fixed", payloadField(t, r, "body"))
_, _ = w.Write([]byte(`{"id":40,"body":"Fixed"}`))
case "DELETE /api/v1/repos/alice/project/issues/comments/40":
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
run := func(args ...string) error {
return Execute(context.Background(), deps, append([]string{"pr", "comment"}, append(args, "-R", "alice/project", "--no-input")...))
}
require.NoError(t, run("edit", "40", "--body", "Fixed"))
require.NoError(t, run("delete", "40", "--yes"))
require.Contains(t, calls, "PATCH /api/v1/repos/alice/project/issues/comments/40")
require.Contains(t, calls, "DELETE /api/v1/repos/alice/project/issues/comments/40")
// Viewing still explains why an inline review comment cannot be shown.
require.ErrorContains(t, run("view", "40"), "not a plain comment")
}
+25 -5
View File
@@ -42,30 +42,50 @@ func TestIssueCreateSeedsFromTemplate(t *testing.T) {
run := func(args ...string) error { run := func(args ...string) error {
return Execute(context.Background(), deps, append([]string{"issue", "create", "-R", "alice/project", "--no-input", "--json=number"}, args...)) return Execute(context.Background(), deps, append([]string{"issue", "create", "-R", "alice/project", "--no-input", "--json=number"}, args...))
} }
require.NoError(t, run("--template", "bug report")) err := run("--template", "bug report")
require.ErrorContains(t, err, "missing required input: supply --title", "the template title is a placeholder and is never filed")
require.NoError(t, run("--template", "bug report", "--title", "Crash"))
require.NoError(t, run("--template", "Bug Report", "--title", "Explicit", "--body", "Override")) require.NoError(t, run("--template", "Bug Report", "--title", "Explicit", "--body", "Override"))
require.NoError(t, run("--template", "feature", "--title", "Idea", "--editor")) require.NoError(t, run("--template", "feature", "--title", "Idea", "--editor"))
require.Equal(t, "Describe the idea", editor.initial) require.Equal(t, "Describe the idea", editor.initial)
err := run("--template", "missing", "--title", "x") err = run("--template", "missing", "--title", "x")
require.ErrorContains(t, err, `issue template "missing" does not exist; available: Bug Report, Feature`) require.ErrorContains(t, err, `issue template "missing" does not exist; available: Bug Report, Feature`)
require.Equal(t, clierr.Failure, clierr.ExitCode(err)) require.Equal(t, clierr.Failure, clierr.ExitCode(err))
err = run("--template", "feature") err = run("--template", "feature")
require.ErrorContains(t, err, "missing required input: supply --title") require.ErrorContains(t, err, "missing required input: supply --title")
deps.Streams.In = strings.NewReader("From stdin") deps.Streams.In = strings.NewReader("From stdin")
require.NoError(t, run("--template", "bug report", "--body-file", "-")) require.NoError(t, run("--template", "bug report", "--title", "Crash", "--body-file", "-"))
err = run("--template", "feature", "--body-file", "-") err = run("--template", "feature", "--body-file", "-")
require.ErrorContains(t, err, "--body-file - requires --title") require.ErrorContains(t, err, "--body-file - requires --title")
require.Len(t, created, 4) require.Len(t, created, 4)
for i, want := range []string{ for i, want := range []string{
`{"title":"[bug] ","body":"Steps:"}`, `{"title":"Crash","body":"Steps:"}`,
`{"title":"Explicit","body":"Override"}`, `{"title":"Explicit","body":"Override"}`,
`{"title":"Idea","body":"edited idea"}`, `{"title":"Idea","body":"edited idea"}`,
`{"title":"[bug] ","body":"From stdin"}`, `{"title":"Crash","body":"From stdin"}`,
} { } {
requirePayload(t, want, strings.NewReader(created[i])) requirePayload(t, want, strings.NewReader(created[i]))
} }
} }
func TestIssueCreateTemplateOpensFormWithTemplateBody(t *testing.T) {
var createPayload map[string]any
server := newAuthenticatedServer(selectablesHandler(t, true, func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&createPayload))
_, _ = w.Write([]byte(`{"number":9,"title":"Mine"}`))
}))
defer server.Close()
form := &recordingIssueForm{values: terminal.IssueFormValues{Title: "Mine", Body: "## Steps"}}
deps, _, _ := serverDeps(t, server.URL)
deps.IssueForm = form
deps.Prompter = &scriptedPrompter{accept: true}
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "create", "-R", "alice/project", "--json=number", "--interactive", "--template", "bug"}))
require.Equal(t, 1, form.calls)
require.Equal(t, &terminal.IssueTemplateChoice{Name: "Bug", About: "Report", Body: "## Steps"}, form.choices.Template, "the form starts with the template body and an empty title")
require.Equal(t, "Mine", createPayload["title"])
}
func TestIssueCreateUploadsAttachmentsAndKeepsIssueOnFailure(t *testing.T) { func TestIssueCreateUploadsAttachmentsAndKeepsIssueOnFailure(t *testing.T) {
var requests, uploaded []string var requests, uploaded []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+17 -7
View File
@@ -203,7 +203,11 @@ func (a *application) issueCreateCommand() *cobra.Command {
var useEditor, web bool var useEditor, web bool
var assignees, labelNames, attachmentPaths []string var assignees, labelNames, attachmentPaths []string
cmd := &cobra.Command{Use: "create", Aliases: []string{"new"}, Short: "Create an issue", Long: `Create an issue in a repository. Without flags on a terminal, a form asks for the title and cmd := &cobra.Command{Use: "create", Aliases: []string{"new"}, Short: "Create an issue", Long: `Create an issue in a repository. Without flags on a terminal, a form asks for the title and
body, then optionally for assignees, a milestone, labels and a due date.`, Example: `$ tea issue create body, then optionally for assignees, a milestone, labels and a due date.
--template starts from the body of a repository issue template; on a terminal the form opens with
that body and an empty title. Without a terminal, --title is required, and --body or --body-file
replace the template body.`, Example: `$ tea issue create
$ tea issue create --title "Crash on start" --body "Steps to reproduce…" --label bug --milestone v1.0 $ tea issue create --title "Crash on start" --body "Steps to reproduce…" --label bug --milestone v1.0
$ tea issue create --template "Bug report"`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) { $ tea issue create --template "Bug report"`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
var draft terminal.Draft var draft terminal.Draft
@@ -243,8 +247,8 @@ $ tea issue create --template "Bug report"`, Args: argsExact(0), RunE: func(cmd
} }
} }
} }
if !localFlagsChanged(cmd) && a.canPrompt() && a.deps.IssueForm != nil { if !localFlagsChanged(cmd, "template") && a.canPrompt() && a.deps.IssueForm != nil {
return a.createIssueInteractively(cmd) return a.createIssueInteractively(cmd, strings.TrimSpace(templateName))
} }
var target target var target target
var templateBody string var templateBody string
@@ -258,9 +262,7 @@ $ tea issue create --template "Bug report"`, Args: argsExact(0), RunE: func(cmd
if err != nil { if err != nil {
return err return err
} }
if !cmd.Flags().Changed("title") { // Like gh, the template only seeds the body; its title is a placeholder, so --title is required.
title = template.Title
}
templateBody = issueTemplateBody(template) templateBody = issueTemplateBody(template)
} }
if web { if web {
@@ -344,7 +346,8 @@ func (a *application) issueCreated(cmd *cobra.Command, t target, item services.I
return a.created(cmd, item, item.URL) return a.created(cmd, item, item.URL)
} }
func (a *application) createIssueInteractively(cmd *cobra.Command) (runErr error) { // createIssueInteractively runs the create form; a non-empty templateName pre-fills its body.
func (a *application) createIssueInteractively(cmd *cobra.Command, templateName string) (runErr error) {
target, err := a.resolve(cmd.Context(), true) target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
@@ -363,6 +366,13 @@ func (a *application) createIssueInteractively(cmd *cobra.Command) (runErr error
choices.Templates = append(choices.Templates, terminal.IssueTemplateChoice{Name: template.Name, About: template.About, Title: template.Title, Body: issueTemplateBody(template)}) choices.Templates = append(choices.Templates, terminal.IssueTemplateChoice{Name: template.Name, About: template.About, Title: template.Title, Body: issueTemplateBody(template)})
} }
choices.Milestones, choices.Labels = milestoneChoices(selectables.Milestones), labelChoices(selectables.Labels) choices.Milestones, choices.Labels = milestoneChoices(selectables.Milestones), labelChoices(selectables.Labels)
if templateName != "" {
template, err := issueTemplate(cmd.Context(), service, templateName)
if err != nil {
return err
}
choices.Template = &terminal.IssueTemplateChoice{Name: template.Name, About: template.About, Body: issueTemplateBody(template)}
}
values, err := a.deps.IssueForm.CreateIssue(cmd.Context(), choices) values, err := a.deps.IssueForm.CreateIssue(cmd.Context(), choices)
defer finishDraft(&values.Draft, &runErr) defer finishDraft(&values.Draft, &runErr)
if err != nil { if err != nil {
+12 -1
View File
@@ -61,6 +61,17 @@ func (s Comments) Page(ctx context.Context, n int64, limit, page int) ([]Comment
return pageOf(items, limit, page) return pageOf(items, limit, page)
} }
// NotPlainCommentError reports a comment that the single-comment endpoint answers with no body,
// such as an inline review comment. Such comments can still be edited and deleted by ID.
type NotPlainCommentError struct {
ID int64
Repository string
}
func (e NotPlainCommentError) Error() string {
return fmt.Sprintf("comment %d in %s is not a plain comment, such as an inline review comment, which this command cannot show", e.ID, e.Repository)
}
// View retrieves a comment by its repository-scoped ID. // View retrieves a comment by its repository-scoped ID.
func (s Comments) View(ctx context.Context, id int64) (Comment, error) { func (s Comments) View(ctx context.Context, id int64) (Comment, error) {
status := 0 status := 0
@@ -73,7 +84,7 @@ func (s Comments) View(ctx context.Context, id int64) (Comment, error) {
}) })
if status == http.StatusNoContent { if status == http.StatusNoContent {
// Gitea answers without a body for the comments that are not plain ones, such as inline review comments. // Gitea answers without a body for the comments that are not plain ones, such as inline review comments.
return Comment{}, fmt.Errorf("comment %d in %s is not a plain comment, such as an inline review comment, which this command cannot show", id, s.Repository) return Comment{}, NotPlainCommentError{ID: id, Repository: s.Repository}
} }
return item, Classify(err, "comment", strconv.FormatInt(id, 10), s.Repository) return item, Classify(err, "comment", strconv.FormatInt(id, 10), s.Repository)
} }
+5 -1
View File
@@ -37,6 +37,8 @@ type BodyEditor func(context.Context, string) (Draft, error)
// IssueFormChoices supplies options for an interactive issue create form. // IssueFormChoices supplies options for an interactive issue create form.
type IssueFormChoices struct { type IssueFormChoices struct {
Templates []IssueTemplateChoice Templates []IssueTemplateChoice
// Template, when set, is used instead of asking: the form starts with its body and an empty title.
Template *IssueTemplateChoice
Assignees []string Assignees []string
Milestones []IssueMilestoneChoice Milestones []IssueMilestoneChoice
Labels []IssueLabelChoice Labels []IssueLabelChoice
@@ -64,7 +66,9 @@ type IssueForm interface {
// CreateIssue runs the interactive issue create form on stderr. // CreateIssue runs the interactive issue create form on stderr.
func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices) (IssueFormValues, error) { func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices) (IssueFormValues, error) {
title, body := "", "" title, body := "", ""
if len(choices.Templates) > 0 { if choices.Template != nil {
body = choices.Template.Body
} else if len(choices.Templates) > 0 {
options := []huh.Option[int]{huh.NewOption("[none]", -1)} options := []huh.Option[int]{huh.NewOption("[none]", -1)}
for i, template := range choices.Templates { for i, template := range choices.Templates {
label := template.Name label := template.Name