Normalizes job API error messages to include status code and text, ensures errors from job polling are surfaced to stderr without halting execution, and updates tests to verify error output and remove outdated fallback behavior.
181 lines
5.4 KiB
Go
181 lines
5.4 KiB
Go
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 problem statement 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 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("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
|
|
}
|