mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
milestone view v2.0 failed with "invalid resource number" although its usage promised <id-or-name>, and issue create --milestone v1.0 failed with a strconv error. Every milestone argument (view, edit, delete, close, reopen, milestone issue add|remove|list) and issue create|edit -m/--milestone now accept a title or an ID: a number is an ID first, then a title; --by-name matches numeric titles only. --milestone-name stays as a hidden name of --milestone, and issue edit gains --remove-milestone (as does --milestone 0). Signed-off-by: bircni <bircni@icloud.com>
475 lines
18 KiB
Go
475 lines
18 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dev/tea/internal/clierr"
|
|
"gitea.dev/tea/internal/services"
|
|
"gitea.dev/tea/internal/terminal"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func (a *application) createIssueInteractively(cmd *cobra.Command) (runErr error) {
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
service := services.Issues{Client: target.client, Repository: target.Repo}
|
|
selectables, err := service.CreateSelectables(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
choices := terminal.IssueFormChoices{
|
|
CanPush: selectables.CanPush, Assignees: selectables.Assignees,
|
|
EditBody: a.bodyEditor(cmd),
|
|
}
|
|
for _, template := range selectables.Templates {
|
|
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)
|
|
values, err := a.deps.IssueForm.CreateIssue(cmd.Context(), choices)
|
|
defer finishDraft(&values.Draft, &runErr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := a.confirm(cmd, "Create issue on "+target.Repo+": "+values.Title); err != nil {
|
|
return err
|
|
}
|
|
item, err := service.Create(cmd.Context(), services.IssueOptions{
|
|
Title: values.Title, Body: values.Body, Assignees: values.Assignees, Milestone: values.Milestone, Labels: values.Labels, Deadline: values.Deadline,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeRecord(a, cmd, item, issueRecord)
|
|
}
|
|
|
|
// issueTemplateBody seeds a body from markdown content, or from the description of form templates.
|
|
func issueTemplateBody(template services.IssueTemplate) string {
|
|
if template.Form && template.Content == "" {
|
|
return template.About
|
|
}
|
|
return template.Content
|
|
}
|
|
|
|
// issueTemplate selects a repository issue template by case-insensitive name.
|
|
func issueTemplate(ctx context.Context, service services.Issues, name string) (services.IssueTemplate, error) {
|
|
templates, err := service.IssueTemplates(ctx)
|
|
if err != nil {
|
|
return services.IssueTemplate{}, err
|
|
}
|
|
names := make([]string, len(templates))
|
|
for i, template := range templates {
|
|
if strings.EqualFold(template.Name, name) {
|
|
return template, nil
|
|
}
|
|
names[i] = template.Name
|
|
}
|
|
if len(names) == 0 {
|
|
return services.IssueTemplate{}, fmt.Errorf("issue template %q does not exist; %s has no issue templates", name, service.Repository)
|
|
}
|
|
return services.IssueTemplate{}, fmt.Errorf("issue template %q does not exist; available: %s", name, strings.Join(names, ", "))
|
|
}
|
|
|
|
// milestoneFlag reads -m/--milestone, a milestone title or ID, before any request: value is
|
|
// "" when the flag is absent, and remove reports 0 or --remove-milestone, which clear it.
|
|
func milestoneFlag(cmd *cobra.Command, value string) (selected string, remove bool, err error) {
|
|
remove, _ = cmd.Flags().GetBool("remove-milestone")
|
|
switch {
|
|
case !cmd.Flags().Changed("milestone"):
|
|
return "", remove, nil
|
|
case remove:
|
|
return "", false, clierr.Wrap(clierr.Usage, errors.New("--milestone and --remove-milestone cannot be combined"))
|
|
case strings.TrimSpace(value) == "":
|
|
return "", false, clierr.Wrap(clierr.Usage, errors.New("--milestone needs a title or ID; use --remove-milestone to clear it"))
|
|
case strings.TrimSpace(value) == "0":
|
|
return "", true, nil
|
|
}
|
|
return strings.TrimSpace(value), false, nil
|
|
}
|
|
|
|
// milestoneID resolves a milestone title or ID in the target repository.
|
|
func milestoneID(cmd *cobra.Command, t target, raw string) (int64, error) {
|
|
milestone, err := (services.Milestones{Client: t.client, Repository: t.Repo}).FindOne(cmd.Context(), raw, false)
|
|
return milestone.ID, err
|
|
}
|
|
|
|
// validateMilestoneFlags checks the --milestone ID and --milestone-name flags of pr create and pr
|
|
// edit before any request.
|
|
func validateMilestoneFlags(cmd *cobra.Command, name string, id int64) error {
|
|
if cmd.Flags().Changed("milestone-name") {
|
|
if cmd.Flags().Changed("milestone") {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--milestone and --milestone-name are mutually exclusive"))
|
|
}
|
|
if strings.TrimSpace(name) == "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--milestone-name cannot be empty; use --milestone=0 for none"))
|
|
}
|
|
}
|
|
if id < 0 {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--milestone must be a positive ID, or 0 for none"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func milestoneChoices(items []services.Milestone) []terminal.IssueMilestoneChoice {
|
|
choices := make([]terminal.IssueMilestoneChoice, len(items))
|
|
for i, item := range items {
|
|
choices[i] = terminal.IssueMilestoneChoice{Title: item.Title, ID: item.ID}
|
|
}
|
|
return choices
|
|
}
|
|
|
|
func labelChoices(items []services.Label) []terminal.IssueLabelChoice {
|
|
choices := make([]terminal.IssueLabelChoice, len(items))
|
|
for i, item := range items {
|
|
choices[i] = terminal.IssueLabelChoice{Name: item.Name, ID: item.ID}
|
|
}
|
|
return choices
|
|
}
|
|
|
|
func issueLabel(n int64) string { return fmt.Sprintf("issue %d", n) }
|
|
|
|
// nameFilter splits a comma-separated filter of kind, trimming entries and rejecting blanks.
|
|
func nameFilter(kind, raw string) ([]string, error) {
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
names := strings.Split(raw, ",")
|
|
return names, requireNames(kind, "", names)
|
|
}
|
|
|
|
// issueFilters checks the label and milestone filters of an issue listing in the target repository
|
|
// and returns them as query values. Gitea ignores labels and milestones it cannot find, and lists
|
|
// every issue when no filter value is left, so unknown names are errors.
|
|
func issueFilters(cmd *cobra.Command, t target, labels, milestones []string) (labelQuery, milestoneQuery string, err error) {
|
|
if len(labels) > 0 {
|
|
if err := (services.Labels{Client: t.client, Repository: t.Repo}).RequireFilterNames(cmd.Context(), labels); err != nil {
|
|
return "", "", err
|
|
}
|
|
}
|
|
var ids []string
|
|
if len(milestones) > 0 {
|
|
if ids, err = (services.Milestones{Client: t.client, Repository: t.Repo}).FilterIDs(cmd.Context(), milestones); err != nil {
|
|
return "", "", err
|
|
}
|
|
}
|
|
return strings.Join(labels, ","), strings.Join(ids, ","), nil
|
|
}
|
|
|
|
// metadataFlags are the label and assignee flags shared by issue and pull request edits.
|
|
type metadataFlags struct {
|
|
setLabels, addLabels, removeLabels []string
|
|
setAssignees, addAssignees, removeAssignees []string
|
|
}
|
|
|
|
func (f *metadataFlags) register(cmd *cobra.Command) {
|
|
cmd.Flags().StringSliceVar(&f.setLabels, "set-labels", nil, "Replace labels by exact name; repo:/org: disambiguate; empty clears")
|
|
cmd.Flags().StringSliceVar(&f.addLabels, "add-labels", nil, "Add labels by exact name; repo:/org: disambiguate")
|
|
cmd.Flags().StringSliceVar(&f.removeLabels, "remove-labels", nil, "Remove labels by exact name; repo:/org: disambiguate")
|
|
cmd.Flags().StringSliceVar(&f.setAssignees, "set-assignees", nil, "Replace assignees; an empty value clears")
|
|
cmd.Flags().StringSliceVar(&f.addAssignees, "add-assignees", nil, "Add assignees")
|
|
cmd.Flags().StringSliceVar(&f.removeAssignees, "remove-assignees", nil, "Remove assignees")
|
|
}
|
|
|
|
func (f *metadataFlags) validate(cmd *cobra.Command) error {
|
|
for _, kind := range []string{"assignees", "labels"} {
|
|
if cmd.Flags().Changed("set-"+kind) && (cmd.Flags().Changed("add-"+kind) || cmd.Flags().Changed("remove-"+kind)) {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("--set-%[1]s cannot be combined with --add-%[1]s or --remove-%[1]s", kind))
|
|
}
|
|
}
|
|
if err := requireNames("assignee", "; use --set-assignees= to clear", f.setAssignees, f.addAssignees, f.removeAssignees); err != nil {
|
|
return err
|
|
}
|
|
return requireNames("label", "; use --set-labels= to clear", f.setLabels, f.addLabels, f.removeLabels)
|
|
}
|
|
|
|
// changed reports whether the flags edit anything; empty --add-*/--remove-* values do not.
|
|
func (f *metadataFlags) changed(cmd *cobra.Command) bool {
|
|
return cmd.Flags().Changed("set-labels") || cmd.Flags().Changed("set-assignees") || f.incremental()
|
|
}
|
|
|
|
// incremental reports whether any --add-* or --remove-* flag names a value.
|
|
func (f *metadataFlags) incremental() bool {
|
|
return len(f.addLabels)+len(f.removeLabels)+len(f.addAssignees)+len(f.removeAssignees) > 0
|
|
}
|
|
|
|
// resolve maps set flags to replacements, and add and remove flags to changes that the service
|
|
// merges with each item's current state.
|
|
func (f *metadataFlags) resolve(cmd *cobra.Command, t target) (labels *[]int64, assignees *[]string, changes *services.MetadataChanges, err error) {
|
|
resolver := services.Labels{Client: t.client, Repository: t.Repo}
|
|
if cmd.Flags().Changed("set-labels") {
|
|
ids, err := resolver.ResolveIssueNames(cmd.Context(), f.setLabels)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
labels = &ids
|
|
}
|
|
if cmd.Flags().Changed("set-assignees") {
|
|
names := append([]string{}, f.setAssignees...)
|
|
assignees = &names
|
|
}
|
|
if !f.incremental() {
|
|
return labels, assignees, nil, nil
|
|
}
|
|
changes = &services.MetadataChanges{AddAssignees: f.addAssignees, RemoveAssignees: f.removeAssignees}
|
|
if changes.AddLabels, err = resolver.ResolveIssueNames(cmd.Context(), f.addLabels); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if changes.RemoveLabels, err = resolver.ResolveIssueNames(cmd.Context(), f.removeLabels); err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
return labels, assignees, changes, nil
|
|
}
|
|
|
|
func issueRecord(item services.Issue) terminal.Record {
|
|
return numberedRecord(item.Number, item.Title, item.URL)
|
|
}
|
|
|
|
func issueRow(item services.Issue) []terminal.Cell {
|
|
return []terminal.Cell{{Text: strconv.FormatInt(item.Number, 10), Link: item.URL}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}, {Text: item.Author}}
|
|
}
|
|
|
|
func (a *application) issueCommand() *cobra.Command {
|
|
group := &cobra.Command{Use: "issue", Short: "Work with repository issues"}
|
|
opts := services.ListOptions{State: "open", Limit: 30, Page: 1}
|
|
var updated timeRange
|
|
list := &cobra.Command{Use: "list", Short: "List repository issues", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
|
|
if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil {
|
|
return err
|
|
}
|
|
if err := oneOf("kind", opts.Kind, "issues", "pulls", "all"); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
if opts.Since, opts.Before, err = updated.parse(a.deps.Now()); err != nil {
|
|
return err
|
|
}
|
|
labelNames, err := labelFilter(opts.Labels)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
milestones, err := nameFilter("milestone", opts.Milestones)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
if errors.Is(err, errRepositoryRequired) {
|
|
return clierr.Wrap(clierr.Usage, errors.New("repository required: use --repo owner/name, or tea search issue for instance-wide results"))
|
|
}
|
|
return err
|
|
}
|
|
if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labelNames, milestones); err != nil {
|
|
return err
|
|
}
|
|
items, more, err := (services.Issues{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writeList(a, cmd, items, issueRow); err != nil {
|
|
return err
|
|
}
|
|
return a.morePagesHint(cmd, more, opts.Page)
|
|
}}
|
|
list.Flags().StringVar(&opts.State, "state", "open", "Issue state: open, closed, all")
|
|
list.Flags().StringVar(&opts.Kind, "kind", "issues", "Resource kind: issues, pulls, or all")
|
|
pageFlags(list, &opts.Limit, &opts.Page)
|
|
list.Flags().StringVar(&opts.Search, "search", "", "Search issue titles and bodies")
|
|
list.Flags().StringVar(&opts.Assignee, "assignee", "", "Filter by assigned username")
|
|
list.Flags().StringVar(&opts.Labels, "label", "", "Filter by comma-separated label names")
|
|
list.Flags().StringVar(&opts.Milestones, "milestone", "", "Filter by comma-separated milestone names or IDs")
|
|
list.Flags().StringVar(&opts.Author, "author", "", "Filter by author username")
|
|
list.Flags().StringVar(&opts.Mentions, "mentions", "", "Filter by mentioned username")
|
|
updated.register(list, "since", "Updated since a date, timestamp, or duration ago", "until", "Updated before a date, timestamp, or duration ago")
|
|
group.AddCommand(list)
|
|
var web, comments bool
|
|
view := &cobra.Command{Use: "view <number>", Short: "View a repository issue", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
|
n, err := number(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
service := services.Issues{Client: target.client, Repository: target.Repo}
|
|
item, err := service.View(cmd.Context(), n)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if web {
|
|
return a.openBrowser(cmd, item.URL)
|
|
}
|
|
if a.options.json != "" {
|
|
return a.writeJSON(cmd, item)
|
|
}
|
|
dependencies, err := service.Dependencies(cmd.Context(), n)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.writeDiscussion(cmd, target, issueView(item, target.Repo, dependencies), comments)
|
|
}}
|
|
view.Flags().BoolVar(&web, "web", false, "Open the issue in a browser")
|
|
view.Flags().BoolVarP(&comments, "comments", "c", false, "View issue comments")
|
|
group.AddCommand(view)
|
|
var title, body, bodyFile, deadline, templateName string
|
|
var useEditor bool
|
|
var ref string
|
|
var assignees, labelNames, attachmentPaths []string
|
|
var milestone string
|
|
create := &cobra.Command{Use: "create", Short: "Create a repository issue", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
|
|
var draft terminal.Draft
|
|
defer finishDraft(&draft, &runErr)
|
|
if err := validateBodySources(cmd); err != nil {
|
|
return err
|
|
}
|
|
milestone, _, err := milestoneFlag(cmd, milestone)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := requireNames("label", "", labelNames); err != nil {
|
|
return err
|
|
}
|
|
if err := requireNames("assignee", "", assignees); err != nil {
|
|
return err
|
|
}
|
|
if cmd.Flags().Changed("template") && strings.TrimSpace(templateName) == "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--template cannot be empty"))
|
|
}
|
|
files, err := openRegularFiles(attachmentPaths)
|
|
defer closeFiles(files)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var due *time.Time
|
|
if cmd.Flags().Changed("deadline") {
|
|
due, _, err = parseDeadline(deadline)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
snapshot, err := a.deps.Settings.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
policy := a.policy(snapshot)
|
|
if !localFlagsChanged(cmd) && policy.CanPrompt(a.deps.Streams) && a.deps.IssueForm != nil {
|
|
return a.createIssueInteractively(cmd)
|
|
}
|
|
var resolved target
|
|
var templateBody string
|
|
if cmd.Flags().Changed("template") {
|
|
if resolved, err = a.resolve(cmd.Context(), true); err != nil {
|
|
return err
|
|
}
|
|
template, err := issueTemplate(cmd.Context(), services.Issues{Client: resolved.client, Repository: resolved.Repo}, strings.TrimSpace(templateName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !cmd.Flags().Changed("title") {
|
|
title = template.Title
|
|
}
|
|
templateBody = issueTemplateBody(template)
|
|
}
|
|
if bodyFile == "-" && title == "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires --title; stdin cannot also supply a prompt"))
|
|
}
|
|
prompted, err := a.require(cmd, &title, "title")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case useEditor:
|
|
draft, err = a.editDraft(cmd, templateBody)
|
|
body = draft.Body
|
|
case cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file"):
|
|
body, err = a.readBody(cmd, body, bodyFile)
|
|
default:
|
|
body = templateBody
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := resolved
|
|
if target.client == nil {
|
|
if target, err = a.resolve(cmd.Context(), true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var milestoneValue int64
|
|
if milestone != "" {
|
|
if milestoneValue, err = milestoneID(cmd, target, milestone); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
labelIDs, err := (services.Labels{Client: target.client, Repository: target.Repo}).ResolveIssueNames(cmd.Context(), labelNames)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if prompted {
|
|
if err := a.confirm(cmd, "Create issue on "+target.Repo+": "+title); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
service := services.Issues{Client: target.client, Repository: target.Repo}
|
|
item, err := service.Create(cmd.Context(), services.IssueOptions{Title: title, Body: body, Deadline: due, Ref: ref, Assignees: assignees, Milestone: milestoneValue, Labels: labelIDs})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var failures []error
|
|
for _, file := range files {
|
|
if _, err := service.UploadAttachment(cmd.Context(), item.Number, filepath.Base(file.Name()), file); err != nil {
|
|
failures = append(failures, fmt.Errorf("issue %d created; attachment %s upload failed: %w", item.Number, filepath.Base(file.Name()), err))
|
|
}
|
|
}
|
|
if err := writeRecord(a, cmd, item, issueRecord); err != nil {
|
|
return err
|
|
}
|
|
return errors.Join(failures...)
|
|
}}
|
|
create.Flags().StringVar(&title, "title", "", "Issue title")
|
|
create.Flags().StringVar(&ref, "ref", "", "Referenced branch or tag")
|
|
create.Flags().StringSliceVar(&labelNames, "label", nil, "Apply labels by exact name; use repo: or org: to disambiguate")
|
|
create.Flags().StringSliceVar(&assignees, "assignee", nil, "Assign users by username")
|
|
create.Flags().StringVarP(&milestone, "milestone", "m", "", "Add the issue to a milestone by `title` or ID")
|
|
flagAliases(create, map[string]string{"milestone-name": "milestone"})
|
|
create.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp")
|
|
create.Flags().StringVar(&body, "body", "", "Issue body")
|
|
create.Flags().StringVar(&bodyFile, "body-file", "", "Read body from a file, or - for stdin")
|
|
create.Flags().BoolVar(&useEditor, "editor", false, "Compose the issue body in the configured editor")
|
|
create.Flags().StringVar(&templateName, "template", "", "Seed title and body from the repository issue template with this name")
|
|
create.Flags().StringArrayVar(&attachmentPaths, "attachment", nil, "File to attach after creation (repeatable)")
|
|
group.AddCommand(create)
|
|
for _, action := range []string{"close", "reopen"} {
|
|
group.AddCommand(&cobra.Command{Use: action + " <number>...", Short: titleCase(action) + " repository issues", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
|
|
numbers, err := resourceNumbers(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target, err := a.resolve(cmd.Context(), true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state := "closed"
|
|
if action == "reopen" {
|
|
state = "open"
|
|
}
|
|
service := services.Issues{Client: target.client, Repository: target.Repo}
|
|
return batch(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
|
|
return service.SetState(cmd.Context(), n, state)
|
|
}, issueRow)
|
|
}})
|
|
}
|
|
group.AddCommand(a.issueAttachmentCommand(issueRef), a.commentCommand(issueRef), a.issueEditCommand())
|
|
return group
|
|
}
|