Feat: interactive issue edit command (#708)

If there are no flags passed to the `issues edit` command, it prompts
for changes to the properties like title, description, labels, etc.

This is a follow-up to <https://gitea.com/gitea/tea/pulls/506>.

Closes: <https://gitea.com/gitea/tea/issues/605>
Co-authored-by: techknowlogick <techknowlogick@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/708
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: Vincent Neubauer <v.neubauer@darlor.de>
Co-committed-by: Vincent Neubauer <v.neubauer@darlor.de>
This commit is contained in:
Vincent Neubauer 2025-02-27 18:49:24 +00:00 committed by techknowlogick
parent 60636cd7d8
commit 57e3400f0f
5 changed files with 181 additions and 11 deletions

View File

@ -8,6 +8,7 @@ import (
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils"
@ -47,6 +48,14 @@ func runIssuesEdit(cmd *cli.Context) error {
client := ctx.Login.Client()
for _, opts.Index = range indices {
if ctx.NumFlags() == 0 {
var err error
opts, err = interact.EditIssue(*ctx, opts.Index)
if err != nil {
return err
}
}
issue, err := task.EditIssue(ctx, client, *opts)
if err != nil {
return err

View File

@ -70,7 +70,7 @@ func promptIssueProperties(login *config.Login, owner, repo string, o *gitea.Cre
// milestone
if len(selectables.MilestoneList) != 0 {
if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]"); err != nil {
if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]", ""); err != nil {
return err
}
o.Milestone = selectables.MilestoneMap[milestoneName]

View File

@ -0,0 +1,153 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"slices"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/task"
"github.com/AlecAivazis/survey/v2"
)
// EditIssue interactively edits an issue
func EditIssue(ctx context.TeaContext, index int64) (*task.EditIssueOption, error) {
var opts = task.EditIssueOption{}
var err error
ctx.Owner, ctx.Repo, err = promptRepoSlug(ctx.Owner, ctx.Repo)
if err != nil {
return &opts, err
}
c := ctx.Login.Client()
i, _, err := c.GetIssue(ctx.Owner, ctx.Repo, index)
if err != nil {
return &opts, err
}
opts = task.EditIssueOption{
Index: index,
Title: &i.Title,
Body: &i.Body,
Deadline: i.Deadline,
}
if len(i.Assignees) != 0 {
for _, a := range i.Assignees {
opts.AddAssignees = append(opts.AddAssignees, a.UserName)
}
}
if len(i.Labels) != 0 {
for _, l := range i.Labels {
opts.AddLabels = append(opts.AddLabels, l.Name)
}
}
if i.Milestone != nil {
opts.Milestone = &i.Milestone.Title
}
if err := promptIssueEditProperties(&ctx, &opts); err != nil {
return &opts, err
}
return &opts, err
}
func promptIssueEditProperties(ctx *context.TeaContext, o *task.EditIssueOption) error {
var milestoneName string
var labelsSelected []string
var err error
selectableChan := make(chan (issueSelectables), 1)
go fetchIssueSelectables(ctx.Login, ctx.Owner, ctx.Repo, selectableChan)
// title
promptOpts := survey.WithValidator(survey.Required)
promptI := &survey.Input{Message: "Issue title:", Default: *o.Title}
if err = survey.AskOne(promptI, o.Title, promptOpts); err != nil {
return err
}
// description
promptD := NewMultiline(Multiline{
Message: "Issue description:",
Default: *o.Body,
Syntax: "md",
UseEditor: config.GetPreferences().Editor,
EditorAppendDefault: true,
EditorHideDefault: true,
})
if err = survey.AskOne(promptD, o.Body); err != nil {
return err
}
// wait until selectables are fetched
selectables := <-selectableChan
if selectables.Err != nil {
return selectables.Err
}
// skip remaining props if we don't have permission to set them
if !selectables.Repo.Permissions.Push {
return nil
}
currAssignees := o.AddAssignees
newAssignees := selectables.Assignees
for _, c := range currAssignees {
if i := slices.Index(newAssignees, c); i != -1 {
newAssignees = slices.Delete(newAssignees, i, i+1)
}
}
// assignees
if o.AddAssignees, err = promptMultiSelect("Add Assignees:", newAssignees, "[other]"); err != nil {
return err
}
// milestone
if len(selectables.MilestoneList) != 0 {
var defaultMS string
if o.Milestone != nil {
defaultMS = *o.Milestone
}
if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]", defaultMS); err != nil {
return err
}
o.Milestone = &milestoneName
}
// labels
if len(selectables.LabelList) != 0 {
promptL := &survey.MultiSelect{Message: "Labels:", Options: selectables.LabelList, VimMode: true, Default: o.AddLabels}
if err := survey.AskOne(promptL, &labelsSelected); err != nil {
return err
}
// removed labels
for _, l := range o.AddLabels {
if !slices.Contains(labelsSelected, l) {
o.RemoveLabels = append(o.RemoveLabels, l)
}
}
// added labels
o.AddLabels = make([]string, len(labelsSelected))
for i, l := range labelsSelected {
o.AddLabels[i] = l
}
}
// deadline
if o.Deadline, err = promptDatetime("Due date:"); err != nil {
return err
}
return nil
}

View File

@ -96,7 +96,7 @@ func CreateLogin() error {
}
if sshKey == "" {
sshKey, err = promptSelect("Select ssh-key: ", task.ListSSHPubkey(), "", "")
sshKey, err = promptSelect("Select ssh-key: ", task.ListSSHPubkey(), "", "", "")
if err != nil {
return err
}

View File

@ -19,6 +19,8 @@ type Multiline struct {
Default string
Syntax string
UseEditor bool
EditorAppendDefault bool
EditorHideDefault bool
}
// NewMultiline creates a prompt that switches between the inline multiline text
@ -29,6 +31,8 @@ func NewMultiline(opts Multiline) (prompt survey.Prompt) {
Message: opts.Message,
Default: opts.Default,
FileName: "*." + opts.Syntax,
AppendDefault: opts.EditorAppendDefault,
HideDefault: opts.EditorHideDefault,
}
} else {
prompt = &survey.Multiline{Message: opts.Message, Default: opts.Default}
@ -146,13 +150,17 @@ func promptSelectV2(prompt string, options []string) (string, error) {
}
// promptSelect creates a generic select prompt, with processing of custom values or none-option.
func promptSelect(prompt string, options []string, customVal, noneVal string) (string, error) {
func promptSelect(prompt string, options []string, customVal, noneVal, defaultVal string) (string, error) {
var selection string
if defaultVal == "" && noneVal != "" {
defaultVal = noneVal
}
promptA := &survey.Select{
Message: prompt,
Options: makeSelectOpts(options, customVal, noneVal),
VimMode: true,
Default: noneVal,
Default: defaultVal,
}
if err := survey.AskOne(promptA, &selection); err != nil {
return "", err