cli/pkg/cmd/agent-task/view/view.go
Kynan Ware 0fb10fca7d Various log rendering improvements
Updated the LogRenderer interface and implementations to accept *iostreams.IOStreams instead of *iostreams.ColorScheme, enabling access to terminal theme and width for improved markdown rendering. Refactored related code, tests, and mocks to support this change, and enhanced log rendering to better handle markdown and code output for various tool calls.
2025-09-15 10:13:55 -06:00

354 lines
10 KiB
Go

package view
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/agent-task/capi"
"github.com/cli/cli/v2/pkg/cmd/agent-task/shared"
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
const (
defaultLimit = 40
defaultLogPollInterval = 5 * time.Second
)
type ViewOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
CapiClient func() (capi.CapiClient, error)
HttpClient func() (*http.Client, error)
Finder prShared.PRFinder
Prompter prompter.Prompter
Browser browser.Browser
LogRenderer func() shared.LogRenderer
Sleep func(d time.Duration)
SelectorArg string
PRNumber int
SessionID string
Web bool
Log bool
Follow bool
}
func defaultLogRenderer() shared.LogRenderer {
return shared.NewLogRenderer()
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
CapiClient: shared.CapiClientFunc(f),
Prompter: f.Prompter,
Browser: f.Browser,
LogRenderer: defaultLogRenderer,
Sleep: time.Sleep,
}
cmd := &cobra.Command{
Use: "view [<session-id> | <pr-number> | <pr-url> | <pr-branch>]",
Short: "View an agent task session (preview)",
Long: heredoc.Doc(`
View an agent task session.
`),
Example: heredoc.Doc(`
# View an agent task by session ID
$ gh agent-task view e2fa49d2-f164-4a56-ab99-498090b8fcdf
# View an agent task by pull request number in current repo
$ gh agent-task view 12345
# View an agent task by pull request number
$ gh agent-task view --repo OWNER/REPO 12345
# View an agent task by pull request reference
$ gh agent-task view OWNER/REPO#12345
# View a pull request agents tasks in the browser
$ gh agent-task view 12345 --web
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Support -R/--repo override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.SelectorArg = args[0]
if shared.IsSessionID(opts.SelectorArg) {
opts.SessionID = opts.SelectorArg
} else if sessionID, err := shared.ParseSessionIDFromURL(opts.SelectorArg); err == nil {
opts.SessionID = sessionID
}
}
if opts.SessionID == "" && !opts.IO.CanPrompt() {
return fmt.Errorf("session ID is required when not running interactively")
}
if opts.Follow && !opts.Log {
return cmdutil.FlagErrorf("--log is required when providing --follow")
}
if opts.Finder == nil {
opts.Finder = prShared.NewFinder(f)
}
if runF != nil {
return runF(opts)
}
return viewRun(opts)
},
}
cmdutil.EnableRepoOverride(cmd, f)
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent task in the browser")
cmd.Flags().BoolVar(&opts.Log, "log", false, "Show agent session logs")
cmd.Flags().BoolVar(&opts.Follow, "follow", false, "Follow agent session logs")
return cmd
}
func viewRun(opts *ViewOptions) error {
capiClient, err := opts.CapiClient()
if err != nil {
return err
}
ctx := context.Background()
cs := opts.IO.ColorScheme()
opts.IO.StartProgressIndicatorWithLabel("Fetching agent session...")
defer opts.IO.StopProgressIndicator()
var session *capi.Session
if opts.SessionID != "" {
sess, err := capiClient.GetSession(ctx, opts.SessionID)
if err != nil {
if errors.Is(err, capi.ErrSessionNotFound) {
fmt.Fprintln(opts.IO.ErrOut, "session not found")
return cmdutil.SilentError
}
return err
}
opts.IO.StopProgressIndicator()
if opts.Web {
var webURL string
if sess.PullRequest != nil {
webURL = fmt.Sprintf("%s/agent-sessions/%s", sess.PullRequest.URL, url.PathEscape(sess.ID))
} else {
// Currently the web Copilot Agents home GUI does not support focusing
// on a given session, so we should just navigate to the home page.
webURL = capi.AgentsHomeURL
}
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL))
}
return opts.Browser.Browse(webURL)
}
session = sess
} else {
var prID int64
var prURL string
if opts.SelectorArg != "" {
// Finder does not support the PR/issue reference format (e.g. owner/repo#123)
// so we need to check if the selector arg is a reference and fetch the PR
// directly.
if repo, num, err := prShared.ParseFullReference(opts.SelectorArg); err == nil {
// Since the selector was a reference (i.e. without hostname data), we need to
// check the base repo to get the hostname.
baseRepo, err := opts.BaseRepo()
if err != nil {
return err
}
hostname := baseRepo.RepoHost()
if hostname != ghinstance.Default() {
return fmt.Errorf("agent tasks are not supported on this host: %s", hostname)
}
prID, prURL, err = capiClient.GetPullRequestDatabaseID(ctx, hostname, repo.RepoOwner(), repo.RepoName(), num)
if err != nil {
return fmt.Errorf("failed to fetch pull request: %w", err)
}
}
}
if prID == 0 {
findOptions := prShared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"id", "url", "fullDatabaseId"},
}
pr, repo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
if repo.RepoHost() != ghinstance.Default() {
return fmt.Errorf("agent tasks are not supported on this host: %s", repo.RepoHost())
}
databaseID, err := strconv.ParseInt(pr.FullDatabaseID, 10, 64)
if err != nil {
return fmt.Errorf("failed to parse pull request: %w", err)
}
prID = databaseID
prURL = pr.URL
}
// TODO(babakks): currently we just fetch a pre-defined number of
// matching sessions to avoid hitting the API too many times, but it's
// technically possible for a PR to be associated with lots of sessions
// (i.e. above our selected limit).
sessions, err := capiClient.ListSessionsByResourceID(ctx, "pull", prID, defaultLimit)
if err != nil {
return fmt.Errorf("failed to list sessions for pull request: %w", err)
}
if len(sessions) == 0 {
fmt.Fprintln(opts.IO.ErrOut, "no session found for pull request")
return cmdutil.SilentError
}
opts.IO.StopProgressIndicator()
if opts.Web {
// Note that, we needed to make sure the PR exists and it has at least one session
// associated with it, other wise the `/agent-sessions` page would display the 404
// error.
// We don't need to navigate to a specific session; if there's only one session
// then the GUI will automatically show it, otherwise the user can select from the
// list. This is to avoid unnecessary prompting.
webURL := prURL + "/agent-sessions"
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL))
}
return opts.Browser.Browse(webURL)
}
session = sessions[0]
if len(sessions) > 1 {
now := time.Now()
options := make([]string, 0, len(sessions))
for _, session := range sessions {
options = append(options, fmt.Sprintf(
"%s %s • %s",
shared.SessionSymbol(cs, session.State),
session.Name,
text.FuzzyAgo(now, session.CreatedAt),
))
}
selected, err := opts.Prompter.Select("Select a session", "", options)
if err != nil {
return err
}
session = sessions[selected]
}
}
printSession(opts, session)
if opts.Log {
return printLogs(opts, capiClient, session.ID)
}
return nil
}
func printSession(opts *ViewOptions, session *capi.Session) {
cs := opts.IO.ColorScheme()
if session.PullRequest != nil {
fmt.Fprintf(opts.IO.Out, "%s • %s • %s%s\n",
shared.ColorFuncForSessionState(*session, cs)(shared.SessionStateString(session.State)),
cs.Bold(session.PullRequest.Title),
session.PullRequest.Repository.NameWithOwner,
cs.ColorFromString(prShared.ColorForPRState(*session.PullRequest))(fmt.Sprintf("#%d", session.PullRequest.Number)),
)
} else {
// This can happen when the session is just created and a PR is not yet available for it
fmt.Fprintf(opts.IO.Out, "%s\n", shared.ColorFuncForSessionState(*session, cs)(shared.SessionStateString(session.State)))
}
if session.User != nil {
fmt.Fprintf(opts.IO.Out, "Started on behalf of %s %s\n", session.User.Login, text.FuzzyAgo(time.Now(), session.CreatedAt))
} else {
// Should never happen, but we need to cover the path
fmt.Fprintf(opts.IO.Out, "Started %s\n", text.FuzzyAgo(time.Now(), session.CreatedAt))
}
if !opts.Log {
fmt.Fprintln(opts.IO.Out, "")
fmt.Fprintf(opts.IO.Out, "For detailed session logs, try:\ngh agent-task view '%s' --log\n", session.ID)
} else if !opts.Follow {
fmt.Fprintln(opts.IO.Out, "")
fmt.Fprintf(opts.IO.Out, "To follow session logs, try:\ngh agent-task view '%s' --log --follow\n", session.ID)
}
if session.PullRequest != nil {
fmt.Fprintln(opts.IO.Out, "")
fmt.Fprintln(opts.IO.Out, cs.Muted("View this session on GitHub:"))
fmt.Fprintln(opts.IO.Out, cs.Muted(fmt.Sprintf("%s/agent-sessions/%s", session.PullRequest.URL, url.PathEscape(session.ID))))
}
}
func printLogs(opts *ViewOptions, capiClient capi.CapiClient, sessionID string) error {
ctx := context.Background()
renderer := opts.LogRenderer()
if opts.Follow {
var called bool
fetcher := func() ([]byte, error) {
if called {
opts.Sleep(defaultLogPollInterval)
}
called = true
raw, err := capiClient.GetSessionLogs(ctx, sessionID)
if err != nil {
return nil, err
}
return raw, nil
}
fmt.Fprintln(opts.IO.Out, "")
return renderer.Follow(fetcher, opts.IO.Out, opts.IO)
}
raw, err := capiClient.GetSessionLogs(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to fetch session logs: %w", err)
}
fmt.Fprintln(opts.IO.Out, "")
_, err = renderer.Render(raw, opts.IO.Out, opts.IO)
return err
}