Skip to content

Commit dd424d8

Browse files
BagToadbabakks
andcommitted
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>
1 parent b939188 commit dd424d8

8 files changed

Lines changed: 528 additions & 2 deletions

File tree

api/queries_pr.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type PullRequest struct {
6262
MergedBy *Author
6363
HeadRepositoryOwner Owner
6464
HeadRepository *PRRepository
65+
Repository *PRRepository
6566
IsCrossRepository bool
6667
IsDraft bool
6768
MaintainerCanModify bool
@@ -251,8 +252,9 @@ type Workflow struct {
251252
}
252253

253254
type PRRepository struct {
254-
ID string `json:"id"`
255-
Name string `json:"name"`
255+
ID string `json:"id"`
256+
Name string `json:"name"`
257+
NameWithOwner string `json:"nameWithOwner"`
256258
}
257259

258260
type AutoMergeRequest struct {

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ require (
5151
github.com/spf13/pflag v1.0.7
5252
github.com/stretchr/testify v1.10.0
5353
github.com/theupdateframework/go-tuf/v2 v2.1.1
54+
github.com/vmihailenco/msgpack/v5 v5.4.1
5455
github.com/yuin/goldmark v1.7.13
5556
github.com/zalando/go-keyring v0.2.6
5657
golang.org/x/crypto v0.41.0
@@ -205,6 +206,7 @@ require (
205206
github.com/transparency-dev/merkle v0.0.2 // indirect
206207
github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823 // indirect
207208
github.com/vbatts/tar-split v0.12.1 // indirect
209+
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
208210
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
209211
github.com/yuin/goldmark-emoji v1.0.6 // indirect
210212
github.com/zeebo/errs v1.4.0 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,10 @@ github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823 h1:s3p7
14151415
github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823/go.mod h1:Jv2IDwG1q8QNXZTaI1X6QX8s96WlJn73ka2hT1n4N5c=
14161416
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
14171417
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
1418+
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
1419+
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
1420+
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
1421+
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
14181422
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
14191423
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
14201424
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

pkg/cmd/agent-task/agent_task.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"strings"
77

8+
cmdList "github.com/cli/cli/v2/pkg/cmd/agent-task/list"
89
"github.com/cli/cli/v2/pkg/cmdutil"
910
"github.com/cli/go-gh/v2/pkg/auth"
1011
"github.com/spf13/cobra"
@@ -25,6 +26,10 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command {
2526
return cmd.Help()
2627
},
2728
}
29+
30+
// register subcommands
31+
cmd.AddCommand(cmdList.NewCmdList(f, nil))
32+
2833
return cmd
2934
}
3035

pkg/cmd/agent-task/capi/client.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package capi
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"github.com/cli/cli/v2/internal/gh"
8+
)
9+
10+
const baseCAPIURL = "https://api.githubcopilot.com"
11+
const capiHost = "api.githubcopilot.com"
12+
13+
// CapiClient defines the methods used by the caller. Implementations
14+
// may be replaced with test doubles in unit tests.
15+
type CapiClient interface {
16+
ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error)
17+
}
18+
19+
// CAPIClient is a client for interacting with the Copilot API
20+
type CAPIClient struct {
21+
httpClient *http.Client
22+
authCfg gh.AuthConfig
23+
}
24+
25+
// NewCAPIClient creates a new CAPI client. Provide a token and an HTTP client which
26+
// will be used as the base transport for CAPI requests.
27+
//
28+
// The provided HTTP client will be mutated for use with CAPI, so it should not
29+
// be reused elsewhere.
30+
func NewCAPIClient(httpClient *http.Client, authCfg gh.AuthConfig) *CAPIClient {
31+
host, _ := authCfg.DefaultHost()
32+
token, _ := authCfg.ActiveToken(host)
33+
34+
httpClient.Transport = newCAPITransport(token, httpClient.Transport)
35+
return &CAPIClient{
36+
httpClient: httpClient,
37+
authCfg: authCfg,
38+
}
39+
}
40+
41+
// capiTransport adds the Copilot auth headers
42+
type capiTransport struct {
43+
rp http.RoundTripper
44+
token string
45+
}
46+
47+
func newCAPITransport(token string, rp http.RoundTripper) *capiTransport {
48+
return &capiTransport{
49+
rp: rp,
50+
token: token,
51+
}
52+
}
53+
54+
func (ct *capiTransport) RoundTrip(req *http.Request) (*http.Response, error) {
55+
req.Header.Set("Authorization", "Bearer "+ct.token)
56+
57+
// Since this RoundTrip is reused for both Copilot API and
58+
// GitHub API requests, we conditionally add the integration
59+
// ID only when performing requests to the Copilot API.
60+
if req.URL.Host == capiHost {
61+
req.Header.Add("Copilot-Integration-Id", "copilot-4-cli")
62+
}
63+
return ct.rp.RoundTrip(req)
64+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package capi
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/base64"
7+
"encoding/json"
8+
"fmt"
9+
"net/http"
10+
"slices"
11+
"strconv"
12+
"time"
13+
14+
"github.com/cli/cli/v2/api"
15+
"github.com/vmihailenco/msgpack/v5"
16+
)
17+
18+
// session is an in-flight agent task
19+
type session struct {
20+
ID string `json:"id"`
21+
Name string `json:"name"`
22+
UserID uint64 `json:"user_id"`
23+
AgentID int64 `json:"agent_id"`
24+
Logs string `json:"logs"`
25+
State string `json:"state"`
26+
OwnerID uint64 `json:"owner_id"`
27+
RepoID uint64 `json:"repo_id"`
28+
ResourceType string `json:"resource_type"`
29+
ResourceID int64 `json:"resource_id"`
30+
LastUpdatedAt time.Time `json:"last_updated_at,omitempty"`
31+
CreatedAt time.Time `json:"created_at,omitempty"`
32+
CompletedAt time.Time `json:"completed_at,omitempty"`
33+
EventURL string `json:"event_url"`
34+
EventType string `json:"event_type"`
35+
}
36+
37+
// A shim of a full pull request because looking up by node ID
38+
// using the full api.PullRequest type fails on unions (actors)
39+
type sessionPullRequest struct {
40+
ID string
41+
FullDatabaseID string
42+
Number int
43+
Title string
44+
State string
45+
URL string
46+
Body string
47+
48+
CreatedAt time.Time
49+
UpdatedAt time.Time
50+
ClosedAt *time.Time
51+
MergedAt *time.Time
52+
53+
// Uncomment one of these to see error
54+
// Author api.Author
55+
// MergedBy *api.Author
56+
Repository *api.PRRepository
57+
}
58+
59+
// Session is a hydrated in-flight agent task
60+
type Session struct {
61+
session
62+
PullRequest *api.PullRequest `json:"-"`
63+
}
64+
65+
// ListSessionsForViewer lists all agent sessions for the
66+
// authenticated user up to limit.
67+
func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) {
68+
url := baseCAPIURL + "/agents/sessions"
69+
70+
var sessions []session
71+
page := 1
72+
perPage := 50
73+
74+
for {
75+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
q := req.URL.Query()
81+
q.Set("page_size", strconv.Itoa(perPage))
82+
q.Set("page_number", strconv.Itoa(page))
83+
req.URL.RawQuery = q.Encode()
84+
85+
res, err := c.httpClient.Do(req)
86+
if err != nil {
87+
return nil, err
88+
}
89+
defer res.Body.Close()
90+
if res.StatusCode != http.StatusOK {
91+
return nil, fmt.Errorf("failed to list sessions: %s", res.Status)
92+
}
93+
var response struct {
94+
Sessions []session `json:"sessions"`
95+
}
96+
if err := json.NewDecoder(res.Body).Decode(&response); err != nil {
97+
return nil, fmt.Errorf("failed to decode sessions response: %w", err)
98+
}
99+
if len(response.Sessions) == 0 || len(sessions) >= limit {
100+
break
101+
}
102+
sessions = append(sessions, response.Sessions...)
103+
page++
104+
}
105+
106+
// Drop any above the limit
107+
if len(sessions) > limit {
108+
sessions = sessions[:limit]
109+
}
110+
111+
// Hydrate the Sessions with pull request data.
112+
Sessions, err := c.hydrateSessionPullRequests(sessions)
113+
if err != nil {
114+
return nil, err
115+
}
116+
117+
return Sessions, nil
118+
}
119+
120+
// hydrateSessionPullRequests hydrates pull request information in sessions
121+
func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, error) {
122+
if len(sessions) == 0 {
123+
return nil, nil
124+
}
125+
126+
prNodeIds := make([]string, 0, len(sessions))
127+
128+
for _, session := range sessions {
129+
prNodeID := generatePullRequestNodeID(int64(session.RepoID), session.ResourceID)
130+
if slices.Contains(prNodeIds, prNodeID) {
131+
continue
132+
}
133+
prNodeIds = append(prNodeIds, prNodeID)
134+
}
135+
136+
apiClient := api.NewClientFromHTTP(c.httpClient)
137+
138+
var resp struct {
139+
Nodes []struct {
140+
PullRequest sessionPullRequest `graphql:"... on PullRequest"`
141+
} `graphql:"nodes(ids: $ids)"`
142+
}
143+
144+
host, _ := c.authCfg.DefaultHost()
145+
err := apiClient.Query(host, "FetchPRs", &resp, map[string]any{
146+
"ids": prNodeIds,
147+
})
148+
149+
if err != nil {
150+
return nil, err
151+
}
152+
153+
prs := make([]*api.PullRequest, 0, len(prNodeIds))
154+
for _, node := range resp.Nodes {
155+
prs = append(prs, &api.PullRequest{
156+
ID: node.PullRequest.ID,
157+
FullDatabaseID: node.PullRequest.FullDatabaseID,
158+
Number: node.PullRequest.Number,
159+
Title: node.PullRequest.Title,
160+
State: node.PullRequest.State,
161+
URL: node.PullRequest.URL,
162+
Body: node.PullRequest.Body,
163+
CreatedAt: node.PullRequest.CreatedAt,
164+
UpdatedAt: node.PullRequest.UpdatedAt,
165+
ClosedAt: node.PullRequest.ClosedAt,
166+
MergedAt: node.PullRequest.MergedAt,
167+
Repository: node.PullRequest.Repository,
168+
})
169+
}
170+
171+
newSessions := make([]*Session, 0, len(sessions))
172+
// For each session, we need to attach the Pull Request
173+
for _, s := range sessions {
174+
// For each Pull Request, check if it matches the session
175+
for _, pr := range prs {
176+
if strconv.FormatInt(s.ResourceID, 10) == pr.FullDatabaseID {
177+
newSessions = append(newSessions, &Session{
178+
session: s,
179+
PullRequest: pr,
180+
})
181+
}
182+
}
183+
}
184+
185+
return newSessions, nil
186+
}
187+
188+
// generatePullRequestNodeID converts an int64 databaseID and repoID to a GraphQL Node ID format
189+
// with the "PR_" prefix for pull requests
190+
func generatePullRequestNodeID(repoID, pullRequestID int64) string {
191+
buf := bytes.Buffer{}
192+
parts := []int64{0, repoID, pullRequestID}
193+
194+
encoder := msgpack.NewEncoder(&buf)
195+
encoder.UseCompactInts(true)
196+
197+
// Encode the parts
198+
err := encoder.Encode(parts)
199+
if err != nil {
200+
panic(err)
201+
}
202+
203+
// Use URL-safe Base64 encoding without padding
204+
encoded := base64.RawURLEncoding.EncodeToString(buf.Bytes())
205+
206+
// Return with the PR_ prefix
207+
return "PR_" + encoded
208+
}

0 commit comments

Comments
 (0)