mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
refactor: remove features beyond the UX plan
Drop additions that the UX plan does not ask for: - the interactive label edit form; label edit now requires a flag - label clone and the skip mode of label creation it needed - repo rename rewriting the Git remote or tea.repository; it now warns that the remote still points to the old name - the extra "source" remote on clones of pull mirrors - run artifact download, which duplicates run download --zip - 256-color and 24-bit label swatches; colors stay ANSI 16 - config list --show-source (config get keeps it) - the membership and access GETs before team member/repo add and remove; Gitea's PUT and DELETE are idempotent label edit/delete keep --by-name: it is the only way to address a label whose name is a number that is also another label's ID. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
22 files changed
+56
-547
No files matched your search
@@ -25,10 +25,8 @@ import (
|
||||
func (a *application) artifactCommand() *cobra.Command {
|
||||
group := &cobra.Command{
|
||||
Use: "artifact",
|
||||
Short: "List and download the artifacts of runs",
|
||||
Long: `List the artifacts that a run uploaded and download one as a ZIP archive.
|
||||
|
||||
To download and extract the artifacts of a run by name, use tea run download.`,
|
||||
Short: "List the artifacts of runs",
|
||||
Long: "List the artifacts that a run uploaded. To download them, use tea run download.",
|
||||
}
|
||||
var limit, page int
|
||||
list := &cobra.Command{
|
||||
@@ -55,55 +53,6 @@ $ tea run artifact list 34`,
|
||||
list.ValidArgsFunction = a.completeRunArg()
|
||||
group.AddCommand(list)
|
||||
|
||||
var destination downloadFlags
|
||||
download := &cobra.Command{
|
||||
Use: "download <artifact-id>",
|
||||
Short: "Download an artifact as a ZIP archive",
|
||||
Long: "Download an artifact as a ZIP archive without extracting it, named after the artifact by default.",
|
||||
Example: `# download artifact 7 to <name>.zip
|
||||
$ tea run artifact download 7
|
||||
# write it to standard output
|
||||
$ tea run artifact download 7 --output - > build.zip`,
|
||||
Annotations: textOutput("artifact downloads are binary"),
|
||||
Args: argsExact(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := number(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
service := services.Actions{Client: t.client, Repository: t.Repo}
|
||||
name := ""
|
||||
if destination.output == "" || isDirectory(destination.output) {
|
||||
artifact, err := service.Artifact(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name = artifact.Name + ".zip"
|
||||
}
|
||||
path, skip, err := destination.destination(name)
|
||||
if err != nil || skip {
|
||||
return err
|
||||
}
|
||||
body, err := service.DownloadArtifact(cmd.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer body.Close()
|
||||
if err := a.download(cmd, destination, path, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if path != "-" {
|
||||
a.done(cmd, "Downloaded artifact %d to %s", id, path)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
destination.register(download)
|
||||
group.AddCommand(download)
|
||||
return group
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestArtifactDownloadsValidateDestinationAndKeepBinaryOutputClean(t *testing.T) {
|
||||
requests := 0
|
||||
func TestRunDownloadZipToStdoutKeepsBinaryOutputClean(t *testing.T) {
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/repo/actions/artifacts/9":
|
||||
_, _ = w.Write([]byte(`{"id":9,"name":"build"}`))
|
||||
case "/api/v1/repos/owner/repo/actions/runs/29/artifacts":
|
||||
_, _ = w.Write([]byte(`{"total_count":1,"artifacts":[{"id":9,"name":"build"}]}`))
|
||||
case "/api/v1/repos/owner/repo/actions/artifacts/9/zip":
|
||||
require.Equal(t, "token token", r.Header.Get("Authorization"))
|
||||
http.Redirect(w, r, "/signed?signature=private", http.StatusFound)
|
||||
@@ -36,27 +34,11 @@ func TestArtifactDownloadsValidateDestinationAndKeepBinaryOutputClean(t *testing
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, out, diagnostics := serverDeps(t, server.URL)
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output-file", "-"}))
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"run", "download", "29", "-R", "owner/repo", "--zip", "-n", "build", "-O", "-"}))
|
||||
require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, out.Bytes())
|
||||
require.Empty(t, diagnostics.String())
|
||||
|
||||
dir := t.TempDir()
|
||||
out.Reset()
|
||||
args := []string{"run", "artifact", "download", "#9", "-R", "owner/repo", "--dir", dir}
|
||||
require.NoError(t, Execute(context.Background(), deps, args))
|
||||
data, err := os.ReadFile(filepath.Join(dir, "build.zip"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, data)
|
||||
require.Empty(t, out.String())
|
||||
before := requests
|
||||
err = Execute(context.Background(), deps, append(args, "--output", filepath.Join(dir, "build.zip")))
|
||||
require.ErrorContains(t, err, "--output and --dir cannot be combined")
|
||||
err = Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output", filepath.Join(dir, "build.zip")})
|
||||
require.ErrorContains(t, err, "already exists; pass --clobber")
|
||||
require.Equal(t, before, requests)
|
||||
err = Execute(context.Background(), deps, []string{"run", "artifact", "download", "9", "-R", "owner/repo", "-O", "-", "--json"})
|
||||
require.ErrorContains(t, err, "binary")
|
||||
require.Equal(t, before, requests)
|
||||
err := Execute(context.Background(), deps, []string{"run", "download", "29", "-R", "owner/repo", "--zip", "-n", "build", "-O", "-", "--json"})
|
||||
require.ErrorContains(t, err, "run download writes files")
|
||||
}
|
||||
|
||||
// zipOf returns a ZIP archive of files, by name.
|
||||
|
||||
@@ -150,28 +150,6 @@ func TestSSHCloneTrustsAPIEndpointUnlessSSHHostIsConfigured(t *testing.T) {
|
||||
require.Empty(t, runner.calls)
|
||||
}
|
||||
|
||||
func TestCloneAddsSourceRemoteForMirrors(t *testing.T) {
|
||||
original := ""
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `{"id":9,"name":"project","full_name":"alice/project","mirror":true,"clone_url":"http://%s/alice/project.git","original_url":%q}`, r.Host, original)
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, out, _ := serverDeps(t, server.URL)
|
||||
clone := credentialCall(t, deps, server.URL, "alice", "clone", "--", server.URL+"/alice/project.git", "destination")
|
||||
original = "https://github.com/upstream/project.git"
|
||||
runner := &fakeGit{results: map[string]string{clone: "", "remote add -- source " + original: ""}}
|
||||
deps.Git = runner
|
||||
args := []string{"repo", "clone", "alice/project", "destination", "--no-input", "--json"}
|
||||
require.NoError(t, Execute(context.Background(), deps, args))
|
||||
require.Equal(t, []string{clone, "remote add -- source " + original}, runner.calls)
|
||||
require.NotContains(t, out.String(), "github.com")
|
||||
for _, unsafe := range []string{"https://user:secret@github.com/upstream/project.git", "file:///srv/project.git", "ext::sh -c touch% /tmp/pwned"} {
|
||||
original, runner.calls = unsafe, nil
|
||||
require.NoError(t, Execute(context.Background(), deps, args), unsafe)
|
||||
require.Equal(t, []string{clone}, runner.calls, unsafe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClonePassesGitFlagsAndNamesTheUpstreamRemote(t *testing.T) {
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, `{"name":"project","full_name":"bob/project","fork":true,"clone_url":"http://%[1]s/bob/project.git","parent":{"full_name":"alice/project","default_branch":"main","clone_url":"http://%[1]s/alice/project.git"}}`, r.Host)
|
||||
|
||||
+8
-19
@@ -107,11 +107,10 @@ func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings
|
||||
return preferences, sources, nil
|
||||
}
|
||||
|
||||
// configSetting is one effective setting and where its value comes from.
|
||||
// configSetting is one effective setting.
|
||||
type configSetting struct {
|
||||
Key string
|
||||
Value any
|
||||
Source string
|
||||
}
|
||||
|
||||
// settingValues returns the effective preferences, the settings of the selected instance when it is
|
||||
@@ -242,45 +241,35 @@ $ tea config get ssh_key --host https://gitea.com --user alice`,
|
||||
}
|
||||
get.Flags().BoolVar(&getSource, "show-source", false, "Also print where the value comes from")
|
||||
group.AddCommand(get)
|
||||
var listSource bool
|
||||
list := &cobra.Command{
|
||||
Use: "list", Short: "List settings and their values", Args: argsExact(0),
|
||||
Example: `$ tea config list
|
||||
# Include the settings of an instance and where each value comes from
|
||||
$ tea config list --host https://gitea.com --show-source`,
|
||||
# Include the settings of an instance
|
||||
$ tea config list --host https://gitea.com`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
snapshot, err := a.deps.Settings.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values, sources, err := a.settingValues(snapshot)
|
||||
values, _, err := a.settingValues(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.options.json != "" {
|
||||
return a.writeSettingsJSON(cmd, values, sources, listSource)
|
||||
return a.writeJSON(cmd, values)
|
||||
}
|
||||
items := make([]configSetting, 0, len(values))
|
||||
for _, key := range slices.Sorted(maps.Keys(values)) {
|
||||
items = append(items, configSetting{key, values[key], settingSource(sources, key)})
|
||||
}
|
||||
columns := []terminal.Column{{Header: "Key"}, {Header: "Value", Flex: true}}
|
||||
if listSource {
|
||||
columns = append(columns, terminal.Column{Header: "Source"})
|
||||
items = append(items, configSetting{key, values[key]})
|
||||
}
|
||||
return writeTable(a, cmd, items, listSpec[configSetting]{
|
||||
Columns: columns,
|
||||
Columns: []terminal.Column{{Header: "Key"}, {Header: "Value", Flex: true}},
|
||||
Row: func(item configSetting) []terminal.Cell {
|
||||
row := []terminal.Cell{{Text: item.Key}, {Text: fmt.Sprint(item.Value)}}
|
||||
if listSource {
|
||||
row = append(row, terminal.Cell{Text: item.Source, Muted: true})
|
||||
}
|
||||
return row
|
||||
return []terminal.Cell{{Text: item.Key}, {Text: fmt.Sprint(item.Value)}}
|
||||
},
|
||||
}, listPage{Number: 1})
|
||||
},
|
||||
}
|
||||
list.Flags().BoolVar(&listSource, "show-source", false, "Show where each value comes from")
|
||||
group.AddCommand(list)
|
||||
group.AddCommand(&cobra.Command{
|
||||
Use: "set <key> <value>", Short: "Change a setting", Args: argsExact(2), ValidArgsFunction: a.completeConfig,
|
||||
|
||||
@@ -35,7 +35,6 @@ func (a *application) display() terminal.Display {
|
||||
return terminal.Display{
|
||||
TTY: streams.OutTTY,
|
||||
Color: terminal.ColorEnabled(preferences.Color, streams.OutTTY, a.deps.Getenv),
|
||||
TrueColor: terminal.TrueColor(a.deps.Getenv),
|
||||
Links: terminal.HyperlinksEnabled(preferences.Hyperlinks, streams.OutTTY, a.deps.Getenv),
|
||||
Markdown: terminal.MarkdownEnabled(preferences.Markdown, streams.OutTTY),
|
||||
Width: streams.Width,
|
||||
|
||||
@@ -62,12 +62,11 @@ func TestLabelListAppliesColorAndPagesWithNoInput(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"ff0000","description":"Defect"},{"id":2,"name":"docs","color":"00ff00","description":"Docs"}]`))
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, out, diagnostics, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "fake-pager", "COLORTERM": "truecolor"}, "color", "always")
|
||||
deps, out, diagnostics, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "fake-pager"}, "color", "always")
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
|
||||
require.Equal(t, 1, pager.calls)
|
||||
require.Equal(t, "fake-pager", pager.command)
|
||||
require.Contains(t, pager.input, "\x1b[38;2;255;0;0m● ff0000\x1b[0m")
|
||||
require.Contains(t, pager.input, "\x1b[38;2;0;255;0m● 00ff00\x1b[0m")
|
||||
require.Contains(t, pager.input, "\x1b[90mID\x1b[0m")
|
||||
require.Equal(t, "PAGED:"+pager.input, out.String())
|
||||
require.Empty(t, diagnostics.String())
|
||||
}
|
||||
@@ -120,7 +119,7 @@ func TestTeaPagerAndColorEnvironmentOverrides(t *testing.T) {
|
||||
deps, out, _, pager := pagedDeps(t, server.URL, env, "pager", "configured-pager", "color", "never")
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
|
||||
require.Equal(t, "tea-pager", pager.command)
|
||||
require.Contains(t, pager.input, "\x1b[38;5;196m● ff0000\x1b[0m")
|
||||
require.Contains(t, pager.input, "\x1b[90mID\x1b[0m")
|
||||
out.Reset()
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "pager", "--show-source", "--json"}))
|
||||
require.JSONEq(t, `{"pager":{"value":"tea-pager","source":"TEA_PAGER"}}`, withoutANSI(out.String()))
|
||||
@@ -142,7 +141,7 @@ func TestColorAutoUsesStdoutTTY(t *testing.T) {
|
||||
out.Reset()
|
||||
deps.Streams.OutTTY = true
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
|
||||
require.Contains(t, out.String(), "\x1b[38;5;196m● ff0000\x1b[0m")
|
||||
require.Contains(t, out.String(), "\x1b[90mID\x1b[0m")
|
||||
}
|
||||
|
||||
func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tea/internal/terminal"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -96,15 +95,11 @@ func TestLabelsByName(t *testing.T) {
|
||||
labels := `[{"id":11,"name":"bug","color":"ee0701"},{"id":12,"name":"kind/feature","color":"84b6eb","exclusive":true},{"id":13,"name":"2026","color":"cccccc"}]`
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /api/v1/repos/alice/project/labels", "GET /api/v1/repos/bob/source/labels":
|
||||
case "GET /api/v1/repos/alice/project/labels":
|
||||
if r.URL.Query().Get("page") != "1" {
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v1/repos/bob/source/labels" {
|
||||
_, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"000000"},{"id":2,"name":"docs","color":"0000ff","description":"Docs"}]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(labels))
|
||||
default:
|
||||
changed = append(changed, r.Method+" "+r.URL.Path+" "+requestFields(t, r, "name", "color", "description"))
|
||||
@@ -131,15 +126,12 @@ func TestLabelsByName(t *testing.T) {
|
||||
require.NoError(t, run("label", "edit", "2026", "--by-name", "-d", "Year"))
|
||||
require.NoError(t, run("label", "delete", "BUG", "12", "--yes"))
|
||||
require.Equal(t, "✓ Deleted label \"bug\" from alice/project\n✓ Deleted label \"kind/feature\" from alice/project\n", diagnostics.String())
|
||||
require.NoError(t, run("label", "clone", "bob/source"))
|
||||
require.Equal(t, "! Skipped label \"bug\" in alice/project: it exists already\n✓ Created label \"docs\" in alice/project\n", diagnostics.String())
|
||||
require.Equal(t, []string{
|
||||
"POST /api/v1/repos/alice/project/labels name=docs color=0000FF description=Docs",
|
||||
"PATCH /api/v1/repos/alice/project/labels/12 name=kind/enhancement color=<nil> description=<nil>",
|
||||
"PATCH /api/v1/repos/alice/project/labels/13 name=<nil> color=<nil> description=Year",
|
||||
"DELETE /api/v1/repos/alice/project/labels/11 ",
|
||||
"DELETE /api/v1/repos/alice/project/labels/12 ",
|
||||
"POST /api/v1/repos/alice/project/labels name=docs color=0000ff description=Docs",
|
||||
}, changed)
|
||||
for _, test := range []struct {
|
||||
args []string
|
||||
@@ -194,45 +186,3 @@ func TestLabelCreatePicksAColorAndPrompts(t *testing.T) {
|
||||
require.Equal(t, "From a prompt", created[1]["description"])
|
||||
require.Equal(t, []string{"Label name", "Color (hex, e.g. d73a4a; empty = random):", "Description (optional):"}, prompt.asked)
|
||||
}
|
||||
|
||||
// labelFormPrompter answers the label form with edit applied to the current values.
|
||||
type labelFormPrompter struct {
|
||||
scriptedPrompter
|
||||
heading string
|
||||
edit func(*terminal.LabelFormValues)
|
||||
}
|
||||
|
||||
func (p *labelFormPrompter) EditLabel(_ context.Context, heading string, current terminal.LabelFormValues) (terminal.LabelFormValues, error) {
|
||||
p.heading = heading
|
||||
p.edit(¤t)
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func TestLabelEditFormStartsFromTheLabel(t *testing.T) {
|
||||
var edited string
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /api/v1/repos/alice/project/labels":
|
||||
if r.URL.Query().Get("page") != "1" {
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`[{"id":11,"name":"bug","color":"ee0701","description":"Broken"}]`))
|
||||
case "PATCH /api/v1/repos/alice/project/labels/11":
|
||||
edited = requestFields(t, r, "name", "color", "description", "exclusive")
|
||||
_, _ = w.Write([]byte(`{"id":11,"name":"defect"}`))
|
||||
default:
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, _, _ := serverDeps(t, server.URL)
|
||||
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
|
||||
prompt := &labelFormPrompter{edit: func(v *terminal.LabelFormValues) { v.Name, v.Exclusive = "defect", true }}
|
||||
deps.Prompter = prompt
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"label", "edit", "bug", "-R", "alice/project"}))
|
||||
require.Equal(t, `Editing label "bug" in alice/project`, prompt.heading)
|
||||
require.Equal(t, "name=defect color=ee0701 description=Broken exclusive=true", edited)
|
||||
deps.Streams.InTTY, deps.Streams.ErrTTY = false, false
|
||||
require.ErrorIs(t, Execute(context.Background(), deps, []string{"label", "edit", "bug", "-R", "alice/project"}), errNothingToEdit)
|
||||
}
|
||||
+6
-50
@@ -42,7 +42,7 @@ addressed by name or ID.`}
|
||||
}
|
||||
complete := a.completeScopedLabels(&org)
|
||||
group.AddCommand(a.labelListCommand(scope), a.labelCreateCommand(scope), a.labelEditCommand(scope, complete), a.labelDeleteCommand(scope, complete),
|
||||
a.labelImportCommand(scope), a.labelCloneCommand(scope), a.labelExportCommand(scope))
|
||||
a.labelImportCommand(scope), a.labelExportCommand(scope))
|
||||
return group
|
||||
}
|
||||
|
||||
@@ -240,16 +240,14 @@ func (a *application) reportLabel(cmd *cobra.Command, s services.Labels, item se
|
||||
func (a *application) labelEditCommand(scope labelScope, complete cobra.CompletionFunc) *cobra.Command {
|
||||
var fields labelFields
|
||||
var byName bool
|
||||
cmd := &cobra.Command{Use: "edit <label>", Short: "Edit a label", Long: "Edit a label. Without flags on a terminal, a form starts from the current values.", Example: `$ tea label edit bug --name defect --color b60205
|
||||
cmd := &cobra.Command{Use: "edit <label>", Short: "Edit a label", Example: `$ tea label edit bug --name defect --color b60205
|
||||
$ tea label edit "kind/question" --description "Further information is requested"
|
||||
$ tea label edit old-stuff --archived`, Annotations: map[string]string{"tea.arguments": labelArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts, err := fields.options(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prompt := a.canPrompt()
|
||||
form, hasForm := a.deps.Prompter.(terminal.LabelForm)
|
||||
if opts == (services.LabelOptions{}) && (!prompt || !hasForm) {
|
||||
if opts == (services.LabelOptions{}) {
|
||||
return errNothingToEdit
|
||||
}
|
||||
s, err := scope(cmd)
|
||||
@@ -260,16 +258,6 @@ $ tea label edit old-stuff --archived`, Annotations: map[string]string{"tea.argu
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts == (services.LabelOptions{}) {
|
||||
current := found[0]
|
||||
values, err := form.EditLabel(cmd.Context(), "Editing "+labelName(s, current), terminal.LabelFormValues{
|
||||
Name: current.Name, Color: current.Color, Description: current.Description, Exclusive: current.Exclusive, Archived: current.Archived,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts = services.LabelOptions{Name: &values.Name, Color: &values.Color, Description: &values.Description, Exclusive: &values.Exclusive, Archived: &values.Archived}
|
||||
}
|
||||
item, err := s.Edit(cmd.Context(), found[0].ID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -345,12 +333,12 @@ func (a *application) importLabels(cmd *cobra.Command, scope labelScope, file st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.createLabels(cmd, s, rows, force, false)
|
||||
return a.createLabels(cmd, s, rows, force)
|
||||
}
|
||||
|
||||
// createLabels creates each label in rows, continuing past failures. A label that exists already
|
||||
// is updated with force, skipped with skip, and fails otherwise.
|
||||
func (a *application) createLabels(cmd *cobra.Command, s services.Labels, rows []services.LabelOptions, force, skip bool) error {
|
||||
// is updated with force and fails otherwise.
|
||||
func (a *application) createLabels(cmd *cobra.Command, s services.Labels, rows []services.LabelOptions, force bool) error {
|
||||
existing, err := s.All(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -358,11 +346,7 @@ func (a *application) createLabels(cmd *cobra.Command, s services.Labels, rows [
|
||||
outcome := map[string]string{}
|
||||
results, failures := applyBatch(cmd, rows, func(row services.LabelOptions) string { return "label " + strconv.Quote(*row.Name) }, func(row services.LabelOptions) (services.Label, error) {
|
||||
item, updated, err := s.CreateUnique(cmd.Context(), existing, row, force)
|
||||
var conflict *services.ConflictError
|
||||
switch {
|
||||
case skip && errors.As(err, &conflict) && conflict.Reason == services.ReasonExists:
|
||||
outcome[*row.Name] = "Skipped"
|
||||
return services.Label{Name: *row.Name}, nil
|
||||
case err != nil:
|
||||
return item, err
|
||||
case updated:
|
||||
@@ -373,10 +357,6 @@ func (a *application) createLabels(cmd *cobra.Command, s services.Labels, rows [
|
||||
}
|
||||
return item, nil
|
||||
}, func(item services.Label) {
|
||||
if outcome[item.Name] == "Skipped" {
|
||||
a.warn(cmd, "Skipped %s: it exists already", labelName(s, item))
|
||||
return
|
||||
}
|
||||
a.done(cmd, "%s %s", outcome[item.Name], labelName(s, item))
|
||||
})
|
||||
if a.options.json != "" {
|
||||
@@ -385,30 +365,6 @@ func (a *application) createLabels(cmd *cobra.Command, s services.Labels, rows [
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func (a *application) labelCloneCommand(scope labelScope) *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{Use: "clone <source-repository>", Short: "Copy the labels of another repository", Long: `Create the labels of the source repository in the selected repository or organization.
|
||||
Labels that exist already are skipped unless --force updates them.`, Example: `$ tea label clone alice/project
|
||||
$ tea label clone alice/project --repo alice/new --force`, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
s, err := scope(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, err := (services.Labels{Client: s.Client, Repository: args[0]}).All(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([]services.LabelOptions, len(source))
|
||||
for i, item := range source {
|
||||
rows[i] = services.LabelOptions{Name: &item.Name, Color: &item.Color, Description: &item.Description, Exclusive: &item.Exclusive}
|
||||
}
|
||||
return a.createLabels(cmd, s, rows, force, true)
|
||||
}}
|
||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "Update labels that exist already")
|
||||
cmd.ValidArgsFunction = a.completeRepos()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// completeScopedLabels completes the label names of the repository or, with --org, of the
|
||||
// organization that *org names.
|
||||
func (a *application) completeScopedLabels(org *string) cobra.CompletionFunc {
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestManIncludesFullCommandTree(t *testing.T) {
|
||||
deps, out, _ := testDeps(t)
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"man"}))
|
||||
manual := out.String()
|
||||
for _, command := range []string{"tea-pr-checkout", "tea-issue-comment-create", "tea-auth-login", "tea-run-artifact-download"} {
|
||||
for _, command := range []string{"tea-pr-checkout", "tea-issue-comment-create", "tea-auth-login", "tea-run-artifact-list"} {
|
||||
require.Contains(t, manual, command)
|
||||
}
|
||||
require.Contains(t, manual, `.TH "TEA-PR-CHECKOUT" "1" "Jan 2026"`)
|
||||
|
||||
@@ -487,13 +487,6 @@ func (a *application) cloneRepository(cmd *cobra.Command, t target, item service
|
||||
}
|
||||
a.done(cmd, "Added remote %s for %s; tea commands in the clone target it (change with tea repo set-default)", opts.upstream, item.Parent.FullName)
|
||||
}
|
||||
// The remote is named source because repository discovery prefers a remote named upstream.
|
||||
if source := mirrorSourceURL(item); source != "" {
|
||||
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", "source", source); err != nil {
|
||||
return clone, fmt.Errorf("cloned the mirror, but could not add the source remote: %w", err)
|
||||
}
|
||||
a.done(cmd, "Added remote source for the mirror's source")
|
||||
}
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
@@ -593,9 +586,7 @@ func (a *application) repoRenameCommand() *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "rename <new-name>",
|
||||
Short: "Rename a repository",
|
||||
Long: `Rename a repository; its owner stays the same. Gitea redirects the old name to the new one. When
|
||||
the repository was found through a Git remote of the current clone, or through tea repo set-default,
|
||||
that remote or setting moves to the new name.`,
|
||||
Long: "Rename a repository; its owner stays the same. Gitea redirects the old name to the new one.",
|
||||
Example: `# Rename the repository of the current clone
|
||||
$ tea repo rename new-name
|
||||
|
||||
@@ -630,8 +621,8 @@ $ tea repo rename new-name -R alice/old-name`,
|
||||
return err
|
||||
}
|
||||
a.done(cmd, "Renamed repository %s to %s", t.Repo, item.FullName)
|
||||
if err := a.followRename(cmd, t, current, item); err != nil {
|
||||
return err
|
||||
if remote, ok := strings.CutPrefix(t.RepoSource, "remote "); ok {
|
||||
a.warn(cmd, "Git remote %s still points to %s; update it with git remote set-url", remote, t.Repo)
|
||||
}
|
||||
return a.writeMutationJSON(cmd, item)
|
||||
},
|
||||
@@ -639,46 +630,6 @@ $ tea repo rename new-name -R alice/old-name`,
|
||||
return command
|
||||
}
|
||||
|
||||
// followRename points the Git remote or the saved default repository that selected a renamed
|
||||
// repository, previously named old, at its new name.
|
||||
func (a *application) followRename(cmd *cobra.Command, t target, old string, item services.Repository) error {
|
||||
if remote, ok := strings.CutPrefix(t.RepoSource, "remote "); ok {
|
||||
raw, err := a.git(cmd.Context(), "remote", "get-url", remote)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renamed %s, but could not read Git remote %s: %w", item.FullName, remote, err)
|
||||
}
|
||||
renamed, ok := renamedURL(raw, old, item.Name)
|
||||
if !ok {
|
||||
a.warn(cmd, "Git remote %s still points to %s; update it with git remote set-url", remote, t.Repo)
|
||||
return nil
|
||||
}
|
||||
if _, err := a.git(cmd.Context(), "remote", "set-url", remote, renamed); err != nil {
|
||||
return fmt.Errorf("renamed %s, but could not update Git remote %s: %w", item.FullName, remote, err)
|
||||
}
|
||||
a.done(cmd, "Updated Git remote %s to %s", remote, item.FullName)
|
||||
}
|
||||
if t.RepoSource == "git config tea.repository" {
|
||||
if _, err := a.git(cmd.Context(), "config", "--local", "tea.repository", t.Host+"/"+item.FullName); err != nil {
|
||||
return fmt.Errorf("renamed %s, but could not update the default repository: %w", item.FullName, err)
|
||||
}
|
||||
a.done(cmd, "Set %s as the default repository for this clone", item.FullName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renamedURL replaces the repository name old at the end of a remote URL with name, keeping the
|
||||
// rest of the URL, such as an SSH host alias, and its .git suffix.
|
||||
func renamedURL(raw, old, name string) (string, bool) {
|
||||
base, suffix := strings.TrimSuffix(raw, "/"), ""
|
||||
if trimmed, ok := strings.CutSuffix(base, ".git"); ok {
|
||||
base, suffix = trimmed, ".git"
|
||||
}
|
||||
if len(base) <= len(old) || !strings.EqualFold(base[len(base)-len(old):], old) || !strings.ContainsRune("/:", rune(base[len(base)-len(old)-1])) {
|
||||
return "", false
|
||||
}
|
||||
return base[:len(base)-len(old)] + name + suffix, true
|
||||
}
|
||||
|
||||
func (a *application) repoDeleteCommand() *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "delete [<repository>]",
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestRepoArchiveAsksWithTheConsequence(t *testing.T) {
|
||||
require.Equal(t, []string{"Archive alice/project (from git remote upstream)? It becomes read-only."}, prompt.confirmations)
|
||||
}
|
||||
|
||||
func TestRepoRenameFollowsTheRemote(t *testing.T) {
|
||||
func TestRepoRenameHintsAtTheRemote(t *testing.T) {
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPatch+" /api/v1/repos/alice/project", r.Method+" "+r.URL.Path)
|
||||
requirePayload(t, `{"name":"renamed"}`, r.Body)
|
||||
@@ -132,31 +132,14 @@ func TestRepoRenameFollowsTheRemote(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, out, diagnostics := repoTTYDeps(t, server.URL)
|
||||
runner := &fakeGit{results: map[string]string{
|
||||
deps.Git = &fakeGit{results: map[string]string{
|
||||
"symbolic-ref --short HEAD": "main",
|
||||
"config --get branch.main.remote": "origin",
|
||||
"remote get-url origin": server.URL + "/alice/project.git",
|
||||
"remote set-url origin " + server.URL + "/alice/renamed.git": "",
|
||||
}}
|
||||
deps.Git = runner
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "rename", "renamed", "--yes"}))
|
||||
require.Contains(t, runner.calls, "remote set-url origin "+server.URL+"/alice/renamed.git")
|
||||
require.Empty(t, out.String())
|
||||
require.Equal(t, "✓ Renamed repository alice/project to alice/renamed\n✓ Updated Git remote origin to alice/renamed\n", diagnostics.String())
|
||||
}
|
||||
|
||||
func TestRenamedURL(t *testing.T) {
|
||||
for _, test := range []struct{ raw, want string }{
|
||||
{"https://gitea.test/alice/project.git", "https://gitea.test/alice/renamed.git"},
|
||||
{"https://gitea.test/alice/project/", "https://gitea.test/alice/renamed"},
|
||||
{"git@gitea-alias:alice/Project.git", "git@gitea-alias:alice/renamed.git"},
|
||||
{"ssh://git@gitea.test:2222/alice/project", "ssh://git@gitea.test:2222/alice/renamed"},
|
||||
{"https://gitea.test/alice/subproject.git", ""},
|
||||
} {
|
||||
renamed, ok := renamedURL(test.raw, "project", "renamed")
|
||||
require.Equal(t, test.want != "", ok, test.raw)
|
||||
require.Equal(t, test.want, renamed, test.raw)
|
||||
}
|
||||
require.Equal(t, "✓ Renamed repository alice/project to alice/renamed\n! Git remote origin still points to alice/project; update it with git remote set-url\n", diagnostics.String())
|
||||
}
|
||||
|
||||
func TestRepoForkReportsAnExistingFork(t *testing.T) {
|
||||
|
||||
+1
-37
@@ -537,14 +537,6 @@ $ tea team member add 2 bob carol`,
|
||||
return err
|
||||
}
|
||||
for _, username := range args[1:] {
|
||||
member, err := service.IsMember(cmd.Context(), team.ID, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if member {
|
||||
a.warn(cmd, "%s is already a member of team %s", username, team.Slug())
|
||||
continue
|
||||
}
|
||||
if err := service.SetMember(cmd.Context(), team.ID, username, true); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -566,21 +558,7 @@ $ tea team member add 2 bob carol`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var members []string
|
||||
for _, username := range args[1:] {
|
||||
member, err := service.IsMember(cmd.Context(), team.ID, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !member {
|
||||
a.warn(cmd, "%s is not a member of team %s", username, team.Slug())
|
||||
continue
|
||||
}
|
||||
members = append(members, username)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil
|
||||
}
|
||||
members := args[1:]
|
||||
if err := a.confirmAction(cmd, terminal.Confirmation{
|
||||
Prompt: fmt.Sprintf("Remove %s from team %s?", joinNames(members), team.Slug()),
|
||||
NonTTY: fmt.Sprintf("remove %s from team %s", joinNames(members), team.Slug()),
|
||||
@@ -683,22 +661,8 @@ repositories of other owners cannot be granted to a team.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
has, err := service.HasRepository(cmd.Context(), team.ID, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case has && grant:
|
||||
a.warn(cmd, "Team %s already has access to %s", team.Slug(), repo)
|
||||
case !has && !grant:
|
||||
a.warn(cmd, "Team %s has no access to %s", team.Slug(), repo)
|
||||
default:
|
||||
repos = append(repos, repo)
|
||||
}
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !grant {
|
||||
if err := a.confirmAction(cmd, terminal.Confirmation{
|
||||
Prompt: fmt.Sprintf("Revoke team %s access to %s?", team.Slug(), joinNames(repos)),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -86,8 +87,6 @@ func TestTeamRepoAddTakesRepositoriesAsArguments(t *testing.T) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /api/v1/teams/5":
|
||||
_, _ = w.Write([]byte(`{"id":5,"name":"devs","permission":"none","organization":{"username":"acme"}}`))
|
||||
case "GET /api/v1/teams/5/repos/acme/service", "GET /api/v1/teams/5/repos/acme/website":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case "PUT /api/v1/teams/5/repos/acme/service", "PUT /api/v1/teams/5/repos/acme/website":
|
||||
granted = append(granted, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -182,18 +181,14 @@ func TestTeamPermissionSetsEveryUnit(t *testing.T) {
|
||||
require.Equal(t, map[string]any{"permission": "read", "units_map": wikiWrite}, sent[1])
|
||||
}
|
||||
|
||||
func TestTeamMemberRemoveNamesMembersAndSkipsOthers(t *testing.T) {
|
||||
func TestTeamMemberRemoveConfirmsOnce(t *testing.T) {
|
||||
var removed []string
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /api/v1/teams/5":
|
||||
_, _ = w.Write([]byte(`{"id":5,"name":"devs","organization":{"username":"acme"}}`))
|
||||
case "GET /api/v1/teams/5/members/carol":
|
||||
_, _ = w.Write([]byte(`{"login":"carol"}`))
|
||||
case "GET /api/v1/teams/5/members/dave":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case "DELETE /api/v1/teams/5/members/carol":
|
||||
removed = append(removed, "carol")
|
||||
case "DELETE /api/v1/teams/5/members/carol", "DELETE /api/v1/teams/5/members/dave":
|
||||
removed = append(removed, path.Base(r.URL.Path))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
@@ -205,7 +200,7 @@ func TestTeamMemberRemoveNamesMembersAndSkipsOthers(t *testing.T) {
|
||||
prompt := &scriptedPrompter{accept: true}
|
||||
deps.Prompter = prompt
|
||||
require.NoError(t, Execute(context.Background(), deps, []string{"team", "member", "remove", "5", "carol", "dave"}))
|
||||
require.Equal(t, []string{"Remove carol from team acme/devs?"}, prompt.confirmations)
|
||||
require.Equal(t, []string{"carol"}, removed)
|
||||
require.Equal(t, "! dave is not a member of team acme/devs\n✓ Removed carol from team acme/devs\n", withoutANSI(diagnostics.String()))
|
||||
require.Equal(t, []string{"Remove carol and dave from team acme/devs?"}, prompt.confirmations)
|
||||
require.Equal(t, []string{"carol", "dave"}, removed)
|
||||
require.Equal(t, "✓ Removed carol from team acme/devs\n✓ Removed dave from team acme/devs\n", withoutANSI(diagnostics.String()))
|
||||
}
|
||||
@@ -59,14 +59,6 @@ func (s Actions) AllArtifacts(ctx context.Context, run int64) ([]Artifact, error
|
||||
return nil, fmt.Errorf("listing exceeded %d pages", maxListPages)
|
||||
}
|
||||
|
||||
// Artifact retrieves one artifact.
|
||||
func (s Actions) Artifact(ctx context.Context, id int64) (Artifact, error) {
|
||||
artifact, err := repoConvert(ctx, s.Client, s.Repository, artifactFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.ActionsArtifact, *gitea.Response, error) {
|
||||
return c.Actions.GetRepoArtifact(ctx, owner, repo, id)
|
||||
})
|
||||
return artifact, Classify(err, "artifact", strconv.FormatInt(id, 10), s.Repository)
|
||||
}
|
||||
|
||||
// DownloadArtifact streams an artifact ZIP, accepting a scoped signed redirect.
|
||||
func (s Actions) DownloadArtifact(ctx context.Context, id int64) (io.ReadCloser, error) {
|
||||
endpoint, err := s.Client.URL(fmt.Sprintf("%sartifacts/%d/zip", s.endpoint(), id))
|
||||
|
||||
@@ -282,17 +282,6 @@ func (s Teams) Members(ctx context.Context, id int64, limit, page int) (Page[Org
|
||||
return convertPage(users, memberFrom), err
|
||||
}
|
||||
|
||||
// IsMember reports whether username belongs to the team.
|
||||
func (s Teams) IsMember(ctx context.Context, id int64, username string) (bool, error) {
|
||||
_, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) {
|
||||
return c.Organizations.GetTeamMember(ctx, id, username)
|
||||
})
|
||||
if statusNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// SetMember adds or removes one team membership.
|
||||
func (s Teams) SetMember(ctx context.Context, id int64, username string, add bool) error {
|
||||
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
|
||||
@@ -315,17 +304,6 @@ func (s Teams) Repositories(ctx context.Context, id int64, limit, page int) (Pag
|
||||
return convertPage(repos, repositoryFrom), err
|
||||
}
|
||||
|
||||
// HasRepository reports whether the team has access to the owner/name repository.
|
||||
func (s Teams) HasRepository(ctx context.Context, id int64, repository string) (bool, error) {
|
||||
_, err := repoCall(ctx, s.Client, repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
|
||||
return c.Organizations.GetTeamRepository(ctx, id, owner, name)
|
||||
})
|
||||
if statusNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// SetRepository adds or removes team access to a repository.
|
||||
func (s Teams) SetRepository(ctx context.Context, id int64, repository string, add bool) error {
|
||||
err := repoDo(ctx, s.Client, repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
@@ -39,80 +37,17 @@ func HyperlinksEnabled(preference string, outTTY bool, getenv func(string) strin
|
||||
}
|
||||
}
|
||||
|
||||
// TrueColor reports whether the terminal advertises 24-bit color through COLORTERM.
|
||||
func TrueColor(getenv func(string) string) bool {
|
||||
if getenv == nil {
|
||||
return false
|
||||
}
|
||||
value := getenv("COLORTERM")
|
||||
return value == "truecolor" || value == "24bit"
|
||||
}
|
||||
|
||||
// namedColors are the ANSI 16 colors for states and status, so they follow the terminal's own theme.
|
||||
var namedColors = map[string]string{"red": "31", "green": "32", "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36", "muted": "90"}
|
||||
|
||||
// Colorize colors text by name (red, green, yellow, blue, magenta, cyan or muted) from the terminal's palette,
|
||||
// or by six-digit RGB hex with 24-bit color. Unknown colors leave the text plain.
|
||||
// Colorize colors text by name (red, green, yellow, blue, magenta, cyan or muted) from the terminal's
|
||||
// ANSI 16 palette. Other colors, such as label hex colors, leave the text plain.
|
||||
func Colorize(color, text string, enabled bool) string {
|
||||
if !enabled {
|
||||
code, ok := namedColors[color]
|
||||
if !enabled || !ok || text == "" {
|
||||
return text
|
||||
}
|
||||
return paint(color, text, true)
|
||||
}
|
||||
|
||||
// paint is Colorize for enabled color; without trueColor, hex colors use the nearest 256-color index.
|
||||
func paint(color, text string, trueColor bool) string {
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
if code, ok := namedColors[color]; ok {
|
||||
return "\x1b[" + code + "m" + text + "\x1b[0m"
|
||||
}
|
||||
r, g, b, ok := parseHex(color)
|
||||
switch {
|
||||
case !ok:
|
||||
return text
|
||||
case trueColor:
|
||||
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", r, g, b, text)
|
||||
default:
|
||||
return fmt.Sprintf("\x1b[38;5;%dm%s\x1b[0m", ansi256(r, g, b), text)
|
||||
}
|
||||
}
|
||||
|
||||
func parseHex(hex string) (r, g, b int, ok bool) {
|
||||
hex = strings.TrimPrefix(strings.TrimSpace(hex), "#")
|
||||
if len(hex) != 6 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
value, err := strconv.ParseUint(hex, 16, 24)
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return int(value >> 16), int(value >> 8 & 0xff), int(value & 0xff), true
|
||||
}
|
||||
|
||||
// ansi256 returns the 256-color palette index nearest to an RGB color: one of the 6×6×6 cube colors
|
||||
// (16–231) or one of the 24 grays (232–255).
|
||||
func ansi256(r, g, b int) int {
|
||||
levels := [6]int{0, 95, 135, 175, 215, 255}
|
||||
level := func(v int) int {
|
||||
switch {
|
||||
case v < 48:
|
||||
return 0
|
||||
case v < 115:
|
||||
return 1
|
||||
default:
|
||||
return (v - 35) / 40
|
||||
}
|
||||
}
|
||||
ri, gi, bi := level(r), level(g), level(b)
|
||||
gray := min(max((r+g+b)/3-3, 0)/10, 23)
|
||||
grayValue := 8 + 10*gray
|
||||
distance := func(cr, cg, cb int) int { return (r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb) }
|
||||
if distance(levels[ri], levels[gi], levels[bi]) <= distance(grayValue, grayValue, grayValue) {
|
||||
return 16 + 36*ri + 6*gi + bi
|
||||
}
|
||||
return 232 + gray
|
||||
}
|
||||
|
||||
// Emphasis applies simple bold styling for TTY human tables.
|
||||
|
||||
@@ -42,29 +42,16 @@ func TestHyperlinksEnabledIgnoresColorPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrueColor(t *testing.T) {
|
||||
require.True(t, TrueColor(env(map[string]string{"COLORTERM": "truecolor"})))
|
||||
require.True(t, TrueColor(env(map[string]string{"COLORTERM": "24bit"})))
|
||||
require.False(t, TrueColor(env(map[string]string{"COLORTERM": "yes"})))
|
||||
require.False(t, TrueColor(nil))
|
||||
}
|
||||
|
||||
func TestColorizeAndEmphasis(t *testing.T) {
|
||||
require.Equal(t, "bug", Colorize("ff0000", "bug", false))
|
||||
require.Equal(t, "bug", Colorize("bad", "bug", true))
|
||||
require.Equal(t, "\x1b[38;2;255;0;0mbug\x1b[0m", Colorize("#ff0000", "bug", true))
|
||||
require.Equal(t, "bug", Colorize("#ff0000", "bug", true))
|
||||
require.Equal(t, "\x1b[32mopen\x1b[0m", Colorize("green", "open", true))
|
||||
require.Equal(t, "\x1b[90mdraft\x1b[0m", Colorize("muted", "draft", true))
|
||||
require.Equal(t, "open", Emphasis("open", false))
|
||||
require.Equal(t, "\x1b[1mopen\x1b[0m", Emphasis("open", true))
|
||||
}
|
||||
|
||||
func TestPaintApproximatesHexColorsWithoutTrueColor(t *testing.T) {
|
||||
for hex, index := range map[string]string{"ff0000": "196", "00ff00": "46", "000000": "16", "ffffff": "231", "808080": "244", "d73a4a": "167", "7057ff": "63"} {
|
||||
require.Equal(t, "\x1b[38;5;"+index+"mx\x1b[0m", paint(hex, "x", false), hex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHyperlink(t *testing.T) {
|
||||
require.Equal(t, "#7", Hyperlink("https://example.org/i/7", "#7", false))
|
||||
require.Equal(t, "\x1b]8;;https://example.org/i/7\x1b\\#7\x1b]8;;\x1b\\", Hyperlink("https://example.org/i/7", "#7", true))
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// LabelFormValues holds interactively edited label fields.
|
||||
type LabelFormValues struct {
|
||||
Name, Color, Description string
|
||||
Exclusive, Archived bool
|
||||
}
|
||||
|
||||
// LabelForm collects interactive label input.
|
||||
type LabelForm interface {
|
||||
// EditLabel asks for the fields of a label, starting from current, under heading, such as
|
||||
// `Editing label "bug" in alice/x`.
|
||||
EditLabel(ctx context.Context, heading string, current LabelFormValues) (LabelFormValues, error)
|
||||
}
|
||||
|
||||
var hexColor = regexp.MustCompile(`^#?[0-9a-fA-F]{6}$`)
|
||||
|
||||
// EditLabel runs the interactive label form on stderr.
|
||||
func (p *FormPrompter) EditLabel(ctx context.Context, heading string, current LabelFormValues) (LabelFormValues, error) {
|
||||
values := current
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().Title("Label name (required):").Value(&values.Name).Validate(huh.ValidateNotEmpty()),
|
||||
huh.NewInput().Title("Color (hex, e.g. d73a4a):").Value(&values.Color).Validate(func(s string) error {
|
||||
if !hexColor.MatchString(strings.TrimSpace(s)) {
|
||||
return errors.New("use six hexadecimal digits")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("Description (optional):").Value(&values.Description),
|
||||
huh.NewConfirm().Title("Exclusive (one label of its scope per issue):").Value(&values.Exclusive),
|
||||
huh.NewConfirm().Title("Archived:").Value(&values.Archived),
|
||||
).Title(heading))); err != nil {
|
||||
return LabelFormValues{}, err
|
||||
}
|
||||
values.Name, values.Color = strings.TrimSpace(values.Name), strings.TrimPrefix(strings.TrimSpace(values.Color), "#")
|
||||
return values, nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLabelFormStartsFromCurrentValues(t *testing.T) {
|
||||
p, _ := testPrompter(strings.NewReader("\r\r\r\r\r"))
|
||||
current := LabelFormValues{Name: "bug", Color: "#EE0701", Description: "Broken", Exclusive: true}
|
||||
values, err := p.EditLabel(context.Background(), `Editing label "bug" in alice/x`, current)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, LabelFormValues{Name: "bug", Color: "EE0701", Description: "Broken", Exclusive: true}, values)
|
||||
}
|
||||
|
||||
func TestLabelFormMarksNameRequired(t *testing.T) {
|
||||
p, rendered := accessiblePrompter(t, "bug\nee0701\n\nn\nn\n")
|
||||
values, err := p.EditLabel(context.Background(), "", LabelFormValues{})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, rendered.String(), "Label name (required):")
|
||||
require.Equal(t, "bug", values.Name)
|
||||
}
|
||||
@@ -16,9 +16,8 @@ import (
|
||||
// for scripts, without header, color or links.
|
||||
type Display struct {
|
||||
TTY bool
|
||||
// Color enables SGR colors and emphasis on a TTY; TrueColor writes hex colors in 24-bit instead
|
||||
// of the nearest of 256.
|
||||
Color, TrueColor bool
|
||||
// Color enables SGR colors and emphasis on a TTY.
|
||||
Color bool
|
||||
// Links enables OSC 8 links on the first table column on a TTY.
|
||||
Links bool
|
||||
// Markdown renders view bodies on a TTY.
|
||||
@@ -79,7 +78,7 @@ func (c Cell) style(text string, d Display, link bool) string {
|
||||
if c.Muted {
|
||||
color = "muted"
|
||||
}
|
||||
text = Emphasis(paint(color, text, d.TrueColor), c.Bold)
|
||||
text = Emphasis(Colorize(color, text, true), c.Bold)
|
||||
}
|
||||
return Hyperlink(c.Link, text, link && d.Links)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestTable(t *testing.T) {
|
||||
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000", Link: "https://example.org/bug"}, {Text: "open", Color: "green", Bold: true}}, row("12", "feature", "closed")},
|
||||
Display{TTY: true, Color: true, Links: true},
|
||||
"\x1b[90mID\x1b[0m \x1b[90mNAME\x1b[0m \x1b[90mSTATE\x1b[0m\n" +
|
||||
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;5;196mbug\x1b[0m \x1b[1m\x1b[32mopen\x1b[0m\x1b[0m\n" +
|
||||
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ bug \x1b[1m\x1b[32mopen\x1b[0m\x1b[0m\n" +
|
||||
"12 feature closed\n",
|
||||
},
|
||||
{
|
||||
@@ -109,11 +109,11 @@ func TestTable(t *testing.T) {
|
||||
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ bug\n",
|
||||
},
|
||||
{
|
||||
"writes 24-bit color on true-color terminals",
|
||||
"leaves hex colors plain",
|
||||
nil,
|
||||
[][]Cell{{{Text: "bug", Color: "#ff0000"}, {Text: "old", Muted: true}}},
|
||||
Display{TTY: true, Color: true, TrueColor: true},
|
||||
"\x1b[38;2;255;0;0mbug\x1b[0m \x1b[90mold\x1b[0m\n",
|
||||
Display{TTY: true, Color: true},
|
||||
"bug \x1b[90mold\x1b[0m\n",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -52,8 +52,8 @@ func TestViewRender(t *testing.T) {
|
||||
{
|
||||
"styled for color terminals",
|
||||
View{Title: "v1.0", Badges: []Cell{{Text: "Latest", Color: "green"}}, Fields: []Field{{Label: "Labels", Cells: []Cell{{Text: "bug", Color: "ee0701"}}}}, URL: "https://example.org/r"},
|
||||
Display{TTY: true, Color: true, TrueColor: true},
|
||||
"\x1b[1mv1.0\x1b[0m\n\x1b[32mLatest\x1b[0m\n\x1b[1mLabels:\x1b[0m \x1b[38;2;238;7;1mbug\x1b[0m\n\n" +
|
||||
Display{TTY: true, Color: true},
|
||||
"\x1b[1mv1.0\x1b[0m\n\x1b[32mLatest\x1b[0m\n\x1b[1mLabels:\x1b[0m bug\n\n" +
|
||||
"\x1b[90mNo description provided\x1b[0m\n\n\x1b[90mView on Gitea: https://example.org/r\x1b[0m\n",
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user