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:
Kynan Ware 2025-08-27 17:02:09 -06:00
parent b939188e6d
commit dd424d85fd
8 changed files with 528 additions and 2 deletions

View file

@ -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
@ -251,8 +252,9 @@ 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
View file

@ -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
View file

@ -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=

View file

@ -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
} }

View 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)
}

View 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
}

View 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
}

View 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(&reg)
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(&reg)
opts.CapiClient = &mockCAPIClient{sessions: []*capi.Session{}}
err := listRun(opts)
require.NoError(t, err)
out := stdout.String()
require.Contains(t, out, "no agent tasks found")
}