cli/pkg/cmd/agent-task/create/create.go
Kynan Ware 33d1196645 Escape URL path segments in agent session links
Uses url.PathEscape for repo owner, repo name, and session ID when constructing agent session URLs to ensure proper encoding and prevent issues with special characters.
2025-09-03 10:40:00 -06:00

179 lines
5.3 KiB
Go

package create
import (
"context"
"errors"
"fmt"
"io"
"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.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.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, 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
}