diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index cbbd3e278..e732e31de 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -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 } diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index 480692c16..9021d6086 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -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 diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go new file mode 100644 index 000000000..03eaa376d --- /dev/null +++ b/pkg/cmd/agent-task/capi/job.go @@ -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 " " 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 +} diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go new file mode 100644 index 000000000..41f615c3f --- /dev/null +++ b/pkg/cmd/agent-task/create/create.go @@ -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 \"\"", + 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 +} diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go new file mode 100644 index 000000000..977d32dfb --- /dev/null +++ b/pkg/cmd/agent-task/create/create_test.go @@ -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) + } + }) + } +}