Repository: babakks/cli
Pull request #1: Add sponsors/list tty
Author: login: babakks
Created: 2025-03-25T12:59:40Z | Merged: 2025-03-25T22:57:35Z

This PR adds `sponsors list <user>` command. The implementation only supports tty mode.

## Acceptance criteria

### 1. Non-empty list of sponsors

**Given** I have the name of a Sponsorable user that has less than 30 sponsors
**When** I run gh sponsors list <user>
**Then** I see a list of sponsors in a single column table with a header e.g.

```
SPONSOR
------
User-1
User-2
User-3
```

Implementation result (table's horizontal rule in not shown):

```sh
$ go run ./cmd/gh sponsors list onsi
SPONSOR      
austince
authzed
belden
erdnas
ibrasho
mercedes-benz
mrkmcknz
ppeble
srevinsaju
williammartin
```

### 2. Empty list of sponsors

**Given** I have the name of a Sponsorable user that has no sponsors
**When** I run gh sponsors list <user>
**Then** I see an informative message that there are no sponsors for this user

Implementation result:

```sh
$ go run ./cmd/gh sponsors list babakks
no sponsor found
```








Diff:
diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go
index c0dad93ec05..815879f3cf6 100644
--- a/pkg/cmd/root/root.go
+++ b/pkg/cmd/root/root.go
@@ -33,6 +33,7 @@ import (
 	runCmd "github.com/cli/cli/v2/pkg/cmd/run"
 	searchCmd "github.com/cli/cli/v2/pkg/cmd/search"
 	secretCmd "github.com/cli/cli/v2/pkg/cmd/secret"
+	sponsorsCmd "github.com/cli/cli/v2/pkg/cmd/sponsors"
 	sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key"
 	statusCmd "github.com/cli/cli/v2/pkg/cmd/status"
 	variableCmd "github.com/cli/cli/v2/pkg/cmd/variable"
@@ -134,6 +135,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command,
 	cmd.AddCommand(extensionCmd.NewCmdExtension(f))
 	cmd.AddCommand(searchCmd.NewCmdSearch(f))
 	cmd.AddCommand(secretCmd.NewCmdSecret(f))
+	cmd.AddCommand(sponsorsCmd.NewCmdSponsors(f))
 	cmd.AddCommand(variableCmd.NewCmdVariable(f))
 	cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
 	cmd.AddCommand(statusCmd.NewCmdStatus(f, nil))
diff --git a/pkg/cmd/sponsors/list/list.go b/pkg/cmd/sponsors/list/list.go
new file mode 100644
index 00000000000..5e8b6bba859
--- /dev/null
+++ b/pkg/cmd/sponsors/list/list.go
@@ -0,0 +1,138 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/shurcooL/graphql"
+	"github.com/spf13/cobra"
+
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+)
+
+const defaultListLimit = 30
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (gh.Config, error)
+
+	Username string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "list <user>",
+		Short: "List sponsors",
+		Long: heredoc.Doc(`
+			List sponsors of a given user.
+		`),
+		Aliases: []string{"ls"},
+		Args:    cmdutil.ExactArgs(1, "must specify username"),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// This is for consistency with the other commands, but the check seems
+			// irrelevant since we have already used the cobra.ExactArgs.
+			if len(args) > 0 {
+				opts.Username = args[0]
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return listRun(opts)
+		},
+	}
+
+	return cmd
+}
+
+func listRun(opts *ListOptions) error {
+	client, err := opts.HttpClient()
+	if err != nil {
+		return fmt.Errorf("could not create http client: %w", err)
+	}
+
+	cfg, err := opts.Config()
+	if err != nil {
+		return err
+	}
+
+	username := opts.Username
+
+	hostname, _ := cfg.Authentication().DefaultHost()
+	sponsors, err := listSponsors(client, hostname, username, defaultListLimit)
+	if err != nil {
+		return err
+	}
+
+	if len(sponsors) == 0 {
+		return cmdutil.NewNoResultsError("no sponsor found")
+	}
+
+	headers := []string{"Sponsor"}
+	table := tableprinter.New(opts.IO, tableprinter.WithHeader(headers...))
+	for _, sponsor := range sponsors {
+		table.AddField(sponsor)
+		table.EndRow()
+	}
+
+	err = table.Render()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listSponsors(httpClient *http.Client, hostname string, username string, limit uint) ([]string, error) {
+	var query struct {
+		User struct {
+			Sponsors struct {
+				Edges []struct {
+					Node struct {
+						User struct {
+							Login graphql.String
+						} `graphql:"... on User"`
+						Org struct {
+							Login graphql.String
+						} `graphql:"... on Organization"`
+					}
+				}
+			} `graphql:"sponsors(first: $limit, orderBy: { direction: ASC, field: LOGIN })"`
+		} `graphql:"user(login: $login)"`
+	}
+
+	client := api.NewClientFromHTTP(httpClient)
+
+	variables := map[string]any{
+		"login": graphql.String(username),
+		"limit": graphql.Int(limit),
+	}
+
+	err := client.Query(hostname, "UserSponsorList", &query, variables)
+	if err != nil {
+		return nil, err
+	}
+
+	result := make([]string, 0, len(query.User.Sponsors.Edges))
+	for _, edge := range query.User.Sponsors.Edges {
+		if edge.Node.User.Login != "" {
+			result = append(result, string(edge.Node.User.Login))
+		} else if edge.Node.Org.Login != "" {
+			result = append(result, string(edge.Node.Org.Login))
+		}
+	}
+	return result, nil
+}
diff --git a/pkg/cmd/sponsors/list/list_test.go b/pkg/cmd/sponsors/list/list_test.go
new file mode 100644
index 00000000000..bd681b597ad
--- /dev/null
+++ b/pkg/cmd/sponsors/list/list_test.go
@@ -0,0 +1,205 @@
+package list
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/httpmock"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/google/shlex"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewCmdList(t *testing.T) {
+	tests := []struct {
+		name    string
+		cli     string
+		wants   ListOptions
+		wantErr string
+	}{
+		{
+			name:    "no arg",
+			cli:     "",
+			wantErr: "must specify username",
+		},
+		{
+			name: "normal",
+			cli:  "johndoe",
+			wants: ListOptions{
+				Username: "johndoe",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+			f := &cmdutil.Factory{
+				IOStreams: ios,
+			}
+
+			argv, err := shlex.Split(tt.cli)
+			assert.NoError(t, err)
+
+			var listOpts *ListOptions
+			cmd := NewCmdList(f, func(opts *ListOptions) error {
+				listOpts = opts
+				return nil
+			})
+			cmd.SetArgs(argv)
+			cmd.SetIn(&bytes.Buffer{})
+			cmd.SetOut(&bytes.Buffer{})
+			cmd.SetErr(&bytes.Buffer{})
+
+			_, err = cmd.ExecuteC()
+			if tt.wantErr != "" {
+				require.Equal(t, err.Error(), tt.wantErr)
+				return
+			}
+			require.NoError(t, err)
+
+			require.Equal(t, tt.wants.Username, listOpts.Username)
+		})
+	}
+}
+
+func Test_listRun(t *testing.T) {
+	tests := []struct {
+		name       string
+		tty        bool
+		opts       *ListOptions
+		httpStubs  func(*httpmock.Registry)
+		wantStdout []string
+		wantStderr string
+		wantErr    string
+	}{
+		{
+			name: "normal tty",
+			tty:  true,
+			opts: &ListOptions{
+				Username: "johndoe",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserSponsorList\b`),
+					httpmock.GraphQLQuery(`
+						{
+							"data": {
+								"user": {
+									"sponsors": {
+										"edges": [
+											{
+												"node": {
+													"login": "foo"
+												}
+											},
+											{
+												"node": {
+													"login": "bar"
+												}
+											}
+										]
+									}
+								}
+							}
+						}`,
+						func(_ string, inputs map[string]interface{}) {
+							assert.Equal(t, "johndoe", inputs["login"])
+							assert.Equal(t, float64(30), inputs["limit"])
+						},
+					),
+				)
+			},
+			wantStdout: []string{
+				"SPONSOR",
+				"foo",
+				"bar",
+			},
+		}, {
+			name: "normal tty, no sponsor",
+			tty:  true,
+			opts: &ListOptions{
+				Username: "johndoe",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserSponsorList\b`),
+					httpmock.GraphQLQuery(`
+						{
+							"data": {
+								"user": {
+									"sponsors": {
+										"edges": []
+									}
+								}
+							}
+						}`,
+						func(_ string, inputs map[string]interface{}) {
+							assert.Equal(t, "johndoe", inputs["login"])
+							assert.Equal(t, float64(30), inputs["limit"])
+						},
+					),
+				)
+			},
+			wantStderr: "no sponsor found\n",
+		}, {
+			name: "api error",
+			tty:  true,
+			opts: &ListOptions{
+				Username: "johndoe",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserSponsorList\b`),
+					httpmock.GraphQLQuery(
+						`{"data":{}, "errors": [{"message": "some gql error"}]}`,
+						func(query string, inputs map[string]interface{}) {},
+					),
+				)
+			},
+			wantErr: "GraphQL: some gql error",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reg := &httpmock.Registry{}
+			defer reg.Verify(t)
+
+			ios, _, stdout, stderr := iostreams.Test()
+
+			ios.SetStdoutTTY(tt.tty)
+
+			tt.opts.IO = ios
+			tt.opts.HttpClient = func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			}
+			tt.opts.Config = func() (gh.Config, error) {
+				return config.NewBlankConfig(), nil
+			}
+
+			tt.httpStubs(reg)
+
+			err := listRun(tt.opts)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+				return
+			}
+			require.NoError(t, err)
+
+			expectedStdout := ""
+			if len(tt.wantStdout) > 0 {
+				expectedStdout = fmt.Sprintf("%s\n", strings.Join(tt.wantStdout, "\n"))
+			}
+			assert.Equal(t, expectedStdout, stdout.String())
+			assert.Equal(t, tt.wantStderr, stderr.String())
+		})
+	}
+}
diff --git a/pkg/cmd/sponsors/sponsors.go b/pkg/cmd/sponsors/sponsors.go
new file mode 100644
index 00000000000..ca00882afb7
--- /dev/null
+++ b/pkg/cmd/sponsors/sponsors.go
@@ -0,0 +1,18 @@
+package sponsorsß
+
+import (
+	cmdList "github.com/cli/cli/v2/pkg/cmd/sponsors/list"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/spf13/cobra"
+)
+
+func NewCmdSponsors(f *cmdutil.Factory) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "sponsors <command>",
+		Short: "Manage sponsors",
+	}
+
+	cmd.AddCommand(cmdList.NewCmdList(f, nil))
+
+	return cmd
+}


Reviews:
user: login: williammartin
body: Looks really good! If this landed as PR on cli/cli, I would have only a few other minor bits of feedback.
state: APPROVED
author_association: NONE
submitted_at: 2025-03-25T19:26:19Z
user: login: babakks
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-25T19:31:25Z
user: login: williammartin
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-25T19:34:07Z
user: login: babakks
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-25T20:24:03Z
user: login: babakks
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-25T21:22:26Z
user: login: babakks
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-25T21:22:51Z
user: login: babakks
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-25T21:25:45Z

Review comments:
diff_hunk: @@ -0,0 +1,146 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+const defaultListLimit = 30
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (gh.Config, error)
+
+	Username string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "list <user>",
+		Short: "List sponsors",
+		Long: heredoc.Doc(`
+			List sponsors of a given user.
+		`),
+		Aliases: []string{"ls"},
+		Args:    cmdutil.ExactArgs(1, "must specify username"),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// This is for consistency with the other commands, but the check seems
path: pkg/cmd/sponsors/list/list.go
user: login: williammartin
body: **Praise**

Nice explanatory comment.
created_at: 2025-03-25T19:18:11Z
author_association: NONE
diff_hunk: @@ -0,0 +1,146 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+const defaultListLimit = 30
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (gh.Config, error)
+
+	Username string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "list <user>",
+		Short: "List sponsors",
+		Long: heredoc.Doc(`
+			List sponsors of a given user.
+		`),
+		Aliases: []string{"ls"},
+		Args:    cmdutil.ExactArgs(1, "must specify username"),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// This is for consistency with the other commands, but the check seems
+			// irrelevant since we have already used the cobra.ExactArgs.
+			if len(args) > 0 {
+				opts.Username = args[0]
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return listRun(opts)
+		},
+	}
+
+	return cmd
+}
+
+func listRun(opts *ListOptions) error {
+	client, err := opts.HttpClient()
+	if err != nil {
+		return fmt.Errorf("could not create http client: %w", err)
+	}
+
+	cfg, err := opts.Config()
+	if err != nil {
+		return err
+	}
+
+	username := opts.Username
+
+	hostname, _ := cfg.Authentication().DefaultHost()
+	sponsors, err := listSponsors(client, hostname, username, defaultListLimit)
+	if err != nil {
+		return err
+	}
+
+	if len(sponsors) == 0 {
path: pkg/cmd/sponsors/list/list.go
user: login: williammartin
body: **Learning Opportunity**

Totally fine, but as a learning exercise you might want to look at the `cmdutil.NoResultsError` and how it is handled.
created_at: 2025-03-25T19:19:44Z
author_association: NONE
diff_hunk: @@ -0,0 +1,146 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+const defaultListLimit = 30
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (gh.Config, error)
+
+	Username string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "list <user>",
+		Short: "List sponsors",
+		Long: heredoc.Doc(`
+			List sponsors of a given user.
+		`),
+		Aliases: []string{"ls"},
+		Args:    cmdutil.ExactArgs(1, "must specify username"),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// This is for consistency with the other commands, but the check seems
+			// irrelevant since we have already used the cobra.ExactArgs.
+			if len(args) > 0 {
+				opts.Username = args[0]
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return listRun(opts)
+		},
+	}
+
+	return cmd
+}
+
+func listRun(opts *ListOptions) error {
+	client, err := opts.HttpClient()
+	if err != nil {
+		return fmt.Errorf("could not create http client: %w", err)
+	}
+
+	cfg, err := opts.Config()
+	if err != nil {
+		return err
+	}
+
+	username := opts.Username
+
+	hostname, _ := cfg.Authentication().DefaultHost()
+	sponsors, err := listSponsors(client, hostname, username, defaultListLimit)
+	if err != nil {
+		return err
+	}
+
+	if len(sponsors) == 0 {
+		fmt.Fprintf(opts.IO.ErrOut, "no sponsor found\n")
+		return nil
+	}
+
+	headers := []string{"Sponsor"}
+	table := tableprinter.New(opts.IO, tableprinter.WithHeader(headers...))
+	for _, sponsor := range sponsors {
+		table.AddField(sponsor)
+		table.EndRow()
+	}
+
+	err = table.Render()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listSponsors(httpClient *http.Client, hostname string, username string, limit uint) ([]string, error) {
+	type response struct {
+		User struct {
+			Sponsors struct {
+				Edges []struct {
+					Node struct {
+						Login string
+					}
+				}
+			}
+		}
+	}
+
+	query := `query UserSponsorList($login: String!, $limit: Int!) {
path: pkg/cmd/sponsors/list/list.go
user: login: williammartin
body: **Learning Opportunity**

Totally fine to use a string query (the CLI does this in places as well). Did you also explore some of the places that use shurcool's struct magic to produce queries? Can you think of a reason we would use one over another?
created_at: 2025-03-25T19:21:46Z
author_association: NONE
diff_hunk: @@ -0,0 +1,205 @@
+package list
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/httpmock"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/google/shlex"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewCmdList(t *testing.T) {
+	tests := []struct {
+		name    string
+		cli     string
+		wants   ListOptions
+		wantErr string
+	}{
+		{
+			name:    "no arg",
+			cli:     "",
+			wantErr: "must specify username",
+		},
+		{
+			name: "normal",
+			cli:  "johndoe",
+			wants: ListOptions{
+				Username: "johndoe",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+			f := &cmdutil.Factory{
+				IOStreams: ios,
+			}
+
+			argv, err := shlex.Split(tt.cli)
+			assert.NoError(t, err)
+
+			var listOpts *ListOptions
+			cmd := NewCmdList(f, func(opts *ListOptions) error {
+				listOpts = opts
+				return nil
+			})
+			cmd.SetArgs(argv)
+			cmd.SetIn(&bytes.Buffer{})
+			cmd.SetOut(&bytes.Buffer{})
+			cmd.SetErr(&bytes.Buffer{})
+
+			_, err = cmd.ExecuteC()
+			if tt.wantErr != "" {
+				require.Equal(t, err.Error(), tt.wantErr)
+				return
+			}
+			require.NoError(t, err)
+
+			require.Equal(t, tt.wants.Username, listOpts.Username)
+		})
+	}
+}
+
+func Test_listRun(t *testing.T) {
+	tests := []struct {
+		name       string
+		tty        bool
+		opts       *ListOptions
+		httpStubs  func(*httpmock.Registry)
+		wantStdout []string
+		wantStderr string
+		wantErr    string
+	}{
+		{
+			name: "normal tty",
+			tty:  true,
+			opts: &ListOptions{
+				Username: "johndoe",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserSponsorList\b`),
+					httpmock.GraphQLQuery(`
+						{
+							"data": {
+								"user": {
+									"sponsors": {
+										"edges": [
+											{
+												"node": {
+													"login": "foo"
+												}
+											},
+											{
+												"node": {
+													"login": "bar"
+												}
+											}
+										]
+									}
+								}
+							}
+						}`,
+						func(_ string, inputs map[string]interface{}) {
+							assert.Equal(t, "johndoe", inputs["login"])
+							assert.Equal(t, float64(30), inputs["limit"])
+						},
+					),
+				)
+			},
+			wantStdout: []string{
+				"SPONSOR",
+				"foo",
+				"bar",
+			},
+		}, {
+			name: "normal tty, no sponsor",
+			tty:  true,
+			opts: &ListOptions{
+				Username: "johndoe",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserSponsorList\b`),
+					httpmock.GraphQLQuery(`
+						{
+							"data": {
+								"user": {
+									"sponsors": {
+										"edges": []
+									}
+								}
+							}
+						}`,
+						func(_ string, inputs map[string]interface{}) {
+							assert.Equal(t, "johndoe", inputs["login"])
+							assert.Equal(t, float64(30), inputs["limit"])
+						},
+					),
+				)
+			},
+			wantStderr: "no sponsor found\n",
+		}, {
+			name: "api error",
+			tty:  true,
+			opts: &ListOptions{
+				Username: "johndoe",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserSponsorList\b`),
+					httpmock.GraphQLQuery(
+						`{"data":{}, "errors": [{"message": "some gql error"}]}`,
+						func(query string, inputs map[string]interface{}) {},
+					),
+				)
+			},
+			wantErr: "GraphQL: some gql error",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reg := &httpmock.Registry{}
+			defer reg.Verify(t)
+
+			ios, _, stdout, stderr := iostreams.Test()
+
+			ios.SetStdoutTTY(tt.tty)
+
+			tt.opts.IO = ios
+			tt.opts.HttpClient = func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			}
+			tt.opts.Config = func() (gh.Config, error) {
+				return config.NewBlankConfig(), nil
+			}
+
+			tt.httpStubs(reg)
+
+			err := listRun(tt.opts)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+				return
+			}
+			require.Nil(t, err)
path: pkg/cmd/sponsors/list/list_test.go
user: login: williammartin
body: **Nit**

Minor nit that I would expect `require.NoError` here for clarity.
created_at: 2025-03-25T19:25:04Z
author_association: NONE
diff_hunk: @@ -0,0 +1,146 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+const defaultListLimit = 30
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (gh.Config, error)
+
+	Username string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "list <user>",
+		Short: "List sponsors",
+		Long: heredoc.Doc(`
+			List sponsors of a given user.
+		`),
+		Aliases: []string{"ls"},
+		Args:    cmdutil.ExactArgs(1, "must specify username"),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// This is for consistency with the other commands, but the check seems
+			// irrelevant since we have already used the cobra.ExactArgs.
+			if len(args) > 0 {
+				opts.Username = args[0]
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return listRun(opts)
+		},
+	}
+
+	return cmd
+}
+
+func listRun(opts *ListOptions) error {
+	client, err := opts.HttpClient()
+	if err != nil {
+		return fmt.Errorf("could not create http client: %w", err)
+	}
+
+	cfg, err := opts.Config()
+	if err != nil {
+		return err
+	}
+
+	username := opts.Username
+
+	hostname, _ := cfg.Authentication().DefaultHost()
+	sponsors, err := listSponsors(client, hostname, username, defaultListLimit)
+	if err != nil {
+		return err
+	}
+
+	if len(sponsors) == 0 {
+		fmt.Fprintf(opts.IO.ErrOut, "no sponsor found\n")
+		return nil
+	}
+
+	headers := []string{"Sponsor"}
+	table := tableprinter.New(opts.IO, tableprinter.WithHeader(headers...))
+	for _, sponsor := range sponsors {
+		table.AddField(sponsor)
+		table.EndRow()
+	}
+
+	err = table.Render()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listSponsors(httpClient *http.Client, hostname string, username string, limit uint) ([]string, error) {
+	type response struct {
+		User struct {
+			Sponsors struct {
+				Edges []struct {
+					Node struct {
+						Login string
+					}
+				}
+			}
+		}
+	}
+
+	query := `query UserSponsorList($login: String!, $limit: Int!) {
path: pkg/cmd/sponsors/list/list.go
user: login: babakks
body: Yeah I know the library, but somehow I forgot to use it, as a better approach. I'll replace this with the library (in case it already supports the underlying queries/interfaces, which it probably does).
created_at: 2025-03-25T19:31:25Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,146 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+const defaultListLimit = 30
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (gh.Config, error)
+
+	Username string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "list <user>",
+		Short: "List sponsors",
+		Long: heredoc.Doc(`
+			List sponsors of a given user.
+		`),
+		Aliases: []string{"ls"},
+		Args:    cmdutil.ExactArgs(1, "must specify username"),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// This is for consistency with the other commands, but the check seems
+			// irrelevant since we have already used the cobra.ExactArgs.
+			if len(args) > 0 {
+				opts.Username = args[0]
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return listRun(opts)
+		},
+	}
+
+	return cmd
+}
+
+func listRun(opts *ListOptions) error {
+	client, err := opts.HttpClient()
+	if err != nil {
+		return fmt.Errorf("could not create http client: %w", err)
+	}
+
+	cfg, err := opts.Config()
+	if err != nil {
+		return err
+	}
+
+	username := opts.Username
+
+	hostname, _ := cfg.Authentication().DefaultHost()
+	sponsors, err := listSponsors(client, hostname, username, defaultListLimit)
+	if err != nil {
+		return err
+	}
+
+	if len(sponsors) == 0 {
+		fmt.Fprintf(opts.IO.ErrOut, "no sponsor found\n")
+		return nil
+	}
+
+	headers := []string{"Sponsor"}
+	table := tableprinter.New(opts.IO, tableprinter.WithHeader(headers...))
+	for _, sponsor := range sponsors {
+		table.AddField(sponsor)
+		table.EndRow()
+	}
+
+	err = table.Render()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listSponsors(httpClient *http.Client, hostname string, username string, limit uint) ([]string, error) {
+	type response struct {
+		User struct {
+			Sponsors struct {
+				Edges []struct {
+					Node struct {
+						Login string
+					}
+				}
+			}
+		}
+	}
+
+	query := `query UserSponsorList($login: String!, $limit: Int!) {
path: pkg/cmd/sponsors/list/list.go
user: login: williammartin
body: I don't mean shurcool/github, just shurcool/graphql, which we use in other places. For example: https://github.com/cli/cli/blob/trunk/api/queries_projects_v2.go#L64

There are advantages and disadvantages, hence my question:

> Can you think of a reason we would use one over another?
created_at: 2025-03-25T19:34:07Z
author_association: NONE
diff_hunk: @@ -0,0 +1,146 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+const defaultListLimit = 30
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (gh.Config, error)
+
+	Username string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "list <user>",
+		Short: "List sponsors",
+		Long: heredoc.Doc(`
+			List sponsors of a given user.
+		`),
+		Aliases: []string{"ls"},
+		Args:    cmdutil.ExactArgs(1, "must specify username"),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// This is for consistency with the other commands, but the check seems
+			// irrelevant since we have already used the cobra.ExactArgs.
+			if len(args) > 0 {
+				opts.Username = args[0]
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return listRun(opts)
+		},
+	}
+
+	return cmd
+}
+
+func listRun(opts *ListOptions) error {
+	client, err := opts.HttpClient()
+	if err != nil {
+		return fmt.Errorf("could not create http client: %w", err)
+	}
+
+	cfg, err := opts.Config()
+	if err != nil {
+		return err
+	}
+
+	username := opts.Username
+
+	hostname, _ := cfg.Authentication().DefaultHost()
+	sponsors, err := listSponsors(client, hostname, username, defaultListLimit)
+	if err != nil {
+		return err
+	}
+
+	if len(sponsors) == 0 {
+		fmt.Fprintf(opts.IO.ErrOut, "no sponsor found\n")
+		return nil
+	}
+
+	headers := []string{"Sponsor"}
+	table := tableprinter.New(opts.IO, tableprinter.WithHeader(headers...))
+	for _, sponsor := range sponsors {
+		table.AddField(sponsor)
+		table.EndRow()
+	}
+
+	err = table.Render()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listSponsors(httpClient *http.Client, hostname string, username string, limit uint) ([]string, error) {
+	type response struct {
+		User struct {
+			Sponsors struct {
+				Edges []struct {
+					Node struct {
+						Login string
+					}
+				}
+			}
+		}
+	}
+
+	query := `query UserSponsorList($login: String!, $limit: Int!) {
path: pkg/cmd/sponsors/list/list.go
user: login: babakks
body: Oh, right.

As it seems, the `shurcool/graphql` works with field tags, which makes playing with strings a bit safer, but you cannot easily use them for complex/dynamically generated queries. Is that correct?

Also, what if a subquery is parameterized? It probably works, though.
created_at: 2025-03-25T20:24:03Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,146 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+const defaultListLimit = 30
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (gh.Config, error)
+
+	Username string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "list <user>",
+		Short: "List sponsors",
+		Long: heredoc.Doc(`
+			List sponsors of a given user.
+		`),
+		Aliases: []string{"ls"},
+		Args:    cmdutil.ExactArgs(1, "must specify username"),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// This is for consistency with the other commands, but the check seems
+			// irrelevant since we have already used the cobra.ExactArgs.
+			if len(args) > 0 {
+				opts.Username = args[0]
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return listRun(opts)
+		},
+	}
+
+	return cmd
+}
+
+func listRun(opts *ListOptions) error {
+	client, err := opts.HttpClient()
+	if err != nil {
+		return fmt.Errorf("could not create http client: %w", err)
+	}
+
+	cfg, err := opts.Config()
+	if err != nil {
+		return err
+	}
+
+	username := opts.Username
+
+	hostname, _ := cfg.Authentication().DefaultHost()
+	sponsors, err := listSponsors(client, hostname, username, defaultListLimit)
+	if err != nil {
+		return err
+	}
+
+	if len(sponsors) == 0 {
path: pkg/cmd/sponsors/list/list.go
user: login: babakks
body: Done.
created_at: 2025-03-25T21:22:26Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,146 @@
+package list
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/tableprinter"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/spf13/cobra"
+)
+
+const defaultListLimit = 30
+
+type ListOptions struct {
+	HttpClient func() (*http.Client, error)
+	IO         *iostreams.IOStreams
+	Config     func() (gh.Config, error)
+
+	Username string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+	opts := &ListOptions{
+		IO:         f.IOStreams,
+		Config:     f.Config,
+		HttpClient: f.HttpClient,
+	}
+
+	cmd := &cobra.Command{
+		Use:   "list <user>",
+		Short: "List sponsors",
+		Long: heredoc.Doc(`
+			List sponsors of a given user.
+		`),
+		Aliases: []string{"ls"},
+		Args:    cmdutil.ExactArgs(1, "must specify username"),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			// This is for consistency with the other commands, but the check seems
+			// irrelevant since we have already used the cobra.ExactArgs.
+			if len(args) > 0 {
+				opts.Username = args[0]
+			}
+
+			if runF != nil {
+				return runF(opts)
+			}
+
+			return listRun(opts)
+		},
+	}
+
+	return cmd
+}
+
+func listRun(opts *ListOptions) error {
+	client, err := opts.HttpClient()
+	if err != nil {
+		return fmt.Errorf("could not create http client: %w", err)
+	}
+
+	cfg, err := opts.Config()
+	if err != nil {
+		return err
+	}
+
+	username := opts.Username
+
+	hostname, _ := cfg.Authentication().DefaultHost()
+	sponsors, err := listSponsors(client, hostname, username, defaultListLimit)
+	if err != nil {
+		return err
+	}
+
+	if len(sponsors) == 0 {
+		fmt.Fprintf(opts.IO.ErrOut, "no sponsor found\n")
+		return nil
+	}
+
+	headers := []string{"Sponsor"}
+	table := tableprinter.New(opts.IO, tableprinter.WithHeader(headers...))
+	for _, sponsor := range sponsors {
+		table.AddField(sponsor)
+		table.EndRow()
+	}
+
+	err = table.Render()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listSponsors(httpClient *http.Client, hostname string, username string, limit uint) ([]string, error) {
+	type response struct {
+		User struct {
+			Sponsors struct {
+				Edges []struct {
+					Node struct {
+						Login string
+					}
+				}
+			}
+		}
+	}
+
+	query := `query UserSponsorList($login: String!, $limit: Int!) {
path: pkg/cmd/sponsors/list/list.go
user: login: babakks
body: Done. It's much nicer that the way I did.
created_at: 2025-03-25T21:22:51Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,205 @@
+package list
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/cli/cli/v2/internal/config"
+	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/pkg/cmdutil"
+	"github.com/cli/cli/v2/pkg/httpmock"
+	"github.com/cli/cli/v2/pkg/iostreams"
+	"github.com/google/shlex"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewCmdList(t *testing.T) {
+	tests := []struct {
+		name    string
+		cli     string
+		wants   ListOptions
+		wantErr string
+	}{
+		{
+			name:    "no arg",
+			cli:     "",
+			wantErr: "must specify username",
+		},
+		{
+			name: "normal",
+			cli:  "johndoe",
+			wants: ListOptions{
+				Username: "johndoe",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ios, _, _, _ := iostreams.Test()
+			f := &cmdutil.Factory{
+				IOStreams: ios,
+			}
+
+			argv, err := shlex.Split(tt.cli)
+			assert.NoError(t, err)
+
+			var listOpts *ListOptions
+			cmd := NewCmdList(f, func(opts *ListOptions) error {
+				listOpts = opts
+				return nil
+			})
+			cmd.SetArgs(argv)
+			cmd.SetIn(&bytes.Buffer{})
+			cmd.SetOut(&bytes.Buffer{})
+			cmd.SetErr(&bytes.Buffer{})
+
+			_, err = cmd.ExecuteC()
+			if tt.wantErr != "" {
+				require.Equal(t, err.Error(), tt.wantErr)
+				return
+			}
+			require.NoError(t, err)
+
+			require.Equal(t, tt.wants.Username, listOpts.Username)
+		})
+	}
+}
+
+func Test_listRun(t *testing.T) {
+	tests := []struct {
+		name       string
+		tty        bool
+		opts       *ListOptions
+		httpStubs  func(*httpmock.Registry)
+		wantStdout []string
+		wantStderr string
+		wantErr    string
+	}{
+		{
+			name: "normal tty",
+			tty:  true,
+			opts: &ListOptions{
+				Username: "johndoe",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserSponsorList\b`),
+					httpmock.GraphQLQuery(`
+						{
+							"data": {
+								"user": {
+									"sponsors": {
+										"edges": [
+											{
+												"node": {
+													"login": "foo"
+												}
+											},
+											{
+												"node": {
+													"login": "bar"
+												}
+											}
+										]
+									}
+								}
+							}
+						}`,
+						func(_ string, inputs map[string]interface{}) {
+							assert.Equal(t, "johndoe", inputs["login"])
+							assert.Equal(t, float64(30), inputs["limit"])
+						},
+					),
+				)
+			},
+			wantStdout: []string{
+				"SPONSOR",
+				"foo",
+				"bar",
+			},
+		}, {
+			name: "normal tty, no sponsor",
+			tty:  true,
+			opts: &ListOptions{
+				Username: "johndoe",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserSponsorList\b`),
+					httpmock.GraphQLQuery(`
+						{
+							"data": {
+								"user": {
+									"sponsors": {
+										"edges": []
+									}
+								}
+							}
+						}`,
+						func(_ string, inputs map[string]interface{}) {
+							assert.Equal(t, "johndoe", inputs["login"])
+							assert.Equal(t, float64(30), inputs["limit"])
+						},
+					),
+				)
+			},
+			wantStderr: "no sponsor found\n",
+		}, {
+			name: "api error",
+			tty:  true,
+			opts: &ListOptions{
+				Username: "johndoe",
+			},
+			httpStubs: func(reg *httpmock.Registry) {
+				reg.Register(
+					httpmock.GraphQL(`query UserSponsorList\b`),
+					httpmock.GraphQLQuery(
+						`{"data":{}, "errors": [{"message": "some gql error"}]}`,
+						func(query string, inputs map[string]interface{}) {},
+					),
+				)
+			},
+			wantErr: "GraphQL: some gql error",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reg := &httpmock.Registry{}
+			defer reg.Verify(t)
+
+			ios, _, stdout, stderr := iostreams.Test()
+
+			ios.SetStdoutTTY(tt.tty)
+
+			tt.opts.IO = ios
+			tt.opts.HttpClient = func() (*http.Client, error) {
+				return &http.Client{Transport: reg}, nil
+			}
+			tt.opts.Config = func() (gh.Config, error) {
+				return config.NewBlankConfig(), nil
+			}
+
+			tt.httpStubs(reg)
+
+			err := listRun(tt.opts)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+				return
+			}
+			require.Nil(t, err)
path: pkg/cmd/sponsors/list/list_test.go
user: login: babakks
body: Done.
created_at: 2025-03-25T21:25:44Z
author_association: OWNER

PR comments:
user: login: williammartin
created_at: 2025-03-25T19:27:37Z
body: Minor feedback on PR description Acceptance Criteria, it's nice if you embed the given,when,then and demonstrate the scenarios, to help frame the UX for a reviewer who maybe isn't so familiar with the original issue.
author_association: NONE
user: login: babakks
created_at: 2025-03-25T21:30:50Z
body: > Minor feedback on PR description Acceptance Criteria, it's nice if you embed the given,when,then and demonstrate the scenarios, to help frame the UX for a reviewer who maybe isn't so familiar with the original issue.

Thanks for the hint. I just updated the PR description.
author_association: OWNER