diff --git a/docs/migration-1.0.md b/docs/migration-1.0.md index 92a994cc..54d61d87 100644 --- a/docs/migration-1.0.md +++ b/docs/migration-1.0.md @@ -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. diff --git a/internal/app/actions.go b/internal/app/actions.go index 96ce3ea5..a5416544 100644 --- a/internal/app/actions.go +++ b/internal/app/actions.go @@ -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 } diff --git a/internal/app/actions_test.go b/internal/app/actions_test.go index a42a7855..6b1b2bcf 100644 --- a/internal/app/actions_test.go +++ b/internal/app/actions_test.go @@ -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 { diff --git a/internal/app/comments.go b/internal/app/comments.go index 3726ac1f..02834d68 100644 --- a/internal/app/comments.go +++ b/internal/app/comments.go @@ -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, ¬Plain) { + 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 ", 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 ", 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 } diff --git a/internal/app/comments_test.go b/internal/app/comments_test.go index f0b8bc65..ecf670bd 100644 --- a/internal/app/comments_test.go +++ b/internal/app/comments_test.go @@ -192,3 +192,33 @@ func TestViewCommentsCannotBeCombinedWithJSON(t *testing.T) { require.ErrorContains(t, err, "tea pr review list --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") +} diff --git a/internal/app/issue_create_test.go b/internal/app/issue_create_test.go index 80e0e126..c60ff3b1 100644 --- a/internal/app/issue_create_test.go +++ b/internal/app/issue_create_test.go @@ -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) { diff --git a/internal/app/issues.go b/internal/app/issues.go index 32486bb0..6174d03e 100644 --- a/internal/app/issues.go +++ b/internal/app/issues.go @@ -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 { diff --git a/internal/services/comments.go b/internal/services/comments.go index 58efcd71..0416548e 100644 --- a/internal/services/comments.go +++ b/internal/services/comments.go @@ -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) } diff --git a/internal/terminal/issue_form.go b/internal/terminal/issue_form.go index 4332988b..49ff33b4 100644 --- a/internal/terminal/issue_form.go +++ b/internal/terminal/issue_form.go @@ -36,7 +36,9 @@ type BodyEditor func(context.Context, string) (Draft, error) // IssueFormChoices supplies options for an interactive issue create form. 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 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