mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
fix: address verified review findings (issues-1)
Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
16 files changed
+225
-27
No files matched your search
@@ -327,7 +327,7 @@ func TestInstanceCompletions(t *testing.T) {
|
||||
require.Equal(t, "issues", r.URL.Query().Get("type"))
|
||||
_, _ = w.Write([]byte(`[{"number":4,"title":"Crash when config is missing"}]`))
|
||||
case "/api/v1/repos/alice/project/milestones":
|
||||
_, _ = w.Write([]byte(`[{"id":1,"title":"v1.0","description":"First release"}]`))
|
||||
_, _ = w.Write([]byte(`[{"id":1,"title":"v1.0","description":"First release"},{"id":25,"title":"23"}]`))
|
||||
case "/api/v1/repos/alice/project/branches":
|
||||
_, _ = w.Write([]byte(`[{"name":"main","protected":true},{"name":"docs/guide"}]`))
|
||||
case "/api/v1/repos/alice/project/tags":
|
||||
@@ -370,7 +370,7 @@ func TestInstanceCompletions(t *testing.T) {
|
||||
{"teams start with organizations", a.completeTeams(), nil, "", []string{"acme/\tAcme Inc"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp},
|
||||
{"teams of an organization", a.completeTeams(), nil, "acme/d", []string{"acme/developers\tDevelopers (ID 2)"}, cobra.ShellCompDirectiveNoFileComp},
|
||||
{"issues", a.completeIssues("open"), nil, "", []string{"4\tCrash when config is missing"}, cobra.ShellCompDirectiveNoFileComp},
|
||||
{"milestones", a.completeMilestones(), nil, "", []string{"v1.0\tFirst release"}, cobra.ShellCompDirectiveNoFileComp},
|
||||
{"milestones", a.completeMilestones(), nil, "", []string{"v1.0\tFirst release", "25\t23"}, cobra.ShellCompDirectiveNoFileComp},
|
||||
{"branches", a.completeBranches(), nil, "", []string{"main\tprotected", "docs/guide"}, cobra.ShellCompDirectiveNoFileComp},
|
||||
{"tags", a.completeTags(), nil, "v", []string{"v0.1.0"}, cobra.ShellCompDirectiveNoFileComp},
|
||||
{"releases", a.completeReleases(), nil, "", []string{"v0.1.0\tFirst release"}, cobra.ShellCompDirectiveNoFileComp},
|
||||
|
||||
@@ -383,7 +383,8 @@ func (a *application) completeLabels() cobra.CompletionFunc {
|
||||
}))
|
||||
}
|
||||
|
||||
// completeMilestones completes the titles of open milestones.
|
||||
// completeMilestones completes the titles of open milestones. A numeric title would be read as
|
||||
// another milestone's ID, so such a milestone is completed by its own ID instead.
|
||||
func (a *application) completeMilestones() cobra.CompletionFunc {
|
||||
return a.completeMilestonesIn("open")
|
||||
}
|
||||
@@ -392,7 +393,13 @@ func (a *application) completeMilestones() cobra.CompletionFunc {
|
||||
func (a *application) completeMilestonesIn(state string) cobra.CompletionFunc {
|
||||
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
|
||||
items, err := services.Milestones{Client: t.client, Repository: t.Repo}.List(ctx, state, completionLimit, 1)
|
||||
return describe(items, func(item services.Milestone) (string, string) { return item.Title, item.Description }), err
|
||||
return describe(items, func(item services.Milestone) (string, string) {
|
||||
// A title that reads as a number would be taken as an ID, so offer the ID instead.
|
||||
if _, parse := strconv.ParseInt(strings.TrimSpace(item.Title), 10, 64); parse == nil {
|
||||
return strconv.FormatInt(item.ID, 10), strings.TrimSpace(item.Title + " " + item.Description)
|
||||
}
|
||||
return item.Title, item.Description
|
||||
}), err
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ func dueText(now time.Time, raw string) string {
|
||||
if due.IsZero() {
|
||||
return raw
|
||||
}
|
||||
// Keep the offset the server sent: Gitea stamps 23:59:59 in its own zone, and UTC would roll the date over.
|
||||
return due.Format("Jan 2, 2006") + " (" + terminal.RelativeTime(now, due) + ")"
|
||||
}
|
||||
|
||||
|
||||
@@ -195,3 +195,10 @@ func TestDueTextKeepsTheDayInTheServerOffset(t *testing.T) {
|
||||
require.Equal(t, "Dec 24, 2026 (in about 2 months)", dueText(now, "2026-12-24T23:59:59-05:00"))
|
||||
require.Equal(t, "Dec 24, 2026 (in about 2 months)", dueText(now, "2026-12-24T23:59:59Z"))
|
||||
}
|
||||
|
||||
func TestDueTextKeepsTheServerCalendarDate(t *testing.T) {
|
||||
now := time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC)
|
||||
require.Contains(t, dueText(now, "2026-12-24T23:59:59Z"), "Dec 24, 2026 (")
|
||||
require.Contains(t, dueText(now, "2026-12-24T23:59:59-05:00"), "Dec 24, 2026 (")
|
||||
require.Equal(t, "garbage", dueText(now, "garbage"))
|
||||
}
|
||||
@@ -21,13 +21,19 @@ func attachmentName(t target, n int64, item services.IssueAttachment) string {
|
||||
return fmt.Sprintf("attachment %d (%s) on %s#%d", item.ID, item.Name, t.Repo, n)
|
||||
}
|
||||
|
||||
func attachmentRow(item services.IssueAttachment) []terminal.Cell {
|
||||
// attachmentRow is one row of the attachment list; piped output carries exact byte counts and the URL.
|
||||
func (a *application) attachmentRow(item services.IssueAttachment) []terminal.Cell {
|
||||
size := terminal.Bytes(item.Size)
|
||||
if !a.deps.Streams.OutTTY {
|
||||
size = strconv.FormatInt(item.Size, 10)
|
||||
}
|
||||
return []terminal.Cell{
|
||||
{Text: strconv.FormatInt(item.ID, 10), Link: item.URL},
|
||||
{Text: item.Name},
|
||||
{Text: terminal.Bytes(item.Size)},
|
||||
{Text: size},
|
||||
{Text: strconv.FormatInt(item.DownloadCount, 10)},
|
||||
{Time: terminal.ParseTime(item.CreatedAt)},
|
||||
{Text: item.URL},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +45,7 @@ func attachmentView(item services.IssueAttachment, d terminal.Display) terminal.
|
||||
{Label: "Downloads", Value: strconv.FormatInt(item.DownloadCount, 10)},
|
||||
timeField("Created", item.CreatedAt, d),
|
||||
},
|
||||
Kind: "attachment", URL: item.URL,
|
||||
Bodyless: true, Kind: "attachment", URL: item.URL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +83,10 @@ are addressed by the %s number and the attachment ID that attachment list shows.
|
||||
return err
|
||||
}
|
||||
where := fmt.Sprintf("on %s#%d", t.Repo, n)
|
||||
return writeTable(a, cmd, items, listSpec[services.IssueAttachment]{
|
||||
Columns: []terminal.Column{{Header: "ID"}, {Header: "Name", Flex: true}, {Header: "Size"}, {Header: "Downloads"}, {Header: "Created"}},
|
||||
Row: attachmentRow, Empty: "no attachments " + where, Context: "attachments " + where,
|
||||
}, listPage{Number: page})
|
||||
return writeTable(a, cmd, items.Items, listSpec[services.IssueAttachment]{
|
||||
Columns: []terminal.Column{{Header: "ID"}, {Header: "Name", Flex: true}, {Header: "Size"}, {Header: "Downloads"}, {Header: "Created"}, {Header: "URL", PipedOnly: true}},
|
||||
Row: a.attachmentRow, Empty: "no attachments " + where, Context: "attachments " + where,
|
||||
}, servicePage(items, page))
|
||||
}}
|
||||
pageFlags(list, &limit, &page)
|
||||
|
||||
@@ -167,11 +173,10 @@ $ tea %[1]s attachment download %[2]s 9 -O - | less`, words.group, words.example
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item, body, err := (services.Issues{Client: t.client, Repository: t.Repo}).DownloadAttachment(cmd.Context(), n, id)
|
||||
item, open, err := (services.Issues{Client: t.client, Repository: t.Repo}).DownloadAttachment(cmd.Context(), n, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer body.Close()
|
||||
path, skip, err := destination.destination(item.Name)
|
||||
switch {
|
||||
case err != nil:
|
||||
@@ -180,6 +185,11 @@ $ tea %[1]s attachment download %[2]s 9 -O - | less`, words.group, words.example
|
||||
a.warn(cmd, "Skipped %s: %s exists", attachmentName(t, n, item), path)
|
||||
return nil
|
||||
}
|
||||
body, err := open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer body.Close()
|
||||
if err := a.download(cmd, destination, path, body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -133,3 +134,53 @@ func TestAttachmentTablesDownloadsAndDoneLines(t *testing.T) {
|
||||
require.NoError(t, run(true, "issue", "attachment", "delete", "1", "9", "--yes"))
|
||||
require.Equal(t, "✓ Deleted attachment 9 (crash.log) on alice/project#1\n", diagnostics.String())
|
||||
}
|
||||
|
||||
func TestAttachmentListViewAndDownloadOrdering(t *testing.T) {
|
||||
var fetched int
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "GET /api/v1/repos/alice/project/issues/1/assets":
|
||||
_, _ = w.Write([]byte(`[{"id":9,"name":"crash.log","size":2048,"browser_download_url":"https://example.org/a/9"},{"id":10,"name":"b.log","size":1}]`))
|
||||
case "GET /api/v1/repos/alice/project/issues/1/assets/9":
|
||||
_, _ = w.Write([]byte(`{"id":9,"name":"crash.log","size":5,"uuid":"abc","browser_download_url":"https://example.org/a/9"}`))
|
||||
case "GET /attachments/abc":
|
||||
fetched++
|
||||
_, _ = w.Write([]byte("hello"))
|
||||
default:
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, out, _ := serverDeps(t, server.URL)
|
||||
deps.Now = func() time.Time { return testNow }
|
||||
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
|
||||
plainOutput(t, deps)
|
||||
run := func(tty bool, args ...string) error {
|
||||
out.Reset()
|
||||
deps.Streams.OutTTY = tty
|
||||
return Execute(context.Background(), deps, append(args, "-R", "alice/project", "--no-input"))
|
||||
}
|
||||
// A limit that cuts the list says so.
|
||||
require.NoError(t, run(true, "issue", "attachment", "list", "1", "--limit", "1"))
|
||||
require.Equal(t, "Showing 1 of 2 attachments on alice/project#1 · next: --page 2\n", firstLine(out.String()))
|
||||
// Piped output keeps exact sizes and the download URL.
|
||||
require.NoError(t, run(false, "issue", "attachment", "list", "1", "--limit", "1"))
|
||||
require.Equal(t, "9\tcrash.log\t2048\t0\t\thttps://example.org/a/9\n", out.String())
|
||||
// An attachment has no description.
|
||||
require.NoError(t, run(true, "issue", "attachment", "view", "1", "9"))
|
||||
require.NotContains(t, out.String(), "No description provided")
|
||||
// Usage errors and skipped downloads never request the file.
|
||||
t.Chdir(t.TempDir())
|
||||
require.ErrorContains(t, run(true, "issue", "attachment", "download", "1", "9", "--clobber", "--skip-existing"), "cannot be combined")
|
||||
require.NoError(t, os.WriteFile("crash.log", []byte("old"), 0o600))
|
||||
require.NoError(t, run(true, "issue", "attachment", "download", "1", "9", "--skip-existing"))
|
||||
require.ErrorContains(t, run(true, "issue", "attachment", "download", "1", "9"), "already exists")
|
||||
require.Zero(t, fetched)
|
||||
require.NoError(t, run(true, "issue", "attachment", "download", "1", "9", "--clobber"))
|
||||
require.Equal(t, 1, fetched)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
line, _, _ := strings.Cut(s, "\n")
|
||||
return line + "\n"
|
||||
}
|
||||
@@ -139,3 +139,18 @@ func TestMissingItemsAreNamed(t *testing.T) {
|
||||
require.EqualError(t, Explain(context.Background(), deps, err), test.want, test.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopeningAMergedPullRequestLeavesNoComment(t *testing.T) {
|
||||
var writes []string
|
||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writes = append(writes, r.Method+" "+r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"number":14,"title":"Merged one","state":"closed","pull_request":{"merged":true}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
deps, _, _ := serverDeps(t, server.URL)
|
||||
err := Execute(context.Background(), deps, []string{"issue", "reopen", "14", "-R", "alice/project", "--no-input", "--comment", "again"})
|
||||
require.ErrorContains(t, err, "pull request alice/project#14 (Merged one) is already merged; it cannot be reopened")
|
||||
require.Empty(t, writes)
|
||||
}
|
||||
+17
-1
@@ -235,6 +235,14 @@ $ tea issue create --template "Bug report"`, Args: argsExact(0), RunE: func(cmd
|
||||
return err
|
||||
}
|
||||
}
|
||||
if web {
|
||||
// The new-issue page is prefilled with the title and body only.
|
||||
for _, name := range []string{"label", "assignee", "milestone", "deadline", "ref", "attachment", "editor"} {
|
||||
if cmd.Flags().Changed(name) {
|
||||
return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be combined with --web; the browser only receives the title and body", name))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !localFlagsChanged(cmd) && a.canPrompt() && a.deps.IssueForm != nil {
|
||||
return a.createIssueInteractively(cmd)
|
||||
}
|
||||
@@ -421,6 +429,10 @@ $ tea issue %[1]s 12 13 --comment "Done in #14"`, verb), Annotations: map[string
|
||||
a.warn(cmd, "%s %s is already %s", titleCase(discussionNoun(current.Kind)), refLabel(target.Repo, n, current.Title), current.State)
|
||||
return current, nil
|
||||
}
|
||||
if state == "open" && current.State == "merged" {
|
||||
// Gitea refuses the state change, so fail before the comment is left behind.
|
||||
return current, fmt.Errorf("pull request %s is already merged; it cannot be reopened", refLabel(target.Repo, n, current.Title))
|
||||
}
|
||||
if comment != "" {
|
||||
if _, err := comments.Create(cmd.Context(), n, comment); err != nil {
|
||||
return current, itemError(err, target.Repo)
|
||||
@@ -458,7 +470,11 @@ func milestoneFlag(cmd *cobra.Command, value string) (selected string, remove bo
|
||||
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"))
|
||||
hint := "omit --milestone for none"
|
||||
if cmd.Flags().Lookup("remove-milestone") != nil {
|
||||
hint = "use --remove-milestone to clear it"
|
||||
}
|
||||
return "", false, clierr.Wrap(clierr.Usage, errors.New("--milestone needs a title or ID; "+hint))
|
||||
case strings.TrimSpace(value) == "0":
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
@@ -50,7 +50,10 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
|
||||
{[]string{"issue", "create", "--no-input"}, "missing required input: supply --title"},
|
||||
{[]string{"issue", "create", "--title", "safe", "--json=unknown"}, "unknown JSON field \"unknown\""},
|
||||
{[]string{"issue", "create", "--title", "safe", "--json", "--jq", "["}, "invalid --jq expression"},
|
||||
{[]string{"issue", "create", "--milestone="}, "--milestone needs a title or ID; use --remove-milestone to clear it"},
|
||||
{[]string{"issue", "create", "--milestone="}, "--milestone needs a title or ID; omit --milestone for none"},
|
||||
{[]string{"issue", "edit", "1", "--milestone="}, "--milestone needs a title or ID; use --remove-milestone to clear it"},
|
||||
{[]string{"issue", "create", "--web", "--label", "bug"}, "--label cannot be combined with --web"},
|
||||
{[]string{"issue", "create", "--web", "--deadline", "2026-12-24"}, "--deadline cannot be combined with --web"},
|
||||
{[]string{"issue", "create", "--assignee", "alice, "}, "assignee names cannot be empty"},
|
||||
{[]string{"issue", "create", "--template="}, "--template cannot be empty"},
|
||||
{[]string{"issue", "create", "--title", "x", "--attachment", "."}, ". is not a regular file"},
|
||||
|
||||
Reference in new issue
Block a user