Add agent task creation command and job API

Introduces the 'create' subcommand for agent tasks, allowing users to create agent jobs via the Copilot API. Adds job API client methods, job model, and polling logic to retrieve associated pull requests. Includes tests for various job creation scenarios.
This commit is contained in:
Kynan Ware 2025-09-02 16:56:10 -06:00
parent fca5e0cd63
commit 3574ee9c30
5 changed files with 490 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,122 @@
package capi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"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"`
SessionID string `json:"session_id"`
ProblemStatement string `json:"problem_statement,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"`
UpdatedAt time.Time `json:"updated_at"`
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, owner, repo)
body := map[string]any{
"problem_statement": problemStatement,
"event_type": defaultEventType,
}
b, _ := json.Marshal(body)
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()
if res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusOK { // accept 201 or 200
// Attempt to parse error body for message
var er struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&er)
msg := er.Error.Message
if msg == "" {
msg = res.Status
}
return nil, fmt.Errorf("failed to create job: %s", msg)
}
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)
}
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, owner, repo, 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 {
return nil, fmt.Errorf("failed to get job: %s", res.Status)
}
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,173 @@
package create
import (
"context"
"errors"
"fmt"
"io"
"strings"
"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 <problem statement>",
Short: "Create an agent task (preview)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.ProblemStatement = strings.Join(args, " ")
// 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 problem statement is required")
}
if opts.BaseRepo == nil {
return errors.New("failed to resolve repository")
}
repo, err := opts.BaseRepo()
if err != nil || repo == nil || repo.RepoOwner() == "" || repo.RepoName() == "" {
// Not printing the error that came back from BaseRepo() here because we want
// something clear, human friendly, and actionable.
return fmt.Errorf("error: 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.IO.ErrOut, opts.BackOff)
if err != nil {
return 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 == nil || j.PullRequest == nil {
return ""
}
if j.SessionID == "" {
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", repo.RepoOwner(), repo.RepoName(), j.PullRequest.Number)
}
return fmt.Sprintf("https://github.com/%s/%s/pull/%d/agent-sessions/%s", repo.RepoOwner(), repo.RepoName(), j.PullRequest.Number, 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, errOut io.Writer, bo backoff.BackOff) (*capi.Job, error) {
// sentinel error to signal retry without surfacing to caller
var errPRNotReady = errors.New("job not ready")
var result *capi.Job
retryErr := backoff.Retry(func() error {
j, getErr := client.GetJob(ctx, repo.RepoOwner(), repo.RepoName(), jobID)
if getErr != nil {
fmt.Fprintf(errOut, "warning: failed to get job status: %v\n", getErr)
return errPRNotReady
}
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 or failed to fetch
return nil, nil
}
return nil, retryErr
}
return result, nil
}

View file

@ -0,0 +1,191 @@
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)
baseRepo ghrepo.Interface
baseRepoErr error
problemStatement string
wantStdout string
wantErr string
}{
{
name: "success with immediate PR",
baseRepo: ghrepo.New("OWNER", "REPO"),
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",
baseRepo: ghrepo.New("OWNER", "REPO"),
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",
baseRepo: ghrepo.New("OWNER", "REPO"),
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),
)
for range 3 {
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",
baseRepo: ghrepo.New("", ""),
wantErr: "error: a repository is required; re-run in a repository or supply one with --repo owner/name",
},
{
name: "create task API failure returns error",
baseRepo: ghrepo.New("OWNER", "REPO"),
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(400, `{"error":{"message":"some API error"}}`),
)
},
wantErr: "failed to create job: some API error",
},
{
name: "error fetching job during polling returns error and falls back to global agents page",
baseRepo: ghrepo.New("OWNER", "REPO"),
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.StringResponse(`{"job_id":"jobABC"}`),
)
reg.Register(
httpmock.WithHost(httpmock.REST("GET", "agents/swe/v1/jobs/OWNER/REPO/jobABC"), "api.githubcopilot.com"),
httpmock.StatusStringResponse(500, `{"error":{"message":"something went wrong"}}`),
)
},
wantStdout: "job jobABC queued. View progress: https://github.com/copilot/agents\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, stdout, _ := iostreams.Test()
opts := &CreateOptions{
IO: ios,
ProblemStatement: tt.problemStatement,
}
if tt.baseRepo != nil || tt.baseRepoErr != nil {
br, bre := tt.baseRepo, tt.baseRepoErr
opts.BaseRepo = func() (ghrepo.Interface, error) { return br, bre }
}
// 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, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
}
if tt.wantStdout != "" {
require.Equal(t, tt.wantStdout, stdout.String())
}
if tt.stubs != nil {
reg.Verify(t)
}
})
}
}