feat!: rebuild tea on Cobra with explicit noun/verb commands (#1126)

- **switch tea to the Cobra command tree:** `internal/app` wires the packages from parts 1–4 into explicit noun/verb commands with `--repo`, `--json` / `--jq`, `--sudo` and `--debug`; `main.go` points at it. Build, release and docs tooling (`tools/cli-docs`, Makefile, goreleaser, snap, CI) and README / CONTRIBUTING are updated.
- **remove the urfave/cli command tree:** deletes `cmd/`, `modules/`, the old integration tests, contrib completion scripts and the generated `docs/CLI.md`, and drops their dependencies. This commit only deletes.

## Breaking changes

This is a breaking release. It requires Gitea 28 or newer; configuration moves to separate `config.yml` and `hosts.yml` files and requires fresh authentication, and command names, aliases, flags and output formats change. `docs/migration-1.0.md` maps every old command, flag and environment variable to its replacement.

## Issues

fixes #1118
fixes #1116
fixes #1115
fixes #1094
fixes #1091
fixes #1073
fixes #1052
fixes #1032
fixes #1023
fixes #966
fixes #908
fixes #833
fixes #831
fixes #817
fixes #804
fixes #779
fixes #770
fixes #737
fixes #697
fixes #726
fixes #677
fixes #672
fixes #633
fixes #632
fixes #560
fixes #556
fixes #523
fixes #516
fixes #510
fixes #504
fixes #500
fixes #482
fixes #406
fixes #403
fixes #373
fixes #351
fixes #344
fixes #326
fixes #325
fixes #323
fixes #246
fixes #240
fixes #232
fixes #197
fixes #172
fixes #141
fixes #69
fixes #1110
fixes #569
fixes #480
fixes #666
fixes #670
fixes #457
fixes #643
fixes #498
fixes #490
fixes #1069
fixes #834
fixes #602
fixes #328
fixes #417
fixes #571
fixes #1029
fixes #489
fixes #1050
fixes #162
fixes #430
fixes #1087
fixes #683

Reviewed-on: https://gitea.com/gitea/tea/pulls/1126
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
bircni committed 2026-10-11 17:23:06 +00:00
1 parent 12e415a7a7
commit f02245449b
456 files changed
+18892 -34015

No files matched your search

+422
View File
@@ -0,0 +1,422 @@
// 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, ", "))
}
// validateMilestoneFlags checks --milestone and --milestone-name 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) }
// 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
}
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 len(labelNames) > 0 {
if err := (services.Labels{Client: target.client, Repository: target.Repo}).RequireFilterNames(cmd.Context(), labelNames); err != nil {
return err
}
opts.Labels = strings.Join(labelNames, ",")
}
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 int64
var milestoneName 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
}
if err := validateMilestoneFlags(cmd, milestoneName, milestone); 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
}
}
if cmd.Flags().Changed("milestone-name") {
milestone, err = (services.Milestones{Client: target.client, Repository: target.Repo}).ResolveName(cmd.Context(), milestoneName)
if 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: milestone, 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().StringVar(&milestoneName, "milestone-name", "", "Milestone by exact title; conflicts with --milestone")
create.Flags().Int64Var(&milestone, "milestone", 0, "Milestone ID")
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(), a.commentCommand(), a.issueEditCommand())
return group
}