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 <babakks@github.com>
This commit is contained in:
parent
b939188e6d
commit
dd424d85fd
8 changed files with 528 additions and 2 deletions
|
|
@ -62,6 +62,7 @@ type PullRequest struct {
|
||||||
MergedBy *Author
|
MergedBy *Author
|
||||||
HeadRepositoryOwner Owner
|
HeadRepositoryOwner Owner
|
||||||
HeadRepository *PRRepository
|
HeadRepository *PRRepository
|
||||||
|
Repository *PRRepository
|
||||||
IsCrossRepository bool
|
IsCrossRepository bool
|
||||||
IsDraft bool
|
IsDraft bool
|
||||||
MaintainerCanModify bool
|
MaintainerCanModify bool
|
||||||
|
|
@ -253,6 +254,7 @@ type Workflow struct {
|
||||||
type PRRepository struct {
|
type PRRepository struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
NameWithOwner string `json:"nameWithOwner"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AutoMergeRequest struct {
|
type AutoMergeRequest struct {
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -51,6 +51,7 @@ require (
|
||||||
github.com/spf13/pflag v1.0.7
|
github.com/spf13/pflag v1.0.7
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/theupdateframework/go-tuf/v2 v2.1.1
|
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/yuin/goldmark v1.7.13
|
||||||
github.com/zalando/go-keyring v0.2.6
|
github.com/zalando/go-keyring v0.2.6
|
||||||
golang.org/x/crypto v0.41.0
|
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/merkle v0.0.2 // indirect
|
||||||
github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823 // indirect
|
github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823 // indirect
|
||||||
github.com/vbatts/tar-split v0.12.1 // 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
github.com/zeebo/errs v1.4.0 // indirect
|
github.com/zeebo/errs v1.4.0 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
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/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 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
|
||||||
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
cmdList "github.com/cli/cli/v2/pkg/cmd/agent-task/list"
|
||||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||||
"github.com/cli/go-gh/v2/pkg/auth"
|
"github.com/cli/go-gh/v2/pkg/auth"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
@ -25,6 +26,10 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command {
|
||||||
return cmd.Help()
|
return cmd.Help()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// register subcommands
|
||||||
|
cmd.AddCommand(cmdList.NewCmdList(f, nil))
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
64
pkg/cmd/agent-task/capi/client.go
Normal file
64
pkg/cmd/agent-task/capi/client.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
208
pkg/cmd/agent-task/capi/sessions.go
Normal file
208
pkg/cmd/agent-task/capi/sessions.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
150
pkg/cmd/agent-task/list/list.go
Normal file
150
pkg/cmd/agent-task/list/list.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
91
pkg/cmd/agent-task/list/list_test.go
Normal file
91
pkg/cmd/agent-task/list/list_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue