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:
Kynan Ware 2025-08-29 15:41:26 -06:00 committed by GitHub
commit c10ac8a326
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 794 additions and 2 deletions

View file

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

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

View file

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

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

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

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

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