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:
parent
fca5e0cd63
commit
3574ee9c30
5 changed files with 490 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
|
||||
|
|
|
|||
122
pkg/cmd/agent-task/capi/job.go
Normal file
122
pkg/cmd/agent-task/capi/job.go
Normal 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
|
||||
}
|
||||
173
pkg/cmd/agent-task/create/create.go
Normal file
173
pkg/cmd/agent-task/create/create.go
Normal 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
|
||||
}
|
||||
191
pkg/cmd/agent-task/create/create_test.go
Normal file
191
pkg/cmd/agent-task/create/create_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue