Merge pull request #11653 from cli/1013-gh-agent-task-create-creates-a-new-agent-task-and-responds-with-pr-url

Introduce `gh agent-task create`
This commit is contained in:
Kynan Ware 2025-09-03 14:33:17 -06:00 committed by GitHub
commit 6a50ecb880
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 497 additions and 0 deletions

View file

@ -5,6 +5,7 @@ import (
"fmt"
"strings"
cmdCreate "github.com/cli/cli/v2/pkg/cmd/agent-task/create"
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"
@ -29,6 +30,7 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command {
// register subcommands
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil))
return cmd
}

View file

@ -15,6 +15,8 @@ const capiHost = "api.githubcopilot.com"
type CapiClient interface {
ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error)
ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error)
CreateJob(ctx context.Context, owner, repo, problemStatement string) (*Job, error)
GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error)
}
// CAPIClient is a client for interacting with the Copilot API

View file

@ -0,0 +1,120 @@
package capi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"time"
)
const defaultEventType = "gh_cli"
// Job represents a coding agent's task. Used to request a new session.
type Job struct {
ID string `json:"job_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
ProblemStatement string `json:"problem_statement,omitempty"`
EventType string `json:"event_type,omitempty"`
ContentFilterMode string `json:"content_filter_mode,omitempty"`
Status string `json:"status,omitempty"`
Result string `json:"result,omitempty"`
Actor *JobActor `json:"actor,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
PullRequest *JobPullRequest `json:"pull_request,omitempty"`
WorkflowRun *struct {
ID string `json:"id"`
} `json:"workflow_run,omitempty"`
ErrorInfo *JobError `json:"error,omitempty"`
}
type JobActor struct {
ID int `json:"id"`
Login string `json:"login"`
}
type JobPullRequest struct {
ID int `json:"id"`
Number int `json:"number"`
}
type JobError struct {
Message string `json:"message"`
ResponseStatusCode int `json:"response_status_code,string"`
Service string `json:"service"`
}
const jobsBasePathV1 = baseCAPIURL + "/agents/swe/v1/jobs"
// CreateJob queues a new job using the v1 Jobs API. It may or may not
// return Pull Request information. If Pull Request information is required
// following up by polling GetJob with the job ID is necessary.
func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatement string) (*Job, error) {
if owner == "" || repo == "" {
return nil, errors.New("owner and repo are required")
}
if problemStatement == "" {
return nil, errors.New("problem statement is required")
}
url := fmt.Sprintf("%s/%s/%s", jobsBasePathV1, url.PathEscape(owner), url.PathEscape(repo))
payload := &Job{
ProblemStatement: problemStatement,
EventType: defaultEventType,
}
b, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
res, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var j Job
if err := json.NewDecoder(res.Body).Decode(&j); err != nil {
return nil, fmt.Errorf("failed to decode create job response: %w", err)
}
if res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusOK { // accept 201 or 200
return nil, fmt.Errorf("failed to create job: %s", j.ErrorInfo.Message)
}
return &j, nil
}
// GetJob retrieves a agent job
func (c *CAPIClient) GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) {
if owner == "" || repo == "" || jobID == "" {
return nil, errors.New("owner, repo, and jobID are required")
}
url := fmt.Sprintf("%s/%s/%s/%s", jobsBasePathV1, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(jobID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
return nil, err
}
res, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
// Normalize to "<code> <text>" form
statusText := fmt.Sprintf("%d %s", res.StatusCode, http.StatusText(res.StatusCode))
return nil, fmt.Errorf("failed to get job: %s", statusText)
}
var j Job
if err := json.NewDecoder(res.Body).Decode(&j); err != nil {
return nil, fmt.Errorf("failed to decode get job response: %w", err)
}
return &j, nil
}

View file

@ -0,0 +1,181 @@
package create
import (
"context"
"errors"
"fmt"
"net/url"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/agent-task/capi"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
// CreateOptions holds options for create command
type CreateOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
CapiClient func() (capi.CapiClient, error)
Config func() (gh.Config, error)
ProblemStatement string
BackOff backoff.BackOff
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := &CreateOptions{
IO: f.IOStreams,
}
cmd := &cobra.Command{
Use: "create \"<task description>\"",
Short: "Create an agent task (preview)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// TODO: We'll support prompting for the problem statement if not provided
// and from file flags, later.
if len(args) == 0 {
return cmdutil.FlagErrorf("a task description is required")
}
opts.ProblemStatement = args[0]
// Support -R/--repo override
if f != nil {
opts.BaseRepo = f.BaseRepo
}
if runF != nil {
return runF(opts)
}
return createRun(opts)
},
}
if f != nil {
cmdutil.EnableRepoOverride(cmd, f)
}
opts.CapiClient = func() (capi.CapiClient, error) {
cfg, err := f.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 createRun(opts *CreateOptions) error {
if opts.ProblemStatement == "" {
return cmdutil.FlagErrorf("a task description is required")
}
if opts.BaseRepo == nil {
return errors.New("failed to resolve repository")
}
repo, err := opts.BaseRepo()
if err != nil || repo == nil {
// Not printing the error that came back from BaseRepo() here because we want
// something clear, human friendly, and actionable.
return fmt.Errorf("a repository is required; re-run in a repository or supply one with --repo owner/name")
}
client, err := opts.CapiClient()
if err != nil {
return err
}
ctx := context.Background()
opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Creating agent task in %s/%s...", repo.RepoOwner(), repo.RepoName()))
defer opts.IO.StopProgressIndicator()
job, err := client.CreateJob(ctx, repo.RepoOwner(), repo.RepoName(), opts.ProblemStatement)
if err != nil {
return err
}
// Print this agent session URL and exit if we happen to get it.
// Right now, this never happens.
if job.PullRequest != nil && job.PullRequest.Number > 0 {
fmt.Fprintf(opts.IO.Out, "%s\n", agentSessionWebURL(repo, job))
return nil
}
// Otherwise, poll using exponential backoff until we either observe a PR or hit the overall timeout.
// Ensure we have a backoff strategy.
if opts.BackOff == nil {
opts.BackOff = backoff.NewExponentialBackOff(
backoff.WithMaxElapsedTime(4*time.Second),
backoff.WithInitialInterval(300*time.Millisecond),
backoff.WithMaxInterval(2*time.Second),
backoff.WithMultiplier(1.5),
)
}
jobWithPR, err := fetchJobWithBackoff(ctx, client, repo, job.ID, opts.BackOff)
if err != nil {
// If this does happen ever, we still want the user to get the
// fallback message and URL. So, we don't return with this error,
// but we do still want to print it.
fmt.Fprintf(opts.IO.ErrOut, "%v\n", err)
}
if jobWithPR != nil {
opts.IO.StopProgressIndicator()
fmt.Fprintln(opts.IO.Out, agentSessionWebURL(repo, jobWithPR))
return nil
}
// Fallback if PR not yet ready
opts.IO.StopProgressIndicator()
fmt.Fprintf(opts.IO.Out, "job %s queued. View progress: https://github.com/copilot/agents\n", job.ID)
return nil
}
func agentSessionWebURL(repo ghrepo.Interface, j *capi.Job) string {
if j.PullRequest == nil {
return ""
}
if j.SessionID == "" {
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), j.PullRequest.Number)
}
return fmt.Sprintf("https://github.com/%s/%s/pull/%d/agent-sessions/%s", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), j.PullRequest.Number, url.PathEscape(j.SessionID))
}
// fetchJobWithBackoff polls the job resource until a PR number is present or the overall
// timeout elapses. It returns the updated Job on success, (nil, nil) on timeout,
// and (nil, error) only for non-retryable failures.
func fetchJobWithBackoff(ctx context.Context, client capi.CapiClient, repo ghrepo.Interface, jobID string, bo backoff.BackOff) (*capi.Job, error) {
// sentinel error to signal timeout
var errPRNotReady = errors.New("job not ready")
var result *capi.Job
retryErr := backoff.Retry(func() error {
j, err := client.GetJob(ctx, repo.RepoOwner(), repo.RepoName(), jobID)
if err != nil {
// Do not retry on GetJob errors; surface immediately.
return backoff.Permanent(err)
}
if j.PullRequest != nil && j.PullRequest.Number > 0 {
result = j
return nil
}
return errPRNotReady
}, backoff.WithContext(bo, ctx))
if retryErr != nil {
if errors.Is(retryErr, errPRNotReady) {
// Timed out
return nil, nil
}
return nil, retryErr
}
return result, nil
}

View file

