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:
commit
6a50ecb880
5 changed files with 497 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
pkg/cmd/agent-task/capi/job.go
Normal file
120
pkg/cmd/agent-task/capi/job.go
Normal 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
|
||||
}
|
||||
181
pkg/cmd/agent-task/create/create.go
Normal file
181
pkg/cmd/agent-task/create/create.go
Normal 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
|
||||
}
|
||||
192
pkg/cmd/agent-task/create/create_test.go
Normal file
192
pkg/cmd/agent-task/create/create_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue