Merge pull request #11606 from cli/kw/1001-gh-agent-task-list-responds-with-table-of-the-users-agent-sessions
Introduce `gh agent-task list`
This commit is contained in:
commit
c10ac8a326
9 changed files with 794 additions and 2 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
2
go.mod
2
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
|
||||
|
|
|
|||
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/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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
195
pkg/cmd/agent-task/capi/sessions.go
Normal file
195
pkg/cmd/agent-task/capi/sessions.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
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
|
||||
|
||||
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 result with pull request data.
|
||||
result, err := c.hydrateSessionPullRequests(sessions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, 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, "FetchPRsForAgentTaskSessions", &resp, map[string]any{
|
||||
"ids": prNodeIds,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prMap := make(map[string]*api.PullRequest, len(prNodeIds))
|
||||
for _, node := range resp.Nodes {
|
||||
prMap[node.PullRequest.FullDatabaseID] = &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 _, s := range sessions {
|
||||
newSessions = append(newSessions, &Session{
|
||||
session: s,
|
||||
PullRequest: prMap[strconv.FormatInt(s.ResourceID, 10)],
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if err := encoder.Encode(parts); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
encoded := base64.RawURLEncoding.EncodeToString(buf.Bytes())
|
||||
|
||||
return "PR_" + encoded
|
||||
}
|
||||
131
pkg/cmd/agent-task/list/list.go
Normal file
131
pkg/cmd/agent-task/list/list.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/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"
|
||||
"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 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,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List agent tasks",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return listRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func listRun(opts *ListOptions) error {
|
||||
if opts.Limit <= 0 {
|
||||
opts.Limit = defaultLimit
|
||||
}
|
||||
|
||||
capiClient, err := opts.CapiClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
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() {
|
||||
tp.AddField(pr, tableprinter.WithColor(cs.ColorFromString(prShared.ColorForPRState(*s.PullRequest))))
|
||||
} else {
|
||||
tp.AddField(pr)
|
||||
}
|
||||
|
||||
// Repo
|
||||
tp.AddField(repo, tableprinter.WithColor(cs.Muted))
|
||||
|
||||
// State
|
||||
if tp.IsTTY() {
|
||||
tp.AddField(s.State, tableprinter.WithColor(shared.ColorFuncForSessionState(*s, cs)))
|
||||
} 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
|
||||
}
|
||||
364
pkg/cmd/agent-task/list/list_test.go
Normal file
364
pkg/cmd/agent-task/list/list_test.go
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"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/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
wantOpts ListOptions
|
||||
}{
|
||||
{
|
||||
name: "no arguments",
|
||||
wantOpts: ListOptions{
|
||||
Limit: defaultLimit,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_listRun(t *testing.T) {
|
||||
sixHours, _ := time.ParseDuration("6h")
|
||||
sixHoursAgo := time.Now().Add(-sixHours)
|
||||
createdAt := sixHoursAgo.Format(time.RFC3339)
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
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)),
|
||||
)
|
||||
}
|
||||
25
pkg/cmd/agent-task/shared/display.go
Normal file
25
pkg/cmd/agent-task/shared/display.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue