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
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
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.
- `pr merge` merges the head it read and refuses when the branch moved in the
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
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
the default branch when the repository does not come from the working directory.`,
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}
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 {
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")
}
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
// with yes, and text by title.
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".
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)
}
@@ -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
// commands refuse comments on issues.
func (a *application) comment(cmd *cobra.Command, arg string, kind refKind) (target, services.Comment, error) {
// commands refuse comments on issues. With changing set, as for edit and delete, a comment that
// 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)
if err != nil {
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
}
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 {
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)
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 {
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 {
return err
}
t, comment, err := a.comment(cmd, args[0], kind)
t, comment, err := a.comment(cmd, args[0], kind, true)
if err != nil {
return err
}
@@ -375,7 +384,7 @@ $ tea %[1]s comment edit 81`, words.group), Args: argsExact(1), RunE: func(cmd *
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 {
t, comment, err := a.comment(cmd, args[0], kind)
t, comment, err := a.comment(cmd, args[0], kind, true)
if err != nil {
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.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 {
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", "feature", "--title", "Idea", "--editor"))
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.Equal(t, clierr.Failure, clierr.ExitCode(err))
err = run("--template", "feature")
require.ErrorContains(t, err, "missing required input: supply --title")
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", "-")
require.ErrorContains(t, err, "--body-file - requires --title")
require.Len(t, created, 4)
for i, want := range []string{
`{"title":"[bug] ","body":"Steps:"}`,
`{"title":"Crash","body":"Steps:"}`,
`{"title":"Explicit","body":"Override"}`,
`{"title":"Idea","body":"edited idea"}`,
`{"title":"[bug] ","body":"From stdin"}`,
`{"title":"Crash","body":"From stdin"}`,
} {
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) {
var requests, uploaded []string
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 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
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 --template "Bug report"`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
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 {
return a.createIssueInteractively(cmd)
if !localFlagsChanged(cmd, "template") && a.canPrompt() && a.deps.IssueForm != nil {
return a.createIssueInteractively(cmd, strings.TrimSpace(templateName))
}
var target target
var templateBody string
@@ -258,9 +262,7 @@ $ tea issue create --template "Bug report"`, Args: argsExact(0), RunE: func(cmd
if err != nil {
return err
}
if !cmd.Flags().Changed("title") {
title = template.Title
}
// Like gh, the template only seeds the body; its title is a placeholder, so --title is required.
templateBody = issueTemplateBody(template)
}
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)
}
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)
if err != nil {
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.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)
defer finishDraft(&values.Draft, &runErr)
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)
}
// 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.
func (s Comments) View(ctx context.Context, id int64) (Comment, error) {
status := 0
@@ -73,7 +84,7 @@ func (s Comments) View(ctx context.Context, id int64) (Comment, error) {
})
if status == http.StatusNoContent {
// 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)
}
+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.
type IssueFormChoices struct {
Templates []IssueTemplateChoice
// Template, when set, is used instead of asking: the form starts with its body and an empty title.
Template *IssueTemplateChoice
Assignees []string
Milestones []IssueMilestoneChoice
Labels []IssueLabelChoice
@@ -64,7 +66,9 @@ type IssueForm interface {
// CreateIssue runs the interactive issue create form on stderr.
func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices) (IssueFormValues, error) {
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)}
for i, template := range choices.Templates {
label := template.Name