mirror of
https://gitea.com/gitea/tea.git
synced 2026-08-26 01:27:38 +02:00
Read issue/PR description from stdin or a file (#1096)
Closes #1095. `tea issues create` and `tea pulls create` now resolve the description in the same way as comments: when stdin is piped and neither `--description` nor `--description-file` is given, the body is read from stdin. Both create and edit commands also accept: ```text --description-file <path> # '-' reads stdin ``` This avoids the PowerShell 5.1 argument mangling and ANSI code page issues described in #1095. ## Changes - Add `--description-file` to `issues create`, `issues edit`, `pulls create`, and `pulls edit`. - Create commands fall back to piped stdin when no description flag is set. - Add unit tests for the new body resolution. --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/tea/pulls/1096 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package flags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stdinPiped reports whether stdin is not a terminal, e.g. when a description
|
||||||
|
// is piped from a file, command substitution, or a CI harness.
|
||||||
|
func stdinPiped() bool {
|
||||||
|
return !term.IsTerminal(int(os.Stdin.Fd()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCreateBody returns the issue/PR description for create commands.
|
||||||
|
//
|
||||||
|
// Precedence:
|
||||||
|
// 1. --description-file (read from the file, or stdin when the path is "-")
|
||||||
|
// 2. --description
|
||||||
|
// 3. piped stdin
|
||||||
|
func resolveCreateBody(description, descriptionFile string, descriptionFileSet, stdinPiped bool, stdin io.Reader) (string, error) {
|
||||||
|
if descriptionFileSet {
|
||||||
|
return readDescriptionSource(descriptionFile, stdin)
|
||||||
|
}
|
||||||
|
if description != "" {
|
||||||
|
return description, nil
|
||||||
|
}
|
||||||
|
if stdinPiped {
|
||||||
|
return readDescriptionStdin(stdin)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveEditBody returns the new issue/PR body when a description flag was
|
||||||
|
// provided, or nil when the caller should leave the body unchanged.
|
||||||
|
func resolveEditBody(description string, descriptionSet bool, descriptionFile string, descriptionFileSet bool, stdin io.Reader) (*string, error) {
|
||||||
|
if descriptionFileSet {
|
||||||
|
body, err := readDescriptionSource(descriptionFile, stdin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &body, nil
|
||||||
|
}
|
||||||
|
if descriptionSet {
|
||||||
|
body := description
|
||||||
|
return &body, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDescriptionSource(source string, stdin io.Reader) (string, error) {
|
||||||
|
if source == "-" {
|
||||||
|
return readDescriptionStdin(stdin)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(source)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not read description file %q: %w", source, err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDescriptionStdin(stdin io.Reader) (string, error) {
|
||||||
|
data, err := io.ReadAll(stdin)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not read description from stdin: %w", err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package flags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveCreateBody(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "body.md")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
descriptionFile string
|
||||||
|
descriptionFileSet bool
|
||||||
|
stdinPiped bool
|
||||||
|
stdin string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "description flag",
|
||||||
|
description: "from -d",
|
||||||
|
want: "from -d",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description file",
|
||||||
|
descriptionFile: file,
|
||||||
|
descriptionFileSet: true,
|
||||||
|
want: "from file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description file wins over description",
|
||||||
|
description: "from -d",
|
||||||
|
descriptionFile: file,
|
||||||
|
descriptionFileSet: true,
|
||||||
|
want: "from file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dash reads stdin",
|
||||||
|
descriptionFile: "-",
|
||||||
|
descriptionFileSet: true,
|
||||||
|
stdin: "from stdin",
|
||||||
|
want: "from stdin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description wins over piped stdin",
|
||||||
|
description: "from -d",
|
||||||
|
stdinPiped: true,
|
||||||
|
stdin: "from stdin",
|
||||||
|
want: "from -d",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "piped stdin",
|
||||||
|
stdinPiped: true,
|
||||||
|
stdin: "from stdin",
|
||||||
|
want: "from stdin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty description falls back to piped stdin",
|
||||||
|
description: "",
|
||||||
|
stdinPiped: true,
|
||||||
|
stdin: "from stdin",
|
||||||
|
want: "from stdin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty when no source provided",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := resolveCreateBody(tt.description, tt.descriptionFile, tt.descriptionFileSet, tt.stdinPiped, strings.NewReader(tt.stdin))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveEditBody(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "body.md")
|
||||||
|
require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
descriptionSet bool
|
||||||
|
descriptionFile string
|
||||||
|
descriptionFileSet bool
|
||||||
|
stdin string
|
||||||
|
wantBody string
|
||||||
|
wantSet bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no description flag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description flag",
|
||||||
|
description: "from -d",
|
||||||
|
descriptionSet: true,
|
||||||
|
wantBody: "from -d",
|
||||||
|
wantSet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty description clears body",
|
||||||
|
descriptionSet: true,
|
||||||
|
wantSet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description file",
|
||||||
|
descriptionFile: file,
|
||||||
|
descriptionFileSet: true,
|
||||||
|
wantBody: "from file",
|
||||||
|
wantSet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description file wins over description",
|
||||||
|
description: "from -d",
|
||||||
|
descriptionSet: true,
|
||||||
|
descriptionFile: file,
|
||||||
|
descriptionFileSet: true,
|
||||||
|
wantBody: "from file",
|
||||||
|
wantSet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dash reads stdin",
|
||||||
|
descriptionFile: "-",
|
||||||
|
descriptionFileSet: true,
|
||||||
|
stdin: "from stdin",
|
||||||
|
wantBody: "from stdin",
|
||||||
|
wantSet: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := resolveEditBody(tt.description, tt.descriptionSet, tt.descriptionFile, tt.descriptionFileSet, strings.NewReader(tt.stdin))
|
||||||
|
require.NoError(t, err)
|
||||||
|
if !tt.wantSet {
|
||||||
|
assert.Nil(t, got)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NotNil(t, got)
|
||||||
|
assert.Equal(t, tt.wantBody, *got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDescriptionSourceError(t *testing.T) {
|
||||||
|
_, err := resolveCreateBody("", filepath.Join(t.TempDir(), "missing.md"), true, false, strings.NewReader(""))
|
||||||
|
require.ErrorContains(t, err, "could not read description file")
|
||||||
|
}
|
||||||
+28
-5
@@ -100,6 +100,10 @@ var issuePRFlags = append([]cli.Flag{
|
|||||||
Name: "description",
|
Name: "description",
|
||||||
Aliases: []string{"d"},
|
Aliases: []string{"d"},
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "description-file",
|
||||||
|
Usage: "Read description from file ('-' for stdin)",
|
||||||
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "referenced-version",
|
Name: "referenced-version",
|
||||||
Aliases: []string{"v"},
|
Aliases: []string{"v"},
|
||||||
@@ -133,12 +137,22 @@ var IssuePRCreateFlags = append([]cli.Flag{
|
|||||||
|
|
||||||
// GetIssuePRCreateFlags parses all IssuePREditFlags
|
// GetIssuePRCreateFlags parses all IssuePREditFlags
|
||||||
func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) (*gitea.CreateIssueOption, error) {
|
func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) (*gitea.CreateIssueOption, error) {
|
||||||
|
body, err := resolveCreateBody(
|
||||||
|
ctx.String("description"),
|
||||||
|
ctx.String("description-file"),
|
||||||
|
ctx.IsSet("description-file"),
|
||||||
|
stdinPiped(),
|
||||||
|
ctx.Reader,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
opts := gitea.CreateIssueOption{
|
opts := gitea.CreateIssueOption{
|
||||||
Title: ctx.String("title"),
|
Title: ctx.String("title"),
|
||||||
Body: ctx.String("description"),
|
Body: body,
|
||||||
Assignees: strings.Split(ctx.String("assignees"), ","),
|
Assignees: strings.Split(ctx.String("assignees"), ","),
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
|
|
||||||
date := ctx.String("deadline")
|
date := ctx.String("deadline")
|
||||||
if date != "" {
|
if date != "" {
|
||||||
@@ -208,9 +222,18 @@ func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error)
|
|||||||
val := ctx.String("title")
|
val := ctx.String("title")
|
||||||
opts.Title = &val
|
opts.Title = &val
|
||||||
}
|
}
|
||||||
if ctx.IsSet("description") {
|
body, err := resolveEditBody(
|
||||||
val := ctx.String("description")
|
ctx.String("description"),
|
||||||
opts.Body = &val
|
ctx.IsSet("description"),
|
||||||
|
ctx.String("description-file"),
|
||||||
|
ctx.IsSet("description-file"),
|
||||||
|
ctx.Reader,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
opts.Body = body
|
||||||
}
|
}
|
||||||
if ctx.IsSet("referenced-version") {
|
if ctx.IsSet("referenced-version") {
|
||||||
val := ctx.String("referenced-version")
|
val := ctx.String("referenced-version")
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ Create an issue on repository
|
|||||||
|
|
||||||
**--description, -d**="":
|
**--description, -d**="":
|
||||||
|
|
||||||
|
**--description-file**="": Read description from file ('-' for stdin)
|
||||||
|
|
||||||
**--labels, -L**="": Comma-separated list of labels to assign
|
**--labels, -L**="": Comma-separated list of labels to assign
|
||||||
|
|
||||||
**--login, -l**="": Use a different Gitea Login. Optional
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
@@ -247,6 +249,8 @@ Edit one or more issues
|
|||||||
|
|
||||||
**--description, -d**="":
|
**--description, -d**="":
|
||||||
|
|
||||||
|
**--description-file**="": Read description from file ('-' for stdin)
|
||||||
|
|
||||||
**--login, -l**="": Use a different Gitea Login. Optional
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|
||||||
**--milestone, -m**="": Milestone to assign
|
**--milestone, -m**="": Milestone to assign
|
||||||
@@ -379,6 +383,8 @@ Create a pull-request
|
|||||||
|
|
||||||
**--description, -d**="":
|
**--description, -d**="":
|
||||||
|
|
||||||
|
**--description-file**="": Read description from file ('-' for stdin)
|
||||||
|
|
||||||
**--draft**: Create as a draft (prepends "WIP: " to the title; Gitea treats WIP-prefixed PRs as drafts)
|
**--draft**: Create as a draft (prepends "WIP: " to the title; Gitea treats WIP-prefixed PRs as drafts)
|
||||||
|
|
||||||
**--head**="": Branch name of the PR source (default is current one). To specify a different head repo, use <user>:<branch>
|
**--head**="": Branch name of the PR source (default is current one). To specify a different head repo, use <user>:<branch>
|
||||||
@@ -437,6 +443,8 @@ Edit one or more pull requests
|
|||||||
|
|
||||||
**--description, -d**="":
|
**--description, -d**="":
|
||||||
|
|
||||||
|
**--description-file**="": Read description from file ('-' for stdin)
|
||||||
|
|
||||||
**--draft**: Mark as draft by prepending "WIP: " to the title (idempotent)
|
**--draft**: Mark as draft by prepending "WIP: " to the title (idempotent)
|
||||||
|
|
||||||
**--login, -l**="": Use a different Gitea Login. Optional
|
**--login, -l**="": Use a different Gitea Login. Optional
|
||||||
|
|||||||
Reference in New Issue
Block a user