@ -0,0 +1,192 @@
package create
import (
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cenkalti/backoff/v4"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"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/require"
)
// Test basic option parsing & repository requirement
func TestNewCmdCreate_Args(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdCreate(f, func(o *CreateOptions) error { return nil })
// no args should error via cobra MinimumNArgs before our runF
// TODO once we support more sources of problem statement input,
// this will change.
_, err := cmd.ExecuteC()
require.Error(t, err)
}
func Test_createRun(t *testing.T) {
createdJobSuccessResponse := heredoc.Doc(`{
"job_id":"job123",
"session_id":"sess1",
"actor":{"id":1,"login":"octocat"},
"created_at":"2025-08-29T00:00:00Z",
"updated_at":"2025-08-29T00:00:00Z"
}`)
createdJobSuccessWithPRResponse := heredoc.Doc(`{
"job_id":"job123",
"session_id":"sess1",
"actor":{"id":1,"login":"octocat"},
"created_at":"2025-08-29T00:00:00Z",
"updated_at":"2025-08-29T00:00:00Z",
"pull_request":{"id":101,"number":42}
}`)
createdJobTimeoutResponse := heredoc.Doc(`{
"job_id":"jobABC",
"session_id":"sess1",
"actor":{"id":1,"login":"octocat"},
"created_at":"2025-08-29T00:00:00Z",
"updated_at":"2025-08-29T00:00:00Z"
}`)
tests := []struct {
name string
stubs func(*httpmock.Registry)
baseRepoFunc func() (ghrepo.Interface, error)
problemStatement string
wantStdout string
wantStdErr string
wantErr string
}{
{
name: "get job API failure surfaces error",
baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil },
problemStatement: "Do the thing",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"),
httpmock.StatusStringResponse(201, createdJobTimeoutResponse),
)
reg.Register(
httpmock.WithHost(httpmock.REST("GET", "agents/swe/v1/jobs/OWNER/REPO/jobABC"), "api.githubcopilot.com"),
httpmock.StatusStringResponse(500, `{"error":{"message":"internal server error"}}`),
)
},
wantStdErr: "failed to get job: 500 Internal Server Error\n",
wantStdout: "job jobABC queued. View progress: https://github.com/copilot/agents\n",
},
{
name: "success with immediate PR",
baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil },
problemStatement: "Do the thing",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"),
httpmock.StatusStringResponse(201, createdJobSuccessWithPRResponse),
)
},
wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n",
},
{
name: "success with delayed PR after polling",
baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil },
problemStatement: "Do the thing",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"),
httpmock.StatusStringResponse(201, createdJobSuccessResponse),
)
reg.Register(
httpmock.WithHost(httpmock.REST("GET", "agents/swe/v1/jobs/OWNER/REPO/job123"), "api.githubcopilot.com"),
httpmock.StringResponse(`{"job_id":"job123","pull_request":{"id":101,"number":42}}`),
)
},
wantStdout: "https://github.com/OWNER/REPO/pull/42\n",
},
{
name: "fallback after timeout returns link to global agents page",
baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil },
problemStatement: "Do the thing",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"),
httpmock.StatusStringResponse(201, createdJobTimeoutResponse),
)
// 4 attempts: initial + 3 retries
for range 4 {
reg.Register(
httpmock.WithHost(httpmock.REST("GET", "agents/swe/v1/jobs/OWNER/REPO/jobABC"), "api.githubcopilot.com"),
httpmock.StringResponse(`{"job_id":"jobABC"}`),
)
}
},
wantStdout: "job jobABC queued. View progress: https://github.com/copilot/agents\n",
},
{
name: "missing repo returns error",
problemStatement: "task",
baseRepoFunc: func() (ghrepo.Interface, error) { return nil, nil },
wantErr: "a repository is required; re-run in a repository or supply one with --repo owner/name",
},
{
name: "create task API failure returns error",
baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil },
problemStatement: "do the thing",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"),
httpmock.StatusStringResponse(500, `{"error":{"message":"some API error"}}`),
)
},
wantErr: "failed to create job: some API error",
},
{
name: "missing task description returns error",
baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil },
problemStatement: "",
wantErr: "a task description is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
opts := &CreateOptions{
IO: ios,
ProblemStatement: tt.problemStatement,
BaseRepo: tt.baseRepoFunc,
}
// A backoff with no internal between retries to keep tests fast,
// and also a max number of retries so we don't infinitely poll.
opts.BackOff = backoff.WithMaxRetries(&backoff.ZeroBackOff{}, 3)
reg := &httpmock.Registry{}
if tt.stubs != nil {
tt.stubs(reg)
cfg := config.NewBlankConfig()
cfg.Set("github.com", "oauth_token", "OTOKEN")
authCfg := cfg.Authentication()
client := capi.NewCAPIClient(&http.Client{Transport: reg}, authCfg)
opts.CapiClient = func() (capi.CapiClient, error) { return client, nil }
}
err := createRun(opts)
if tt.wantErr != "" {
require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error())
} else {
require.NoError(t, err)
}
require.Equal(t, tt.wantStdout, stdout.String())
require.Equal(t, tt.wantStdErr, stderr.String())
if tt.stubs != nil {
reg.Verify(t)
}
})
}
}