diff --git a/cmd/pulls/list.go b/cmd/pulls/list.go index d87e3af..3eaf9d6 100644 --- a/cmd/pulls/list.go +++ b/cmd/pulls/list.go @@ -5,6 +5,8 @@ package pulls import ( stdctx "context" + "fmt" + "slices" "code.gitea.io/sdk/gitea" "code.gitea.io/tea/cmd/flags" @@ -43,7 +45,8 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { return err } - prs, _, err := ctx.Login.Client().ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ + client := ctx.Login.Client() + prs, _, err := client.ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ ListOptions: flags.GetListOptions(cmd), State: state, }) @@ -56,5 +59,21 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { return err } - return print.PullsList(prs, ctx.Output, fields) + var ciStatuses map[int64]*gitea.CombinedStatus + if slices.Contains(fields, "ci") { + ciStatuses = map[int64]*gitea.CombinedStatus{} + for _, pr := range prs { + if pr.Head == nil || pr.Head.Sha == "" { + continue + } + ci, _, err := client.GetCombinedStatus(ctx.Owner, ctx.Repo, pr.Head.Sha) + if err != nil { + fmt.Printf("error fetching CI status for PR #%d: %v\n", pr.Index, err) + continue + } + ciStatuses[pr.Index] = ci + } + } + + return print.PullsList(prs, ctx.Output, fields, ciStatuses) } diff --git a/docs/CLI.md b/docs/CLI.md index e9e8621..05f43e5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -274,7 +274,7 @@ Manage and checkout pull requests **--comments**: Whether to display comments (will prompt if not provided & run interactively) **--fields, -f**="": Comma-separated list of fields to print. Available values: - index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments + index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments,ci (default: "index,title,state,author,milestone,updated,labels") **--limit, --lm**="": specify limit of items per page (default: 30) @@ -296,7 +296,7 @@ Manage and checkout pull requests List pull requests of the repository **--fields, -f**="": Comma-separated list of fields to print. Available values: - index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments + index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments,ci (default: "index,title,state,author,milestone,updated,labels") **--limit, --lm**="": specify limit of items per page (default: 30) diff --git a/modules/print/pull.go b/modules/print/pull.go index c4e537e..09d6cd6 100644 --- a/modules/print/pull.go +++ b/modules/print/pull.go @@ -12,7 +12,7 @@ import ( var ciStatusSymbols = map[gitea.StatusState]string{ gitea.StatusSuccess: "✓ ", - gitea.StatusPending: "⭮ ", + gitea.StatusPending: "⏳ ", gitea.StatusWarning: "⚠ ", gitea.StatusError: "✘ ", gitea.StatusFailure: "❌ ", @@ -42,16 +42,19 @@ func PullDetails(pr *gitea.PullRequest, reviews []*gitea.PullReview, ciStatus *g out += formatReviews(pr, reviews) - if ciStatus != nil { - var summary, errors string + if ciStatus != nil && len(ciStatus.Statuses) != 0 { + out += "- CI:\n" for _, s := range ciStatus.Statuses { - summary += ciStatusSymbols[s.State] - if s.State != gitea.StatusSuccess { - errors += fmt.Sprintf(" - [**%s**:\t%s](%s)\n", s.Context, s.Description, s.TargetURL) + symbol := ciStatusSymbols[s.State] + if s.TargetURL != "" { + out += fmt.Sprintf(" - %s[**%s**](%s)", symbol, s.Context, s.TargetURL) + } else { + out += fmt.Sprintf(" - %s**%s**", symbol, s.Context) } - } - if len(ciStatus.Statuses) != 0 { - out += fmt.Sprintf("- CI: %s\n%s", summary, errors) + if s.Description != "" { + out += fmt.Sprintf(": %s", s.Description) + } + out += "\n" } } @@ -89,6 +92,20 @@ func formatPRState(pr *gitea.PullRequest) string { return string(pr.State) } +func formatCIStatus(ci *gitea.CombinedStatus, machineReadable bool) string { + if ci == nil || len(ci.Statuses) == 0 { + return "" + } + if machineReadable { + return string(ci.State) + } + items := make([]string, 0, len(ci.Statuses)) + for _, s := range ci.Statuses { + items = append(items, fmt.Sprintf("%s%s", ciStatusSymbols[s.State], s.Context)) + } + return strings.Join(items, ", ") +} + func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string { result := "" if len(reviews) == 0 { @@ -138,8 +155,8 @@ func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string { } // PullsList prints a listing of pulls -func PullsList(prs []*gitea.PullRequest, output string, fields []string) error { - return printPulls(prs, output, fields) +func PullsList(prs []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error { + return printPulls(prs, output, fields, ciStatuses) } // PullFields are all available fields to print with PullsList() @@ -168,9 +185,10 @@ var PullFields = []string{ "milestone", "labels", "comments", + "ci", } -func printPulls(pulls []*gitea.PullRequest, output string, fields []string) error { +func printPulls(pulls []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error { labelMap := map[int64]string{} printables := make([]printable, len(pulls)) machineReadable := isMachineReadable(output) @@ -183,7 +201,7 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) erro } } // store items with printable interface - printables[i] = &printablePull{x, &labelMap} + printables[i] = &printablePull{x, &labelMap, &ciStatuses} } t := tableFromItems(fields, printables, machineReadable) @@ -193,6 +211,7 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) erro type printablePull struct { *gitea.PullRequest formattedLabels *map[int64]string + ciStatuses *map[int64]*gitea.CombinedStatus } func (x printablePull) FormatField(field string, machineReadable bool) string { @@ -252,6 +271,13 @@ func (x printablePull) FormatField(field string, machineReadable bool) string { return x.DiffURL case "patch": return x.PatchURL + case "ci": + if x.ciStatuses != nil { + if ci, ok := (*x.ciStatuses)[x.Index]; ok { + return formatCIStatus(ci, machineReadable) + } + } + return "" } return "" } diff --git a/modules/print/pull_test.go b/modules/print/pull_test.go new file mode 100644 index 0000000..7ce2147 --- /dev/null +++ b/modules/print/pull_test.go @@ -0,0 +1,189 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "bytes" + "encoding/json" + "slices" + "testing" + "time" + + "code.gitea.io/sdk/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestPR(index int64, title string) *gitea.PullRequest { + now := time.Now() + return &gitea.PullRequest{ + Index: index, + Title: title, + State: gitea.StateOpen, + Poster: &gitea.User{UserName: "testuser"}, + Head: &gitea.PRBranchInfo{Ref: "branch", Name: "branch"}, + Base: &gitea.PRBranchInfo{Ref: "main", Name: "main"}, + Created: &now, + Updated: &now, + } +} + +func TestFormatCIStatusNil(t *testing.T) { + assert.Equal(t, "", formatCIStatus(nil, false)) + assert.Equal(t, "", formatCIStatus(nil, true)) +} + +func TestFormatCIStatusEmpty(t *testing.T) { + ci := &gitea.CombinedStatus{Statuses: []*gitea.Status{}} + assert.Equal(t, "", formatCIStatus(ci, false)) + assert.Equal(t, "", formatCIStatus(ci, true)) +} + +func TestFormatCIStatusMachineReadable(t *testing.T) { + ci := &gitea.CombinedStatus{ + State: gitea.StatusSuccess, + Statuses: []*gitea.Status{ + {State: gitea.StatusSuccess, Context: "lint"}, + }, + } + assert.Equal(t, "success", formatCIStatus(ci, true)) + + ci.State = gitea.StatusPending + ci.Statuses = []*gitea.Status{ + {State: gitea.StatusPending, Context: "build"}, + } + assert.Equal(t, "pending", formatCIStatus(ci, true)) +} + +func TestFormatCIStatusSingle(t *testing.T) { + ci := &gitea.CombinedStatus{ + State: gitea.StatusSuccess, + Statuses: []*gitea.Status{ + {State: gitea.StatusSuccess, Context: "lint"}, + }, + } + assert.Equal(t, "✓ lint", formatCIStatus(ci, false)) +} + +func TestFormatCIStatusMultiple(t *testing.T) { + ci := &gitea.CombinedStatus{ + State: gitea.StatusFailure, + Statuses: []*gitea.Status{ + {State: gitea.StatusSuccess, Context: "lint"}, + {State: gitea.StatusPending, Context: "build"}, + {State: gitea.StatusFailure, Context: "test"}, + }, + } + assert.Equal(t, "✓ lint, ⏳ build, ❌ test", formatCIStatus(ci, false)) +} + +func TestFormatCIStatusAllStates(t *testing.T) { + tests := []struct { + state gitea.StatusState + context string + expected string + }{ + {gitea.StatusSuccess, "s", "✓ s"}, + {gitea.StatusPending, "p", "⏳ p"}, + {gitea.StatusWarning, "w", "⚠ w"}, + {gitea.StatusError, "e", "✘ e"}, + {gitea.StatusFailure, "f", "❌ f"}, + } + for _, tt := range tests { + ci := &gitea.CombinedStatus{ + State: tt.state, + Statuses: []*gitea.Status{{State: tt.state, Context: tt.context}}, + } + assert.Equal(t, tt.expected, formatCIStatus(ci, false), "state: %s", tt.state) + } +} + +func TestPullsListWithCIField(t *testing.T) { + prs := []*gitea.PullRequest{ + newTestPR(1, "feat: add feature"), + newTestPR(2, "fix: bug fix"), + } + + ciStatuses := map[int64]*gitea.CombinedStatus{ + 1: { + State: gitea.StatusSuccess, + Statuses: []*gitea.Status{ + {State: gitea.StatusSuccess, Context: "ci/build"}, + }, + }, + 2: { + State: gitea.StatusFailure, + Statuses: []*gitea.Status{ + {State: gitea.StatusFailure, Context: "ci/test"}, + }, + }, + } + + buf := &bytes.Buffer{} + tbl := tableFromItems( + []string{"index", "ci"}, + []printable{ + &printablePull{prs[0], &map[int64]string{}, &ciStatuses}, + &printablePull{prs[1], &map[int64]string{}, &ciStatuses}, + }, + true, + ) + require.NoError(t, tbl.fprint(buf, "json")) + + var result []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result, 2) + assert.Equal(t, "1", result[0]["index"]) + assert.Equal(t, "success", result[0]["ci"]) + assert.Equal(t, "2", result[1]["index"]) + assert.Equal(t, "failure", result[1]["ci"]) +} + +func TestPullsListCIFieldEmpty(t *testing.T) { + prs := []*gitea.PullRequest{newTestPR(1, "no ci")} + ciStatuses := map[int64]*gitea.CombinedStatus{} + + buf := &bytes.Buffer{} + tbl := tableFromItems( + []string{"index", "ci"}, + []printable{ + &printablePull{prs[0], &map[int64]string{}, &ciStatuses}, + }, + true, + ) + require.NoError(t, tbl.fprint(buf, "json")) + + var result []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result, 1) + assert.Equal(t, "", result[0]["ci"]) +} + +func TestPullsListNilCIStatusesWithCIField(t *testing.T) { + prs := []*gitea.PullRequest{newTestPR(1, "nil ci")} + + buf := &bytes.Buffer{} + tbl := tableFromItems( + []string{"index", "ci"}, + []printable{ + &printablePull{prs[0], &map[int64]string{}, nil}, + }, + true, + ) + require.NoError(t, tbl.fprint(buf, "json")) + + var result []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result, 1) + assert.Equal(t, "", result[0]["ci"]) +} + +func TestPullsListNoCIFieldNoPanic(t *testing.T) { + prs := []*gitea.PullRequest{newTestPR(1, "test")} + require.NoError(t, PullsList(prs, "", []string{"index", "title"}, nil)) +} + +func TestPullFieldsContainsCI(t *testing.T) { + assert.True(t, slices.Contains(PullFields, "ci"), "PullFields should contain 'ci'") +}