Files
gitea-tea/internal/app/issues.go
T
bircni 1035ff551b fix(milestone): address milestones by title or ID everywhere
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>
2026-10-12 00:14:30 +02:00

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
}