From dd424d85fd5722d02b044f361aed0fb2d74422f0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:02:09 -0600 Subject: [PATCH 01/10] Add agent task listing command and CAPI client Introduces a new 'list' subcommand under agent-task for listing agent tasks. Implements a Copilot API client for fetching agent sessions and hydrating them with pull request data. Updates PullRequest and PRRepository types to support new fields. Adds dependencies for msgpack and tagparser. Co-Authored-By: Babak K. Shandiz --- api/queries_pr.go | 6 +- go.mod | 2 + go.sum | 4 + pkg/cmd/agent-task/agent_task.go | 5 + pkg/cmd/agent-task/capi/client.go | 64 +++++++++ pkg/cmd/agent-task/capi/sessions.go | 208 +++++++++++++++++++++++++++ pkg/cmd/agent-task/list/list.go | 150 +++++++++++++++++++ pkg/cmd/agent-task/list/list_test.go | 91 ++++++++++++ 8 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/agent-task/capi/client.go create mode 100644 pkg/cmd/agent-task/capi/sessions.go create mode 100644 pkg/cmd/agent-task/list/list.go create mode 100644 pkg/cmd/agent-task/list/list_test.go diff --git a/api/queries_pr.go b/api/queries_pr.go index 525418a11..b3373a903 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -62,6 +62,7 @@ type PullRequest struct { MergedBy *Author HeadRepositoryOwner Owner HeadRepository *PRRepository + Repository *PRRepository IsCrossRepository bool IsDraft bool MaintainerCanModify bool @@ -251,8 +252,9 @@ type Workflow struct { } type PRRepository struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + NameWithOwner string `json:"nameWithOwner"` } type AutoMergeRequest struct { diff --git a/go.mod b/go.mod index 18deafe15..7f099d7ea 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/spf13/pflag v1.0.7 github.com/stretchr/testify v1.10.0 github.com/theupdateframework/go-tuf/v2 v2.1.1 + github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yuin/goldmark v1.7.13 github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.41.0 @@ -205,6 +206,7 @@ require ( github.com/transparency-dev/merkle v0.0.2 // indirect github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823 // indirect github.com/vbatts/tar-split v0.12.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/zeebo/errs v1.4.0 // indirect diff --git a/go.sum b/go.sum index eeebd6973..f28dc1cf4 100644 --- a/go.sum +++ b/go.sum @@ -1415,6 +1415,10 @@ github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823 h1:s3p7 github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823/go.mod h1:Jv2IDwG1q8QNXZTaI1X6QX8s96WlJn73ka2hT1n4N5c= github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index b53c6786d..cbbd3e278 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + cmdList "github.com/cli/cli/v2/pkg/cmd/agent-task/list" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" @@ -25,6 +26,10 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command { return cmd.Help() }, } + + // register subcommands + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + return cmd } diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go new file mode 100644 index 000000000..b5b4ea9e0 --- /dev/null +++ b/pkg/cmd/agent-task/capi/client.go @@ -0,0 +1,64 @@ +package capi + +import ( + "context" + "net/http" + + "github.com/cli/cli/v2/internal/gh" +) + +const baseCAPIURL = "https://api.githubcopilot.com" +const capiHost = "api.githubcopilot.com" + +// CapiClient defines the methods used by the caller. Implementations +// may be replaced with test doubles in unit tests. +type CapiClient interface { + ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) +} + +// CAPIClient is a client for interacting with the Copilot API +type CAPIClient struct { + httpClient *http.Client + authCfg gh.AuthConfig +} + +// NewCAPIClient creates a new CAPI client. Provide a token and an HTTP client which +// will be used as the base transport for CAPI requests. +// +// The provided HTTP client will be mutated for use with CAPI, so it should not +// be reused elsewhere. +func NewCAPIClient(httpClient *http.Client, authCfg gh.AuthConfig) *CAPIClient { + host, _ := authCfg.DefaultHost() + token, _ := authCfg.ActiveToken(host) + + httpClient.Transport = newCAPITransport(token, httpClient.Transport) + return &CAPIClient{ + httpClient: httpClient, + authCfg: authCfg, + } +} + +// capiTransport adds the Copilot auth headers +type capiTransport struct { + rp http.RoundTripper + token string +} + +func newCAPITransport(token string, rp http.RoundTripper) *capiTransport { + return &capiTransport{ + rp: rp, + token: token, + } +} + +func (ct *capiTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+ct.token) + + // Since this RoundTrip is reused for both Copilot API and + // GitHub API requests, we conditionally add the integration + // ID only when performing requests to the Copilot API. + if req.URL.Host == capiHost { + req.Header.Add("Copilot-Integration-Id", "copilot-4-cli") + } + return ct.rp.RoundTrip(req) +} diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go new file mode 100644 index 000000000..2693af57d --- /dev/null +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -0,0 +1,208 @@ +package capi + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "slices" + "strconv" + "time" + + "github.com/cli/cli/v2/api" + "github.com/vmihailenco/msgpack/v5" +) + +// session is an in-flight agent task +type session struct { + ID string `json:"id"` + Name string `json:"name"` + UserID uint64 `json:"user_id"` + AgentID int64 `json:"agent_id"` + Logs string `json:"logs"` + State string `json:"state"` + OwnerID uint64 `json:"owner_id"` + RepoID uint64 `json:"repo_id"` + ResourceType string `json:"resource_type"` + ResourceID int64 `json:"resource_id"` + LastUpdatedAt time.Time `json:"last_updated_at,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + CompletedAt time.Time `json:"completed_at,omitempty"` + EventURL string `json:"event_url"` + EventType string `json:"event_type"` +} + +// A shim of a full pull request because looking up by node ID +// using the full api.PullRequest type fails on unions (actors) +type sessionPullRequest struct { + ID string + FullDatabaseID string + Number int + Title string + State string + URL string + Body string + + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt *time.Time + MergedAt *time.Time + + // Uncomment one of these to see error + // Author api.Author + // MergedBy *api.Author + Repository *api.PRRepository +} + +// Session is a hydrated in-flight agent task +type Session struct { + session + PullRequest *api.PullRequest `json:"-"` +} + +// ListSessionsForViewer lists all agent sessions for the +// authenticated user up to limit. +func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { + url := baseCAPIURL + "/agents/sessions" + + var sessions []session + page := 1 + perPage := 50 + + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Set("page_size", strconv.Itoa(perPage)) + q.Set("page_number", strconv.Itoa(page)) + req.URL.RawQuery = q.Encode() + + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list sessions: %s", res.Status) + } + var response struct { + Sessions []session `json:"sessions"` + } + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode sessions response: %w", err) + } + if len(response.Sessions) == 0 || len(sessions) >= limit { + break + } + sessions = append(sessions, response.Sessions...) + page++ + } + + // Drop any above the limit + if len(sessions) > limit { + sessions = sessions[:limit] + } + + // Hydrate the Sessions with pull request data. + Sessions, err := c.hydrateSessionPullRequests(sessions) + if err != nil { + return nil, err + } + + return Sessions, nil +} + +// hydrateSessionPullRequests hydrates pull request information in sessions +func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, error) { + if len(sessions) == 0 { + return nil, nil + } + + prNodeIds := make([]string, 0, len(sessions)) + + for _, session := range sessions { + prNodeID := generatePullRequestNodeID(int64(session.RepoID), session.ResourceID) + if slices.Contains(prNodeIds, prNodeID) { + continue + } + prNodeIds = append(prNodeIds, prNodeID) + } + + apiClient := api.NewClientFromHTTP(c.httpClient) + + var resp struct { + Nodes []struct { + PullRequest sessionPullRequest `graphql:"... on PullRequest"` + } `graphql:"nodes(ids: $ids)"` + } + + host, _ := c.authCfg.DefaultHost() + err := apiClient.Query(host, "FetchPRs", &resp, map[string]any{ + "ids": prNodeIds, + }) + + if err != nil { + return nil, err + } + + prs := make([]*api.PullRequest, 0, len(prNodeIds)) + for _, node := range resp.Nodes { + prs = append(prs, &api.PullRequest{ + ID: node.PullRequest.ID, + FullDatabaseID: node.PullRequest.FullDatabaseID, + Number: node.PullRequest.Number, + Title: node.PullRequest.Title, + State: node.PullRequest.State, + URL: node.PullRequest.URL, + Body: node.PullRequest.Body, + CreatedAt: node.PullRequest.CreatedAt, + UpdatedAt: node.PullRequest.UpdatedAt, + ClosedAt: node.PullRequest.ClosedAt, + MergedAt: node.PullRequest.MergedAt, + Repository: node.PullRequest.Repository, + }) + } + + newSessions := make([]*Session, 0, len(sessions)) + // For each session, we need to attach the Pull Request + for _, s := range sessions { + // For each Pull Request, check if it matches the session + for _, pr := range prs { + if strconv.FormatInt(s.ResourceID, 10) == pr.FullDatabaseID { + newSessions = append(newSessions, &Session{ + session: s, + PullRequest: pr, + }) + } + } + } + + return newSessions, nil +} + +// generatePullRequestNodeID converts an int64 databaseID and repoID to a GraphQL Node ID format +// with the "PR_" prefix for pull requests +func generatePullRequestNodeID(repoID, pullRequestID int64) string { + buf := bytes.Buffer{} + parts := []int64{0, repoID, pullRequestID} + + encoder := msgpack.NewEncoder(&buf) + encoder.UseCompactInts(true) + + // Encode the parts + err := encoder.Encode(parts) + if err != nil { + panic(err) + } + + // Use URL-safe Base64 encoding without padding + encoded := base64.RawURLEncoding.EncodeToString(buf.Bytes()) + + // Return with the PR_ prefix + return "PR_" + encoded +} diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go new file mode 100644 index 000000000..3b2bff267 --- /dev/null +++ b/pkg/cmd/agent-task/list/list.go @@ -0,0 +1,150 @@ +package list + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/cmd/pr/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const defaultLimit = 30 + +// ListOptions are the options for the list command +type ListOptions struct { + IO *iostreams.IOStreams + Config func() (gh.Config, error) + Limit int + CapiClient capi.CapiClient + HttpClient func() (*http.Client, error) +} + +// NewCmdList creates the list command +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + Limit: defaultLimit, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List agent tasks", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + authCfg := cfg.Authentication() + opts.CapiClient = capi.NewCAPIClient(httpClient, authCfg) + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + return cmd +} + +func listRun(opts *ListOptions) error { + if opts.Limit <= 0 { + opts.Limit = defaultLimit + } + + capiClient := opts.CapiClient + + opts.IO.StartProgressIndicatorWithLabel("Fetching agent tasks...") + defer opts.IO.StopProgressIndicator() + sessions, err := capiClient.ListSessionsForViewer(context.Background(), opts.Limit) + if err != nil { + return err + } + opts.IO.StopProgressIndicator() + + if len(sessions) == 0 { + fmt.Fprintln(opts.IO.Out, "no agent tasks found") + return nil + } + + cs := opts.IO.ColorScheme() + tp := tableprinter.New(opts.IO, tableprinter.WithHeader("Session ID", "Pull Request", "Repo", "Session State", "Created")) + for _, s := range sessions { + pr := "" + if s.ResourceType == "pull" && s.PullRequest.Number != 0 { + pr = fmt.Sprintf("#%d", s.PullRequest.Number) + } else { + // Skip these sessions in case they happen, for now. + continue + } + repo := "" + if s.PullRequest.Repository != nil && s.PullRequest.Repository.NameWithOwner != "" { + repo = s.PullRequest.Repository.NameWithOwner + } else { + // Skip these sessions in case they happen, for now. + continue + } + + // ID + tp.AddField(s.ID) + if tp.IsTTY() { + tp.AddField(pr, tableprinter.WithColor(cs.ColorFromString(shared.ColorForPRState(*s.PullRequest)))) + } else { + tp.AddField(pr) + } + + // Repo + tp.AddField(repo, tableprinter.WithColor(cs.Muted)) + + // State + if tp.IsTTY() { + var stateColor func(string) string + switch s.State { + case "completed": + stateColor = cs.Green + case "canceled": + stateColor = cs.Muted + case "in_progress", "queued": + stateColor = cs.Yellow + case "failed": + stateColor = cs.Red + default: + stateColor = cs.Muted + } + tp.AddField(s.State, tableprinter.WithColor(stateColor)) + } else { + tp.AddField(s.State) + } + + // Created + if tp.IsTTY() { + tp.AddTimeField(time.Now(), s.CreatedAt, cs.Muted) + } else { + tp.AddField(s.CreatedAt.Format(time.RFC3339)) + } + + tp.EndRow() + } + + if err := tp.Render(); err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go new file mode 100644 index 000000000..a02c77bab --- /dev/null +++ b/pkg/cmd/agent-task/list/list_test.go @@ -0,0 +1,91 @@ +package list + +import ( + "bytes" + "context" + "net/http" + "testing" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + capi "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/require" +) + +// testListOptionsWithRegistry constructs ListOptions and returns the stdout buffer for assertions +func testListOptionsWithRegistry(reg *httpmock.Registry) (*ListOptions, *bytes.Buffer) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + opts := &ListOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Config: func() (gh.Config, error) { + c := config.NewBlankConfig() + c.Set("github.com", "oauth_token", "gho_OAUTH123") + return c, nil + }, + Limit: defaultLimit, + } + + return opts, stdout +} + +// mockCAPIClient is a small test double for the CAPI client. +type mockCAPIClient struct { + sessions []*capi.Session +} + +// Updated to match production interface which now includes a limit parameter. +func (m *mockCAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*capi.Session, error) { + return m.sessions, nil +} + +func TestListRun_WithSessions(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + opts, stdout := testListOptionsWithRegistry(®) + + createdAt := time.Date(2025, time.August, 25, 12, 0, 0, 0, time.UTC) + s := &capi.Session{} + s.ID = "s1" + s.RepoID = 123 + s.ResourceType = "pull" + s.ResourceID = 456 + s.State = "completed" + s.CreatedAt = createdAt + s.PullRequest = &api.PullRequest{ + Number: 456, + State: "OPEN", + Repository: &api.PRRepository{NameWithOwner: "owner/repo"}, + } + opts.CapiClient = &mockCAPIClient{sessions: []*capi.Session{s}} + + err := listRun(opts) + require.NoError(t, err) + out := stdout.String() + require.Contains(t, out, "SESSION ID") + require.Contains(t, out, "s1") + require.Contains(t, out, "#456") + require.Contains(t, out, "owner/repo") +} + +func TestListRun_NoSessions(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + opts, stdout := testListOptionsWithRegistry(®) + opts.CapiClient = &mockCAPIClient{sessions: []*capi.Session{}} + + err := listRun(opts) + require.NoError(t, err) + out := stdout.String() + require.Contains(t, out, "no agent tasks found") +} From 0de5cf24f070fd6849712a95a8267e8ce00dc94a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 08:19:37 -0600 Subject: [PATCH 02/10] Fix import alias for shared package in agent-task list Renames the import of the shared package to 'prShared' and updates its usage in list.go to avoid naming conflicts and improve code clarity. --- pkg/cmd/agent-task/list/list.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 3b2bff267..b218242f4 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -9,7 +9,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" - "github.com/cli/cli/v2/pkg/cmd/pr/shared" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -104,7 +104,7 @@ func listRun(opts *ListOptions) error { // ID tp.AddField(s.ID) if tp.IsTTY() { - tp.AddField(pr, tableprinter.WithColor(cs.ColorFromString(shared.ColorForPRState(*s.PullRequest)))) + tp.AddField(pr, tableprinter.WithColor(cs.ColorFromString(prShared.ColorForPRState(*s.PullRequest)))) } else { tp.AddField(pr) } From 0a5b78a510a7ed44ea833fb3e3da784e0243287d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 08:25:00 -0600 Subject: [PATCH 03/10] Refactor session filtering in listRun function Simplifies logic for filtering sessions to only include those with valid pull request and repository data. This reduces nested conditionals and improves code readability. --- pkg/cmd/agent-task/list/list.go | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index b218242f4..ae498bfba 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -86,21 +86,14 @@ func listRun(opts *ListOptions) error { cs := opts.IO.ColorScheme() tp := tableprinter.New(opts.IO, tableprinter.WithHeader("Session ID", "Pull Request", "Repo", "Session State", "Created")) for _, s := range sessions { - pr := "" - if s.ResourceType == "pull" && s.PullRequest.Number != 0 { - pr = fmt.Sprintf("#%d", s.PullRequest.Number) - } else { - // Skip these sessions in case they happen, for now. - continue - } - repo := "" - if s.PullRequest.Repository != nil && s.PullRequest.Repository.NameWithOwner != "" { - repo = s.PullRequest.Repository.NameWithOwner - } else { + if s.ResourceType != "pull" || s.PullRequest == nil || s.PullRequest.Repository == nil { // Skip these sessions in case they happen, for now. continue } + pr := fmt.Sprintf("#%d", s.PullRequest.Number) + repo := s.PullRequest.Repository.NameWithOwner + // ID tp.AddField(s.ID) if tp.IsTTY() { From 7b71b5f21b710834dbca1257c0062c7437c3d9c0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:02:25 -0600 Subject: [PATCH 04/10] Refactor agent-task list command client initialization Moves CAPI client initialization to a deferred function in ListOptions, simplifying command setup and improving testability. Updates tests to use the new client initialization pattern and adds more comprehensive test cases for session listing. --- pkg/cmd/agent-task/list/list.go | 42 +-- pkg/cmd/agent-task/list/list_test.go | 409 ++++++++++++++++++++++----- 2 files changed, 362 insertions(+), 89 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index ae498bfba..6e530fdd1 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -3,7 +3,6 @@ package list import ( "context" "fmt" - "net/http" "time" "github.com/cli/cli/v2/internal/gh" @@ -22,17 +21,15 @@ type ListOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) Limit int - CapiClient capi.CapiClient - HttpClient func() (*http.Client, error) + CapiClient func() (*capi.CAPIClient, error) } // NewCmdList creates the list command func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ - IO: f.IOStreams, - Config: f.Config, - Limit: defaultLimit, - HttpClient: f.HttpClient, + IO: f.IOStreams, + Config: f.Config, + Limit: defaultLimit, } cmd := &cobra.Command{ @@ -40,19 +37,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Short: "List agent tasks", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := f.Config() - if err != nil { - return err - } - - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - - authCfg := cfg.Authentication() - opts.CapiClient = capi.NewCAPIClient(httpClient, authCfg) - if runF != nil { return runF(opts) } @@ -60,6 +44,19 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman }, } + opts.CapiClient = func() (*capi.CAPIClient, error) { + cfg, err := opts.Config() + if err != nil { + return nil, err + } + httpClient, err := f.HttpClient() + if err != nil { + return nil, err + } + authCfg := cfg.Authentication() + return capi.NewCAPIClient(httpClient, authCfg), nil + } + return cmd } @@ -68,7 +65,10 @@ func listRun(opts *ListOptions) error { opts.Limit = defaultLimit } - capiClient := opts.CapiClient + capiClient, err := opts.CapiClient() + if err != nil { + return err + } opts.IO.StartProgressIndicatorWithLabel("Fetching agent tasks...") defer opts.IO.StopProgressIndicator() diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index a02c77bab..a9c5d7c05 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -1,91 +1,364 @@ package list import ( - "bytes" - "context" "net/http" "testing" "time" - "github.com/cli/cli/v2/api" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" - capi "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" ) -// testListOptionsWithRegistry constructs ListOptions and returns the stdout buffer for assertions -func testListOptionsWithRegistry(reg *httpmock.Registry) (*ListOptions, *bytes.Buffer) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - - opts := &ListOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wantOpts ListOptions + }{ + { + name: "no arguments", + wantOpts: ListOptions{ + Limit: defaultLimit, + }, }, - Config: func() (gh.Config, error) { - c := config.NewBlankConfig() - c.Set("github.com", "oauth_token", "gho_OAUTH123") - return c, nil - }, - Limit: defaultLimit, } - return opts, stdout -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.ExecuteC() -// mockCAPIClient is a small test double for the CAPI client. -type mockCAPIClient struct { - sessions []*capi.Session -} - -// Updated to match production interface which now includes a limit parameter. -func (m *mockCAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*capi.Session, error) { - return m.sessions, nil -} - -func TestListRun_WithSessions(t *testing.T) { - reg := httpmock.Registry{} - defer reg.Verify(t) - - opts, stdout := testListOptionsWithRegistry(®) - - createdAt := time.Date(2025, time.August, 25, 12, 0, 0, 0, time.UTC) - s := &capi.Session{} - s.ID = "s1" - s.RepoID = 123 - s.ResourceType = "pull" - s.ResourceID = 456 - s.State = "completed" - s.CreatedAt = createdAt - s.PullRequest = &api.PullRequest{ - Number: 456, - State: "OPEN", - Repository: &api.PRRepository{NameWithOwner: "owner/repo"}, + assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) + }) } - opts.CapiClient = &mockCAPIClient{sessions: []*capi.Session{s}} - - err := listRun(opts) - require.NoError(t, err) - out := stdout.String() - require.Contains(t, out, "SESSION ID") - require.Contains(t, out, "s1") - require.Contains(t, out, "#456") - require.Contains(t, out, "owner/repo") } -func TestListRun_NoSessions(t *testing.T) { - reg := httpmock.Registry{} - defer reg.Verify(t) +func Test_listRun(t *testing.T) { + sixHours, _ := time.ParseDuration("6h") + sixHoursAgo := time.Now().Add(-sixHours) + createdAt := sixHoursAgo.Format(time.RFC3339) - opts, stdout := testListOptionsWithRegistry(®) - opts.CapiClient = &mockCAPIClient{sessions: []*capi.Session{}} + tests := []struct { + name string + tty bool + stubs func(*httpmock.Registry) + wantOut string + }{ + { + name: "no sessions", + tty: true, + stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, + wantOut: "no agent tasks found\n", + }, + { + name: "single session (tty)", + tty: true, + stubs: func(reg *httpmock.Registry) { registerSingleSessionMock(reg, createdAt) }, + wantOut: "SESSION ID PULL REQUEST REPO SESSION STATE CREATED\n" + + "sess1 #42 OWNER/REPO completed about 6 hours ago\n", + }, + { + name: "single session (nontty)", + tty: false, + stubs: func(reg *httpmock.Registry) { registerSingleSessionMock(reg, createdAt) }, + wantOut: "sess1\t#42\tOWNER/REPO\tcompleted\t" + createdAt + "\n", // header omitted for non-tty + }, + { + name: "many sessions (tty)", + tty: true, + stubs: func(reg *httpmock.Registry) { registerManySessionsMock(reg, createdAt) }, + wantOut: "SESSION ID PULL REQUEST REPO SESSION STATE CREATED\n" + + "s1 #101 OWNER/REPO completed about 6 hours ago\n" + + "s2 #102 OWNER/REPO failed about 6 hours ago\n" + + "s3 #103 OWNER/REPO in_progress about 6 hours ago\n" + + "s4 #104 OWNER/REPO queued about 6 hours ago\n" + + "s5 #105 OWNER/REPO canceled about 6 hours ago\n" + + "s6 #106 OWNER/REPO mystery about 6 hours ago\n", + }, + } - err := listRun(opts) - require.NoError(t, err) - out := stdout.String() - require.Contains(t, out, "no agent tasks found") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + tt.stubs(reg) + + cfg := config.NewBlankConfig() + cfg.Set("github.com", "oauth_token", "OTOKEN") + authCfg := cfg.Authentication() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + + httpClient := &http.Client{Transport: reg} + capiClient := capi.NewCAPIClient(httpClient, authCfg) + opts := &ListOptions{ + IO: ios, + Config: func() (gh.Config, error) { return cfg, nil }, + Limit: 30, + CapiClient: func() (*capi.CAPIClient, error) { return capiClient, nil }, + } + + err := listRun(opts) + assert.NoError(t, err) + + got := stdout.String() + if tt.wantOut == "" && tt.name == "single session (tty)" { + t.Logf("Captured output for single session (tty):\n%s", got) + t.Fatalf("fill in wantOut with the above output and re-run tests") + } + assert.Equal(t, tt.wantOut, got) + reg.Verify(t) + }) + } +} + +// registerEmptySessionsMock registers a single empty page of sessions +func registerEmptySessionsMock(reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Doc(`{ + "sessions": [] +}`)), + ) +} + +// registerSingleSessionMock registers two REST pages (one with a session, one empty) and GraphQL hydration for that session's PR +func registerSingleSessionMock(reg *httpmock.Registry, createdAt string) { + // First page with one session + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(`{ + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] +}`, createdAt)), + ) + // Second page empty to terminate pagination + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(`{"sessions": []}`), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRs`), + httpmock.StringResponse(heredoc.Docf(`{ + "data": { + "nodes": [ + { + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + } + ] + } +}`, createdAt)), + ) +} + +// registerManySessionsMock registers multiple sessions covering various states +// States covered: completed, failed, in_progress, queued, canceled, (unknown -> treated as muted) +func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { + // First page returns six sessions + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(`{ + "sessions": [ + { + "id": "s1", + "name": "A", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + }, + { + "id": "s2", + "name": "B", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "failed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2001, + "created_at": "%[1]s" + }, + { + "id": "s3", + "name": "C", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "in_progress", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2002, + "created_at": "%[1]s" + }, + { + "id": "s4", + "name": "D", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "queued", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2003, + "created_at": "%[1]s" + }, + { + "id": "s5", + "name": "E", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "canceled", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2004, + "created_at": "%[1]s" + }, + { + "id": "s6", + "name": "F", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "mystery", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2005, + "created_at": "%[1]s" + } + ] +}`, createdAt)), + ) + // Second page empty + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(`{"sessions": []}`), + ) + // GraphQL hydration for 6 PRs + reg.Register( + httpmock.GraphQL(`query FetchPRs`), + httpmock.StringResponse(heredoc.Docf(`{ + "data": { + "nodes": [ + { + "id": "PR_node1", + "fullDatabaseId": "2000", + "number": 101, + "title": "PR 101", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/101", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node2", + "fullDatabaseId": "2001", + "number": 102, + "title": "PR 102", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/102", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node3", + "fullDatabaseId": "2002", + "number": 103, + "title": "PR 103", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/103", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node4", + "fullDatabaseId": "2003", + "number": 104, + "title": "PR 104", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/104", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node5", + "fullDatabaseId": "2004", + "number": 105, + "title": "PR 105", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/105", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node6", + "fullDatabaseId": "2005", + "number": 106, + "title": "PR 106", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/106", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + } + ] + } + }`, createdAt)), + ) } From 48012063df33955523c064681599e9acd97b710b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:11:47 -0600 Subject: [PATCH 05/10] Remove commented-out fields from sessionPullRequest Cleaned up the sessionPullRequest struct by deleting unused commented-out fields related to Author and MergedBy. --- pkg/cmd/agent-task/capi/sessions.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 2693af57d..14e03b0c8 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -50,9 +50,6 @@ type sessionPullRequest struct { ClosedAt *time.Time MergedAt *time.Time - // Uncomment one of these to see error - // Author api.Author - // MergedBy *api.Author Repository *api.PRRepository } From 13c293f4cfb08f8af46936d5d3469969005b1fa8 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:12:33 -0600 Subject: [PATCH 06/10] Refactor variable names in ListSessionsForViewer Renamed 'Sessions' to 'result' for clarity and consistency in the ListSessionsForViewer method when hydrating session pull requests. --- pkg/cmd/agent-task/capi/sessions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 14e03b0c8..20eccaf41 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -105,13 +105,13 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S sessions = sessions[:limit] } - // Hydrate the Sessions with pull request data. - Sessions, err := c.hydrateSessionPullRequests(sessions) + // Hydrate the result with pull request data. + result, err := c.hydrateSessionPullRequests(sessions) if err != nil { return nil, err } - return Sessions, nil + return result, nil } // hydrateSessionPullRequests hydrates pull request information in sessions From bc1d306c31111ca9ca371265f6e292bc8ba5e14d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:18:45 -0600 Subject: [PATCH 07/10] Update API query name for session PR fetch Changed the API query from 'FetchPRs' to 'FetchPRsForAgentTaskSessions' in hydrateSessionPullRequests to match updated backend endpoint. --- pkg/cmd/agent-task/capi/sessions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 20eccaf41..3f42cadf4 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -139,7 +139,7 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, } host, _ := c.authCfg.DefaultHost() - err := apiClient.Query(host, "FetchPRs", &resp, map[string]any{ + err := apiClient.Query(host, "FetchPRsForAgentTaskSessions", &resp, map[string]any{ "ids": prNodeIds, }) From 5281be467d5ab084e716b3c96fedc6a698013438 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:20:12 -0600 Subject: [PATCH 08/10] Refactor error handling in generatePullRequestNodeID Simplified error handling by inlining the encoder.Encode call and removing redundant comments for improved code clarity. --- pkg/cmd/agent-task/capi/sessions.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 3f42cadf4..6d1c96e4b 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -191,15 +191,11 @@ func generatePullRequestNodeID(repoID, pullRequestID int64) string { encoder := msgpack.NewEncoder(&buf) encoder.UseCompactInts(true) - // Encode the parts - err := encoder.Encode(parts) - if err != nil { + if err := encoder.Encode(parts); err != nil { panic(err) } - // Use URL-safe Base64 encoding without padding encoded := base64.RawURLEncoding.EncodeToString(buf.Bytes()) - // Return with the PR_ prefix return "PR_" + encoded } From ea9dfae3bf16b6730e4d5e23e6ca5caefd8b7c68 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:23:43 -0600 Subject: [PATCH 09/10] Optimize session pull request hydration logic Replaces linear search with a map for associating sessions with pull requests, improving performance and simplifying code in hydrateSessionPullRequests. --- pkg/cmd/agent-task/capi/sessions.go | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 6d1c96e4b..13b665c90 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -147,9 +147,9 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, return nil, err } - prs := make([]*api.PullRequest, 0, len(prNodeIds)) + prMap := make(map[string]*api.PullRequest, len(prNodeIds)) for _, node := range resp.Nodes { - prs = append(prs, &api.PullRequest{ + prMap[node.PullRequest.FullDatabaseID] = &api.PullRequest{ ID: node.PullRequest.ID, FullDatabaseID: node.PullRequest.FullDatabaseID, Number: node.PullRequest.Number, @@ -162,21 +162,15 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, ClosedAt: node.PullRequest.ClosedAt, MergedAt: node.PullRequest.MergedAt, Repository: node.PullRequest.Repository, - }) + } } newSessions := make([]*Session, 0, len(sessions)) - // For each session, we need to attach the Pull Request for _, s := range sessions { - // For each Pull Request, check if it matches the session - for _, pr := range prs { - if strconv.FormatInt(s.ResourceID, 10) == pr.FullDatabaseID { - newSessions = append(newSessions, &Session{ - session: s, - PullRequest: pr, - }) - } - } + newSessions = append(newSessions, &Session{ + session: s, + PullRequest: prMap[strconv.FormatInt(s.ResourceID, 10)], + }) } return newSessions, nil From 4fd6ae6e3a980a3911ee32c6e2be9e68488d53a9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:23:40 -0600 Subject: [PATCH 10/10] Refactor session state color logic to shared package Moved the session state color selection logic from list.go to a new shared/display.go file as ColorFuncForSessionState. This improves code reuse and maintainability by centralizing the color mapping for session states. --- pkg/cmd/agent-task/list/list.go | 16 ++-------------- pkg/cmd/agent-task/shared/display.go | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 pkg/cmd/agent-task/shared/display.go diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 6e530fdd1..5d5577e1d 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/cmd/agent-task/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -107,20 +108,7 @@ func listRun(opts *ListOptions) error { // State if tp.IsTTY() { - var stateColor func(string) string - switch s.State { - case "completed": - stateColor = cs.Green - case "canceled": - stateColor = cs.Muted - case "in_progress", "queued": - stateColor = cs.Yellow - case "failed": - stateColor = cs.Red - default: - stateColor = cs.Muted - } - tp.AddField(s.State, tableprinter.WithColor(stateColor)) + tp.AddField(s.State, tableprinter.WithColor(shared.ColorFuncForSessionState(*s, cs))) } else { tp.AddField(s.State) } diff --git a/pkg/cmd/agent-task/shared/display.go b/pkg/cmd/agent-task/shared/display.go new file mode 100644 index 000000000..675143140 --- /dev/null +++ b/pkg/cmd/agent-task/shared/display.go @@ -0,0 +1,25 @@ +package shared + +import ( + "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/iostreams" +) + +// ColorFuncForSessionState returns a function that colors the session state +func ColorFuncForSessionState(s capi.Session, cs *iostreams.ColorScheme) func(string) string { + var stateColor func(string) string + switch s.State { + case "completed": + stateColor = cs.Green + case "canceled": + stateColor = cs.Muted + case "in_progress", "queued": + stateColor = cs.Yellow + case "failed": + stateColor = cs.Red + default: + stateColor = cs.Muted + } + + return stateColor +}