Skip to content

Commit 7ff84cf

Browse files
authored
Merge pull request cli#11797 from cli/github-cli-epic-990
`gh agent-task` commandset
2 parents 4b02071 + 8c75079 commit 7ff84cf

40 files changed

Lines changed: 8989 additions & 30 deletions

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 {

api/queries_repo.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,10 @@ type RepositoryOwner struct {
141141
}
142142

143143
type GitHubUser struct {
144-
ID string `json:"id"`
145-
Login string `json:"login"`
146-
Name string `json:"name"`
144+
ID string `json:"id"`
145+
Login string `json:"login"`
146+
Name string `json:"name"`
147+
DatabaseID int64 `json:"databaseId"`
147148
}
148149

149150
// Actor is a superset of User and Bot, among others.

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.11.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
@@ -1417,6 +1417,10 @@ github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823 h1:s3p7
14171417
github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823/go.mod h1:Jv2IDwG1q8QNXZTaI1X6QX8s96WlJn73ka2hT1n4N5c=
14181418
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
14191419
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
1420+
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
1421+
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
1422+
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
1423+
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
14201424
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
14211425
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
14221426
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

pkg/cmd/agent-task/agent_task.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package agent
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/MakeNowJust/heredoc"
9+
cmdCreate "github.com/cli/cli/v2/pkg/cmd/agent-task/create"
10+
cmdList "github.com/cli/cli/v2/pkg/cmd/agent-task/list"
11+
cmdView "github.com/cli/cli/v2/pkg/cmd/agent-task/view"
12+
"github.com/cli/cli/v2/pkg/cmdutil"
13+
"github.com/cli/go-gh/v2/pkg/auth"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
// NewCmdAgentTask creates the base `agent-task` command.
18+
func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command {
19+
cmd := &cobra.Command{
20+
Use: "agent-task <command>",
21+
Aliases: []string{"agent-tasks", "agent", "agents"},
22+
Short: "Work with agent tasks (preview)",
23+
Long: heredoc.Doc(`
24+
Working with agent tasks in the GitHub CLI is in preview and
25+
subject to change without notice.
26+
`),
27+
Annotations: map[string]string{
28+
"help:arguments": heredoc.Doc(`
29+
A task can be identified as argument in any of the following formats:
30+
- by pull request number, e.g. "123"; or
31+
- by session ID, e.g. "12345abc-12345-12345-12345-12345abc"; or
32+
- by URL, e.g. "https://github.com/OWNER/REPO/pull/123/agent-sessions/12345abc-12345-12345-12345-12345abc";
33+
34+
Identifying tasks by pull request is not recommended for non-interactive use cases as
35+
there may be multiple tasks for a given pull request that require disambiguation.
36+
`),
37+
},
38+
Example: heredoc.Doc(`
39+
# List your most recent agent tasks
40+
$ gh agent-task list
41+
42+
# Create a new agent task on the current repository
43+
$ gh agent-task create "Improve the performance of the data processing pipeline"
44+
45+
# View details about agent tasks associated with a pull request
46+
$ gh agent-task view 123
47+
48+
# View details about a specific agent task
49+
$ gh agent-task view 12345abc-12345-12345-12345-12345abc
50+
`),
51+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
52+
return requireOAuthToken(f)
53+
},
54+
// This is required to run this root command. We want to
55+
// run it to test PersistentPreRunE behavior.
56+
RunE: func(cmd *cobra.Command, args []string) error {
57+
return cmd.Help()
58+
},
59+
}
60+
61+
// register subcommands
62+
cmd.AddCommand(cmdList.NewCmdList(f, nil))
63+
cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil))
64+
cmd.AddCommand(cmdView.NewCmdView(f, nil))
65+
66+
return cmd
67+
}
68+
69+
// requireOAuthToken ensures an OAuth (device flow) token is present and valid.
70+
// agent-task subcommands inherit this check via PersistentPreRunE.
71+
func requireOAuthToken(f *cmdutil.Factory) error {
72+
cfg, err := f.Config()
73+
if err != nil {
74+
return err
75+
}
76+
77+
authCfg := cfg.Authentication()
78+
host, _ := authCfg.DefaultHost()
79+
if host == "" {
80+
return errors.New("no default host configured; run 'gh auth login'")
81+
}
82+
83+
if auth.IsEnterprise(host) {
84+
return errors.New("agent tasks are not supported on this host")
85+
}
86+
87+
token, source := authCfg.ActiveToken(host)
88+
89+
// Tokens from sources "oauth_token" and "keyring" are likely
90+
// minted through our device flow.
91+
tokenSourceIsDeviceFlow := source == "oauth_token" || source == "keyring"
92+
// Tokens with "gho_" prefix are OAuth tokens.
93+
tokenIsOAuth := strings.HasPrefix(token, "gho_")
94+
95+
// Reject if the token is not from a device flow source or is not an OAuth token
96+
if !tokenSourceIsDeviceFlow || !tokenIsOAuth {
97+
return fmt.Errorf("this command requires an OAuth token. Re-authenticate with: gh auth login")
98+
}
99+
return nil
100+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package agent
2+
3+
import (
4+
"testing"
5+
6+
"github.com/cli/cli/v2/internal/config"
7+
"github.com/cli/cli/v2/internal/gh"
8+
ghmock "github.com/cli/cli/v2/internal/gh/mock"
9+
"github.com/cli/cli/v2/pkg/cmdutil"
10+
"github.com/cli/cli/v2/pkg/iostreams"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// setupMockOAuthConfig configures a blank config with a default host and optional token behavior.
15+
func setupMockOAuthConfig(t *testing.T, tokenSource string) gh.Config {
16+
t.Helper()
17+
c := config.NewBlankConfig()
18+
switch tokenSource {
19+
case "oauth_token":
20+
// valid OAuth device flow token stored in config
21+
c.Set("github.com", "oauth_token", "gho_OAUTH123")
22+
case "keyring":
23+
// valid OAuth device flow token stored in keyring
24+
c.Set("github.com", "oauth_token", "gho_OAUTH123")
25+
case "GH_TOKEN":
26+
// classic style token stored in config (will fail prefix check)
27+
c.Set("github.com", "oauth_token", "ghp_CLASSIC123")
28+
case "GH_ENTERPRISE_TOKEN":
29+
// enterprise style token stored in config (will fail prefix check)
30+
c.Set("something.ghes.com", "oauth_token", "ghe_ENTERPRISE123")
31+
}
32+
return c
33+
}
34+
35+
func TestNewCmdAgentTask(t *testing.T) {
36+
tests := []struct {
37+
name string
38+
tokenSource string
39+
customConfig func() (gh.Config, error)
40+
wantErr bool
41+
wantErrContains string
42+
wantStdout string
43+
}{
44+
{
45+
name: "oauth token is accepted",
46+
tokenSource: "oauth_token",
47+
wantErr: false,
48+
wantStdout: "",
49+
},
50+
{
51+
name: "keyring oauth token is accepted",
52+
tokenSource: "keyring",
53+
wantErr: false,
54+
wantStdout: "",
55+
},
56+
{
57+
name: "env var token is rejected",
58+
tokenSource: "GH_TOKEN",
59+
wantErr: true,
60+
wantErrContains: "requires an OAuth token",
61+
},
62+
{
63+
name: "enterprise token alone is ignored and rejected",
64+
tokenSource: "GH_ENTERPRISE_TOKEN",
65+
wantErr: true,
66+
},
67+
{
68+
name: "github.com oauth is accepted and enterprise token ignored",
69+
customConfig: func() (gh.Config, error) {
70+
c := config.NewBlankConfig()
71+
c.Set("something.ghes.com", "oauth_token", "ghe_ENTERPRISE123")
72+
c.Set("github.com", "oauth_token", "gho_OAUTH123")
73+
return c, nil
74+
},
75+
wantErr: false,
76+
wantStdout: "",
77+
},
78+
{
79+
name: "enterprise host is rejected",
80+
customConfig: func() (gh.Config, error) {
81+
return &ghmock.ConfigMock{
82+
AuthenticationFunc: func() gh.AuthConfig {
83+
c := &config.AuthConfig{}
84+
c.SetDefaultHost("something.ghes.com", "GH_HOST")
85+
return c
86+
},
87+
}, nil
88+
},
89+
wantErr: true,
90+
wantErrContains: "not supported on this host",
91+
},
92+
{
93+
name: "empty host is rejected",
94+
customConfig: func() (gh.Config, error) {
95+
return &ghmock.ConfigMock{
96+
AuthenticationFunc: func() gh.AuthConfig {
97+
c := &config.AuthConfig{}
98+
c.SetDefaultHost("", "GH_HOST")
99+
return c
100+
},
101+
}, nil
102+
},
103+
wantErr: true,
104+
wantErrContains: "no default host configured",
105+
},
106+
{
107+
name: "no auth is rejected",
108+
tokenSource: "",
109+
wantErr: true,
110+
},
111+
}
112+
113+
for _, tt := range tests {
114+
t.Run(tt.name, func(t *testing.T) {
115+
f := &cmdutil.Factory{}
116+
ios, _, stdout, _ := iostreams.Test()
117+
f.IOStreams = ios
118+
if tt.customConfig != nil {
119+
f.Config = tt.customConfig
120+
} else {
121+
f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, tt.tokenSource), nil }
122+
}
123+
124+
cmd := NewCmdAgentTask(f)
125+
err := cmd.Execute()
126+
127+
if tt.wantErr {
128+
require.Error(t, err)
129+
if tt.wantErrContains != "" {
130+
require.Contains(t, err.Error(), tt.wantErrContains)
131+
}
132+
} else {
133+
require.NoError(t, err)
134+
require.Equal(t, tt.wantStdout, stdout.String())
135+
}
136+
})
137+
}
138+
}
139+
140+
func TestAliasAreSet(t *testing.T) {
141+
f := &cmdutil.Factory{}
142+
ios, _, _, _ := iostreams.Test()
143+
f.IOStreams = ios
144+
f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, "oauth_token"), nil }
145+
146+
cmd := NewCmdAgentTask(f)
147+
148+
require.ElementsMatch(t, []string{"agent-tasks", "agent", "agents"}, cmd.Aliases)
149+
}

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package capi
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"github.com/cli/cli/v2/internal/gh"
8+
)
9+
10+
//go:generate moq -rm -out client_mock.go . CapiClient
11+
12+
const baseCAPIURL = "https://api.githubcopilot.com"
13+
const capiHost = "api.githubcopilot.com"
14+
15+
// CapiClient defines the methods used by the caller. Implementations
16+
// may be replaced with test doubles in unit tests.
17+
type CapiClient interface {
18+
ListLatestSessionsForViewer(ctx context.Context, limit int) ([]*Session, error)
19+
CreateJob(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*Job, error)
20+
GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error)
21+
GetSession(ctx context.Context, id string) (*Session, error)
22+
GetSessionLogs(ctx context.Context, id string) ([]byte, error)
23+
ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error)
24+
GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, string, error)
25+
}
26+
27+
// CAPIClient is a client for interacting with the Copilot API
28+
type CAPIClient struct {
29+
httpClient *http.Client
30+
authCfg gh.AuthConfig
31+
}
32+
33+
// NewCAPIClient creates a new CAPI client. Provide a token and an HTTP client which
34+
// will be used as the base transport for CAPI requests.
35+
//
36+
// The provided HTTP client will be mutated for use with CAPI, so it should not
37+
// be reused elsewhere.
38+
func NewCAPIClient(httpClient *http.Client, authCfg gh.AuthConfig) *CAPIClient {
39+
host, _ := authCfg.DefaultHost()
40+
token, _ := authCfg.ActiveToken(host)
41+
42+
httpClient.Transport = newCAPITransport(token, httpClient.Transport)
43+
return &CAPIClient{
44+
httpClient: httpClient,
45+
authCfg: authCfg,
46+
}
47+
}
48+
49+
// capiTransport adds the Copilot auth headers
50+
type capiTransport struct {
51+
rp http.RoundTripper
52+
token string
53+
}
54+
55+
func newCAPITransport(token string, rp http.RoundTripper) *capiTransport {
56+
return &capiTransport{
57+
rp: rp,
58+
token: token,
59+
}
60+
}
61+
62+
func (ct *capiTransport) RoundTrip(req *http.Request) (*http.Response, error) {
63+
req.Header.Set("Authorization", "Bearer "+ct.token)
64+
65+
// Since this RoundTrip is reused for both Copilot API and
66+
// GitHub API requests, we conditionally add the integration
67+
// ID only when performing requests to the Copilot API.
68+
if req.URL.Host == capiHost {
69+
req.Header.Add("Copilot-Integration-Id", "copilot-4-cli")
70+
}
71+
return ct.rp.RoundTrip(req)
72+
}

0 commit comments

Comments
 (0)