From 3573f61b863e3e3110f87b8addb1db3ff63a071d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 27 Aug 2025 12:10:06 -0600 Subject: [PATCH 001/208] Add agent-task command with OAuth token validation Introduces a new `agent-task` command under pkg/cmd/agent with strict OAuth (device flow) token validation. Includes comprehensive tests for token source and host validation, and registers the command in the root command set. --- pkg/cmd/agent-task/agent_task.go | 62 ++++++++++ pkg/cmd/agent-task/agent_task_test.go | 172 ++++++++++++++++++++++++++ pkg/cmd/root/root.go | 2 + 3 files changed, 236 insertions(+) create mode 100644 pkg/cmd/agent-task/agent_task.go create mode 100644 pkg/cmd/agent-task/agent_task_test.go diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go new file mode 100644 index 000000000..1e916d034 --- /dev/null +++ b/pkg/cmd/agent-task/agent_task.go @@ -0,0 +1,62 @@ +package agent + +import ( + "errors" + "fmt" + "strings" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/go-gh/v2/pkg/auth" + "github.com/spf13/cobra" +) + +// NewCmdAgentTask creates the base `agent-task` command. +func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent-task", + Aliases: []string{"agent-tasks", "agent", "agents"}, + Short: "Manage agent tasks (preview)", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return requireOAuthToken(f) + }, + // This is required to run this root command. We want to + // run it to test PersistentPreRunE behavior. + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + return cmd +} + +// requireOAuthToken ensures an OAuth (device flow) token is present and valid. +// agent-task subcommands inherit this check via PersistentPreRunE. +func requireOAuthToken(f *cmdutil.Factory) error { + cfg, err := f.Config() + if err != nil { + return err + } + + authCfg := cfg.Authentication() + host, _ := authCfg.DefaultHost() + if host == "" { + return errors.New("no default host configured; run 'gh auth login'") + } + + if auth.IsEnterprise(host) { + return errors.New("agent tasks are not supported on this host") + } + + token, source := authCfg.ActiveToken(host) + + // Tokens from sources "oauth_token" and "keyring" are likely + // minted through our device flow. + tokenSourceIsDeviceFlow := source == "oauth_token" || source == "keyring" + // Tokens with "gho_" prefix are OAuth tokens. + tokenIsOAuth := strings.HasPrefix(token, "gho_") + + // Reject if the token is not from a device flow source or is not an OAuth token + if !tokenSourceIsDeviceFlow || !tokenIsOAuth { + return fmt.Errorf("this command requires an OAuth token. Re-authenticate with: gh auth login") + } + return nil +} diff --git a/pkg/cmd/agent-task/agent_task_test.go b/pkg/cmd/agent-task/agent_task_test.go new file mode 100644 index 000000000..76b2651f1 --- /dev/null +++ b/pkg/cmd/agent-task/agent_task_test.go @@ -0,0 +1,172 @@ +package agent + +import ( + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + ghmock "github.com/cli/cli/v2/internal/gh/mock" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/require" +) + +// setupMockOAuthConfig configures a blank config with a default host and optional token behavior. +func setupMockOAuthConfig(t *testing.T, tokenSource string) gh.Config { + t.Helper() + c := config.NewBlankConfig() + switch tokenSource { + case "oauth_token": + // valid OAuth device flow token stored in config + c.Set("github.com", "oauth_token", "gho_OAUTH123") + case "keyring": + // valid OAuth device flow token stored in keyring + c.Set("github.com", "oauth_token", "gho_OAUTH123") + case "GH_TOKEN": + // classic style token stored in config (will fail prefix check) + c.Set("github.com", "oauth_token", "ghp_CLASSIC123") + case "GH_ENTERPRISE_TOKEN": + // enterprise style token stored in config (will fail prefix check) + c.Set("something.ghes.com", "oauth_token", "ghe_ENTERPRISE123") + } + return c +} + +func TestOAuthTokenAccepted(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, stdout, _ := iostreams.Test() + f.IOStreams = ios + f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, "oauth_token"), nil } + + cmd := NewCmdAgentTask(f) + err := cmd.Execute() + require.NoError(t, err) + require.Equal(t, "", stdout.String()) +} + +func TestKeyringOAuthTokenAccepted(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, stdout, _ := iostreams.Test() + f.IOStreams = ios + f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, "keyring"), nil } + + cmd := NewCmdAgentTask(f) + err := cmd.Execute() + require.NoError(t, err) + require.Equal(t, "", stdout.String()) +} + +func TestEnvVarTokenRejected(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, "GH_TOKEN"), nil } + cmd := NewCmdAgentTask(f) + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "requires an OAuth token") +} + +func TestEnterpriseTokenIgnored(t *testing.T) { + // This test ignores the test helper because we want to test a specific config state + t.Run("enterprise token alone is ignored and rejected", func(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.Config = func() (gh.Config, error) { + return func() gh.Config { + c := config.NewBlankConfig() + c.Set("something.ghes.com", "oauth_token", "ghe_ENTERPRISE123") + return c + }(), nil + } + + cmd := NewCmdAgentTask(f) + err := cmd.Execute() + require.Error(t, err) + }) + + t.Run("github.com oauth is accepted and enterprise token ignored", func(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.Config = func() (gh.Config, error) { + return func() gh.Config { + c := config.NewBlankConfig() + c.Set("something.ghes.com", "oauth_token", "ghe_ENTERPRISE123") + c.Set("github.com", "oauth_token", "gho_OAUTH123") + return c + }(), nil + } + + cmd := NewCmdAgentTask(f) + err := cmd.Execute() + require.NoError(t, err) + }) + +} + +func TestEnterpriseHostRejected(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + + f.Config = func() (gh.Config, error) { + return &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + c := &config.AuthConfig{} + c.SetDefaultHost("something.ghes.com", "GH_HOST") + return c + }, + }, nil + } + + cmd := NewCmdAgentTask(f) + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "not supported on this host") +} + +func TestEmptyHostRejected(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + + f.Config = func() (gh.Config, error) { + return &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + c := &config.AuthConfig{} + c.SetDefaultHost("", "GH_HOST") + return c + }, + }, nil + } + + cmd := NewCmdAgentTask(f) + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "no default host configured") +} + +func TestNoAuthRejected(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + // No token configured + f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, ""), nil } + + cmd := NewCmdAgentTask(f) + err := cmd.Execute() + require.Error(t, err) +} + +func TestAliasAreSet(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, "oauth_token"), nil } + + cmd := NewCmdAgentTask(f) + + require.ElementsMatch(t, []string{"agent-tasks", "agent", "agents"}, cmd.Aliases) +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 6a709c336..27f028e44 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -8,6 +8,7 @@ import ( "github.com/MakeNowJust/heredoc" accessibilityCmd "github.com/cli/cli/v2/pkg/cmd/accessibility" actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions" + agentTaskCmd "github.com/cli/cli/v2/pkg/cmd/agent-task" aliasCmd "github.com/cli/cli/v2/pkg/cmd/alias" "github.com/cli/cli/v2/pkg/cmd/alias/shared" apiCmd "github.com/cli/cli/v2/pkg/cmd/api" @@ -126,6 +127,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(versionCmd.NewCmdVersion(f, version, buildDate)) cmd.AddCommand(accessibilityCmd.NewCmdAccessibility(f)) cmd.AddCommand(actionsCmd.NewCmdActions(f)) + cmd.AddCommand(agentTaskCmd.NewCmdAgentTask(f)) cmd.AddCommand(aliasCmd.NewCmdAlias(f)) cmd.AddCommand(authCmd.NewCmdAuth(f)) cmd.AddCommand(attestationCmd.NewCmdAttestation(f)) From 2128a297b37550b6c98e78f03a6ae1452799a2af Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:06:19 -0600 Subject: [PATCH 002/208] Show help on agent-task command execution Changed the RunE function of the agent-task command to display help output instead of returning nil, improving user guidance when the command is run without arguments. --- pkg/cmd/agent-task/agent_task.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index 1e916d034..b53c6786d 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -22,7 +22,7 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command { // This is required to run this root command. We want to // run it to test PersistentPreRunE behavior. RunE: func(cmd *cobra.Command, args []string) error { - return nil + return cmd.Help() }, } return cmd From 1bc2710c883b27093115dba207febd78817a92aa Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:07:35 -0600 Subject: [PATCH 003/208] Refactor test to use require.Empty assertion Replaces require.Equal with require.Empty in TestOAuthTokenAccepted for improved clarity when checking for empty output. --- pkg/cmd/agent-task/agent_task_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/agent_task_test.go b/pkg/cmd/agent-task/agent_task_test.go index 76b2651f1..d5e89c55f 100644 --- a/pkg/cmd/agent-task/agent_task_test.go +++ b/pkg/cmd/agent-task/agent_task_test.go @@ -41,7 +41,7 @@ func TestOAuthTokenAccepted(t *testing.T) { cmd := NewCmdAgentTask(f) err := cmd.Execute() require.NoError(t, err) - require.Equal(t, "", stdout.String()) + require.Empty(t, stdout.String()) } func TestKeyringOAuthTokenAccepted(t *testing.T) { From b939188e6ddc240d29420e95f14c1e109cf91479 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:20:45 -0600 Subject: [PATCH 004/208] Refactor agent task tests into table-driven format Consolidates multiple individual test functions into a single table-driven test, improving maintainability and readability. This change makes it easier to add new test cases and ensures consistent test structure for agent task command authentication scenarios. --- pkg/cmd/agent-task/agent_task_test.go | 211 ++++++++++++-------------- 1 file changed, 94 insertions(+), 117 deletions(-) diff --git a/pkg/cmd/agent-task/agent_task_test.go b/pkg/cmd/agent-task/agent_task_test.go index d5e89c55f..dd4fe21b0 100644 --- a/pkg/cmd/agent-task/agent_task_test.go +++ b/pkg/cmd/agent-task/agent_task_test.go @@ -32,132 +32,109 @@ func setupMockOAuthConfig(t *testing.T, tokenSource string) gh.Config { return c } -func TestOAuthTokenAccepted(t *testing.T) { - f := &cmdutil.Factory{} - ios, _, stdout, _ := iostreams.Test() - f.IOStreams = ios - f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, "oauth_token"), nil } - - cmd := NewCmdAgentTask(f) - err := cmd.Execute() - require.NoError(t, err) - require.Empty(t, stdout.String()) -} - -func TestKeyringOAuthTokenAccepted(t *testing.T) { - f := &cmdutil.Factory{} - ios, _, stdout, _ := iostreams.Test() - f.IOStreams = ios - f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, "keyring"), nil } - - cmd := NewCmdAgentTask(f) - err := cmd.Execute() - require.NoError(t, err) - require.Equal(t, "", stdout.String()) -} - -func TestEnvVarTokenRejected(t *testing.T) { - f := &cmdutil.Factory{} - ios, _, _, _ := iostreams.Test() - f.IOStreams = ios - f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, "GH_TOKEN"), nil } - cmd := NewCmdAgentTask(f) - err := cmd.Execute() - require.Error(t, err) - require.Contains(t, err.Error(), "requires an OAuth token") -} - -func TestEnterpriseTokenIgnored(t *testing.T) { - // This test ignores the test helper because we want to test a specific config state - t.Run("enterprise token alone is ignored and rejected", func(t *testing.T) { - f := &cmdutil.Factory{} - ios, _, _, _ := iostreams.Test() - f.IOStreams = ios - f.Config = func() (gh.Config, error) { - return func() gh.Config { - c := config.NewBlankConfig() - c.Set("something.ghes.com", "oauth_token", "ghe_ENTERPRISE123") - return c - }(), nil - } - - cmd := NewCmdAgentTask(f) - err := cmd.Execute() - require.Error(t, err) - }) - - t.Run("github.com oauth is accepted and enterprise token ignored", func(t *testing.T) { - f := &cmdutil.Factory{} - ios, _, _, _ := iostreams.Test() - f.IOStreams = ios - f.Config = func() (gh.Config, error) { - return func() gh.Config { +func TestNewCmdAgentTask(t *testing.T) { + tests := []struct { + name string + tokenSource string + customConfig func() (gh.Config, error) + wantErr bool + wantErrContains string + wantStdout string + }{ + { + name: "oauth token is accepted", + tokenSource: "oauth_token", + wantErr: false, + wantStdout: "", + }, + { + name: "keyring oauth token is accepted", + tokenSource: "keyring", + wantErr: false, + wantStdout: "", + }, + { + name: "env var token is rejected", + tokenSource: "GH_TOKEN", + wantErr: true, + wantErrContains: "requires an OAuth token", + }, + { + name: "enterprise token alone is ignored and rejected", + tokenSource: "GH_ENTERPRISE_TOKEN", + wantErr: true, + }, + { + name: "github.com oauth is accepted and enterprise token ignored", + customConfig: func() (gh.Config, error) { c := config.NewBlankConfig() c.Set("something.ghes.com", "oauth_token", "ghe_ENTERPRISE123") c.Set("github.com", "oauth_token", "gho_OAUTH123") - return c - }(), nil - } - - cmd := NewCmdAgentTask(f) - err := cmd.Execute() - require.NoError(t, err) - }) - -} - -func TestEnterpriseHostRejected(t *testing.T) { - f := &cmdutil.Factory{} - ios, _, _, _ := iostreams.Test() - f.IOStreams = ios - - f.Config = func() (gh.Config, error) { - return &ghmock.ConfigMock{ - AuthenticationFunc: func() gh.AuthConfig { - c := &config.AuthConfig{} - c.SetDefaultHost("something.ghes.com", "GH_HOST") - return c + return c, nil }, - }, nil + wantErr: false, + wantStdout: "", + }, + { + name: "enterprise host is rejected", + customConfig: func() (gh.Config, error) { + return &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + c := &config.AuthConfig{} + c.SetDefaultHost("something.ghes.com", "GH_HOST") + return c + }, + }, nil + }, + wantErr: true, + wantErrContains: "not supported on this host", + }, + { + name: "empty host is rejected", + customConfig: func() (gh.Config, error) { + return &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + c := &config.AuthConfig{} + c.SetDefaultHost("", "GH_HOST") + return c + }, + }, nil + }, + wantErr: true, + wantErrContains: "no default host configured", + }, + { + name: "no auth is rejected", + tokenSource: "", + wantErr: true, + }, } - cmd := NewCmdAgentTask(f) - err := cmd.Execute() - require.Error(t, err) - require.Contains(t, err.Error(), "not supported on this host") -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, stdout, _ := iostreams.Test() + f.IOStreams = ios + if tt.customConfig != nil { + f.Config = tt.customConfig + } else { + f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, tt.tokenSource), nil } + } -func TestEmptyHostRejected(t *testing.T) { - f := &cmdutil.Factory{} - ios, _, _, _ := iostreams.Test() - f.IOStreams = ios + cmd := NewCmdAgentTask(f) + err := cmd.Execute() - f.Config = func() (gh.Config, error) { - return &ghmock.ConfigMock{ - AuthenticationFunc: func() gh.AuthConfig { - c := &config.AuthConfig{} - c.SetDefaultHost("", "GH_HOST") - return c - }, - }, nil + if tt.wantErr { + require.Error(t, err) + if tt.wantErrContains != "" { + require.Contains(t, err.Error(), tt.wantErrContains) + } + } else { + require.NoError(t, err) + require.Equal(t, tt.wantStdout, stdout.String()) + } + }) } - - cmd := NewCmdAgentTask(f) - err := cmd.Execute() - require.Error(t, err) - require.Contains(t, err.Error(), "no default host configured") -} - -func TestNoAuthRejected(t *testing.T) { - f := &cmdutil.Factory{} - ios, _, _, _ := iostreams.Test() - f.IOStreams = ios - // No token configured - f.Config = func() (gh.Config, error) { return setupMockOAuthConfig(t, ""), nil } - - cmd := NewCmdAgentTask(f) - err := cmd.Execute() - require.Error(t, err) } func TestAliasAreSet(t *testing.T) { From dd424d85fd5722d02b044f361aed0fb2d74422f0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:02:09 -0600 Subject: [PATCH 005/208] Add agent task listing command and CAPI client Introduces a new 'list' subcommand under agent-task for listing agent tasks. Implements a Copilot API client for fetching agent sessions and hydrating them with pull request data. Updates PullRequest and PRRepository types to support new fields. Adds dependencies for msgpack and tagparser. Co-Authored-By: Babak K. Shandiz --- api/queries_pr.go | 6 +- go.mod | 2 + go.sum | 4 + pkg/cmd/agent-task/agent_task.go | 5 + pkg/cmd/agent-task/capi/client.go | 64 +++++++++ pkg/cmd/agent-task/capi/sessions.go | 208 +++++++++++++++++++++++++++ pkg/cmd/agent-task/list/list.go | 150 +++++++++++++++++++ pkg/cmd/agent-task/list/list_test.go | 91 ++++++++++++ 8 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/agent-task/capi/client.go create mode 100644 pkg/cmd/agent-task/capi/sessions.go create mode 100644 pkg/cmd/agent-task/list/list.go create mode 100644 pkg/cmd/agent-task/list/list_test.go diff --git a/api/queries_pr.go b/api/queries_pr.go index 525418a11..b3373a903 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -62,6 +62,7 @@ type PullRequest struct { MergedBy *Author HeadRepositoryOwner Owner HeadRepository *PRRepository + Repository *PRRepository IsCrossRepository bool IsDraft bool MaintainerCanModify bool @@ -251,8 +252,9 @@ type Workflow struct { } type PRRepository struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + NameWithOwner string `json:"nameWithOwner"` } type AutoMergeRequest struct { diff --git a/go.mod b/go.mod index 18deafe15..7f099d7ea 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/spf13/pflag v1.0.7 github.com/stretchr/testify v1.10.0 github.com/theupdateframework/go-tuf/v2 v2.1.1 + github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yuin/goldmark v1.7.13 github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.41.0 @@ -205,6 +206,7 @@ require ( github.com/transparency-dev/merkle v0.0.2 // indirect github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823 // indirect github.com/vbatts/tar-split v0.12.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/zeebo/errs v1.4.0 // indirect diff --git a/go.sum b/go.sum index eeebd6973..f28dc1cf4 100644 --- a/go.sum +++ b/go.sum @@ -1415,6 +1415,10 @@ github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823 h1:s3p7 github.com/transparency-dev/tessera v0.2.1-0.20250610150926-8ee4e93b2823/go.mod h1:Jv2IDwG1q8QNXZTaI1X6QX8s96WlJn73ka2hT1n4N5c= github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index b53c6786d..cbbd3e278 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + 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" "github.com/spf13/cobra" @@ -25,6 +26,10 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command { return cmd.Help() }, } + + // register subcommands + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + return cmd } diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go new file mode 100644 index 000000000..b5b4ea9e0 --- /dev/null +++ b/pkg/cmd/agent-task/capi/client.go @@ -0,0 +1,64 @@ +package capi + +import ( + "context" + "net/http" + + "github.com/cli/cli/v2/internal/gh" +) + +const baseCAPIURL = "https://api.githubcopilot.com" +const capiHost = "api.githubcopilot.com" + +// CapiClient defines the methods used by the caller. Implementations +// may be replaced with test doubles in unit tests. +type CapiClient interface { + ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) +} + +// CAPIClient is a client for interacting with the Copilot API +type CAPIClient struct { + httpClient *http.Client + authCfg gh.AuthConfig +} + +// NewCAPIClient creates a new CAPI client. Provide a token and an HTTP client which +// will be used as the base transport for CAPI requests. +// +// The provided HTTP client will be mutated for use with CAPI, so it should not +// be reused elsewhere. +func NewCAPIClient(httpClient *http.Client, authCfg gh.AuthConfig) *CAPIClient { + host, _ := authCfg.DefaultHost() + token, _ := authCfg.ActiveToken(host) + + httpClient.Transport = newCAPITransport(token, httpClient.Transport) + return &CAPIClient{ + httpClient: httpClient, + authCfg: authCfg, + } +} + +// capiTransport adds the Copilot auth headers +type capiTransport struct { + rp http.RoundTripper + token string +} + +func newCAPITransport(token string, rp http.RoundTripper) *capiTransport { + return &capiTransport{ + rp: rp, + token: token, + } +} + +func (ct *capiTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+ct.token) + + // Since this RoundTrip is reused for both Copilot API and + // GitHub API requests, we conditionally add the integration + // ID only when performing requests to the Copilot API. + if req.URL.Host == capiHost { + req.Header.Add("Copilot-Integration-Id", "copilot-4-cli") + } + return ct.rp.RoundTrip(req) +} diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go new file mode 100644 index 000000000..2693af57d --- /dev/null +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -0,0 +1,208 @@ +package capi + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "slices" + "strconv" + "time" + + "github.com/cli/cli/v2/api" + "github.com/vmihailenco/msgpack/v5" +) + +// session is an in-flight agent task +type session struct { + ID string `json:"id"` + Name string `json:"name"` + UserID uint64 `json:"user_id"` + AgentID int64 `json:"agent_id"` + Logs string `json:"logs"` + State string `json:"state"` + OwnerID uint64 `json:"owner_id"` + RepoID uint64 `json:"repo_id"` + ResourceType string `json:"resource_type"` + ResourceID int64 `json:"resource_id"` + LastUpdatedAt time.Time `json:"last_updated_at,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + CompletedAt time.Time `json:"completed_at,omitempty"` + EventURL string `json:"event_url"` + EventType string `json:"event_type"` +} + +// A shim of a full pull request because looking up by node ID +// using the full api.PullRequest type fails on unions (actors) +type sessionPullRequest struct { + ID string + FullDatabaseID string + Number int + Title string + State string + URL string + Body string + + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt *time.Time + MergedAt *time.Time + + // Uncomment one of these to see error + // Author api.Author + // MergedBy *api.Author + Repository *api.PRRepository +} + +// Session is a hydrated in-flight agent task +type Session struct { + session + PullRequest *api.PullRequest `json:"-"` +} + +// ListSessionsForViewer lists all agent sessions for the +// authenticated user up to limit. +func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { + url := baseCAPIURL + "/agents/sessions" + + var sessions []session + page := 1 + perPage := 50 + + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Set("page_size", strconv.Itoa(perPage)) + q.Set("page_number", strconv.Itoa(page)) + req.URL.RawQuery = q.Encode() + + 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 list sessions: %s", res.Status) + } + var response struct { + Sessions []session `json:"sessions"` + } + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode sessions response: %w", err) + } + if len(response.Sessions) == 0 || len(sessions) >= limit { + break + } + sessions = append(sessions, response.Sessions...) + page++ + } + + // Drop any above the limit + if len(sessions) > limit { + sessions = sessions[:limit] + } + + // Hydrate the Sessions with pull request data. + Sessions, err := c.hydrateSessionPullRequests(sessions) + if err != nil { + return nil, err + } + + return Sessions, nil +} + +// hydrateSessionPullRequests hydrates pull request information in sessions +func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, error) { + if len(sessions) == 0 { + return nil, nil + } + + prNodeIds := make([]string, 0, len(sessions)) + + for _, session := range sessions { + prNodeID := generatePullRequestNodeID(int64(session.RepoID), session.ResourceID) + if slices.Contains(prNodeIds, prNodeID) { + continue + } + prNodeIds = append(prNodeIds, prNodeID) + } + + apiClient := api.NewClientFromHTTP(c.httpClient) + + var resp struct { + Nodes []struct { + PullRequest sessionPullRequest `graphql:"... on PullRequest"` + } `graphql:"nodes(ids: $ids)"` + } + + host, _ := c.authCfg.DefaultHost() + err := apiClient.Query(host, "FetchPRs", &resp, map[string]any{ + "ids": prNodeIds, + }) + + if err != nil { + return nil, err + } + + prs := make([]*api.PullRequest, 0, len(prNodeIds)) + for _, node := range resp.Nodes { + prs = append(prs, &api.PullRequest{ + ID: node.PullRequest.ID, + FullDatabaseID: node.PullRequest.FullDatabaseID, + Number: node.PullRequest.Number, + Title: node.PullRequest.Title, + State: node.PullRequest.State, + URL: node.PullRequest.URL, + Body: node.PullRequest.Body, + CreatedAt: node.PullRequest.CreatedAt, + UpdatedAt: node.PullRequest.UpdatedAt, + ClosedAt: node.PullRequest.ClosedAt, + MergedAt: node.PullRequest.MergedAt, + Repository: node.PullRequest.Repository, + }) + } + + newSessions := make([]*Session, 0, len(sessions)) + // For each session, we need to attach the Pull Request + for _, s := range sessions { + // For each Pull Request, check if it matches the session + for _, pr := range prs { + if strconv.FormatInt(s.ResourceID, 10) == pr.FullDatabaseID { + newSessions = append(newSessions, &Session{ + session: s, + PullRequest: pr, + }) + } + } + } + + return newSessions, nil +} + +// generatePullRequestNodeID converts an int64 databaseID and repoID to a GraphQL Node ID format +// with the "PR_" prefix for pull requests +func generatePullRequestNodeID(repoID, pullRequestID int64) string { + buf := bytes.Buffer{} + parts := []int64{0, repoID, pullRequestID} + + encoder := msgpack.NewEncoder(&buf) + encoder.UseCompactInts(true) + + // Encode the parts + err := encoder.Encode(parts) + if err != nil { + panic(err) + } + + // Use URL-safe Base64 encoding without padding + encoded := base64.RawURLEncoding.EncodeToString(buf.Bytes()) + + // Return with the PR_ prefix + return "PR_" + encoded +} diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go new file mode 100644 index 000000000..3b2bff267 --- /dev/null +++ b/pkg/cmd/agent-task/list/list.go @@ -0,0 +1,150 @@ +package list + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "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 = 30 + +// ListOptions are the options for the list command +type ListOptions struct { + IO *iostreams.IOStreams + Config func() (gh.Config, error) + Limit int + CapiClient capi.CapiClient + HttpClient func() (*http.Client, error) +} + +// NewCmdList creates the list command +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + Limit: defaultLimit, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List agent tasks", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + authCfg := cfg.Authentication() + opts.CapiClient = capi.NewCAPIClient(httpClient, authCfg) + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + return cmd +} + +func listRun(opts *ListOptions) error { + if opts.Limit <= 0 { + opts.Limit = defaultLimit + } + + capiClient := opts.CapiClient + + opts.IO.StartProgressIndicatorWithLabel("Fetching agent tasks...") + defer opts.IO.StopProgressIndicator() + sessions, err := capiClient.ListSessionsForViewer(context.Background(), opts.Limit) + if err != nil { + return err + } + opts.IO.StopProgressIndicator() + + if len(sessions) == 0 { + fmt.Fprintln(opts.IO.Out, "no agent tasks found") + return nil + } + + cs := opts.IO.ColorScheme() + tp := tableprinter.New(opts.IO, tableprinter.WithHeader("Session ID", "Pull Request", "Repo", "Session State", "Created")) + for _, s := range sessions { + pr := "" + if s.ResourceType == "pull" && s.PullRequest.Number != 0 { + pr = fmt.Sprintf("#%d", s.PullRequest.Number) + } else { + // Skip these sessions in case they happen, for now. + continue + } + repo := "" + if s.PullRequest.Repository != nil && s.PullRequest.Repository.NameWithOwner != "" { + repo = s.PullRequest.Repository.NameWithOwner + } else { + // Skip these sessions in case they happen, for now. + continue + } + + // ID + tp.AddField(s.ID) + if tp.IsTTY() { + tp.AddField(pr, tableprinter.WithColor(cs.ColorFromString(shared.ColorForPRState(*s.PullRequest)))) + } else { + tp.AddField(pr) + } + + // Repo + tp.AddField(repo, tableprinter.WithColor(cs.Muted)) + + // State + if tp.IsTTY() { + var stateColor func(string) string + switch s.State { + case "completed": + stateColor = cs.Green + case "canceled": + stateColor = cs.Muted + case "in_progress", "queued": + stateColor = cs.Yellow + case "failed": + stateColor = cs.Red + default: + stateColor = cs.Muted + } + tp.AddField(s.State, tableprinter.WithColor(stateColor)) + } else { + tp.AddField(s.State) + } + + // Created + if tp.IsTTY() { + tp.AddTimeField(time.Now(), s.CreatedAt, cs.Muted) + } else { + tp.AddField(s.CreatedAt.Format(time.RFC3339)) + } + + tp.EndRow() + } + + if err := tp.Render(); err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go new file mode 100644 index 000000000..a02c77bab --- /dev/null +++ b/pkg/cmd/agent-task/list/list_test.go @@ -0,0 +1,91 @@ +package list + +import ( + "bytes" + "context" + "net/http" + "testing" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + capi "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/require" +) + +// testListOptionsWithRegistry constructs ListOptions and returns the stdout buffer for assertions +func testListOptionsWithRegistry(reg *httpmock.Registry) (*ListOptions, *bytes.Buffer) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + opts := &ListOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Config: func() (gh.Config, error) { + c := config.NewBlankConfig() + c.Set("github.com", "oauth_token", "gho_OAUTH123") + return c, nil + }, + Limit: defaultLimit, + } + + return opts, stdout +} + +// mockCAPIClient is a small test double for the CAPI client. +type mockCAPIClient struct { + sessions []*capi.Session +} + +// Updated to match production interface which now includes a limit parameter. +func (m *mockCAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*capi.Session, error) { + return m.sessions, nil +} + +func TestListRun_WithSessions(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + opts, stdout := testListOptionsWithRegistry(®) + + createdAt := time.Date(2025, time.August, 25, 12, 0, 0, 0, time.UTC) + s := &capi.Session{} + s.ID = "s1" + s.RepoID = 123 + s.ResourceType = "pull" + s.ResourceID = 456 + s.State = "completed" + s.CreatedAt = createdAt + s.PullRequest = &api.PullRequest{ + Number: 456, + State: "OPEN", + Repository: &api.PRRepository{NameWithOwner: "owner/repo"}, + } + opts.CapiClient = &mockCAPIClient{sessions: []*capi.Session{s}} + + err := listRun(opts) + require.NoError(t, err) + out := stdout.String() + require.Contains(t, out, "SESSION ID") + require.Contains(t, out, "s1") + require.Contains(t, out, "#456") + require.Contains(t, out, "owner/repo") +} + +func TestListRun_NoSessions(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + opts, stdout := testListOptionsWithRegistry(®) + opts.CapiClient = &mockCAPIClient{sessions: []*capi.Session{}} + + err := listRun(opts) + require.NoError(t, err) + out := stdout.String() + require.Contains(t, out, "no agent tasks found") +} From 0de5cf24f070fd6849712a95a8267e8ce00dc94a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 08:19:37 -0600 Subject: [PATCH 006/208] Fix import alias for shared package in agent-task list Renames the import of the shared package to 'prShared' and updates its usage in list.go to avoid naming conflicts and improve code clarity. --- pkg/cmd/agent-task/list/list.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 3b2bff267..b218242f4 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -9,7 +9,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" - "github.com/cli/cli/v2/pkg/cmd/pr/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" @@ -104,7 +104,7 @@ func listRun(opts *ListOptions) error { // ID tp.AddField(s.ID) if tp.IsTTY() { - tp.AddField(pr, tableprinter.WithColor(cs.ColorFromString(shared.ColorForPRState(*s.PullRequest)))) + tp.AddField(pr, tableprinter.WithColor(cs.ColorFromString(prShared.ColorForPRState(*s.PullRequest)))) } else { tp.AddField(pr) } From 0a5b78a510a7ed44ea833fb3e3da784e0243287d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 08:25:00 -0600 Subject: [PATCH 007/208] Refactor session filtering in listRun function Simplifies logic for filtering sessions to only include those with valid pull request and repository data. This reduces nested conditionals and improves code readability. --- pkg/cmd/agent-task/list/list.go | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index b218242f4..ae498bfba 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -86,21 +86,14 @@ func listRun(opts *ListOptions) error { cs := opts.IO.ColorScheme() tp := tableprinter.New(opts.IO, tableprinter.WithHeader("Session ID", "Pull Request", "Repo", "Session State", "Created")) for _, s := range sessions { - pr := "" - if s.ResourceType == "pull" && s.PullRequest.Number != 0 { - pr = fmt.Sprintf("#%d", s.PullRequest.Number) - } else { - // Skip these sessions in case they happen, for now. - continue - } - repo := "" - if s.PullRequest.Repository != nil && s.PullRequest.Repository.NameWithOwner != "" { - repo = s.PullRequest.Repository.NameWithOwner - } else { + if s.ResourceType != "pull" || s.PullRequest == nil || s.PullRequest.Repository == nil { // Skip these sessions in case they happen, for now. continue } + pr := fmt.Sprintf("#%d", s.PullRequest.Number) + repo := s.PullRequest.Repository.NameWithOwner + // ID tp.AddField(s.ID) if tp.IsTTY() { From 7b71b5f21b710834dbca1257c0062c7437c3d9c0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:02:25 -0600 Subject: [PATCH 008/208] Refactor agent-task list command client initialization Moves CAPI client initialization to a deferred function in ListOptions, simplifying command setup and improving testability. Updates tests to use the new client initialization pattern and adds more comprehensive test cases for session listing. --- pkg/cmd/agent-task/list/list.go | 42 +-- pkg/cmd/agent-task/list/list_test.go | 409 ++++++++++++++++++++++----- 2 files changed, 362 insertions(+), 89 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index ae498bfba..6e530fdd1 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -3,7 +3,6 @@ package list import ( "context" "fmt" - "net/http" "time" "github.com/cli/cli/v2/internal/gh" @@ -22,17 +21,15 @@ type ListOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) Limit int - CapiClient capi.CapiClient - HttpClient func() (*http.Client, error) + CapiClient func() (*capi.CAPIClient, error) } // NewCmdList creates the list command func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ - IO: f.IOStreams, - Config: f.Config, - Limit: defaultLimit, - HttpClient: f.HttpClient, + IO: f.IOStreams, + Config: f.Config, + Limit: defaultLimit, } cmd := &cobra.Command{ @@ -40,19 +37,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Short: "List agent tasks", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := f.Config() - if err != nil { - return err - } - - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - - authCfg := cfg.Authentication() - opts.CapiClient = capi.NewCAPIClient(httpClient, authCfg) - if runF != nil { return runF(opts) } @@ -60,6 +44,19 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman }, } + opts.CapiClient = func() (*capi.CAPIClient, error) { + cfg, err := opts.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 } @@ -68,7 +65,10 @@ func listRun(opts *ListOptions) error { opts.Limit = defaultLimit } - capiClient := opts.CapiClient + capiClient, err := opts.CapiClient() + if err != nil { + return err + } opts.IO.StartProgressIndicatorWithLabel("Fetching agent tasks...") defer opts.IO.StopProgressIndicator() diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index a02c77bab..a9c5d7c05 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -1,91 +1,364 @@ package list import ( - "bytes" - "context" "net/http" "testing" "time" - "github.com/cli/cli/v2/api" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" - capi "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "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" + "github.com/stretchr/testify/assert" ) -// testListOptionsWithRegistry constructs ListOptions and returns the stdout buffer for assertions -func testListOptionsWithRegistry(reg *httpmock.Registry) (*ListOptions, *bytes.Buffer) { - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - - opts := &ListOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wantOpts ListOptions + }{ + { + name: "no arguments", + wantOpts: ListOptions{ + Limit: defaultLimit, + }, }, - Config: func() (gh.Config, error) { - c := config.NewBlankConfig() - c.Set("github.com", "oauth_token", "gho_OAUTH123") - return c, nil - }, - Limit: defaultLimit, } - return opts, stdout -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.ExecuteC() -// mockCAPIClient is a small test double for the CAPI client. -type mockCAPIClient struct { - sessions []*capi.Session -} - -// Updated to match production interface which now includes a limit parameter. -func (m *mockCAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*capi.Session, error) { - return m.sessions, nil -} - -func TestListRun_WithSessions(t *testing.T) { - reg := httpmock.Registry{} - defer reg.Verify(t) - - opts, stdout := testListOptionsWithRegistry(®) - - createdAt := time.Date(2025, time.August, 25, 12, 0, 0, 0, time.UTC) - s := &capi.Session{} - s.ID = "s1" - s.RepoID = 123 - s.ResourceType = "pull" - s.ResourceID = 456 - s.State = "completed" - s.CreatedAt = createdAt - s.PullRequest = &api.PullRequest{ - Number: 456, - State: "OPEN", - Repository: &api.PRRepository{NameWithOwner: "owner/repo"}, + assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) + }) } - opts.CapiClient = &mockCAPIClient{sessions: []*capi.Session{s}} - - err := listRun(opts) - require.NoError(t, err) - out := stdout.String() - require.Contains(t, out, "SESSION ID") - require.Contains(t, out, "s1") - require.Contains(t, out, "#456") - require.Contains(t, out, "owner/repo") } -func TestListRun_NoSessions(t *testing.T) { - reg := httpmock.Registry{} - defer reg.Verify(t) +func Test_listRun(t *testing.T) { + sixHours, _ := time.ParseDuration("6h") + sixHoursAgo := time.Now().Add(-sixHours) + createdAt := sixHoursAgo.Format(time.RFC3339) - opts, stdout := testListOptionsWithRegistry(®) - opts.CapiClient = &mockCAPIClient{sessions: []*capi.Session{}} + tests := []struct { + name string + tty bool + stubs func(*httpmock.Registry) + wantOut string + }{ + { + name: "no sessions", + tty: true, + stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, + wantOut: "no agent tasks found\n", + }, + { + name: "single session (tty)", + tty: true, + stubs: func(reg *httpmock.Registry) { registerSingleSessionMock(reg, createdAt) }, + wantOut: "SESSION ID PULL REQUEST REPO SESSION STATE CREATED\n" + + "sess1 #42 OWNER/REPO completed about 6 hours ago\n", + }, + { + name: "single session (nontty)", + tty: false, + stubs: func(reg *httpmock.Registry) { registerSingleSessionMock(reg, createdAt) }, + wantOut: "sess1\t#42\tOWNER/REPO\tcompleted\t" + createdAt + "\n", // header omitted for non-tty + }, + { + name: "many sessions (tty)", + tty: true, + stubs: func(reg *httpmock.Registry) { registerManySessionsMock(reg, createdAt) }, + wantOut: "SESSION ID PULL REQUEST REPO SESSION STATE CREATED\n" + + "s1 #101 OWNER/REPO completed about 6 hours ago\n" + + "s2 #102 OWNER/REPO failed about 6 hours ago\n" + + "s3 #103 OWNER/REPO in_progress about 6 hours ago\n" + + "s4 #104 OWNER/REPO queued about 6 hours ago\n" + + "s5 #105 OWNER/REPO canceled about 6 hours ago\n" + + "s6 #106 OWNER/REPO mystery about 6 hours ago\n", + }, + } - err := listRun(opts) - require.NoError(t, err) - out := stdout.String() - require.Contains(t, out, "no agent tasks found") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + tt.stubs(reg) + + cfg := config.NewBlankConfig() + cfg.Set("github.com", "oauth_token", "OTOKEN") + authCfg := cfg.Authentication() + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + + httpClient := &http.Client{Transport: reg} + capiClient := capi.NewCAPIClient(httpClient, authCfg) + opts := &ListOptions{ + IO: ios, + Config: func() (gh.Config, error) { return cfg, nil }, + Limit: 30, + CapiClient: func() (*capi.CAPIClient, error) { return capiClient, nil }, + } + + err := listRun(opts) + assert.NoError(t, err) + + got := stdout.String() + if tt.wantOut == "" && tt.name == "single session (tty)" { + t.Logf("Captured output for single session (tty):\n%s", got) + t.Fatalf("fill in wantOut with the above output and re-run tests") + } + assert.Equal(t, tt.wantOut, got) + reg.Verify(t) + }) + } +} + +// registerEmptySessionsMock registers a single empty page of sessions +func registerEmptySessionsMock(reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Doc(`{ + "sessions": [] +}`)), + ) +} + +// registerSingleSessionMock registers two REST pages (one with a session, one empty) and GraphQL hydration for that session's PR +func registerSingleSessionMock(reg *httpmock.Registry, createdAt string) { + // First page with one session + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(`{ + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] +}`, createdAt)), + ) + // Second page empty to terminate pagination + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(`{"sessions": []}`), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRs`), + httpmock.StringResponse(heredoc.Docf(`{ + "data": { + "nodes": [ + { + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + } + ] + } +}`, createdAt)), + ) +} + +// registerManySessionsMock registers multiple sessions covering various states +// States covered: completed, failed, in_progress, queued, canceled, (unknown -> treated as muted) +func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { + // First page returns six sessions + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(`{ + "sessions": [ + { + "id": "s1", + "name": "A", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + }, + { + "id": "s2", + "name": "B", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "failed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2001, + "created_at": "%[1]s" + }, + { + "id": "s3", + "name": "C", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "in_progress", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2002, + "created_at": "%[1]s" + }, + { + "id": "s4", + "name": "D", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "queued", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2003, + "created_at": "%[1]s" + }, + { + "id": "s5", + "name": "E", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "canceled", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2004, + "created_at": "%[1]s" + }, + { + "id": "s6", + "name": "F", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "mystery", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2005, + "created_at": "%[1]s" + } + ] +}`, createdAt)), + ) + // Second page empty + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), + httpmock.StringResponse(`{"sessions": []}`), + ) + // GraphQL hydration for 6 PRs + reg.Register( + httpmock.GraphQL(`query FetchPRs`), + httpmock.StringResponse(heredoc.Docf(`{ + "data": { + "nodes": [ + { + "id": "PR_node1", + "fullDatabaseId": "2000", + "number": 101, + "title": "PR 101", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/101", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node2", + "fullDatabaseId": "2001", + "number": 102, + "title": "PR 102", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/102", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node3", + "fullDatabaseId": "2002", + "number": 103, + "title": "PR 103", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/103", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node4", + "fullDatabaseId": "2003", + "number": 104, + "title": "PR 104", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/104", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node5", + "fullDatabaseId": "2004", + "number": 105, + "title": "PR 105", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/105", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "id": "PR_node6", + "fullDatabaseId": "2005", + "number": 106, + "title": "PR 106", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/106", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "OWNER/REPO" } + } + ] + } + }`, createdAt)), + ) } From 48012063df33955523c064681599e9acd97b710b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:11:47 -0600 Subject: [PATCH 009/208] Remove commented-out fields from sessionPullRequest Cleaned up the sessionPullRequest struct by deleting unused commented-out fields related to Author and MergedBy. --- pkg/cmd/agent-task/capi/sessions.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 2693af57d..14e03b0c8 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -50,9 +50,6 @@ type sessionPullRequest struct { ClosedAt *time.Time MergedAt *time.Time - // Uncomment one of these to see error - // Author api.Author - // MergedBy *api.Author Repository *api.PRRepository } From 13c293f4cfb08f8af46936d5d3469969005b1fa8 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:12:33 -0600 Subject: [PATCH 010/208] Refactor variable names in ListSessionsForViewer Renamed 'Sessions' to 'result' for clarity and consistency in the ListSessionsForViewer method when hydrating session pull requests. --- pkg/cmd/agent-task/capi/sessions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 14e03b0c8..20eccaf41 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -105,13 +105,13 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S sessions = sessions[:limit] } - // Hydrate the Sessions with pull request data. - Sessions, err := c.hydrateSessionPullRequests(sessions) + // Hydrate the result with pull request data. + result, err := c.hydrateSessionPullRequests(sessions) if err != nil { return nil, err } - return Sessions, nil + return result, nil } // hydrateSessionPullRequests hydrates pull request information in sessions From bc1d306c31111ca9ca371265f6e292bc8ba5e14d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:18:45 -0600 Subject: [PATCH 011/208] Update API query name for session PR fetch Changed the API query from 'FetchPRs' to 'FetchPRsForAgentTaskSessions' in hydrateSessionPullRequests to match updated backend endpoint. --- pkg/cmd/agent-task/capi/sessions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 20eccaf41..3f42cadf4 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -139,7 +139,7 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, } host, _ := c.authCfg.DefaultHost() - err := apiClient.Query(host, "FetchPRs", &resp, map[string]any{ + err := apiClient.Query(host, "FetchPRsForAgentTaskSessions", &resp, map[string]any{ "ids": prNodeIds, }) From 5281be467d5ab084e716b3c96fedc6a698013438 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:20:12 -0600 Subject: [PATCH 012/208] Refactor error handling in generatePullRequestNodeID Simplified error handling by inlining the encoder.Encode call and removing redundant comments for improved code clarity. --- pkg/cmd/agent-task/capi/sessions.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 3f42cadf4..6d1c96e4b 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -191,15 +191,11 @@ func generatePullRequestNodeID(repoID, pullRequestID int64) string { encoder := msgpack.NewEncoder(&buf) encoder.UseCompactInts(true) - // Encode the parts - err := encoder.Encode(parts) - if err != nil { + if err := encoder.Encode(parts); err != nil { panic(err) } - // Use URL-safe Base64 encoding without padding encoded := base64.RawURLEncoding.EncodeToString(buf.Bytes()) - // Return with the PR_ prefix return "PR_" + encoded } From ea9dfae3bf16b6730e4d5e23e6ca5caefd8b7c68 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:23:43 -0600 Subject: [PATCH 013/208] Optimize session pull request hydration logic Replaces linear search with a map for associating sessions with pull requests, improving performance and simplifying code in hydrateSessionPullRequests. --- pkg/cmd/agent-task/capi/sessions.go | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 6d1c96e4b..13b665c90 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -147,9 +147,9 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, return nil, err } - prs := make([]*api.PullRequest, 0, len(prNodeIds)) + prMap := make(map[string]*api.PullRequest, len(prNodeIds)) for _, node := range resp.Nodes { - prs = append(prs, &api.PullRequest{ + prMap[node.PullRequest.FullDatabaseID] = &api.PullRequest{ ID: node.PullRequest.ID, FullDatabaseID: node.PullRequest.FullDatabaseID, Number: node.PullRequest.Number, @@ -162,21 +162,15 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, ClosedAt: node.PullRequest.ClosedAt, MergedAt: node.PullRequest.MergedAt, Repository: node.PullRequest.Repository, - }) + } } newSessions := make([]*Session, 0, len(sessions)) - // For each session, we need to attach the Pull Request for _, s := range sessions { - // For each Pull Request, check if it matches the session - for _, pr := range prs { - if strconv.FormatInt(s.ResourceID, 10) == pr.FullDatabaseID { - newSessions = append(newSessions, &Session{ - session: s, - PullRequest: pr, - }) - } - } + newSessions = append(newSessions, &Session{ + session: s, + PullRequest: prMap[strconv.FormatInt(s.ResourceID, 10)], + }) } return newSessions, nil From 4fd6ae6e3a980a3911ee32c6e2be9e68488d53a9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 15:23:40 -0600 Subject: [PATCH 014/208] Refactor session state color logic to shared package Moved the session state color selection logic from list.go to a new shared/display.go file as ColorFuncForSessionState. This improves code reuse and maintainability by centralizing the color mapping for session states. --- pkg/cmd/agent-task/list/list.go | 16 ++-------------- pkg/cmd/agent-task/shared/display.go | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 pkg/cmd/agent-task/shared/display.go diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 6e530fdd1..5d5577e1d 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/tableprinter" "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" @@ -107,20 +108,7 @@ func listRun(opts *ListOptions) error { // State if tp.IsTTY() { - var stateColor func(string) string - switch s.State { - case "completed": - stateColor = cs.Green - case "canceled": - stateColor = cs.Muted - case "in_progress", "queued": - stateColor = cs.Yellow - case "failed": - stateColor = cs.Red - default: - stateColor = cs.Muted - } - tp.AddField(s.State, tableprinter.WithColor(stateColor)) + tp.AddField(s.State, tableprinter.WithColor(shared.ColorFuncForSessionState(*s, cs))) } else { tp.AddField(s.State) } diff --git a/pkg/cmd/agent-task/shared/display.go b/pkg/cmd/agent-task/shared/display.go new file mode 100644 index 000000000..675143140 --- /dev/null +++ b/pkg/cmd/agent-task/shared/display.go @@ -0,0 +1,25 @@ +package shared + +import ( + "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/iostreams" +) + +// ColorFuncForSessionState returns a function that colors the session state +func ColorFuncForSessionState(s capi.Session, cs *iostreams.ColorScheme) func(string) string { + var stateColor func(string) string + switch s.State { + case "completed": + stateColor = cs.Green + case "canceled": + stateColor = cs.Muted + case "in_progress", "queued": + stateColor = cs.Yellow + case "failed": + stateColor = cs.Red + default: + stateColor = cs.Muted + } + + return stateColor +} From e750e712885b420b1b47593d1b768e36d54539a7 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:27:04 -0600 Subject: [PATCH 015/208] Add repo-scoped agent session listing support Introduces ListSessionsForRepo to CapiClient and CAPIClient, enabling listing agent sessions for a specific repository. Updates the list command and tests to support repo override and repo-scoped session queries, including error handling and test coverage for repo resolution. --- pkg/cmd/agent-task/capi/client.go | 1 + pkg/cmd/agent-task/capi/sessions.go | 62 +++- pkg/cmd/agent-task/list/list.go | 29 +- pkg/cmd/agent-task/list/list_test.go | 420 ++++++++++++++++++++++++--- 4 files changed, 460 insertions(+), 52 deletions(-) diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index b5b4ea9e0..480692c16 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -14,6 +14,7 @@ const capiHost = "api.githubcopilot.com" // may be replaced with test doubles in unit tests. type CapiClient interface { ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) + ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) } // CAPIClient is a client for interacting with the Copilot API diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 13b665c90..50ce36a1e 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "slices" "strconv" "time" @@ -15,6 +16,8 @@ import ( "github.com/vmihailenco/msgpack/v5" ) +const defaultSessionsPerPage = 50 + // session is an in-flight agent task type session struct { ID string `json:"id"` @@ -66,7 +69,7 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S var sessions []session page := 1 - perPage := 50 + perPage := defaultSessionsPerPage for { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) @@ -114,6 +117,63 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S return result, nil } +// ListSessionsForRepo lists agent sessions for a specific repository identified by owner/name up to limit. +func (c *CAPIClient) ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { + if owner == "" || repo == "" { + return nil, fmt.Errorf("owner and repo are required") + } + + url := fmt.Sprintf("%s/agents/sessions/nwo/%s/%s", baseCAPIURL, url.PathEscape(owner), url.PathEscape(repo)) + + var sessions []session + page := 1 + perPage := defaultSessionsPerPage + + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Set("page_size", strconv.Itoa(perPage)) + q.Set("page_number", strconv.Itoa(page)) + req.URL.RawQuery = q.Encode() + + 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 list sessions: %s", res.Status) + } + var response struct { + Sessions []session `json:"sessions"` + } + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode sessions response: %w", err) + } + if len(response.Sessions) == 0 || len(sessions) >= limit { + break + } + sessions = append(sessions, response.Sessions...) + page++ + } + + // Drop any above the limit + if len(sessions) > limit { + sessions = sessions[:limit] + } + + // Hydrate the result with pull request data. + result, err := c.hydrateSessionPullRequests(sessions) + if err != nil { + return nil, err + } + return result, nil +} + // hydrateSessionPullRequests hydrates pull request information in sessions func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, error) { if len(sessions) == 0 { diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 5d5577e1d..64c9e7d33 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -6,6 +6,7 @@ import ( "time" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" "github.com/cli/cli/v2/pkg/cmd/agent-task/shared" @@ -23,6 +24,7 @@ type ListOptions struct { Config func() (gh.Config, error) Limit int CapiClient func() (*capi.CAPIClient, error) + BaseRepo func() (ghrepo.Interface, error) } // NewCmdList creates the list command @@ -38,6 +40,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Short: "List agent tasks", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + // Support -R/--repo override + if f != nil { + opts.BaseRepo = f.BaseRepo + } if runF != nil { return runF(opts) } @@ -45,6 +51,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman }, } + if f != nil { + cmdutil.EnableRepoOverride(cmd, f) + } + opts.CapiClient = func() (*capi.CAPIClient, error) { cfg, err := opts.Config() if err != nil { @@ -73,9 +83,22 @@ func listRun(opts *ListOptions) error { opts.IO.StartProgressIndicatorWithLabel("Fetching agent tasks...") defer opts.IO.StopProgressIndicator() - sessions, err := capiClient.ListSessionsForViewer(context.Background(), opts.Limit) - if err != nil { - return err + var sessions []*capi.Session + ctx := context.Background() + if opts.BaseRepo != nil { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + sessions, err = capiClient.ListSessionsForRepo(ctx, repo.RepoOwner(), repo.RepoName(), opts.Limit) + if err != nil { + return err + } + } else { + sessions, err = capiClient.ListSessionsForViewer(ctx, opts.Limit) + if err != nil { + return err + } } opts.IO.StopProgressIndicator() diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index a9c5d7c05..a653d51be 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -1,6 +1,7 @@ package list import ( + "errors" "net/http" "testing" "time" @@ -8,17 +9,19 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "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/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdList(t *testing.T) { tests := []struct { name string - cli string + args string wantOpts ListOptions }{ { @@ -50,10 +53,13 @@ func Test_listRun(t *testing.T) { createdAt := sixHoursAgo.Format(time.RFC3339) tests := []struct { - name string - tty bool - stubs func(*httpmock.Registry) - wantOut string + name string + tty bool + stubs func(*httpmock.Registry) + baseRepo ghrepo.Interface + baseRepoErr error + wantOut string + wantErr error }{ { name: "no sessions", @@ -65,8 +71,10 @@ func Test_listRun(t *testing.T) { name: "single session (tty)", tty: true, stubs: func(reg *httpmock.Registry) { registerSingleSessionMock(reg, createdAt) }, - wantOut: "SESSION ID PULL REQUEST REPO SESSION STATE CREATED\n" + - "sess1 #42 OWNER/REPO completed about 6 hours ago\n", + wantOut: heredoc.Doc(` + SESSION ID PULL REQUEST REPO SESSION STATE CREATED + sess1 #42 OWNER/REPO completed about 6 hours ago + `), }, { name: "single session (nontty)", @@ -78,20 +86,62 @@ func Test_listRun(t *testing.T) { name: "many sessions (tty)", tty: true, stubs: func(reg *httpmock.Registry) { registerManySessionsMock(reg, createdAt) }, - wantOut: "SESSION ID PULL REQUEST REPO SESSION STATE CREATED\n" + - "s1 #101 OWNER/REPO completed about 6 hours ago\n" + - "s2 #102 OWNER/REPO failed about 6 hours ago\n" + - "s3 #103 OWNER/REPO in_progress about 6 hours ago\n" + - "s4 #104 OWNER/REPO queued about 6 hours ago\n" + - "s5 #105 OWNER/REPO canceled about 6 hours ago\n" + - "s6 #106 OWNER/REPO mystery about 6 hours ago\n", + wantOut: heredoc.Doc(` + SESSION ID PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago + s2 #102 OWNER/REPO failed about 6 hours ago + s3 #103 OWNER/REPO in_progress about 6 hours ago + s4 #104 OWNER/REPO queued about 6 hours ago + s5 #105 OWNER/REPO canceled about 6 hours ago + s6 #106 OWNER/REPO mystery about 6 hours ago + `), + }, + { + name: "repo scoped single session", + tty: true, + stubs: func(reg *httpmock.Registry) { registerRepoSingleSessionMock(reg, createdAt, "OWNER", "REPO") }, + baseRepo: ghrepo.New("OWNER", "REPO"), + wantOut: heredoc.Doc(` + SESSION ID PULL REQUEST REPO SESSION STATE CREATED + sessR1 #55 OWNER/REPO completed about 6 hours ago + `), + }, + { + name: "repo scoped no sessions", + tty: true, + stubs: func(reg *httpmock.Registry) { registerRepoEmptySessionsMock(reg, "OWNER", "REPO") }, + baseRepo: ghrepo.New("OWNER", "REPO"), + wantOut: "no agent tasks found\n", + }, + { + name: "repo resolution error surfaces", + tty: true, + baseRepoErr: errors.New("ambiguous repo"), + wantErr: errors.New("ambiguous repo"), + }, + { + name: "repo scoped many sessions (tty)", + tty: true, + stubs: func(reg *httpmock.Registry) { registerRepoManySessionsMock(reg, createdAt, "OWNER", "REPO") }, + baseRepo: ghrepo.New("OWNER", "REPO"), + wantOut: heredoc.Doc(` + SESSION ID PULL REQUEST REPO SESSION STATE CREATED + r1 #301 OWNER/REPO completed about 6 hours ago + r2 #302 OWNER/REPO failed about 6 hours ago + r3 #303 OWNER/REPO in_progress about 6 hours ago + r4 #304 OWNER/REPO queued about 6 hours ago + r5 #305 OWNER/REPO canceled about 6 hours ago + r6 #306 OWNER/REPO mystery about 6 hours ago + `), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} - tt.stubs(reg) + if tt.stubs != nil { + tt.stubs(reg) + } cfg := config.NewBlankConfig() cfg.Set("github.com", "oauth_token", "OTOKEN") @@ -108,28 +158,284 @@ func Test_listRun(t *testing.T) { Limit: 30, CapiClient: func() (*capi.CAPIClient, error) { return capiClient, nil }, } + if tt.baseRepo != nil || tt.baseRepoErr != nil { + baseRepo := tt.baseRepo + baseRepoErr := tt.baseRepoErr + opts.BaseRepo = func() (ghrepo.Interface, error) { return baseRepo, baseRepoErr } + } err := listRun(opts) - assert.NoError(t, err) - - got := stdout.String() - if tt.wantOut == "" && tt.name == "single session (tty)" { - t.Logf("Captured output for single session (tty):\n%s", got) - t.Fatalf("fill in wantOut with the above output and re-run tests") + if tt.wantErr != nil { + assert.Error(t, err) + require.EqualError(t, err, tt.wantErr.Error()) + } else { + require.NoError(t, err) } - assert.Equal(t, tt.wantOut, got) + got := stdout.String() + require.Equal(t, tt.wantOut, got) reg.Verify(t) }) } } +// registerRepoSingleSessionMock mocks repo-scoped endpoint with one session and hydration. +func registerRepoSingleSessionMock(reg *httpmock.Registry, createdAt, owner, repo string) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(`{ + "sessions": [ + { + "id": "sessR1", + "name": "Repo build", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3000, + "created_at": "%[1]s" + } + ] + }`, createdAt)), + ) + // Second page empty (pagination end) + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Doc(`{ + "sessions": [] + }`)), + ) + // Hydration + reg.Register( + httpmock.GraphQL(`query FetchPRs`), + httpmock.StringResponse(heredoc.Docf(`{ + "data": { + "nodes": [ + { + "id": "PR_nodeR1", + "fullDatabaseId": "3000", + "number": 55, + "title": "Improve build", + "state": "OPEN", + "url": "https://github.com/%[2]s/%[3]s/pull/55", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { "nameWithOwner": "%[2]s/%[3]s" } + } + ] + } +}`, createdAt, owner, repo)), + ) +} + +// registerRepoEmptySessionsMock mocks repo-scoped endpoint returning no sessions. +func registerRepoEmptySessionsMock(reg *httpmock.Registry, owner, repo string) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Doc(`{ + "sessions": [] +}`)), + ) +} + +// registerRepoManySessionsMock mirrors registerManySessionsMock but for repo-scoped endpoint +func registerRepoManySessionsMock(reg *httpmock.Registry, createdAt, owner, repo string) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(`{ + "sessions": [ + { + "id": "r1", + "name": "A", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3001, + "created_at": "%[1]s" + }, + { + "id": "r2", + "name": "B", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "failed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3002, + "created_at": "%[1]s" + }, + { + "id": "r3", + "name": "C", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "in_progress", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3003, + "created_at": "%[1]s" + }, + { + "id": "r4", + "name": "D", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "queued", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3004, + "created_at": "%[1]s" + }, + { + "id": "r5", + "name": "E", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "canceled", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3005, + "created_at": "%[1]s" + }, + { + "id": "r6", + "name": "F", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "mystery", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3006, + "created_at": "%[1]s" + } + ] + }`, createdAt)), + ) + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Doc(`{ + "sessions": [] + }`)), + ) + reg.Register( + httpmock.GraphQL(`query FetchPRs`), + httpmock.StringResponse(heredoc.Docf(`{ + "data": { + "nodes": [ + { + "id": "PR_r1", + "fullDatabaseId": "3001", + "number": 301, + "title": "PR 301", + "state": "OPEN", + "url": "https://github.com/%[2]s/%[3]s/pull/301", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "%[2]s/%[3]s" + } + }, + { + "id": "PR_r2", + "fullDatabaseId": "3002", + "number": 302, + "title": "PR 302", + "state": "OPEN", + "url": "https://github.com/%[2]s/%[3]s/pull/302", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "%[2]s/%[3]s" + } + }, + { + "id": "PR_r3", + "fullDatabaseId": "3003", + "number": 303, + "title": "PR 303", + "state": "OPEN", + "url": "https://github.com/%[2]s/%[3]s/pull/303", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "%[2]s/%[3]s" + } + }, + { + "id": "PR_r4", + "fullDatabaseId": "3004", + "number": 304, + "title": "PR 304", + "state": "OPEN", + "url": "https://github.com/%[2]s/%[3]s/pull/304", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "%[2]s/%[3]s" + } + }, + { + "id": "PR_r5", + "fullDatabaseId": "3005", + "number": 305, + "title": "PR 305", + "state": "OPEN", + "url": "https://github.com/%[2]s/%[3]s/pull/305", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "%[2]s/%[3]s" + } + }, + { + "id": "PR_r6", + "fullDatabaseId": "3006", + "number": 306, + "title": "PR 306", + "state": "OPEN", + "url": "https://github.com/%[2]s/%[3]s/pull/306", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "%[2]s/%[3]s" + } + } + ] + } + }`, createdAt, owner, repo)), + ) +} + // registerEmptySessionsMock registers a single empty page of sessions func registerEmptySessionsMock(reg *httpmock.Registry) { reg.Register( httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), httpmock.StringResponse(heredoc.Doc(`{ - "sessions": [] -}`)), + "sessions": [] + }`)), ) } @@ -159,29 +465,33 @@ func registerSingleSessionMock(reg *httpmock.Registry, createdAt string) { // Second page empty to terminate pagination reg.Register( httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), - httpmock.StringResponse(`{"sessions": []}`), + httpmock.StringResponse(heredoc.Doc(`{ + "sessions": [] + }`)), ) // GraphQL hydration reg.Register( httpmock.GraphQL(`query FetchPRs`), httpmock.StringResponse(heredoc.Docf(`{ - "data": { - "nodes": [ - { - "id": "PR_node", - "fullDatabaseId": "2000", - "number": 42, - "title": "Improve docs", - "state": "OPEN", - "url": "https://github.com/OWNER/REPO/pull/42", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { "nameWithOwner": "OWNER/REPO" } + "data": { + "nodes": [ + { + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + } + ] } - ] - } -}`, createdAt)), + }`, createdAt)), ) } @@ -277,7 +587,9 @@ func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { // Second page empty reg.Register( httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), - httpmock.StringResponse(`{"sessions": []}`), + httpmock.StringResponse(heredoc.Doc(`{ + "sessions": [] + }`)), ) // GraphQL hydration for 6 PRs reg.Register( @@ -295,7 +607,9 @@ func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { "body": "", "createdAt": "%[1]s", "updatedAt": "%[1]s", - "repository": { "nameWithOwner": "OWNER/REPO" } + "repository": { + "nameWithOwner": "OWNER/REPO" + } }, { "id": "PR_node2", @@ -307,7 +621,9 @@ func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { "body": "", "createdAt": "%[1]s", "updatedAt": "%[1]s", - "repository": { "nameWithOwner": "OWNER/REPO" } + "repository": { + "nameWithOwner": "OWNER/REPO" + } }, { "id": "PR_node3", @@ -319,7 +635,9 @@ func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { "body": "", "createdAt": "%[1]s", "updatedAt": "%[1]s", - "repository": { "nameWithOwner": "OWNER/REPO" } + "repository": { + "nameWithOwner": "OWNER/REPO" + } }, { "id": "PR_node4", @@ -331,7 +649,9 @@ func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { "body": "", "createdAt": "%[1]s", "updatedAt": "%[1]s", - "repository": { "nameWithOwner": "OWNER/REPO" } + "repository": { + "nameWithOwner": "OWNER/REPO" + } }, { "id": "PR_node5", @@ -343,7 +663,9 @@ func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { "body": "", "createdAt": "%[1]s", "updatedAt": "%[1]s", - "repository": { "nameWithOwner": "OWNER/REPO" } + "repository": { + "nameWithOwner": "OWNER/REPO" + } }, { "id": "PR_node6", @@ -355,7 +677,9 @@ func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { "body": "", "createdAt": "%[1]s", "updatedAt": "%[1]s", - "repository": { "nameWithOwner": "OWNER/REPO" } + "repository": { + "nameWithOwner": "OWNER/REPO" + } } ] } From a49994defa653c0375664a83e162ee857cb3ee59 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:52:27 -0600 Subject: [PATCH 016/208] Add limit flag to agent-task list command Introduces a --limit flag to control the maximum number of agent tasks fetched. Validates that the limit is greater than zero and updates tests to cover custom and invalid limit scenarios. --- pkg/cmd/agent-task/list/list.go | 5 +++ pkg/cmd/agent-task/list/list_test.go | 46 +++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 64c9e7d33..767ad2cfc 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -44,6 +44,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman if f != nil { opts.BaseRepo = f.BaseRepo } + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) + } if runF != nil { return runF(opts) } @@ -55,6 +58,8 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmdutil.EnableRepoOverride(cmd, f) } + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of agent tasks to fetch") + opts.CapiClient = func() (*capi.CAPIClient, error) { cfg, err := opts.Config() if err != nil { diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index a653d51be..f05d61b9e 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -3,6 +3,7 @@ package list import ( "errors" "net/http" + "strings" "testing" "time" @@ -23,6 +24,7 @@ func TestNewCmdList(t *testing.T) { name string args string wantOpts ListOptions + wantErr string }{ { name: "no arguments", @@ -30,18 +32,35 @@ func TestNewCmdList(t *testing.T) { Limit: defaultLimit, }, }, + { + name: "custom limit", + args: "--limit 15", + wantOpts: ListOptions{ + Limit: 15, + }, + }, + { + name: "invalid limit", + args: "--limit 0", + wantErr: "invalid limit: 0", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := &cmdutil.Factory{} var gotOpts *ListOptions - cmd := NewCmdList(f, func(opts *ListOptions) error { - gotOpts = opts - return nil - }) - cmd.ExecuteC() - + cmd := NewCmdList(f, func(opts *ListOptions) error { gotOpts = opts; return nil }) + if tt.args != "" { + cmd.SetArgs(strings.Split(tt.args, " ")) + } + _, err := cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) }) } @@ -58,6 +77,7 @@ func Test_listRun(t *testing.T) { stubs func(*httpmock.Registry) baseRepo ghrepo.Interface baseRepoErr error + limit int wantOut string wantErr error }{ @@ -67,6 +87,18 @@ func Test_listRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, wantOut: "no agent tasks found\n", }, + { + name: "limit truncates sessions", + tty: true, + limit: 3, + stubs: func(reg *httpmock.Registry) { registerManySessionsMock(reg, createdAt) }, + wantOut: heredoc.Doc(` + SESSION ID PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago + s2 #102 OWNER/REPO failed about 6 hours ago + s3 #103 OWNER/REPO in_progress about 6 hours ago + `), + }, { name: "single session (tty)", tty: true, @@ -155,7 +187,7 @@ func Test_listRun(t *testing.T) { opts := &ListOptions{ IO: ios, Config: func() (gh.Config, error) { return cfg, nil }, - Limit: 30, + Limit: tt.limit, CapiClient: func() (*capi.CAPIClient, error) { return capiClient, nil }, } if tt.baseRepo != nil || tt.baseRepoErr != nil { From 83c597ff533bd5204333f10bf0d344da6a5d459f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 17:56:04 -0600 Subject: [PATCH 017/208] Show default limit in agent-task list flag help Updates the help text for the --limit flag in the agent-task list command to display the default value, improving clarity for users. --- pkg/cmd/agent-task/list/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 767ad2cfc..43f409ffe 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -58,7 +58,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmdutil.EnableRepoOverride(cmd, f) } - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of agent tasks to fetch") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, fmt.Sprintf("Maximum number of agent tasks to fetch (default %d)", defaultLimit)) opts.CapiClient = func() (*capi.CAPIClient, error) { cfg, err := opts.Config() From a2d75d12f67ec4c83462f50bc0152f3269e86066 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 18:21:47 -0600 Subject: [PATCH 018/208] Add --web flag to agent-task list command Introduces a --web flag to the agent-task list command, allowing users to open the agent tasks page in their browser. Updates tests to cover the new flag and browser interaction. --- pkg/cmd/agent-task/list/list.go | 20 +++++++-- pkg/cmd/agent-task/list/list_test.go | 67 ++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 43f409ffe..d2e717b72 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -5,9 +5,11 @@ import ( "fmt" "time" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" + "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" @@ -25,14 +27,17 @@ type ListOptions struct { Limit int CapiClient func() (*capi.CAPIClient, error) BaseRepo func() (ghrepo.Interface, error) + Web bool + Browser browser.Browser } // NewCmdList creates the list command func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ - IO: f.IOStreams, - Config: f.Config, - Limit: defaultLimit, + IO: f.IOStreams, + Config: f.Config, + Limit: defaultLimit, + Browser: f.Browser, } cmd := &cobra.Command{ @@ -59,6 +64,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman } cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, fmt.Sprintf("Maximum number of agent tasks to fetch (default %d)", defaultLimit)) + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent tasks in the browser") opts.CapiClient = func() (*capi.CAPIClient, error) { cfg, err := opts.Config() @@ -77,6 +83,14 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman } func listRun(opts *ListOptions) error { + if opts.Web { + const webURL = "https://github.com/copilot/agents" + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) + } + return opts.Browser.Browse(webURL) + } + if opts.Limit <= 0 { opts.Limit = defaultLimit } diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index f05d61b9e..fc45b34d2 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" @@ -44,6 +45,14 @@ func TestNewCmdList(t *testing.T) { args: "--limit 0", wantErr: "invalid limit: 0", }, + { + name: "web flag", + args: "--web", + wantOpts: ListOptions{ + Limit: defaultLimit, + Web: true, + }, + }, } for _, tt := range tests { @@ -62,6 +71,7 @@ func TestNewCmdList(t *testing.T) { } require.NoError(t, err) assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) + assert.Equal(t, tt.wantOpts.Web, gotOpts.Web) }) } } @@ -72,14 +82,17 @@ func Test_listRun(t *testing.T) { createdAt := sixHoursAgo.Format(time.RFC3339) tests := []struct { - name string - tty bool - stubs func(*httpmock.Registry) - baseRepo ghrepo.Interface - baseRepoErr error - limit int - wantOut string - wantErr error + name string + tty bool + stubs func(*httpmock.Registry) + baseRepo ghrepo.Interface + baseRepoErr error + limit int + web bool + wantOut string + wantErr error + wantStderr string + wantBrowserURL string }{ { name: "no sessions", @@ -166,6 +179,14 @@ func Test_listRun(t *testing.T) { r6 #306 OWNER/REPO mystery about 6 hours ago `), }, + { + name: "web mode", + tty: true, + web: true, + wantOut: "", + wantStderr: "Opening https://github.com/copilot/agents in your browser.\n", + wantBrowserURL: "https://github.com/copilot/agents", + }, } for _, tt := range tests { @@ -179,16 +200,28 @@ func Test_listRun(t *testing.T) { cfg.Set("github.com", "oauth_token", "OTOKEN") authCfg := cfg.Authentication() - ios, _, stdout, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) + var br *browser.Stub + if tt.web { + br = &browser.Stub{} + } + httpClient := &http.Client{Transport: reg} capiClient := capi.NewCAPIClient(httpClient, authCfg) opts := &ListOptions{ - IO: ios, - Config: func() (gh.Config, error) { return cfg, nil }, - Limit: tt.limit, - CapiClient: func() (*capi.CAPIClient, error) { return capiClient, nil }, + IO: ios, + Config: func() (gh.Config, error) { return cfg, nil }, + Limit: tt.limit, + Web: tt.web, + Browser: br, + CapiClient: func() (*capi.CAPIClient, error) { + if tt.web { + panic("CapiClient should not be invoked when --web is set") + } + return capiClient, nil + }, } if tt.baseRepo != nil || tt.baseRepoErr != nil { baseRepo := tt.baseRepo @@ -205,6 +238,14 @@ func Test_listRun(t *testing.T) { } got := stdout.String() require.Equal(t, tt.wantOut, got) + if tt.wantStderr != "" { + require.Equal(t, tt.wantStderr, stderr.String()) + } else { + require.Equal(t, "", stderr.String()) + } + if tt.web { + br.Verify(t, tt.wantBrowserURL) + } reg.Verify(t) }) } From 3197193e42ebfe9bf20fde3072b638739721f1ed Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 29 Aug 2025 19:13:35 -0600 Subject: [PATCH 019/208] Handle repo resolution errors gracefully in agent-task list Updated listRun to ignore errors from BaseRepo resolution and proceed when possible. Adjusted tests to reflect that repo resolution errors no longer surface, improving robustness when repo information is ambiguous. --- pkg/cmd/agent-task/list/list.go | 10 ++++++---- pkg/cmd/agent-task/list/list_test.go | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 64c9e7d33..e6cd4e557 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -85,11 +85,13 @@ func listRun(opts *ListOptions) error { defer opts.IO.StopProgressIndicator() var sessions []*capi.Session ctx := context.Background() + + var repo ghrepo.Interface if opts.BaseRepo != nil { - repo, err := opts.BaseRepo() - if err != nil { - return err - } + repo, _ = opts.BaseRepo() + } + + if repo != nil && repo.RepoOwner() != "" && repo.RepoName() != "" { sessions, err = capiClient.ListSessionsForRepo(ctx, repo.RepoOwner(), repo.RepoName(), opts.Limit) if err != nil { return err diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index a653d51be..8b809f880 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -114,10 +114,12 @@ func Test_listRun(t *testing.T) { wantOut: "no agent tasks found\n", }, { - name: "repo resolution error surfaces", + name: "repo resolution error does not surface", tty: true, baseRepoErr: errors.New("ambiguous repo"), - wantErr: errors.New("ambiguous repo"), + wantErr: nil, + stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, + wantOut: "no agent tasks found\n", }, { name: "repo scoped many sessions (tty)", From 97da7fc1d230f50e10f419df19faf6906be3670f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:50:15 -0600 Subject: [PATCH 020/208] Simplify BaseRepo assignment in list command Removes unnecessary nil check for Factory before assigning BaseRepo in the agent-task list command. This streamlines the code since Factory is always expected to be non-nil. --- pkg/cmd/agent-task/list/list.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 5b5ff0c38..6add74df8 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -46,9 +46,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // Support -R/--repo override - if f != nil { - opts.BaseRepo = f.BaseRepo - } + + opts.BaseRepo = f.BaseRepo + if opts.Limit < 1 { return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) } From 4a07cdf94049791e670fac48f2cd71f0c7490a48 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:52:52 -0600 Subject: [PATCH 021/208] Simplify time calculation in listRun test Replaces explicit duration parsing with direct multiplication for calculating a timestamp 6 hours ago in Test_listRun, improving code clarity. --- pkg/cmd/agent-task/list/list_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index a2c9d62ea..0df882b25 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -77,9 +77,7 @@ func TestNewCmdList(t *testing.T) { } func Test_listRun(t *testing.T) { - sixHours, _ := time.ParseDuration("6h") - sixHoursAgo := time.Now().Add(-sixHours) - createdAt := sixHoursAgo.Format(time.RFC3339) + createdAt := time.Now().Add(-6 * time.Hour).Format(time.RFC3339) // 6h ago tests := []struct { name string From 227f0bd01d7ca712f2bccb8644a6dd930d7376b3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:55:03 -0600 Subject: [PATCH 022/208] Add comment explaining error handling in listRun Added a comment to clarify why the error from opts.BaseRepo() is ignored in listRun. This provides context for handling cases when the current working directory is not a repo and --repo is not set. --- pkg/cmd/agent-task/list/list.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 6add74df8..437767fe5 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -107,6 +107,8 @@ func listRun(opts *ListOptions) error { var repo ghrepo.Interface if opts.BaseRepo != nil { + // We swallow this error because when CWD is not a repo and + // the --repo flag is not set, we use the global/user session listing. repo, _ = opts.BaseRepo() } From 163eb7e7c88c0049d734fb28fe732137f6cf4060 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:04:27 -0600 Subject: [PATCH 023/208] Return NoResultsError when no agent tasks found Replaces plain output with a NoResultsError in listRun when no agent tasks are found. Updates related tests to expect the error instead of output, improving error handling consistency. --- pkg/cmd/agent-task/list/list.go | 3 +-- pkg/cmd/agent-task/list/list_test.go | 13 ++++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 437767fe5..896c0fadd 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -126,8 +126,7 @@ func listRun(opts *ListOptions) error { opts.IO.StopProgressIndicator() if len(sessions) == 0 { - fmt.Fprintln(opts.IO.Out, "no agent tasks found") - return nil + return cmdutil.NewNoResultsError("no agent tasks found") } cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index 0df882b25..a1545596a 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -96,7 +96,7 @@ func Test_listRun(t *testing.T) { name: "no sessions", tty: true, stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, - wantOut: "no agent tasks found\n", + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), }, { name: "limit truncates sessions", @@ -154,15 +154,14 @@ func Test_listRun(t *testing.T) { tty: true, stubs: func(reg *httpmock.Registry) { registerRepoEmptySessionsMock(reg, "OWNER", "REPO") }, baseRepo: ghrepo.New("OWNER", "REPO"), - wantOut: "no agent tasks found\n", + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), }, { name: "repo resolution error does not surface", tty: true, baseRepoErr: errors.New("ambiguous repo"), - wantErr: nil, + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, - wantOut: "no agent tasks found\n", }, { name: "repo scoped many sessions (tty)", @@ -238,11 +237,7 @@ func Test_listRun(t *testing.T) { } got := stdout.String() require.Equal(t, tt.wantOut, got) - if tt.wantStderr != "" { - require.Equal(t, tt.wantStderr, stderr.String()) - } else { - require.Equal(t, "", stderr.String()) - } + require.Equal(t, tt.wantStderr, stderr.String()) if tt.web { br.Verify(t, tt.wantBrowserURL) } From 51aa033b2e46cbe3999100d0c13f0fe3b061b622 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:09:12 -0600 Subject: [PATCH 024/208] Add test case for negative limit in list command Introduces a test to verify that passing a negative value to the --limit flag in the agent-task list command returns the expected error message. --- pkg/cmd/agent-task/list/list_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index a1545596a..43884f553 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -45,6 +45,11 @@ func TestNewCmdList(t *testing.T) { args: "--limit 0", wantErr: "invalid limit: 0", }, + { + name: "negative limit", + args: "--limit -5", + wantErr: "invalid limit: -5", + }, { name: "web flag", args: "--web", From 93f7e9847f33d22451b4e3d3ba0b66b781309819 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:11:10 -0600 Subject: [PATCH 025/208] Add comment on web dashboard filtering limitation Added a comment explaining that the web GUI does not currently support filtering by repo, so the agents dashboard is opened without arguments. Notes future improvement if repo filtering becomes available. --- pkg/cmd/agent-task/list/list.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 896c0fadd..0e3c43c08 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -84,6 +84,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman func listRun(opts *ListOptions) error { if opts.Web { + // Currently the web GUI does not have a page that supports filtering + // based on repo, so we just open the agents dashboard with no args. + // If that page is ever added in the future, we should route to that + // page instead of the global one when --repo is set. const webURL = "https://github.com/copilot/agents" if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) From c4c0ddc8c265c82e077d21d05ad100cc2dbe7406 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:13:35 -0600 Subject: [PATCH 026/208] Add test for web mode with repo flag in listRun Added a test case to ensure that web mode in listRun uses the global URL even when the --repo flag is set. This improves coverage for scenarios where both web mode and repo are specified. --- pkg/cmd/agent-task/list/list_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index 43884f553..a1c34ac32 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -191,6 +191,15 @@ func Test_listRun(t *testing.T) { wantStderr: "Opening https://github.com/copilot/agents in your browser.\n", wantBrowserURL: "https://github.com/copilot/agents", }, + { + name: "web mode with repo still uses global URL, even when --repo is set", + tty: true, + web: true, + baseRepo: ghrepo.New("OWNER", "REPO"), + wantOut: "", + wantStderr: "Opening https://github.com/copilot/agents in your browser.\n", + wantBrowserURL: "https://github.com/copilot/agents", + }, } for _, tt := range tests { From c5f7be9adbb77c75eae7f652a9f3cdbb9354f13c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:14:37 -0600 Subject: [PATCH 027/208] Replace panic with require.FailNow in test Updated the test to use require.FailNow instead of panic when CapiClient is called with --web, improving test failure reporting and consistency. --- pkg/cmd/agent-task/list/list_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index a1c34ac32..ef3cf8a7b 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -231,7 +231,7 @@ func Test_listRun(t *testing.T) { Browser: br, CapiClient: func() (*capi.CAPIClient, error) { if tt.web { - panic("CapiClient should not be invoked when --web is set") + require.FailNow(t, "CapiClient was called with --web") } return capiClient, nil }, From 2a99dcd2ce702ca415e6b689d031ee0f39a82f92 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:13:43 -0600 Subject: [PATCH 028/208] Add pager support to agent-task list output Introduces pager functionality to the agent-task list command output for improved readability. If the pager fails to start, an error message is printed to stderr. Aligns with implementations in other commands. --- pkg/cmd/agent-task/list/list.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 0e3c43c08..c6ed68de2 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -133,6 +133,12 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError("no agent tasks found") } + if err := opts.IO.StartPager(); err == nil { + defer opts.IO.StopPager() + } else { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + cs := opts.IO.ColorScheme() tp := tableprinter.New(opts.IO, tableprinter.WithHeader("Session ID", "Pull Request", "Repo", "Session State", "Created")) for _, s := range sessions { From 3574ee9c30a96f12ca36b9593ab8b05b370b8741 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:56:10 -0600 Subject: [PATCH 029/208] 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. --- pkg/cmd/agent-task/agent_task.go | 2 + pkg/cmd/agent-task/capi/client.go | 2 + pkg/cmd/agent-task/capi/job.go | 122 +++++++++++++++ pkg/cmd/agent-task/create/create.go | 173 ++++++++++++++++++++ pkg/cmd/agent-task/create/create_test.go | 191 +++++++++++++++++++++++ 5 files changed, 490 insertions(+) create mode 100644 pkg/cmd/agent-task/capi/job.go create mode 100644 pkg/cmd/agent-task/create/create.go create mode 100644 pkg/cmd/agent-task/create/create_test.go diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index cbbd3e278..e732e31de 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -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 } diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index 480692c16..9021d6086 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -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 diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go new file mode 100644 index 000000000..47410526e --- /dev/null +++ b/pkg/cmd/agent-task/capi/job.go @@ -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 +} diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go new file mode 100644 index 000000000..b7667dcf5 --- /dev/null +++ b/pkg/cmd/agent-task/create/create.go @@ -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 ", + 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 +} diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go new file mode 100644 index 000000000..67ca541e9 --- /dev/null +++ b/pkg/cmd/agent-task/create/create_test.go @@ -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) + } + }) + } +} From a3fa83071d791e024c76b8f52fec902a51272fa1 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:03:48 -0600 Subject: [PATCH 030/208] Update agent-task create command argument handling Changed argument parsing to accept a maximum of one argument and assign it directly to ProblemStatement. Added error handling for missing problem statement and removed unused strings import. --- pkg/cmd/agent-task/create/create.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index b7667dcf5..df0b14658 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "strings" "time" "github.com/cenkalti/backoff/v4" @@ -35,9 +34,15 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "create ", Short: "Create an agent task (preview)", - Args: cobra.MinimumNArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.ProblemStatement = strings.Join(args, " ") + // 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 From a81cff3fdfddb21adb8c544d14f70a442440e565 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:06:30 -0600 Subject: [PATCH 031/208] Update command usage for agent task creation Changed the command usage string from 'create ' to 'create ""' for improved clarity in the agent task creation command. --- pkg/cmd/agent-task/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index df0b14658..7238bf286 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -32,7 +32,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co IO: f.IOStreams, } cmd := &cobra.Command{ - Use: "create ", + Use: "create \"\"", Short: "Create an agent task (preview)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { From 3a7465ed96c5206f423cf33bfc7e7b95f823ffc1 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:26:58 -0600 Subject: [PATCH 032/208] Remove redundant 'error:' prefix from repo error Simplifies the error message when a repository is missing by removing the unnecessary 'error:' prefix, making the output clearer and more user-friendly. --- pkg/cmd/agent-task/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 7238bf286..dfed399be 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -84,7 +84,7 @@ func createRun(opts *CreateOptions) error { 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") + return fmt.Errorf("a repository is required; re-run in a repository or supply one with --repo owner/name") } client, err := opts.CapiClient() From 2bec2bcf65df40642f51f0787f1505f817d7d852 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:28:39 -0600 Subject: [PATCH 033/208] Update error message in createRun test Removed 'error:' prefix from expected error message in the 'missing repo returns error' test case for consistency with actual output. --- pkg/cmd/agent-task/create/create_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 67ca541e9..3bbdcc18a 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -109,7 +109,7 @@ func Test_createRun(t *testing.T) { 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", + wantErr: "a repository is required; re-run in a repository or supply one with --repo owner/name", }, { name: "create task API failure returns error", From 3d8d5f3e312321b4993583b694a0468ffcd2da60 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:29:38 -0600 Subject: [PATCH 034/208] Update test to expect 500 error response Changed the mocked API response status from 400 to 500 in Test_createRun to better reflect the expected error scenario. --- pkg/cmd/agent-task/create/create_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 3bbdcc18a..3e7ba4043 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -118,7 +118,7 @@ func Test_createRun(t *testing.T) { 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"}}`), + httpmock.StatusStringResponse(500, `{"error":{"message":"some API error"}}`), ) }, wantErr: "failed to create job: some API error", From c3bbd374aa97444298f7cd76d017919b0bd4fcea Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:33:31 -0600 Subject: [PATCH 035/208] Remove nil check for job in agentSessionWebURL Simplifies the agentSessionWebURL function by removing the redundant nil check for the job parameter, assuming it is always non-nil when called. --- pkg/cmd/agent-task/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index dfed399be..843579ba6 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -137,7 +137,7 @@ func createRun(opts *CreateOptions) error { } func agentSessionWebURL(repo ghrepo.Interface, j *capi.Job) string { - if j == nil || j.PullRequest == nil { + if j.PullRequest == nil { return "" } if j.SessionID == "" { From 33d119664525dc2f7fc78dab68c45507b24355f1 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:40:00 -0600 Subject: [PATCH 036/208] 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. --- pkg/cmd/agent-task/create/create.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 843579ba6..af3b27bfa 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net/url" "time" "github.com/cenkalti/backoff/v4" @@ -141,9 +142,9 @@ func agentSessionWebURL(repo ghrepo.Interface, j *capi.Job) string { 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", url.PathEscape(repo.RepoOwner()), url.PathEscape(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) + 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 From b890f880c0ed6a5f984735a6e6bc2cc43b542906 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:48:17 -0600 Subject: [PATCH 037/208] Escape owner and repo in job creation URL Uses url.PathEscape for owner and repo when constructing the job creation URL to ensure proper encoding and prevent issues with special characters. --- pkg/cmd/agent-task/capi/job.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index 47410526e..405bbf443 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "time" ) @@ -59,7 +60,7 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen return nil, errors.New("problem statement is required") } - url := fmt.Sprintf("%s/%s/%s", jobsBasePathV1, owner, repo) + url := fmt.Sprintf("%s/%s/%s", jobsBasePathV1, url.PathEscape(owner), url.PathEscape(repo)) body := map[string]any{ "problem_statement": problemStatement, "event_type": defaultEventType, From 9ec10a4d318c2d4f68bd60959862c34e457e7a4e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:49:35 -0600 Subject: [PATCH 038/208] Escape URL path parameters in GetJob request Uses url.PathEscape for owner, repo, and jobID in the GetJob API request to ensure proper encoding and prevent issues with special characters. --- pkg/cmd/agent-task/capi/job.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index 405bbf443..f07da20f2 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -102,7 +102,7 @@ func (c *CAPIClient) GetJob(ctx context.Context, owner, repo, jobID string) (*Jo 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) + 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 From 5472fcffe9a41ae84f8a7d0c216ddd13e17c5cc9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:28:16 -0600 Subject: [PATCH 039/208] Use Job struct in request Refactored CreateJob to use the Job struct as the payload instead of a map, improving consistency and maintainability. --- pkg/cmd/agent-task/capi/job.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index f07da20f2..f504b66e0 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -15,15 +15,16 @@ 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"` + 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"` - UpdatedAt time.Time `json:"updated_at"` + 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"` @@ -61,11 +62,13 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen } url := fmt.Sprintf("%s/%s/%s", jobsBasePathV1, url.PathEscape(owner), url.PathEscape(repo)) - body := map[string]any{ - "problem_statement": problemStatement, - "event_type": defaultEventType, + + payload := &Job{ + ProblemStatement: problemStatement, + EventType: defaultEventType, } - b, _ := json.Marshal(body) + b, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) if err != nil { return nil, err From 32bf9159ea429de66c8130760009c5343e3ca711 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:34:13 -0600 Subject: [PATCH 040/208] Improve job error handling and update tests 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. --- pkg/cmd/agent-task/capi/job.go | 4 +- pkg/cmd/agent-task/create/create.go | 22 ++++++----- pkg/cmd/agent-task/create/create_test.go | 49 +++++++++++++----------- 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index f504b66e0..ec215c5fe 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -116,7 +116,9 @@ func (c *CAPIClient) GetJob(ctx context.Context, owner, repo, jobID string) (*Jo } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get job: %s", res.Status) + // Normalize to " " 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 { diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index af3b27bfa..61e3d54b9 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "net/url" "time" @@ -120,9 +119,12 @@ func createRun(opts *CreateOptions) error { ) } - jobWithPR, err := fetchJobWithBackoff(ctx, client, repo, job.ID, opts.IO.ErrOut, opts.BackOff) + jobWithPR, err := fetchJobWithBackoff(ctx, client, repo, job.ID, opts.BackOff) if err != nil { - return err + // 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 { @@ -150,16 +152,16 @@ func agentSessionWebURL(repo ghrepo.Interface, j *capi.Job) string { // 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 +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, 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 + 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 @@ -170,7 +172,7 @@ func fetchJobWithBackoff(ctx context.Context, client capi.CapiClient, repo ghrep if retryErr != nil { if errors.Is(retryErr, errPRNotReady) { - // Timed out or failed to fetch + // Timed out return nil, nil } return nil, retryErr diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 3e7ba4043..aec6831c4 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -57,8 +57,26 @@ func Test_createRun(t *testing.T) { baseRepoErr error problemStatement string wantStdout string + wantStdErr string wantErr string }{ + { + name: "get job API failure surfaces 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(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", baseRepo: ghrepo.New("OWNER", "REPO"), @@ -96,7 +114,8 @@ func Test_createRun(t *testing.T) { httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), httpmock.StatusStringResponse(201, createdJobTimeoutResponse), ) - for range 3 { + // 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"}`), @@ -123,31 +142,12 @@ func Test_createRun(t *testing.T) { }, 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", - }, + // Removed test case that previously expected fallback after polling error. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() opts := &CreateOptions{ IO: ios, ProblemStatement: tt.problemStatement, @@ -176,13 +176,16 @@ func Test_createRun(t *testing.T) { if tt.wantErr != "" { require.Error(t, err) - require.Equal(t, err.Error(), tt.wantErr) + require.Equal(t, tt.wantErr, err.Error()) } else { require.NoError(t, err) } if tt.wantStdout != "" { require.Equal(t, tt.wantStdout, stdout.String()) } + + require.Equal(t, tt.wantStdErr, stderr.String()) + if tt.stubs != nil { reg.Verify(t) } From 44e81b021c0436a895486fb0762869c0d9d35d05 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:36:12 -0600 Subject: [PATCH 041/208] Simplify stdout assertion in createRun test Removed unnecessary conditional check for wantStdout in Test_createRun. Now always asserts equality between wantStdout and actual stdout output, improving test clarity. --- pkg/cmd/agent-task/create/create_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index aec6831c4..79cf24631 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -180,10 +180,8 @@ func Test_createRun(t *testing.T) { } else { require.NoError(t, err) } - if tt.wantStdout != "" { - require.Equal(t, tt.wantStdout, stdout.String()) - } + require.Equal(t, tt.wantStdout, stdout.String()) require.Equal(t, tt.wantStdErr, stderr.String()) if tt.stubs != nil { From 21ccabc832f1c8e2ea88e7fb9678815e97dc330a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:01:40 -0600 Subject: [PATCH 042/208] Simplify error handling in CreateJob response Refactored the CreateJob method to decode the response body into the Job struct before checking for error status codes. Error messages are now extracted directly from the Job struct, removing redundant error parsing logic. --- pkg/cmd/agent-task/capi/job.go | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index ec215c5fe..03eaa376d 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -79,24 +79,16 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen 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) } + + 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 } From a821b408d41db3113a56bfd10666a649e604e494 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:25:38 -0600 Subject: [PATCH 043/208] Update error messages and test repo handling in agent-task create Replaces 'problem statement' with 'task description' in error messages for clarity. Refactors tests to use a BaseRepo function instead of direct repo objects, and adds a test for missing task description error. --- pkg/cmd/agent-task/create/create.go | 6 ++--- pkg/cmd/agent-task/create/create_test.go | 28 ++++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 61e3d54b9..41f615c3f 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -39,7 +39,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co // 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") + return cmdutil.FlagErrorf("a task description is required") } opts.ProblemStatement = args[0] @@ -75,13 +75,13 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co func createRun(opts *CreateOptions) error { if opts.ProblemStatement == "" { - return cmdutil.FlagErrorf("a problem statement is required") + 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 || repo.RepoOwner() == "" || repo.RepoName() == "" { + 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") diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 79cf24631..977d32dfb 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -53,8 +53,7 @@ func Test_createRun(t *testing.T) { tests := []struct { name string stubs func(*httpmock.Registry) - baseRepo ghrepo.Interface - baseRepoErr error + baseRepoFunc func() (ghrepo.Interface, error) problemStatement string wantStdout string wantStdErr string @@ -62,7 +61,7 @@ func Test_createRun(t *testing.T) { }{ { name: "get job API failure surfaces error", - baseRepo: ghrepo.New("OWNER", "REPO"), + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, problemStatement: "Do the thing", stubs: func(reg *httpmock.Registry) { reg.Register( @@ -79,7 +78,7 @@ func Test_createRun(t *testing.T) { }, { name: "success with immediate PR", - baseRepo: ghrepo.New("OWNER", "REPO"), + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, problemStatement: "Do the thing", stubs: func(reg *httpmock.Registry) { reg.Register( @@ -91,7 +90,7 @@ func Test_createRun(t *testing.T) { }, { name: "success with delayed PR after polling", - baseRepo: ghrepo.New("OWNER", "REPO"), + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, problemStatement: "Do the thing", stubs: func(reg *httpmock.Registry) { reg.Register( @@ -107,7 +106,7 @@ func Test_createRun(t *testing.T) { }, { name: "fallback after timeout returns link to global agents page", - baseRepo: ghrepo.New("OWNER", "REPO"), + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, problemStatement: "Do the thing", stubs: func(reg *httpmock.Registry) { reg.Register( @@ -127,12 +126,12 @@ func Test_createRun(t *testing.T) { { name: "missing repo returns error", problemStatement: "task", - baseRepo: ghrepo.New("", ""), + 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", - baseRepo: ghrepo.New("OWNER", "REPO"), + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, problemStatement: "do the thing", stubs: func(reg *httpmock.Registry) { reg.Register( @@ -142,7 +141,12 @@ func Test_createRun(t *testing.T) { }, wantErr: "failed to create job: some API error", }, - // Removed test case that previously expected fallback after polling 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 { @@ -151,11 +155,7 @@ func Test_createRun(t *testing.T) { 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 } + BaseRepo: tt.baseRepoFunc, } // A backoff with no internal between retries to keep tests fast, From b94ffe90c4bac3389659c8b663d831871cc00d0d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:24:01 -0600 Subject: [PATCH 044/208] Add file input support to agent-task create command The agent-task create command now accepts a task description from a file using the -F/--from-file flag, with mutual exclusivity enforced between inline and file input. Tests were updated to cover new input scenarios and error cases, and usage examples were added to the command help. --- pkg/cmd/agent-task/create/create.go | 41 +++++++++-- pkg/cmd/agent-task/create/create_test.go | 94 ++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 41f615c3f..efc930621 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -5,10 +5,13 @@ import ( "errors" "fmt" "net/url" + "os" + "strings" "time" "github.com/cenkalti/backoff/v4" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" @@ -31,18 +34,35 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, } + + var fromFileName string + cmd := &cobra.Command{ - Use: "create \"\"", + Use: "create [] [flags]", 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") + if err := cmdutil.MutuallyExclusive("only one of -F or arg can be provided", len(args) > 0, fromFileName != ""); err != nil { + return err } - opts.ProblemStatement = args[0] + // Populate ProblemStatement from either arg or file + if len(args) > 0 { + opts.ProblemStatement = args[0] + } else if fromFileName != "" { + fileContent, err := os.ReadFile(fromFileName) + if err != nil { + return cmdutil.FlagErrorf("could not read task description file: %v", err) + } + trimmed := strings.TrimSpace(string(fileContent)) + if trimmed == "" { + return cmdutil.FlagErrorf("task description file is empty") + } + opts.ProblemStatement = trimmed + } + if opts.ProblemStatement == "" { + return cmdutil.FlagErrorf("a task description is required") + } // Support -R/--repo override if f != nil { opts.BaseRepo = f.BaseRepo @@ -52,11 +72,20 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co } return createRun(opts) }, + Example: heredoc.Doc(` + # Create a task from an inline description + $ gh agent-task create "build me a new app" + + # Create a task from a file + $ gh agent-task create -F task-desc.md + `), } if f != nil { cmdutil.EnableRepoOverride(cmd, f) } + cmd.Flags().StringVarP(&fromFileName, "from-file", "F", "", "Read task description from file") + opts.CapiClient = func() (capi.CapiClient, error) { cfg, err := f.Config() if err != nil { diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 977d32dfb..e1aebab1e 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -2,6 +2,7 @@ package create import ( "net/http" + "os" "testing" "github.com/MakeNowJust/heredoc" @@ -17,13 +18,92 @@ import ( // 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) + type tc struct { + name string + args []string + fileContent string // if non-empty, create temp file and substitute {{FILE}} token in args + wantOpts *CreateOptions // nil when expecting error + expectedErr string + } + + tests := []tc{ + { + name: "no args nor file", + args: []string{}, + expectedErr: "a task description is required", + }, + { + name: "arg only success", + args: []string{"task description from args"}, + wantOpts: &CreateOptions{ + ProblemStatement: "task description from args", + }, + }, + { + name: "from-file success", + args: []string{"-F", "{{FILE}}"}, + fileContent: "task description from file", + wantOpts: &CreateOptions{ + ProblemStatement: "task description from file", + }, + }, + { + name: "mutually exclusive arg and file", + args: []string{"Some task inline", "-F", "{{FILE}}"}, + fileContent: "Some task", + expectedErr: "only one of -F or arg can be provided", + }, + { + name: "missing file path", + args: []string{"-F", "does-not-exist.md"}, + expectedErr: "could not read task description file: open does-not-exist.md: no such file or directory", + }, + { + name: "empty file", + args: []string{"-F", "{{FILE}}"}, + fileContent: " \n\n", + expectedErr: "task description file is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test file creation + var filePath string + if tt.fileContent != "" { + dir := t.TempDir() + filePath = dir + "/task.md" + if err := os.WriteFile(filePath, []byte(tt.fileContent), 0o600); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + // substitute placeholder + for i, a := range tt.args { + if a == "{{FILE}}" { + tt.args[i] = filePath + } + } + } + + f := &cmdutil.Factory{} + var gotOpts *CreateOptions + cmd := NewCmdCreate(f, func(o *CreateOptions) error { + gotOpts = o + return nil + }) + cmd.SetArgs(tt.args) + _, err := cmd.ExecuteC() + + if tt.expectedErr != "" { + require.Error(t, err) + require.Equal(t, tt.expectedErr, err.Error()) + return + } + require.NoError(t, err) + if tt.wantOpts != nil { + require.Equal(t, tt.wantOpts.ProblemStatement, gotOpts.ProblemStatement) + } + }) + } } func Test_createRun(t *testing.T) { From 9dfe89fe50957672236be9f0799367a50f6d6856 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:24:21 -0600 Subject: [PATCH 045/208] Increase backoff timing for agent task creation Extended the max elapsed time and max interval for the exponential backoff strategy in agent task creation to improve reliability during retries. --- pkg/cmd/agent-task/create/create.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index efc930621..12f2008fa 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -141,9 +141,9 @@ func createRun(opts *CreateOptions) error { // Ensure we have a backoff strategy. if opts.BackOff == nil { opts.BackOff = backoff.NewExponentialBackOff( - backoff.WithMaxElapsedTime(4*time.Second), + backoff.WithMaxElapsedTime(10*time.Second), backoff.WithInitialInterval(300*time.Millisecond), - backoff.WithMaxInterval(2*time.Second), + backoff.WithMaxInterval(10*time.Second), backoff.WithMultiplier(1.5), ) } From 8af1787b31731ca445d61b6219e97a4a40152a34 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:52:45 -0600 Subject: [PATCH 046/208] Support reading task description from stdin Refactored file reading in agent-task create command to use cmdutil.ReadFile, allowing input from stdin. Updated tests to cover reading file content from stdin and improved test setup for file and stdin input cases. --- pkg/cmd/agent-task/create/create.go | 3 +- pkg/cmd/agent-task/create/create_test.go | 40 +++++++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 12f2008fa..76425005a 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/url" - "os" "strings" "time" @@ -50,7 +49,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if len(args) > 0 { opts.ProblemStatement = args[0] } else if fromFileName != "" { - fileContent, err := os.ReadFile(fromFileName) + fileContent, err := cmdutil.ReadFile(fromFileName, opts.IO.In) if err != nil { return cmdutil.FlagErrorf("could not read task description file: %v", err) } diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index e1aebab1e..323793166 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -3,6 +3,8 @@ package create import ( "net/http" "os" + "path/filepath" + "slices" "testing" "github.com/MakeNowJust/heredoc" @@ -47,6 +49,12 @@ func TestNewCmdCreate_Args(t *testing.T) { ProblemStatement: "task description from file", }, }, + { + name: "file content from stdin success", + args: []string{"-F", "-"}, + fileContent: "task from stdin", + wantOpts: &CreateOptions{ProblemStatement: "task from stdin"}, + }, { name: "mutually exclusive arg and file", args: []string{"Some task inline", "-F", "{{FILE}}"}, @@ -68,23 +76,31 @@ func TestNewCmdCreate_Args(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Test file creation - var filePath string + ios, stdinBuf, _, _ := iostreams.Test() + + // Provide file content either via stdin ( -F - ) or by creating a temp file if tt.fileContent != "" { - dir := t.TempDir() - filePath = dir + "/task.md" - if err := os.WriteFile(filePath, []byte(tt.fileContent), 0o600); err != nil { - t.Fatalf("failed to write temp file: %v", err) - } - // substitute placeholder - for i, a := range tt.args { - if a == "{{FILE}}" { - tt.args[i] = filePath + isStdin := len(tt.args) == 2 && tt.args[0] == "-F" && tt.args[1] == "-" + hasFileToken := slices.Contains(tt.args, "{{FILE}}") + + switch { + case isStdin: + stdinBuf.WriteString(tt.fileContent) + case hasFileToken: + dir := t.TempDir() + path := filepath.Join(dir, "task.md") + if err := os.WriteFile(path, []byte(tt.fileContent), 0o600); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + for i, a := range tt.args { + if a == "{{FILE}}" { + tt.args[i] = path + } } } } - f := &cmdutil.Factory{} + f := &cmdutil.Factory{IOStreams: ios} var gotOpts *CreateOptions cmd := NewCmdCreate(f, func(o *CreateOptions) error { gotOpts = o From b2f506fd5099bc4c41926d838a0cfb6f262088fe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:33:38 -0600 Subject: [PATCH 047/208] Add base branch option to agent task creation Introduces a BaseBranch field to CreateOptions and updates the job creation flow to include the base branch in the payload if specified. The CapiClient and related methods are updated to accept and handle the baseBranch parameter, and tests are added to verify correct behavior when a base branch is provided. --- pkg/cmd/agent-task/capi/client.go | 2 +- pkg/cmd/agent-task/capi/job.go | 14 ++++++++++--- pkg/cmd/agent-task/create/create.go | 4 +++- pkg/cmd/agent-task/create/create_test.go | 26 ++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index 9021d6086..1e9cad3c8 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -15,7 +15,7 @@ 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) + CreateJob(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*Job, error) GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) } diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index 03eaa376d..26bd3cf51 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -38,8 +38,9 @@ type JobActor struct { } type JobPullRequest struct { - ID int `json:"id"` - Number int `json:"number"` + ID int `json:"id"` + Number int `json:"number"` + BaseRef string `json:"base_ref,omitempty"` } type JobError struct { @@ -53,7 +54,7 @@ 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) { +func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*Job, error) { if owner == "" || repo == "" { return nil, errors.New("owner and repo are required") } @@ -63,10 +64,17 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen url := fmt.Sprintf("%s/%s/%s", jobsBasePathV1, url.PathEscape(owner), url.PathEscape(repo)) + prOpts := JobPullRequest{} + if baseBranch != "" { + prOpts.BaseRef = "refs/heads/" + baseBranch + } + payload := &Job{ ProblemStatement: problemStatement, EventType: defaultEventType, + PullRequest: &prOpts, } + b, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b)) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 76425005a..89e09561c 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -27,6 +27,7 @@ type CreateOptions struct { Config func() (gh.Config, error) ProblemStatement string BackOff backoff.BackOff + BaseBranch string } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -84,6 +85,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co } cmd.Flags().StringVarP(&fromFileName, "from-file", "F", "", "Read task description from file") + cmd.Flags().StringVarP(&opts.BaseBranch, "base", "b", "", "Base branch for the task") opts.CapiClient = func() (capi.CapiClient, error) { cfg, err := f.Config() @@ -124,7 +126,7 @@ func createRun(opts *CreateOptions) error { 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) + job, err := client.CreateJob(ctx, repo.RepoOwner(), repo.RepoName(), opts.ProblemStatement, opts.BaseBranch) if err != nil { return err } diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 323793166..1b3f80ba4 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -151,10 +151,35 @@ func Test_createRun(t *testing.T) { stubs func(*httpmock.Registry) baseRepoFunc func() (ghrepo.Interface, error) problemStatement string + baseBranch string wantStdout string wantStdErr string wantErr string }{ + { + name: "base branch included in create payload", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", + baseBranch: "feature", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), + httpmock.RESTPayload(201, createdJobSuccessWithPRResponse, func(payload map[string]interface{}) { + prRaw, ok := payload["pull_request"].(map[string]interface{}) + if !ok { + require.FailNow(t, "expected pull_request object in payload") + } + if prRaw["base_ref"] != "refs/heads/feature" { + require.FailNow(t, "expected pull_request.base_ref to be 'refs/heads/feature'") + } + if payload["problem_statement"] != "Do the thing" { + require.FailNow(t, "unexpected problem_statement value") + } + }), + ) + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, { name: "get job API failure surfaces error", baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -252,6 +277,7 @@ func Test_createRun(t *testing.T) { IO: ios, ProblemStatement: tt.problemStatement, BaseRepo: tt.baseRepoFunc, + BaseBranch: tt.baseBranch, } // A backoff with no internal between retries to keep tests fast, From 3c3b5669f1d52b5bd34ef457e72c031939f63417 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:35:17 -0600 Subject: [PATCH 048/208] Refactor test case struct in create_test.go Replaces custom 'tc' type with an inline struct for test cases in TestNewCmdCreate_Args, simplifying the test code structure. --- pkg/cmd/agent-task/create/create_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 1b3f80ba4..a7bb8a166 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -20,15 +20,13 @@ import ( // Test basic option parsing & repository requirement func TestNewCmdCreate_Args(t *testing.T) { - type tc struct { + tests := []struct { name string args []string fileContent string // if non-empty, create temp file and substitute {{FILE}} token in args wantOpts *CreateOptions // nil when expecting error expectedErr string - } - - tests := []tc{ + }{ { name: "no args nor file", args: []string{}, From 28a7daee406f548eed67cb4c0685b6c6227e054c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 4 Sep 2025 11:13:21 -0600 Subject: [PATCH 049/208] Improve agent-task create command help text Expanded usage examples in the command help to clarify reading from stdin and selecting a base branch. Updated flag descriptions for --from-file and --base to provide clearer guidance to users. --- pkg/cmd/agent-task/create/create.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 89e09561c..03d526180 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -78,14 +78,20 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co # Create a task from a file $ gh agent-task create -F task-desc.md + + # Create a task with problem statement from stdin + $ echo "build me a new app" | gh agent-task create -F - + + # Select a different base branch for the PR + $ gh agent-task create "fix errors" --base branch `), } if f != nil { cmdutil.EnableRepoOverride(cmd, f) } - cmd.Flags().StringVarP(&fromFileName, "from-file", "F", "", "Read task description from file") - cmd.Flags().StringVarP(&opts.BaseBranch, "base", "b", "", "Base branch for the task") + cmd.Flags().StringVarP(&fromFileName, "from-file", "F", "", "Read task description from `file` (use \"-\" to read from standard input)") + cmd.Flags().StringVarP(&opts.BaseBranch, "base", "b", "", "Base branch for the pull request (use default branch if not provided)") opts.CapiClient = func() (capi.CapiClient, error) { cfg, err := f.Config() @@ -139,7 +145,6 @@ func createRun(opts *CreateOptions) error { } // 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(10*time.Second), From 107edc3dd6f85515062362046d86f9bb37145478 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 3 Sep 2025 11:09:56 +0100 Subject: [PATCH 050/208] fix(agent-task/shared): add `CapiClientFunc` helper Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/capi.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 pkg/cmd/agent-task/shared/capi.go diff --git a/pkg/cmd/agent-task/shared/capi.go b/pkg/cmd/agent-task/shared/capi.go new file mode 100644 index 000000000..f23ee86d2 --- /dev/null +++ b/pkg/cmd/agent-task/shared/capi.go @@ -0,0 +1,23 @@ +package shared + +import ( + "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/cmdutil" +) + +func CapiClientFunc(f *cmdutil.Factory) func() (capi.CapiClient, error) { + return 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 + } +} From f3c3797d5c608f094393683773fc73c173831a6b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 3 Sep 2025 11:10:33 +0100 Subject: [PATCH 051/208] refactor(agent-task list): use shared `CapiClientFunc` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 25 ++++++++++++------------- pkg/cmd/agent-task/list/list.go | 23 +++++------------------ pkg/cmd/agent-task/list/list_test.go | 2 +- 3 files changed, 18 insertions(+), 32 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 03d526180..6a5d169be 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -14,6 +14,7 @@ import ( "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/cmd/agent-task/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -46,6 +47,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return err } + // 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") + } + // Populate ProblemStatement from either arg or file if len(args) > 0 { opts.ProblemStatement = args[0] @@ -66,7 +73,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co // Support -R/--repo override if f != nil { opts.BaseRepo = f.BaseRepo + + if opts.CapiClient == nil { + opts.CapiClient = shared.CapiClientFunc(f) + } } + if runF != nil { return runF(opts) } @@ -93,19 +105,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(&fromFileName, "from-file", "F", "", "Read task description from `file` (use \"-\" to read from standard input)") cmd.Flags().StringVarP(&opts.BaseBranch, "base", "b", "", "Base branch for the pull request (use default branch if not provided)") - 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 } diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index c6ed68de2..9c19c61f6 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -6,7 +6,6 @@ import ( "time" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" @@ -23,9 +22,8 @@ const defaultLimit = 30 // ListOptions are the options for the list command type ListOptions struct { IO *iostreams.IOStreams - Config func() (gh.Config, error) Limit int - CapiClient func() (*capi.CAPIClient, error) + CapiClient func() (capi.CapiClient, error) BaseRepo func() (ghrepo.Interface, error) Web bool Browser browser.Browser @@ -35,7 +33,6 @@ type ListOptions struct { func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, - Config: f.Config, Limit: defaultLimit, Browser: f.Browser, } @@ -46,9 +43,12 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // Support -R/--repo override - opts.BaseRepo = f.BaseRepo + if opts.CapiClient == nil { + opts.CapiClient = shared.CapiClientFunc(f) + } + if opts.Limit < 1 { return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) } @@ -66,19 +66,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, fmt.Sprintf("Maximum number of agent tasks to fetch (default %d)", defaultLimit)) cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent tasks in the browser") - opts.CapiClient = func() (*capi.CAPIClient, error) { - cfg, err := opts.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 } diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index ef3cf8a7b..178704dc7 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -229,7 +229,7 @@ func Test_listRun(t *testing.T) { Limit: tt.limit, Web: tt.web, Browser: br, - CapiClient: func() (*capi.CAPIClient, error) { + CapiClient: func() (capi.CapiClient, error) { if tt.web { require.FailNow(t, "CapiClient was called with --web") } From 0138bf3dab50e797e5a0b6029cdaba46e0b9f8be Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 12:56:56 +0100 Subject: [PATCH 052/208] refactor(agent-task/capi): improve pagination Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 42 +++++++++++++++++------------ 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 50ce36a1e..a3e1ded1e 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -16,7 +16,7 @@ import ( "github.com/vmihailenco/msgpack/v5" ) -const defaultSessionsPerPage = 50 +var defaultSessionsPerPage = 50 // session is an in-flight agent task type session struct { @@ -65,20 +65,23 @@ type Session struct { // ListSessionsForViewer lists all agent sessions for the // authenticated user up to limit. func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { + if limit == 0 { + return nil, nil + } + url := baseCAPIURL + "/agents/sessions" + pageSize := defaultSessionsPerPage - var sessions []session - page := 1 - perPage := defaultSessionsPerPage + sessions := make([]session, 0, limit+pageSize) - for { + for page := 1; ; page++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return nil, err } q := req.URL.Query() - q.Set("page_size", strconv.Itoa(perPage)) + q.Set("page_size", strconv.Itoa(pageSize)) q.Set("page_number", strconv.Itoa(page)) req.URL.RawQuery = q.Encode() @@ -96,11 +99,11 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S if err := json.NewDecoder(res.Body).Decode(&response); err != nil { return nil, fmt.Errorf("failed to decode sessions response: %w", err) } - if len(response.Sessions) == 0 || len(sessions) >= limit { + + sessions = append(sessions, response.Sessions...) + if len(response.Sessions) < pageSize || len(sessions) >= limit { break } - sessions = append(sessions, response.Sessions...) - page++ } // Drop any above the limit @@ -123,20 +126,23 @@ func (c *CAPIClient) ListSessionsForRepo(ctx context.Context, owner string, repo return nil, fmt.Errorf("owner and repo are required") } + if limit == 0 { + return nil, nil + } + url := fmt.Sprintf("%s/agents/sessions/nwo/%s/%s", baseCAPIURL, url.PathEscape(owner), url.PathEscape(repo)) + pageSize := defaultSessionsPerPage - var sessions []session - page := 1 - perPage := defaultSessionsPerPage + sessions := make([]session, 0, limit+pageSize) - for { + for page := 1; ; page++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return nil, err } q := req.URL.Query() - q.Set("page_size", strconv.Itoa(perPage)) + q.Set("page_size", strconv.Itoa(pageSize)) q.Set("page_number", strconv.Itoa(page)) req.URL.RawQuery = q.Encode() @@ -154,11 +160,11 @@ func (c *CAPIClient) ListSessionsForRepo(ctx context.Context, owner string, repo if err := json.NewDecoder(res.Body).Decode(&response); err != nil { return nil, fmt.Errorf("failed to decode sessions response: %w", err) } - if len(response.Sessions) == 0 || len(sessions) >= limit { + + sessions = append(sessions, response.Sessions...) + if len(response.Sessions) < pageSize || len(sessions) >= limit { break } - sessions = append(sessions, response.Sessions...) - page++ } // Drop any above the limit @@ -194,10 +200,12 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, var resp struct { Nodes []struct { + TypeName string `graphql:"__typename"` PullRequest sessionPullRequest `graphql:"... on PullRequest"` } `graphql:"nodes(ids: $ids)"` } + // TODO handle pagination host, _ := c.authCfg.DefaultHost() err := apiClient.Query(host, "FetchPRsForAgentTaskSessions", &resp, map[string]any{ "ids": prNodeIds, From 77bb72c2d4be918138e1f28b0e6a94c764e61d7a Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 14:35:43 +0100 Subject: [PATCH 053/208] fix(agent-task/capi): improve returned errs Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index a3e1ded1e..eee4ca649 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -114,7 +114,7 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S // Hydrate the result with pull request data. result, err := c.hydrateSessionPullRequests(sessions) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to fetch session resources: %w", err) } return result, nil @@ -175,7 +175,7 @@ func (c *CAPIClient) ListSessionsForRepo(ctx context.Context, owner string, repo // Hydrate the result with pull request data. result, err := c.hydrateSessionPullRequests(sessions) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to fetch session resources: %w", err) } return result, nil } From 0f5fd6ece0795a5e3619eb9658e913f9faa254d6 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 14:36:12 +0100 Subject: [PATCH 054/208] fix(agent-task/capi): handle non-JSON error response Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/job.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index 26bd3cf51..c45d972de 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -94,7 +94,11 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen } 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) + if j.ErrorInfo != nil { + return nil, fmt.Errorf("failed to create job: %s", j.ErrorInfo.Message) + } + statusText := fmt.Sprintf("%d %s", res.StatusCode, http.StatusText(res.StatusCode)) + return nil, fmt.Errorf("failed to create job: %s", statusText) } return &j, nil From 79602d3334dc6a27e8a29adcede727e45d49c7a7 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 14:37:37 +0100 Subject: [PATCH 055/208] test(agent-task/capi): add tests for session-related methods Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions_test.go | 822 +++++++++++++++++++++++ 1 file changed, 822 insertions(+) create mode 100644 pkg/cmd/agent-task/capi/sessions_test.go diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go new file mode 100644 index 000000000..2ac2dab6e --- /dev/null +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -0,0 +1,822 @@ +package capi + +import ( + "context" + "net/http" + "net/url" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListSessionsForViewer(t *testing.T) { + sampleDateString := "2025-08-29T00:00:00Z" + sampleDate, err := time.Parse(time.RFC3339, sampleDateString) + require.NoError(t, err) + + tests := []struct { + name string + perPage int + limit int + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantOut []*Session + }{ + { + name: "zero limit", + limit: 0, + wantOut: nil, + }, + { + name: "no sessions", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(`{"sessions":[]}`), + ) + }, + wantOut: nil, + }, + { + name: "single session", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + session: session{ + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + }, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2000", + Number: 42, + Title: "Improve docs", + State: "OPEN", + URL: "https://github.com/OWNER/REPO/pull/42", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, + }, + { + name: "multiple sessions, paginated", + perPage: 1, // to enforce pagination + limit: 2, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"1"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + + // Second page + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"2"}, + "page_size": {"1"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess2", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2001, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + }, + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2001", + "number": 43, + "title": "Improve docs", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/43", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "PR_kwDNA-jNB9E"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + session: session{ + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + }, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2000", + Number: 42, + Title: "Improve docs", + State: "OPEN", + URL: "https://github.com/OWNER/REPO/pull/42", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + session: session{ + ID: "sess2", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2001, + CreatedAt: sampleDate, + }, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2001", + Number: 43, + Title: "Improve docs", + State: "OPEN", + URL: "https://github.com/OWNER/REPO/pull/43", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, + }, + { + name: "API error", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StatusStringResponse(500, "{}"), + ) + }, + wantErr: "failed to list sessions:", + }, { + name: "API error at hydration", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.StatusStringResponse(500, `{}`), + ) + }, + wantErr: `failed to fetch session resources: non-200 OK status code:`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + defer reg.Verify(t) + + httpClient := &http.Client{Transport: reg} + + cfg := config.NewBlankConfig() + capiClient := NewCAPIClient(httpClient, cfg.Authentication()) + + if tt.perPage != 0 { + last := defaultSessionsPerPage + defaultSessionsPerPage = tt.perPage + defer func() { + defaultSessionsPerPage = last + }() + } + + sessions, err := capiClient.ListSessionsForViewer(context.Background(), tt.limit) + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.Nil(t, sessions) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantOut, sessions) + }) + } +} + +func TestListSessionForRepoRequiresRepo(t *testing.T) { + client := &CAPIClient{} + + _, err := client.ListSessionsForRepo(context.Background(), "", "only-repo", 0) + assert.EqualError(t, err, "owner and repo are required") + _, err = client.ListSessionsForRepo(context.Background(), "only-owner", "", 0) + assert.EqualError(t, err, "owner and repo are required") + _, err = client.ListSessionsForRepo(context.Background(), "", "", 0) + assert.EqualError(t, err, "owner and repo are required") +} + +func TestListSessionsForRepo(t *testing.T) { + sampleDateString := "2025-08-29T00:00:00Z" + sampleDate, err := time.Parse(time.RFC3339, sampleDateString) + require.NoError(t, err) + + tests := []struct { + name string + perPage int + limit int + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantOut []*Session + }{ + { + name: "zero limit", + limit: 0, + wantOut: nil, + }, + { + name: "no sessions", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(`{"sessions":[]}`), + ) + }, + wantOut: nil, + }, + { + name: "single session", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + session: session{ + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + }, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2000", + Number: 42, + Title: "Improve docs", + State: "OPEN", + URL: "https://github.com/OWNER/REPO/pull/42", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, + }, + { + name: "multiple sessions, paginated", + perPage: 1, // to enforce pagination + limit: 2, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ + "page_number": {"1"}, + "page_size": {"1"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + + // Second page + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ + "page_number": {"2"}, + "page_size": {"1"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess2", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2001, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + }, + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2001", + "number": 43, + "title": "Improve docs", + "state": "OPEN", + "url": "https://github.com/OWNER/REPO/pull/43", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "PR_kwDNA-jNB9E"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + session: session{ + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + }, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2000", + Number: 42, + Title: "Improve docs", + State: "OPEN", + URL: "https://github.com/OWNER/REPO/pull/42", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + session: session{ + ID: "sess2", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2001, + CreatedAt: sampleDate, + }, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2001", + Number: 43, + Title: "Improve docs", + State: "OPEN", + URL: "https://github.com/OWNER/REPO/pull/43", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, + }, + { + name: "API error", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StatusStringResponse(500, "{}"), + ) + }, + wantErr: "failed to list sessions:", + }, { + name: "API error at hydration", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.StatusStringResponse(500, `{}`), + ) + }, + wantErr: `failed to fetch session resources: non-200 OK status code:`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + defer reg.Verify(t) + + httpClient := &http.Client{Transport: reg} + + cfg := config.NewBlankConfig() + capiClient := NewCAPIClient(httpClient, cfg.Authentication()) + + if tt.perPage != 0 { + last := defaultSessionsPerPage + defaultSessionsPerPage = tt.perPage + defer func() { + defaultSessionsPerPage = last + }() + } + + sessions, err := capiClient.ListSessionsForRepo(context.Background(), "OWNER", "REPO", tt.limit) + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.Nil(t, sessions) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantOut, sessions) + }) + } +} From be8e6f64919205bdcae96a3dd24929956940c6ba Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 14:37:59 +0100 Subject: [PATCH 056/208] test(agent-task/capi): add tests for job-related methods Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/job_test.go | 369 ++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 pkg/cmd/agent-task/capi/job_test.go diff --git a/pkg/cmd/agent-task/capi/job_test.go b/pkg/cmd/agent-task/capi/job_test.go new file mode 100644 index 000000000..987b43b9f --- /dev/null +++ b/pkg/cmd/agent-task/capi/job_test.go @@ -0,0 +1,369 @@ +package capi + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetJobRequiresRepoAndJobID(t *testing.T) { + client := &CAPIClient{} + _, err := client.GetJob(context.Background(), "", "", "only-job-id") + assert.EqualError(t, err, "owner, repo, and jobID are required") + _, err = client.GetJob(context.Background(), "", "only-repo", "") + assert.EqualError(t, err, "owner, repo, and jobID are required") + _, err = client.GetJob(context.Background(), "only-owner", "", "") + assert.EqualError(t, err, "owner, repo, and jobID are required") + _, err = client.GetJob(context.Background(), "", "", "") + assert.EqualError(t, err, "owner, repo, and jobID are required") +} + +func TestGetJob(t *testing.T) { + sampleDateString := "2025-08-29T00:00:00Z" + sampleDate, err := time.Parse(time.RFC3339, sampleDateString) + require.NoError(t, err) + + tests := []struct { + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantOut *Job + }{ + { + name: "job without PR", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/swe/v1/jobs/OWNER/REPO/job123"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(200, heredoc.Docf(` + { + "job_id": "job123", + "session_id": "sess1", + "problem_statement": "Do the thing", + "event_type": "foo", + "content_filter_mode": "foo", + "status": "foo", + "result": "foo", + "actor": { + "id": 1, + "login": "octocat" + }, + "created_at": "%[1]s", + "updated_at": "%[1]s" + }`, + sampleDateString, + )), + ) + }, + wantOut: &Job{ + ID: "job123", + SessionID: "sess1", + ProblemStatement: "Do the thing", + EventType: "foo", + ContentFilterMode: "foo", + Status: "foo", + Result: "foo", + Actor: &JobActor{ + ID: 1, + Login: "octocat", + }, + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + }, + }, + { + name: "job with PR", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/swe/v1/jobs/OWNER/REPO/job123"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(200, heredoc.Docf(` + { + "job_id": "job123", + "session_id": "sess1", + "problem_statement": "Do the thing", + "event_type": "foo", + "content_filter_mode": "foo", + "status": "foo", + "result": "foo", + "actor": { + "id": 1, + "login": "octocat" + }, + "created_at": "%[1]s", + "updated_at": "%[1]s", + "pull_request": { + "id": 101, + "number": 42 + } + }`, + sampleDateString, + )), + ) + }, + wantOut: &Job{ + ID: "job123", + SessionID: "sess1", + ProblemStatement: "Do the thing", + EventType: "foo", + ContentFilterMode: "foo", + Status: "foo", + Result: "foo", + Actor: &JobActor{ + ID: 1, + Login: "octocat", + }, + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + PullRequest: &JobPullRequest{ + ID: 101, + Number: 42, + }, + }, + }, + { + name: "job not found", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/swe/v1/jobs/OWNER/REPO/job123"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(404, `{}`), + ) + }, + wantErr: "failed to get job: 404 Not Found", + }, + { + name: "API error", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/swe/v1/jobs/OWNER/REPO/job123"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(500, `{}`), + ) + }, + wantErr: "failed to get job: 500 Internal Server Error", + }, + { + name: "invalid JSON response", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/swe/v1/jobs/OWNER/REPO/job123"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(200, ``), + ) + }, + wantErr: "failed to decode get job response: EOF", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + defer reg.Verify(t) + + httpClient := &http.Client{Transport: reg} + + cfg := config.NewBlankConfig() + capiClient := NewCAPIClient(httpClient, cfg.Authentication()) + + job, err := capiClient.GetJob(context.Background(), "OWNER", "REPO", "job123") + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Nil(t, job) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantOut, job) + }) + } +} + +func TestCreateJobRequiresRepoAndProblemStatement(t *testing.T) { + client := &CAPIClient{} + + _, err := client.CreateJob(context.Background(), "", "only-repo", "", "") + assert.EqualError(t, err, "owner and repo are required") + _, err = client.CreateJob(context.Background(), "only-owner", "", "", "") + assert.EqualError(t, err, "owner and repo are required") + _, err = client.CreateJob(context.Background(), "", "", "", "") + assert.EqualError(t, err, "owner and repo are required") + + _, err = client.CreateJob(context.Background(), "owner", "repo", "", "") + assert.EqualError(t, err, "problem statement is required") +} + +func TestCreateJob(t *testing.T) { + sampleDateString := "2025-08-29T00:00:00Z" + sampleDate, err := time.Parse(time.RFC3339, sampleDateString) + require.NoError(t, err) + + tests := []struct { + name string + baseBranch string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantOut *Job + }{ + { + name: "success", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), + httpmock.RESTPayload(201, + heredoc.Docf(` + { + "job_id": "job123", + "session_id": "sess1", + "problem_statement": "Do the thing", + "event_type": "foo", + "content_filter_mode": "foo", + "status": "foo", + "result": "foo", + "actor": { + "id": 1, + "login": "octocat" + }, + "created_at": "%[1]s", + "updated_at": "%[1]s" + } + `, sampleDateString), + func(payload map[string]interface{}) { + assert.Equal(t, "Do the thing", payload["problem_statement"]) + assert.Equal(t, "gh_cli", payload["event_type"]) + }, + ), + ) + }, + wantOut: &Job{ + ID: "job123", + SessionID: "sess1", + ProblemStatement: "Do the thing", + EventType: "foo", + ContentFilterMode: "foo", + Status: "foo", + Result: "foo", + Actor: &JobActor{ + ID: 1, + Login: "octocat", + }, + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + }, + }, + { + name: "success with base branch", + baseBranch: "some-branch", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), + httpmock.RESTPayload(201, + heredoc.Docf(` + { + "job_id": "job123", + "session_id": "sess1", + "problem_statement": "Do the thing", + "event_type": "foo", + "content_filter_mode": "foo", + "status": "foo", + "result": "foo", + "actor": { + "id": 1, + "login": "octocat" + }, + "created_at": "%[1]s", + "updated_at": "%[1]s" + } + `, sampleDateString), + func(payload map[string]interface{}) { + assert.Equal(t, "Do the thing", payload["problem_statement"]) + assert.Equal(t, "gh_cli", payload["event_type"]) + assert.Equal(t, "refs/heads/some-branch", payload["pull_request"].(map[string]interface{})["base_ref"]) + }, + ), + ) + }, + wantOut: &Job{ + ID: "job123", + SessionID: "sess1", + ProblemStatement: "Do the thing", + EventType: "foo", + ContentFilterMode: "foo", + Status: "foo", + Result: "foo", + Actor: &JobActor{ + ID: 1, + Login: "octocat", + }, + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + }, + }, + { + name: "API error, included in response body", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(500, heredoc.Doc(`{ + "error": { + "message": "some error" + } + }`)), + ) + }, + wantErr: "failed to create job: some error", + }, + { + name: "API error", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(500, `{}`), + ) + }, + wantErr: "failed to create job: 500 Internal Server Error", + }, + { + name: "invalid JSON response", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(200, ``), + ) + }, + wantErr: "failed to decode create job response: EOF", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + defer reg.Verify(t) + + httpClient := &http.Client{Transport: reg} + + cfg := config.NewBlankConfig() + capiClient := NewCAPIClient(httpClient, cfg.Authentication()) + + job, err := capiClient.CreateJob(context.Background(), "OWNER", "REPO", "Do the thing", tt.baseBranch) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Nil(t, job) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantOut, job) + }) + } +} From ab99ee530aca664e7c0fd9efc1ea06c36cfaa346 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 14:38:39 +0100 Subject: [PATCH 057/208] test(agent-task/capi): add `go:generate` directive to gen mock Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/client.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index 1e9cad3c8..ecec9a024 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -7,6 +7,8 @@ import ( "github.com/cli/cli/v2/internal/gh" ) +//go:generate moq -rm -out client_mock.go . CapiClient + const baseCAPIURL = "https://api.githubcopilot.com" const capiHost = "api.githubcopilot.com" From 585b6392736ec024b2e80fefab52a0d6c85b352d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 14:39:12 +0100 Subject: [PATCH 058/208] test(agent-task/capi): add `CapiClientMock` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/client_mock.go | 273 +++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 pkg/cmd/agent-task/capi/client_mock.go diff --git a/pkg/cmd/agent-task/capi/client_mock.go b/pkg/cmd/agent-task/capi/client_mock.go new file mode 100644 index 000000000..621585587 --- /dev/null +++ b/pkg/cmd/agent-task/capi/client_mock.go @@ -0,0 +1,273 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package capi + +import ( + "context" + "sync" +) + +// Ensure, that CapiClientMock does implement CapiClient. +// If this is not the case, regenerate this file with moq. +var _ CapiClient = &CapiClientMock{} + +// CapiClientMock is a mock implementation of CapiClient. +// +// func TestSomethingThatUsesCapiClient(t *testing.T) { +// +// // make and configure a mocked CapiClient +// mockedCapiClient := &CapiClientMock{ +// CreateJobFunc: func(ctx context.Context, owner string, repo string, problemStatement string, baseBranch string) (*Job, error) { +// panic("mock out the CreateJob method") +// }, +// GetJobFunc: func(ctx context.Context, owner string, repo string, jobID string) (*Job, error) { +// panic("mock out the GetJob method") +// }, +// ListSessionsForRepoFunc: func(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { +// panic("mock out the ListSessionsForRepo method") +// }, +// ListSessionsForViewerFunc: func(ctx context.Context, limit int) ([]*Session, error) { +// panic("mock out the ListSessionsForViewer method") +// }, +// } +// +// // use mockedCapiClient in code that requires CapiClient +// // and then make assertions. +// +// } +type CapiClientMock struct { + // CreateJobFunc mocks the CreateJob method. + CreateJobFunc func(ctx context.Context, owner string, repo string, problemStatement string, baseBranch string) (*Job, error) + + // GetJobFunc mocks the GetJob method. + GetJobFunc func(ctx context.Context, owner string, repo string, jobID string) (*Job, error) + + // ListSessionsForRepoFunc mocks the ListSessionsForRepo method. + ListSessionsForRepoFunc func(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) + + // ListSessionsForViewerFunc mocks the ListSessionsForViewer method. + ListSessionsForViewerFunc func(ctx context.Context, limit int) ([]*Session, error) + + // calls tracks calls to the methods. + calls struct { + // CreateJob holds details about calls to the CreateJob method. + CreateJob []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Owner is the owner argument value. + Owner string + // Repo is the repo argument value. + Repo string + // ProblemStatement is the problemStatement argument value. + ProblemStatement string + // BaseBranch is the baseBranch argument value. + BaseBranch string + } + // GetJob holds details about calls to the GetJob method. + GetJob []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Owner is the owner argument value. + Owner string + // Repo is the repo argument value. + Repo string + // JobID is the jobID argument value. + JobID string + } + // ListSessionsForRepo holds details about calls to the ListSessionsForRepo method. + ListSessionsForRepo []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Owner is the owner argument value. + Owner string + // Repo is the repo argument value. + Repo string + // Limit is the limit argument value. + Limit int + } + // ListSessionsForViewer holds details about calls to the ListSessionsForViewer method. + ListSessionsForViewer []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Limit is the limit argument value. + Limit int + } + } + lockCreateJob sync.RWMutex + lockGetJob sync.RWMutex + lockListSessionsForRepo sync.RWMutex + lockListSessionsForViewer sync.RWMutex +} + +// CreateJob calls CreateJobFunc. +func (mock *CapiClientMock) CreateJob(ctx context.Context, owner string, repo string, problemStatement string, baseBranch string) (*Job, error) { + if mock.CreateJobFunc == nil { + panic("CapiClientMock.CreateJobFunc: method is nil but CapiClient.CreateJob was just called") + } + callInfo := struct { + Ctx context.Context + Owner string + Repo string + ProblemStatement string + BaseBranch string + }{ + Ctx: ctx, + Owner: owner, + Repo: repo, + ProblemStatement: problemStatement, + BaseBranch: baseBranch, + } + mock.lockCreateJob.Lock() + mock.calls.CreateJob = append(mock.calls.CreateJob, callInfo) + mock.lockCreateJob.Unlock() + return mock.CreateJobFunc(ctx, owner, repo, problemStatement, baseBranch) +} + +// CreateJobCalls gets all the calls that were made to CreateJob. +// Check the length with: +// +// len(mockedCapiClient.CreateJobCalls()) +func (mock *CapiClientMock) CreateJobCalls() []struct { + Ctx context.Context + Owner string + Repo string + ProblemStatement string + BaseBranch string +} { + var calls []struct { + Ctx context.Context + Owner string + Repo string + ProblemStatement string + BaseBranch string + } + mock.lockCreateJob.RLock() + calls = mock.calls.CreateJob + mock.lockCreateJob.RUnlock() + return calls +} + +// GetJob calls GetJobFunc. +func (mock *CapiClientMock) GetJob(ctx context.Context, owner string, repo string, jobID string) (*Job, error) { + if mock.GetJobFunc == nil { + panic("CapiClientMock.GetJobFunc: method is nil but CapiClient.GetJob was just called") + } + callInfo := struct { + Ctx context.Context + Owner string + Repo string + JobID string + }{ + Ctx: ctx, + Owner: owner, + Repo: repo, + JobID: jobID, + } + mock.lockGetJob.Lock() + mock.calls.GetJob = append(mock.calls.GetJob, callInfo) + mock.lockGetJob.Unlock() + return mock.GetJobFunc(ctx, owner, repo, jobID) +} + +// GetJobCalls gets all the calls that were made to GetJob. +// Check the length with: +// +// len(mockedCapiClient.GetJobCalls()) +func (mock *CapiClientMock) GetJobCalls() []struct { + Ctx context.Context + Owner string + Repo string + JobID string +} { + var calls []struct { + Ctx context.Context + Owner string + Repo string + JobID string + } + mock.lockGetJob.RLock() + calls = mock.calls.GetJob + mock.lockGetJob.RUnlock() + return calls +} + +// ListSessionsForRepo calls ListSessionsForRepoFunc. +func (mock *CapiClientMock) ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { + if mock.ListSessionsForRepoFunc == nil { + panic("CapiClientMock.ListSessionsForRepoFunc: method is nil but CapiClient.ListSessionsForRepo was just called") + } + callInfo := struct { + Ctx context.Context + Owner string + Repo string + Limit int + }{ + Ctx: ctx, + Owner: owner, + Repo: repo, + Limit: limit, + } + mock.lockListSessionsForRepo.Lock() + mock.calls.ListSessionsForRepo = append(mock.calls.ListSessionsForRepo, callInfo) + mock.lockListSessionsForRepo.Unlock() + return mock.ListSessionsForRepoFunc(ctx, owner, repo, limit) +} + +// ListSessionsForRepoCalls gets all the calls that were made to ListSessionsForRepo. +// Check the length with: +// +// len(mockedCapiClient.ListSessionsForRepoCalls()) +func (mock *CapiClientMock) ListSessionsForRepoCalls() []struct { + Ctx context.Context + Owner string + Repo string + Limit int +} { + var calls []struct { + Ctx context.Context + Owner string + Repo string + Limit int + } + mock.lockListSessionsForRepo.RLock() + calls = mock.calls.ListSessionsForRepo + mock.lockListSessionsForRepo.RUnlock() + return calls +} + +// ListSessionsForViewer calls ListSessionsForViewerFunc. +func (mock *CapiClientMock) ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { + if mock.ListSessionsForViewerFunc == nil { + panic("CapiClientMock.ListSessionsForViewerFunc: method is nil but CapiClient.ListSessionsForViewer was just called") + } + callInfo := struct { + Ctx context.Context + Limit int + }{ + Ctx: ctx, + Limit: limit, + } + mock.lockListSessionsForViewer.Lock() + mock.calls.ListSessionsForViewer = append(mock.calls.ListSessionsForViewer, callInfo) + mock.lockListSessionsForViewer.Unlock() + return mock.ListSessionsForViewerFunc(ctx, limit) +} + +// ListSessionsForViewerCalls gets all the calls that were made to ListSessionsForViewer. +// Check the length with: +// +// len(mockedCapiClient.ListSessionsForViewerCalls()) +func (mock *CapiClientMock) ListSessionsForViewerCalls() []struct { + Ctx context.Context + Limit int +} { + var calls []struct { + Ctx context.Context + Limit int + } + mock.lockListSessionsForViewer.RLock() + calls = mock.calls.ListSessionsForViewer + mock.lockListSessionsForViewer.RUnlock() + return calls +} From 19e17c54fd4c882ff2e96b93654fd6d49cd00864 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 18:47:24 +0100 Subject: [PATCH 059/208] refactor(agent-task/capi): drop embedding of unexported struct Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 46 ++++++- pkg/cmd/agent-task/capi/sessions_test.go | 145 +++++++++++------------ 2 files changed, 107 insertions(+), 84 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index eee4ca649..8bf29575c 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -58,8 +58,23 @@ type sessionPullRequest struct { // Session is a hydrated in-flight agent task type Session struct { - session - PullRequest *api.PullRequest `json:"-"` + ID string + Name string + UserID uint64 + AgentID int64 + Logs string + State string + OwnerID uint64 + RepoID uint64 + ResourceType string + ResourceID int64 + LastUpdatedAt time.Time + CreatedAt time.Time + CompletedAt time.Time + EventURL string + EventType string + + PullRequest *api.PullRequest } // ListSessionsForViewer lists all agent sessions for the @@ -235,10 +250,9 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, newSessions := make([]*Session, 0, len(sessions)) for _, s := range sessions { - newSessions = append(newSessions, &Session{ - session: s, - PullRequest: prMap[strconv.FormatInt(s.ResourceID, 10)], - }) + newSession := fromAPISession(s) + newSession.PullRequest = prMap[strconv.FormatInt(s.ResourceID, 10)] + newSessions = append(newSessions, newSession) } return newSessions, nil @@ -261,3 +275,23 @@ func generatePullRequestNodeID(repoID, pullRequestID int64) string { return "PR_" + encoded } + +func fromAPISession(s session) *Session { + return &Session{ + ID: s.ID, + Name: s.Name, + UserID: s.UserID, + AgentID: s.AgentID, + Logs: s.Logs, + State: s.State, + OwnerID: s.OwnerID, + RepoID: s.RepoID, + ResourceType: s.ResourceType, + ResourceID: s.ResourceID, + LastUpdatedAt: s.LastUpdatedAt, + CreatedAt: s.CreatedAt, + CompletedAt: s.CompletedAt, + EventURL: s.EventURL, + EventType: s.EventType, + } +} diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 2ac2dab6e..20b670d56 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -117,19 +117,18 @@ func TestListSessionsForViewer(t *testing.T) { }, wantOut: []*Session{ { - session: session{ - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, - }, + + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -260,19 +259,17 @@ func TestListSessionsForViewer(t *testing.T) { }, wantOut: []*Session{ { - session: session{ - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, - }, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -289,19 +286,17 @@ func TestListSessionsForViewer(t *testing.T) { }, }, { - session: session{ - ID: "sess2", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2001, - CreatedAt: sampleDate, - }, + ID: "sess2", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2001, + CreatedAt: sampleDate, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2001", @@ -525,19 +520,17 @@ func TestListSessionsForRepo(t *testing.T) { }, wantOut: []*Session{ { - session: session{ - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, - }, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -668,19 +661,17 @@ func TestListSessionsForRepo(t *testing.T) { }, wantOut: []*Session{ { - session: session{ - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, - }, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -697,19 +688,17 @@ func TestListSessionsForRepo(t *testing.T) { }, }, { - session: session{ - ID: "sess2", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2001, - CreatedAt: sampleDate, - }, + ID: "sess2", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2001, + CreatedAt: sampleDate, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2001", From 27d9a0d5fc344b1af2034ffc46523a7a6a29c607 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 19:33:19 +0100 Subject: [PATCH 060/208] test(agent-task list): update `TestNewCmdList` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/list/list_test.go | 41 +++++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index 178704dc7..2185c5393 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -1,31 +1,31 @@ package list import ( + "context" "errors" - "net/http" - "strings" "testing" "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/config" - "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/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewCmdList(t *testing.T) { tests := []struct { - name string - args string - wantOpts ListOptions - wantErr string + name string + args string + wantOpts ListOptions + wantBaseRepo ghrepo.Interface + wantErr string }{ { name: "no arguments", @@ -33,6 +33,14 @@ func TestNewCmdList(t *testing.T) { Limit: defaultLimit, }, }, + { + name: "base repo specified", + args: "--repo OWNER/REPO", + wantOpts: ListOptions{ + Limit: defaultLimit, + }, + wantBaseRepo: ghrepo.New("OWNER", "REPO"), + }, { name: "custom limit", args: "--limit 15", @@ -62,11 +70,18 @@ func TestNewCmdList(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + var gotOpts *ListOptions cmd := NewCmdList(f, func(opts *ListOptions) error { gotOpts = opts; return nil }) + if tt.args != "" { - cmd.SetArgs(strings.Split(tt.args, " ")) + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) } _, err := cmd.ExecuteC() if tt.wantErr != "" { @@ -77,6 +92,12 @@ func TestNewCmdList(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) assert.Equal(t, tt.wantOpts.Web, gotOpts.Web) + + if tt.wantBaseRepo != nil { + baseRepo, err := gotOpts.BaseRepo() + require.NoError(t, err) + assert.True(t, ghrepo.IsSame(tt.wantBaseRepo, baseRepo)) + } }) } } From c708e58f694090ea21be84ee9fd1c54b9a9637e2 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 19:33:47 +0100 Subject: [PATCH 061/208] test(agent-task list): use `CapiClientMock` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/list/list_test.go | 906 ++++++++++----------------- 1 file changed, 324 insertions(+), 582 deletions(-) diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index 2185c5393..5ed0d097d 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -103,12 +103,14 @@ func TestNewCmdList(t *testing.T) { } func Test_listRun(t *testing.T) { - createdAt := time.Now().Add(-6 * time.Hour).Format(time.RFC3339) // 6h ago + sampleDate := time.Now().Add(-6 * time.Hour) // 6h ago + sampleDateString := sampleDate.Format(time.RFC3339) tests := []struct { name string tty bool stubs func(*httpmock.Registry) + capiStubs func(*testing.T, *capi.CapiClientMock) baseRepo ghrepo.Interface baseRepoErr error limit int @@ -119,90 +121,347 @@ func Test_listRun(t *testing.T) { wantBrowserURL string }{ { - name: "no sessions", - tty: true, - stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, + name: "viewer-scoped no sessions", + tty: true, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + return nil, nil + } + }, wantErr: cmdutil.NewNoResultsError("no agent tasks found"), }, { - name: "limit truncates sessions", + name: "viewer-scoped respects --limit", tty: true, - limit: 3, - stubs: func(reg *httpmock.Registry) { registerManySessionsMock(reg, createdAt) }, + limit: 999, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + assert.Equal(t, 999, limit) + return nil, nil + } + }, + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), // not important + }, + { + name: "viewer-scoped single session (tty)", + tty: true, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + return []*capi.Session{ + { + ID: "s1", + State: "completed", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 101, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, nil + } + }, wantOut: heredoc.Doc(` - SESSION ID PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago - s2 #102 OWNER/REPO failed about 6 hours ago - s3 #103 OWNER/REPO in_progress about 6 hours ago + SESSION ID PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago `), }, { - name: "single session (tty)", - tty: true, - stubs: func(reg *httpmock.Registry) { registerSingleSessionMock(reg, createdAt) }, + name: "viewer-scoped single session (nontty)", + tty: false, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + return []*capi.Session{ + { + ID: "s1", + State: "completed", + ResourceType: "pull", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Number: 101, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, nil + } + }, + wantOut: "s1\t#101\tOWNER/REPO\tcompleted\t" + sampleDateString + "\n", // header omitted for non-tty + }, + { + name: "viewer-scoped many sessions (tty)", + tty: true, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + return []*capi.Session{ + { + ID: "s1", + State: "completed", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 101, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s2", + State: "failed", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 102, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s3", + State: "in_progress", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 103, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s4", + State: "queued", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 104, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s5", + State: "canceled", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 105, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s6", + State: "mystery", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 106, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, nil + } + }, wantOut: heredoc.Doc(` - SESSION ID PULL REQUEST REPO SESSION STATE CREATED - sess1 #42 OWNER/REPO completed about 6 hours ago + SESSION ID PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago + s2 #102 OWNER/REPO failed about 6 hours ago + s3 #103 OWNER/REPO in_progress about 6 hours ago + s4 #104 OWNER/REPO queued about 6 hours ago + s5 #105 OWNER/REPO canceled about 6 hours ago + s6 #106 OWNER/REPO mystery about 6 hours ago `), }, { - name: "single session (nontty)", - tty: false, - stubs: func(reg *httpmock.Registry) { registerSingleSessionMock(reg, createdAt) }, - wantOut: "sess1\t#42\tOWNER/REPO\tcompleted\t" + createdAt + "\n", // header omitted for non-tty - }, - { - name: "many sessions (tty)", - tty: true, - stubs: func(reg *httpmock.Registry) { registerManySessionsMock(reg, createdAt) }, - wantOut: heredoc.Doc(` - SESSION ID PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago - s2 #102 OWNER/REPO failed about 6 hours ago - s3 #103 OWNER/REPO in_progress about 6 hours ago - s4 #104 OWNER/REPO queued about 6 hours ago - s5 #105 OWNER/REPO canceled about 6 hours ago - s6 #106 OWNER/REPO mystery about 6 hours ago - `), - }, - { - name: "repo scoped single session", + name: "repo-scoped no sessions", tty: true, - stubs: func(reg *httpmock.Registry) { registerRepoSingleSessionMock(reg, createdAt, "OWNER", "REPO") }, baseRepo: ghrepo.New("OWNER", "REPO"), + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { + return nil, nil + } + }, + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), + }, + { + name: "repo-scoped respects --limit/--repo", + tty: true, + limit: 999, + baseRepo: ghrepo.New("OWNER", "REPO"), + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { + assert.Equal(t, 999, limit) + assert.Equal(t, "OWNER", owner) + assert.Equal(t, "REPO", repo) + return nil, nil + } + }, + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), // not important + }, + { + name: "repo-scoped single session (tty)", + tty: true, + baseRepo: ghrepo.New("OWNER", "REPO"), + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { + return []*capi.Session{ + { + ID: "s1", + State: "completed", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 101, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, nil + } + }, wantOut: heredoc.Doc(` - SESSION ID PULL REQUEST REPO SESSION STATE CREATED - sessR1 #55 OWNER/REPO completed about 6 hours ago + SESSION ID PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago `), }, { - name: "repo scoped no sessions", - tty: true, - stubs: func(reg *httpmock.Registry) { registerRepoEmptySessionsMock(reg, "OWNER", "REPO") }, + name: "repo-scoped single session (nontty)", + tty: false, baseRepo: ghrepo.New("OWNER", "REPO"), - wantErr: cmdutil.NewNoResultsError("no agent tasks found"), + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { + return []*capi.Session{ + { + ID: "s1", + State: "completed", + ResourceType: "pull", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Number: 101, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, nil + } + }, + wantOut: "s1\t#101\tOWNER/REPO\tcompleted\t" + sampleDateString + "\n", // header omitted for non-tty + }, + { + name: "repo-scoped many sessions (tty)", + tty: true, + baseRepo: ghrepo.New("OWNER", "REPO"), + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { + return []*capi.Session{ + { + ID: "s1", + State: "completed", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 101, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s2", + State: "failed", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 102, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s3", + State: "in_progress", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 103, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s4", + State: "queued", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 104, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s5", + State: "canceled", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 105, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + { + ID: "s6", + State: "mystery", + CreatedAt: sampleDate, + ResourceType: "pull", + PullRequest: &api.PullRequest{ + Number: 106, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + SESSION ID PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago + s2 #102 OWNER/REPO failed about 6 hours ago + s3 #103 OWNER/REPO in_progress about 6 hours ago + s4 #104 OWNER/REPO queued about 6 hours ago + s5 #105 OWNER/REPO canceled about 6 hours ago + s6 #106 OWNER/REPO mystery about 6 hours ago + `), }, { name: "repo resolution error does not surface", tty: true, baseRepoErr: errors.New("ambiguous repo"), - wantErr: cmdutil.NewNoResultsError("no agent tasks found"), - stubs: func(reg *httpmock.Registry) { registerEmptySessionsMock(reg) }, - }, - { - name: "repo scoped many sessions (tty)", - tty: true, - stubs: func(reg *httpmock.Registry) { registerRepoManySessionsMock(reg, createdAt, "OWNER", "REPO") }, - baseRepo: ghrepo.New("OWNER", "REPO"), - wantOut: heredoc.Doc(` - SESSION ID PULL REQUEST REPO SESSION STATE CREATED - r1 #301 OWNER/REPO completed about 6 hours ago - r2 #302 OWNER/REPO failed about 6 hours ago - r3 #303 OWNER/REPO in_progress about 6 hours ago - r4 #304 OWNER/REPO queued about 6 hours ago - r5 #305 OWNER/REPO canceled about 6 hours ago - r6 #306 OWNER/REPO mystery about 6 hours ago - `), + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + // We expect a viewer-scoped fetch request: + m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + return nil, nil + } + }, + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), }, { name: "web mode", @@ -225,15 +484,11 @@ func Test_listRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reg := &httpmock.Registry{} - if tt.stubs != nil { - tt.stubs(reg) + capiClientMock := &capi.CapiClientMock{} + if tt.capiStubs != nil { + tt.capiStubs(t, capiClientMock) } - cfg := config.NewBlankConfig() - cfg.Set("github.com", "oauth_token", "OTOKEN") - authCfg := cfg.Authentication() - ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) @@ -242,11 +497,8 @@ func Test_listRun(t *testing.T) { br = &browser.Stub{} } - httpClient := &http.Client{Transport: reg} - capiClient := capi.NewCAPIClient(httpClient, authCfg) opts := &ListOptions{ IO: ios, - Config: func() (gh.Config, error) { return cfg, nil }, Limit: tt.limit, Web: tt.web, Browser: br, @@ -254,7 +506,7 @@ func Test_listRun(t *testing.T) { if tt.web { require.FailNow(t, "CapiClient was called with --web") } - return capiClient, nil + return capiClientMock, nil }, } if tt.baseRepo != nil || tt.baseRepoErr != nil { @@ -276,516 +528,6 @@ func Test_listRun(t *testing.T) { if tt.web { br.Verify(t, tt.wantBrowserURL) } - reg.Verify(t) }) } } - -// registerRepoSingleSessionMock mocks repo-scoped endpoint with one session and hydration. -func registerRepoSingleSessionMock(reg *httpmock.Registry, createdAt, owner, repo string) { - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Docf(`{ - "sessions": [ - { - "id": "sessR1", - "name": "Repo build", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 3000, - "created_at": "%[1]s" - } - ] - }`, createdAt)), - ) - // Second page empty (pagination end) - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Doc(`{ - "sessions": [] - }`)), - ) - // Hydration - reg.Register( - httpmock.GraphQL(`query FetchPRs`), - httpmock.StringResponse(heredoc.Docf(`{ - "data": { - "nodes": [ - { - "id": "PR_nodeR1", - "fullDatabaseId": "3000", - "number": 55, - "title": "Improve build", - "state": "OPEN", - "url": "https://github.com/%[2]s/%[3]s/pull/55", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { "nameWithOwner": "%[2]s/%[3]s" } - } - ] - } -}`, createdAt, owner, repo)), - ) -} - -// registerRepoEmptySessionsMock mocks repo-scoped endpoint returning no sessions. -func registerRepoEmptySessionsMock(reg *httpmock.Registry, owner, repo string) { - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Doc(`{ - "sessions": [] -}`)), - ) -} - -// registerRepoManySessionsMock mirrors registerManySessionsMock but for repo-scoped endpoint -func registerRepoManySessionsMock(reg *httpmock.Registry, createdAt, owner, repo string) { - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Docf(`{ - "sessions": [ - { - "id": "r1", - "name": "A", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 3001, - "created_at": "%[1]s" - }, - { - "id": "r2", - "name": "B", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "failed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 3002, - "created_at": "%[1]s" - }, - { - "id": "r3", - "name": "C", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "in_progress", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 3003, - "created_at": "%[1]s" - }, - { - "id": "r4", - "name": "D", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "queued", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 3004, - "created_at": "%[1]s" - }, - { - "id": "r5", - "name": "E", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "canceled", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 3005, - "created_at": "%[1]s" - }, - { - "id": "r6", - "name": "F", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "mystery", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 3006, - "created_at": "%[1]s" - } - ] - }`, createdAt)), - ) - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions/nwo/"+owner+"/"+repo), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Doc(`{ - "sessions": [] - }`)), - ) - reg.Register( - httpmock.GraphQL(`query FetchPRs`), - httpmock.StringResponse(heredoc.Docf(`{ - "data": { - "nodes": [ - { - "id": "PR_r1", - "fullDatabaseId": "3001", - "number": 301, - "title": "PR 301", - "state": "OPEN", - "url": "https://github.com/%[2]s/%[3]s/pull/301", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "%[2]s/%[3]s" - } - }, - { - "id": "PR_r2", - "fullDatabaseId": "3002", - "number": 302, - "title": "PR 302", - "state": "OPEN", - "url": "https://github.com/%[2]s/%[3]s/pull/302", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "%[2]s/%[3]s" - } - }, - { - "id": "PR_r3", - "fullDatabaseId": "3003", - "number": 303, - "title": "PR 303", - "state": "OPEN", - "url": "https://github.com/%[2]s/%[3]s/pull/303", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "%[2]s/%[3]s" - } - }, - { - "id": "PR_r4", - "fullDatabaseId": "3004", - "number": 304, - "title": "PR 304", - "state": "OPEN", - "url": "https://github.com/%[2]s/%[3]s/pull/304", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "%[2]s/%[3]s" - } - }, - { - "id": "PR_r5", - "fullDatabaseId": "3005", - "number": 305, - "title": "PR 305", - "state": "OPEN", - "url": "https://github.com/%[2]s/%[3]s/pull/305", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "%[2]s/%[3]s" - } - }, - { - "id": "PR_r6", - "fullDatabaseId": "3006", - "number": 306, - "title": "PR 306", - "state": "OPEN", - "url": "https://github.com/%[2]s/%[3]s/pull/306", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "%[2]s/%[3]s" - } - } - ] - } - }`, createdAt, owner, repo)), - ) -} - -// registerEmptySessionsMock registers a single empty page of sessions -func registerEmptySessionsMock(reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Doc(`{ - "sessions": [] - }`)), - ) -} - -// registerSingleSessionMock registers two REST pages (one with a session, one empty) and GraphQL hydration for that session's PR -func registerSingleSessionMock(reg *httpmock.Registry, createdAt string) { - // First page with one session - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Docf(`{ - "sessions": [ - { - "id": "sess1", - "name": "Build artifacts", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2000, - "created_at": "%[1]s" - } - ] -}`, createdAt)), - ) - // Second page empty to terminate pagination - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Doc(`{ - "sessions": [] - }`)), - ) - // GraphQL hydration - reg.Register( - httpmock.GraphQL(`query FetchPRs`), - httpmock.StringResponse(heredoc.Docf(`{ - "data": { - "nodes": [ - { - "id": "PR_node", - "fullDatabaseId": "2000", - "number": 42, - "title": "Improve docs", - "state": "OPEN", - "url": "https://github.com/OWNER/REPO/pull/42", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - } - ] - } - }`, createdAt)), - ) -} - -// registerManySessionsMock registers multiple sessions covering various states -// States covered: completed, failed, in_progress, queued, canceled, (unknown -> treated as muted) -func registerManySessionsMock(reg *httpmock.Registry, createdAt string) { - // First page returns six sessions - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Docf(`{ - "sessions": [ - { - "id": "s1", - "name": "A", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2000, - "created_at": "%[1]s" - }, - { - "id": "s2", - "name": "B", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "failed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2001, - "created_at": "%[1]s" - }, - { - "id": "s3", - "name": "C", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "in_progress", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2002, - "created_at": "%[1]s" - }, - { - "id": "s4", - "name": "D", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "queued", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2003, - "created_at": "%[1]s" - }, - { - "id": "s5", - "name": "E", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "canceled", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2004, - "created_at": "%[1]s" - }, - { - "id": "s6", - "name": "F", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "mystery", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2005, - "created_at": "%[1]s" - } - ] -}`, createdAt)), - ) - // Second page empty - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/sessions"), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Doc(`{ - "sessions": [] - }`)), - ) - // GraphQL hydration for 6 PRs - reg.Register( - httpmock.GraphQL(`query FetchPRs`), - httpmock.StringResponse(heredoc.Docf(`{ - "data": { - "nodes": [ - { - "id": "PR_node1", - "fullDatabaseId": "2000", - "number": 101, - "title": "PR 101", - "state": "OPEN", - "url": "https://github.com/OWNER/REPO/pull/101", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "id": "PR_node2", - "fullDatabaseId": "2001", - "number": 102, - "title": "PR 102", - "state": "OPEN", - "url": "https://github.com/OWNER/REPO/pull/102", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "id": "PR_node3", - "fullDatabaseId": "2002", - "number": 103, - "title": "PR 103", - "state": "OPEN", - "url": "https://github.com/OWNER/REPO/pull/103", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "id": "PR_node4", - "fullDatabaseId": "2003", - "number": 104, - "title": "PR 104", - "state": "OPEN", - "url": "https://github.com/OWNER/REPO/pull/104", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "id": "PR_node5", - "fullDatabaseId": "2004", - "number": 105, - "title": "PR 105", - "state": "OPEN", - "url": "https://github.com/OWNER/REPO/pull/105", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "id": "PR_node6", - "fullDatabaseId": "2005", - "number": 106, - "title": "PR 106", - "state": "OPEN", - "url": "https://github.com/OWNER/REPO/pull/106", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - } - ] - } - }`, createdAt)), - ) -} From 3762d978948100f7f1b2eeabd538aa1c3796aa84 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 19:54:33 +0100 Subject: [PATCH 062/208] test(agent-task list): apply test args anyway Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/list/list_test.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index 5ed0d097d..ce0fd323f 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -1,8 +1,10 @@ package list import ( + "bytes" "context" "errors" + "io" "testing" "time" @@ -78,12 +80,15 @@ func TestNewCmdList(t *testing.T) { var gotOpts *ListOptions cmd := NewCmdList(f, func(opts *ListOptions) error { gotOpts = opts; return nil }) - if tt.args != "" { - argv, err := shlex.Split(tt.args) - require.NoError(t, err) - cmd.SetArgs(argv) - } - _, err := cmd.ExecuteC() + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) From b0ac06e4f449aef5fde1b8c91025c20e6406b933 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 20:17:43 +0100 Subject: [PATCH 063/208] fix(agent-task create): allow no positional arg Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 6a5d169be..c7f6d3ad9 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -47,12 +47,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return err } - // 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") - } - // Populate ProblemStatement from either arg or file if len(args) > 0 { opts.ProblemStatement = args[0] From d17fdb3e8c16b843195b492053d608b5596093c4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 20:19:35 +0100 Subject: [PATCH 064/208] test(agent-task list): update `TestNewCmdCreate` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create_test.go | 123 ++++++++++++----------- 1 file changed, 65 insertions(+), 58 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index a7bb8a166..8cd82ac8c 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -1,10 +1,11 @@ package create import ( + "fmt" + "io" "net/http" "os" "path/filepath" - "slices" "testing" "github.com/MakeNowJust/heredoc" @@ -15,103 +16,109 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" "github.com/stretchr/testify/require" ) -// Test basic option parsing & repository requirement -func TestNewCmdCreate_Args(t *testing.T) { +func TestNewCmdCreate(t *testing.T) { + tmpDir := t.TempDir() + + tmpEmptyFile := filepath.Join(tmpDir, "empty-task-description.md") + err := os.WriteFile(tmpEmptyFile, []byte(" \n\n"), 0600) + require.NoError(t, err) + + tmpFile := filepath.Join(tmpDir, "task-description.md") + err = os.WriteFile(tmpFile, []byte("task description from file"), 0600) + require.NoError(t, err) + tests := []struct { - name string - args []string - fileContent string // if non-empty, create temp file and substitute {{FILE}} token in args - wantOpts *CreateOptions // nil when expecting error - expectedErr string + name string + args string + stdin string + wantOpts *CreateOptions // nil when expecting error + wantErr string }{ { - name: "no args nor file", - args: []string{}, - expectedErr: "a task description is required", + name: "no args nor file", + wantErr: "a task description is required", }, { name: "arg only success", - args: []string{"task description from args"}, + args: "'task description from args'", wantOpts: &CreateOptions{ ProblemStatement: "task description from args", }, }, { - name: "from-file success", - args: []string{"-F", "{{FILE}}"}, - fileContent: "task description from file", + name: "from-file success", + args: fmt.Sprintf("-F %s", tmpFile), wantOpts: &CreateOptions{ ProblemStatement: "task description from file", }, }, { - name: "file content from stdin success", - args: []string{"-F", "-"}, - fileContent: "task from stdin", - wantOpts: &CreateOptions{ProblemStatement: "task from stdin"}, + name: "file content from stdin success", + args: "-F -", + stdin: "task description from stdin", + wantOpts: &CreateOptions{ + ProblemStatement: "task description from stdin", + }, }, { - name: "mutually exclusive arg and file", - args: []string{"Some task inline", "-F", "{{FILE}}"}, - fileContent: "Some task", - expectedErr: "only one of -F or arg can be provided", + name: "mutually exclusive arg and file", + args: "'some task inline' -F foo.md", + wantErr: "only one of -F or arg can be provided", }, { - name: "missing file path", - args: []string{"-F", "does-not-exist.md"}, - expectedErr: "could not read task description file: open does-not-exist.md: no such file or directory", + name: "missing file path", + args: "-F does-not-exist.md", + wantErr: "could not read task description file: open does-not-exist.md:", }, { - name: "empty file", - args: []string{"-F", "{{FILE}}"}, - fileContent: " \n\n", - expectedErr: "task description file is empty", + name: "empty file", + args: fmt.Sprintf("-F %s", tmpEmptyFile), + wantErr: "task description file is empty", + }, + { + name: "empty from stdin", + args: "-F -", + stdin: " \n\n", + wantErr: "task description file is empty", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ios, stdinBuf, _, _ := iostreams.Test() - - // Provide file content either via stdin ( -F - ) or by creating a temp file - if tt.fileContent != "" { - isStdin := len(tt.args) == 2 && tt.args[0] == "-F" && tt.args[1] == "-" - hasFileToken := slices.Contains(tt.args, "{{FILE}}") - - switch { - case isStdin: - stdinBuf.WriteString(tt.fileContent) - case hasFileToken: - dir := t.TempDir() - path := filepath.Join(dir, "task.md") - if err := os.WriteFile(path, []byte(tt.fileContent), 0o600); err != nil { - t.Fatalf("failed to write temp file: %v", err) - } - for i, a := range tt.args { - if a == "{{FILE}}" { - tt.args[i] = path - } - } - } + ios, stdin, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, } - f := &cmdutil.Factory{IOStreams: ios} var gotOpts *CreateOptions cmd := NewCmdCreate(f, func(o *CreateOptions) error { gotOpts = o return nil }) - cmd.SetArgs(tt.args) - _, err := cmd.ExecuteC() - if tt.expectedErr != "" { - require.Error(t, err) - require.Equal(t, tt.expectedErr, err.Error()) + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(stdin) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + if tt.stdin != "" { + stdin.WriteString(tt.stdin) + } + + _, err = cmd.ExecuteC() + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) return } + require.NoError(t, err) if tt.wantOpts != nil { require.Equal(t, tt.wantOpts.ProblemStatement, gotOpts.ProblemStatement) From 28bb0f62bbe402751056143b92f15d2c562930dc Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 20:59:33 +0100 Subject: [PATCH 065/208] fix(agent-task create): simplify command initialisation Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index c7f6d3ad9..7b081b4e5 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -33,7 +33,8 @@ type CreateOptions struct { func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ - IO: f.IOStreams, + IO: f.IOStreams, + CapiClient: shared.CapiClientFunc(f), } var fromFileName string @@ -43,6 +44,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co Short: "Create an agent task (preview)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // Support -R/--repo override + opts.BaseRepo = f.BaseRepo + if err := cmdutil.MutuallyExclusive("only one of -F or arg can be provided", len(args) > 0, fromFileName != ""); err != nil { return err } @@ -61,17 +65,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co } opts.ProblemStatement = trimmed } + if opts.ProblemStatement == "" { return cmdutil.FlagErrorf("a task description is required") } - // Support -R/--repo override - if f != nil { - opts.BaseRepo = f.BaseRepo - - if opts.CapiClient == nil { - opts.CapiClient = shared.CapiClientFunc(f) - } - } if runF != nil { return runF(opts) @@ -103,12 +100,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co } 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 From 07ec8c629d13e5f5c8a43d727fad4fdae2c987f6 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 21:01:56 +0100 Subject: [PATCH 066/208] test(agent-task create): use `CapiClientMock` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create_test.go | 300 +++++++++++------------ 1 file changed, 150 insertions(+), 150 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 8cd82ac8c..3a244c0e7 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -1,20 +1,19 @@ package create import ( + "context" + "errors" "fmt" "io" - "net/http" "os" "path/filepath" "testing" + "time" - "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/google/shlex" "github.com/stretchr/testify/require" @@ -128,177 +127,182 @@ func TestNewCmdCreate(t *testing.T) { } 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" - }`) + sampleDateString := "2025-08-29T00:00:00Z" + sampleDate, err := time.Parse(time.RFC3339, sampleDateString) + require.NoError(t, err) + + createdJobSuccess := capi.Job{ + ID: "job123", + SessionID: "sess1", + Actor: &capi.JobActor{ + ID: 1, + Login: "octocat", + }, + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + } + createdJobSuccessWithPR := capi.Job{ + ID: "job123", + SessionID: "sess1", + Actor: &capi.JobActor{ + ID: 1, + Login: "octocat", + }, + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + PullRequest: &capi.JobPullRequest{ + ID: 101, + Number: 42, + }, + } tests := []struct { - name string - stubs func(*httpmock.Registry) - baseRepoFunc func() (ghrepo.Interface, error) - problemStatement string - baseBranch string - wantStdout string - wantStdErr string - wantErr string + name string + capiStubs func(*testing.T, *capi.CapiClientMock) + baseRepoFunc func() (ghrepo.Interface, error) + baseBranch string + wantStdout string + wantStdErr string + wantErr string }{ { - name: "base branch included in create payload", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - problemStatement: "Do the thing", - baseBranch: "feature", - stubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), - httpmock.RESTPayload(201, createdJobSuccessWithPRResponse, func(payload map[string]interface{}) { - prRaw, ok := payload["pull_request"].(map[string]interface{}) - if !ok { - require.FailNow(t, "expected pull_request object in payload") - } - if prRaw["base_ref"] != "refs/heads/feature" { - require.FailNow(t, "expected pull_request.base_ref to be 'refs/heads/feature'") - } - if payload["problem_statement"] != "Do the thing" { - require.FailNow(t, "unexpected problem_statement value") - } - }), - ) - }, - wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + name: "missing repo returns error", + 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: "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"}`), - ) + name: "base branch included in create payload", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + baseBranch: "feature", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "Do the thing", problemStatement) + require.Equal(t, "feature", baseBranch) + return &createdJobSuccess, nil + } + m.GetJobFunc = func(ctx context.Context, owner, repo, jobID string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "job123", jobID) + return &createdJobSuccessWithPR, nil } }, - wantStdout: "job jobABC queued. View progress: https://github.com/copilot/agents\n", + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\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"}}`), - ) + name: "create task API failure returns error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "Do the thing", problemStatement) + require.Equal(t, "", baseBranch) + return nil, errors.New("some error") + } }, - wantErr: "failed to create job: some API error", + wantErr: "some 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", + name: "get job API failure surfaces error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "Do the thing", problemStatement) + require.Equal(t, "", baseBranch) + return &createdJobSuccess, nil + } + m.GetJobFunc = func(ctx context.Context, owner, repo, jobID string) (*capi.Job, error) { + return nil, errors.New("some error") + } + }, + wantStdErr: "some error\n", + wantStdout: "job job123 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 }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "Do the thing", problemStatement) + require.Equal(t, "", baseBranch) + return &createdJobSuccessWithPR, nil + } + }, + 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 }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "Do the thing", problemStatement) + require.Equal(t, "", baseBranch) + return &createdJobSuccess, nil + } + m.GetJobFunc = func(ctx context.Context, owner, repo, jobID string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "job123", jobID) + return &createdJobSuccessWithPR, nil + } + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + name: "fallback after timeout returns link to global agents page", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "Do the thing", problemStatement) + require.Equal(t, "", baseBranch) + return &createdJobSuccess, nil + } + + count := 0 + m.GetJobFunc = func(ctx context.Context, owner, repo, jobID string) (*capi.Job, error) { + if count++; count > 4 { + require.FailNow(t, "too many get calls") + } + return &createdJobSuccess, nil + } + }, + wantStdout: "job job123 queued. View progress: https://github.com/copilot/agents\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + capiClientMock := &capi.CapiClientMock{} + if tt.capiStubs != nil { + tt.capiStubs(t, capiClientMock) + } + ios, _, stdout, stderr := iostreams.Test() opts := &CreateOptions{ IO: ios, - ProblemStatement: tt.problemStatement, + ProblemStatement: "Do the thing", BaseRepo: tt.baseRepoFunc, BaseBranch: tt.baseBranch, + CapiClient: func() (capi.CapiClient, error) { + return capiClientMock, nil + }, } // 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 != "" { @@ -310,10 +314,6 @@ func Test_createRun(t *testing.T) { require.Equal(t, tt.wantStdout, stdout.String()) require.Equal(t, tt.wantStdErr, stderr.String()) - - if tt.stubs != nil { - reg.Verify(t) - } }) } } From 844c0ab56eec219cd237667098e899c9638f17c4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 21:06:15 +0100 Subject: [PATCH 067/208] refactor(agent-task create): remove redundant `if` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 7b081b4e5..028740612 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -89,9 +89,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co $ gh agent-task create "fix errors" --base branch `), } - if f != nil { - cmdutil.EnableRepoOverride(cmd, f) - } + + cmdutil.EnableRepoOverride(cmd, f) cmd.Flags().StringVarP(&fromFileName, "from-file", "F", "", "Read task description from `file` (use \"-\" to read from standard input)") cmd.Flags().StringVarP(&opts.BaseBranch, "base", "b", "", "Base branch for the pull request (use default branch if not provided)") From 7824b43f8a23dea62c102d7916b3895a685b862d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 4 Sep 2025 21:06:38 +0100 Subject: [PATCH 068/208] refactor(agent-task list): simplify cmd initialisation Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/list/list.go | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 9c19c61f6..b9f892a63 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -32,9 +32,10 @@ type ListOptions struct { // NewCmdList creates the list command func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ - IO: f.IOStreams, - Limit: defaultLimit, - Browser: f.Browser, + IO: f.IOStreams, + CapiClient: shared.CapiClientFunc(f), + Limit: defaultLimit, + Browser: f.Browser, } cmd := &cobra.Command{ @@ -45,10 +46,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman // Support -R/--repo override opts.BaseRepo = f.BaseRepo - if opts.CapiClient == nil { - opts.CapiClient = shared.CapiClientFunc(f) - } - if opts.Limit < 1 { return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) } @@ -59,9 +56,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman }, } - if f != nil { - cmdutil.EnableRepoOverride(cmd, f) - } + cmdutil.EnableRepoOverride(cmd, f) cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, fmt.Sprintf("Maximum number of agent tasks to fetch (default %d)", defaultLimit)) cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent tasks in the browser") From 004be9da2005289013167faf5c464cdc92b10dc0 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 09:28:44 +0100 Subject: [PATCH 069/208] test(agent-task create): quote file paths to pass CI on Windows To keep the backslashes in Windows file paths and stop `shlex.Split` from interpreting them as escape characters, we need to quote the paths. Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 3a244c0e7..00134792f 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -50,7 +50,7 @@ func TestNewCmdCreate(t *testing.T) { }, { name: "from-file success", - args: fmt.Sprintf("-F %s", tmpFile), + args: fmt.Sprintf("-F '%s'", tmpFile), wantOpts: &CreateOptions{ ProblemStatement: "task description from file", }, @@ -75,7 +75,7 @@ func TestNewCmdCreate(t *testing.T) { }, { name: "empty file", - args: fmt.Sprintf("-F %s", tmpEmptyFile), + args: fmt.Sprintf("-F '%s'", tmpEmptyFile), wantErr: "task description file is empty", }, { From 1e36e9f1e386b9b1bde7840afbd638c8d55a2a09 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 10:55:42 +0100 Subject: [PATCH 070/208] refactor(agent-task/capi): hydrate user data Signed-off-by: Babak K. Shandiz --- api/queries_repo.go | 7 +- pkg/cmd/agent-task/capi/sessions.go | 84 ++++++++++++++++-------- pkg/cmd/agent-task/capi/sessions_test.go | 74 ++++++++++++++++++--- 3 files changed, 126 insertions(+), 39 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 324200afd..e797cead4 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -141,9 +141,10 @@ type RepositoryOwner struct { } type GitHubUser struct { - ID string `json:"id"` - Login string `json:"login"` - Name string `json:"name"` + ID string `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + DatabaseID int64 `json:"databaseId"` } // Actor is a superset of User and Bot, among others. diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 8bf29575c..b553fc932 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -22,7 +22,7 @@ var defaultSessionsPerPage = 50 type session struct { ID string `json:"id"` Name string `json:"name"` - UserID uint64 `json:"user_id"` + UserID int64 `json:"user_id"` AgentID int64 `json:"agent_id"` Logs string `json:"logs"` State string `json:"state"` @@ -60,7 +60,7 @@ type sessionPullRequest struct { type Session struct { ID string Name string - UserID uint64 + UserID int64 AgentID int64 Logs string State string @@ -75,6 +75,7 @@ type Session struct { EventType string PullRequest *api.PullRequest + User *api.GitHubUser } // ListSessionsForViewer lists all agent sessions for the @@ -127,7 +128,7 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S } // Hydrate the result with pull request data. - result, err := c.hydrateSessionPullRequests(sessions) + result, err := c.hydrateSessionPullRequestsAndUsers(sessions) if err != nil { return nil, fmt.Errorf("failed to fetch session resources: %w", err) } @@ -188,42 +189,50 @@ func (c *CAPIClient) ListSessionsForRepo(ctx context.Context, owner string, repo } // Hydrate the result with pull request data. - result, err := c.hydrateSessionPullRequests(sessions) + result, err := c.hydrateSessionPullRequestsAndUsers(sessions) if err != nil { return nil, fmt.Errorf("failed to fetch session resources: %w", err) } return result, nil } -// hydrateSessionPullRequests hydrates pull request information in sessions -func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, error) { +// hydrateSessionPullRequestsAndUsers hydrates pull request and user information in sessions +func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]*Session, error) { if len(sessions) == 0 { return nil, nil } prNodeIds := make([]string, 0, len(sessions)) - + userNodeIds := make([]string, 0, len(sessions)) for _, session := range sessions { prNodeID := generatePullRequestNodeID(int64(session.RepoID), session.ResourceID) - if slices.Contains(prNodeIds, prNodeID) { - continue + if !slices.Contains(prNodeIds, prNodeID) { + prNodeIds = append(prNodeIds, prNodeID) } - prNodeIds = append(prNodeIds, prNodeID) - } + userNodeId := generateUserNodeID(session.UserID) + if !slices.Contains(userNodeIds, userNodeId) { + userNodeIds = append(userNodeIds, userNodeId) + } + } apiClient := api.NewClientFromHTTP(c.httpClient) var resp struct { Nodes []struct { TypeName string `graphql:"__typename"` PullRequest sessionPullRequest `graphql:"... on PullRequest"` + User api.GitHubUser `graphql:"... on User"` } `graphql:"nodes(ids: $ids)"` } + ids := make([]string, 0, len(prNodeIds)+len(userNodeIds)) + ids = append(ids, prNodeIds...) + ids = append(ids, userNodeIds...) + // TODO handle pagination host, _ := c.authCfg.DefaultHost() - err := apiClient.Query(host, "FetchPRsForAgentTaskSessions", &resp, map[string]any{ - "ids": prNodeIds, + err := apiClient.Query(host, "FetchPRsAndUsersForAgentTaskSessions", &resp, map[string]any{ + "ids": ids, }) if err != nil { @@ -231,20 +240,26 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, } prMap := make(map[string]*api.PullRequest, len(prNodeIds)) + userMap := make(map[int64]*api.GitHubUser, len(userNodeIds)) for _, node := range resp.Nodes { - prMap[node.PullRequest.FullDatabaseID] = &api.PullRequest{ - ID: node.PullRequest.ID, - FullDatabaseID: node.PullRequest.FullDatabaseID, - Number: node.PullRequest.Number, - Title: node.PullRequest.Title, - State: node.PullRequest.State, - URL: node.PullRequest.URL, - Body: node.PullRequest.Body, - CreatedAt: node.PullRequest.CreatedAt, - UpdatedAt: node.PullRequest.UpdatedAt, - ClosedAt: node.PullRequest.ClosedAt, - MergedAt: node.PullRequest.MergedAt, - Repository: node.PullRequest.Repository, + switch node.TypeName { + case "User": + userMap[node.User.DatabaseID] = &node.User + case "PullRequest": + prMap[node.PullRequest.FullDatabaseID] = &api.PullRequest{ + ID: node.PullRequest.ID, + FullDatabaseID: node.PullRequest.FullDatabaseID, + Number: node.PullRequest.Number, + Title: node.PullRequest.Title, + State: node.PullRequest.State, + URL: node.PullRequest.URL, + Body: node.PullRequest.Body, + CreatedAt: node.PullRequest.CreatedAt, + UpdatedAt: node.PullRequest.UpdatedAt, + ClosedAt: node.PullRequest.ClosedAt, + MergedAt: node.PullRequest.MergedAt, + Repository: node.PullRequest.Repository, + } } } @@ -252,6 +267,7 @@ func (c *CAPIClient) hydrateSessionPullRequests(sessions []session) ([]*Session, for _, s := range sessions { newSession := fromAPISession(s) newSession.PullRequest = prMap[strconv.FormatInt(s.ResourceID, 10)] + newSession.User = userMap[s.UserID] newSessions = append(newSessions, newSession) } @@ -276,6 +292,22 @@ func generatePullRequestNodeID(repoID, pullRequestID int64) string { return "PR_" + encoded } +func generateUserNodeID(userID int64) string { + buf := bytes.Buffer{} + parts := []int64{0, userID} + + encoder := msgpack.NewEncoder(&buf) + encoder.UseCompactInts(true) + + if err := encoder.Encode(parts); err != nil { + panic(err) + } + + encoded := base64.RawURLEncoding.EncodeToString(buf.Bytes()) + + return "U_" + encoded +} + func fromAPISession(s session) *Session { return &Session{ ID: s.ID, diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 20b670d56..0ff3a46f8 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -86,7 +86,7 @@ func TestListSessionsForViewer(t *testing.T) { ) // GraphQL hydration reg.Register( - httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), httpmock.GraphQLQuery(heredoc.Docf(` { "data": { @@ -105,13 +105,19 @@ func TestListSessionsForViewer(t *testing.T) { "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 } ] } }`, sampleDateString, ), func(q string, vars map[string]interface{}) { - assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A"}, vars["ids"]) + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) }), ) }, @@ -143,6 +149,11 @@ func TestListSessionsForViewer(t *testing.T) { NameWithOwner: "OWNER/REPO", }, }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, }, }, }, @@ -213,7 +224,7 @@ func TestListSessionsForViewer(t *testing.T) { ) // GraphQL hydration reg.Register( - httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), httpmock.GraphQLQuery(heredoc.Docf(` { "data": { @@ -247,13 +258,19 @@ func TestListSessionsForViewer(t *testing.T) { "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 } ] } }`, sampleDateString, ), func(q string, vars map[string]interface{}) { - assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "PR_kwDNA-jNB9E"}, vars["ids"]) + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "PR_kwDNA-jNB9E", "U_kgAB"}, vars["ids"]) }), ) }, @@ -284,6 +301,11 @@ func TestListSessionsForViewer(t *testing.T) { NameWithOwner: "OWNER/REPO", }, }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, }, { ID: "sess2", @@ -311,6 +333,11 @@ func TestListSessionsForViewer(t *testing.T) { NameWithOwner: "OWNER/REPO", }, }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, }, }, }, @@ -365,7 +392,7 @@ func TestListSessionsForViewer(t *testing.T) { ) // GraphQL hydration reg.Register( - httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), httpmock.StatusStringResponse(500, `{}`), ) }, @@ -489,7 +516,7 @@ func TestListSessionsForRepo(t *testing.T) { ) // GraphQL hydration reg.Register( - httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), httpmock.GraphQLQuery(heredoc.Docf(` { "data": { @@ -508,13 +535,19 @@ func TestListSessionsForRepo(t *testing.T) { "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 } ] } }`, sampleDateString, ), func(q string, vars map[string]interface{}) { - assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A"}, vars["ids"]) + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) }), ) }, @@ -545,6 +578,11 @@ func TestListSessionsForRepo(t *testing.T) { NameWithOwner: "OWNER/REPO", }, }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, }, }, }, @@ -615,7 +653,7 @@ func TestListSessionsForRepo(t *testing.T) { ) // GraphQL hydration reg.Register( - httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), httpmock.GraphQLQuery(heredoc.Docf(` { "data": { @@ -649,13 +687,19 @@ func TestListSessionsForRepo(t *testing.T) { "repository": { "nameWithOwner": "OWNER/REPO" } + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 } ] } }`, sampleDateString, ), func(q string, vars map[string]interface{}) { - assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "PR_kwDNA-jNB9E"}, vars["ids"]) + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "PR_kwDNA-jNB9E", "U_kgAB"}, vars["ids"]) }), ) }, @@ -686,6 +730,11 @@ func TestListSessionsForRepo(t *testing.T) { NameWithOwner: "OWNER/REPO", }, }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, }, { ID: "sess2", @@ -713,6 +762,11 @@ func TestListSessionsForRepo(t *testing.T) { NameWithOwner: "OWNER/REPO", }, }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, }, }, }, @@ -767,7 +821,7 @@ func TestListSessionsForRepo(t *testing.T) { ) // GraphQL hydration reg.Register( - httpmock.GraphQL(`query FetchPRsForAgentTaskSessions\b`), + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), httpmock.StatusStringResponse(500, `{}`), ) }, From 4e1fcf1da93092dcee7e5d3ee774bd183d74a11e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 10:59:51 +0100 Subject: [PATCH 071/208] refactor(agent-task/capi): populate PRs `IsDraft` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 2 ++ pkg/cmd/agent-task/capi/sessions_test.go | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index b553fc932..f53028941 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -47,6 +47,7 @@ type sessionPullRequest struct { State string URL string Body string + IsDraft bool CreatedAt time.Time UpdatedAt time.Time @@ -252,6 +253,7 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* Number: node.PullRequest.Number, Title: node.PullRequest.Title, State: node.PullRequest.State, + IsDraft: node.PullRequest.IsDraft, URL: node.PullRequest.URL, Body: node.PullRequest.Body, CreatedAt: node.PullRequest.CreatedAt, diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 0ff3a46f8..94991fb8b 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -98,6 +98,7 @@ func TestListSessionsForViewer(t *testing.T) { "number": 42, "title": "Improve docs", "state": "OPEN", + "isDraft": true, "url": "https://github.com/OWNER/REPO/pull/42", "body": "", "createdAt": "%[1]s", @@ -141,6 +142,7 @@ func TestListSessionsForViewer(t *testing.T) { Number: 42, Title: "Improve docs", State: "OPEN", + IsDraft: true, URL: "https://github.com/OWNER/REPO/pull/42", Body: "", CreatedAt: sampleDate, @@ -236,6 +238,7 @@ func TestListSessionsForViewer(t *testing.T) { "number": 42, "title": "Improve docs", "state": "OPEN", + "isDraft": true, "url": "https://github.com/OWNER/REPO/pull/42", "body": "", "createdAt": "%[1]s", @@ -251,6 +254,7 @@ func TestListSessionsForViewer(t *testing.T) { "number": 43, "title": "Improve docs", "state": "OPEN", + "isDraft": true, "url": "https://github.com/OWNER/REPO/pull/43", "body": "", "createdAt": "%[1]s", @@ -293,6 +297,7 @@ func TestListSessionsForViewer(t *testing.T) { Number: 42, Title: "Improve docs", State: "OPEN", + IsDraft: true, URL: "https://github.com/OWNER/REPO/pull/42", Body: "", CreatedAt: sampleDate, @@ -325,6 +330,7 @@ func TestListSessionsForViewer(t *testing.T) { Number: 43, Title: "Improve docs", State: "OPEN", + IsDraft: true, URL: "https://github.com/OWNER/REPO/pull/43", Body: "", CreatedAt: sampleDate, @@ -528,6 +534,7 @@ func TestListSessionsForRepo(t *testing.T) { "number": 42, "title": "Improve docs", "state": "OPEN", + "isDraft": true, "url": "https://github.com/OWNER/REPO/pull/42", "body": "", "createdAt": "%[1]s", @@ -570,6 +577,7 @@ func TestListSessionsForRepo(t *testing.T) { Number: 42, Title: "Improve docs", State: "OPEN", + IsDraft: true, URL: "https://github.com/OWNER/REPO/pull/42", Body: "", CreatedAt: sampleDate, @@ -665,6 +673,7 @@ func TestListSessionsForRepo(t *testing.T) { "number": 42, "title": "Improve docs", "state": "OPEN", + "isDraft": true, "url": "https://github.com/OWNER/REPO/pull/42", "body": "", "createdAt": "%[1]s", @@ -680,6 +689,7 @@ func TestListSessionsForRepo(t *testing.T) { "number": 43, "title": "Improve docs", "state": "OPEN", + "isDraft": true, "url": "https://github.com/OWNER/REPO/pull/43", "body": "", "createdAt": "%[1]s", @@ -722,6 +732,7 @@ func TestListSessionsForRepo(t *testing.T) { Number: 42, Title: "Improve docs", State: "OPEN", + IsDraft: true, URL: "https://github.com/OWNER/REPO/pull/42", Body: "", CreatedAt: sampleDate, @@ -754,6 +765,7 @@ func TestListSessionsForRepo(t *testing.T) { Number: 43, Title: "Improve docs", State: "OPEN", + IsDraft: true, URL: "https://github.com/OWNER/REPO/pull/43", Body: "", CreatedAt: sampleDate, From dcadeb75d49a71a3937272dfc6a64d5eb7414fdd Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 11:17:13 +0100 Subject: [PATCH 072/208] feat(agent-task/capi): add `GetSession` method Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index f53028941..e0252a8bb 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -18,6 +19,8 @@ import ( var defaultSessionsPerPage = 50 +var ErrSessionNotFound = errors.New("not found") + // session is an in-flight agent task type session struct { ID string `json:"id"` @@ -197,6 +200,45 @@ func (c *CAPIClient) ListSessionsForRepo(ctx context.Context, owner string, repo return result, nil } +// GetSession retrieves a specific agent session by ID. +func (c *CAPIClient) GetSession(ctx context.Context, id string) (*Session, error) { + if id == "" { + return nil, fmt.Errorf("missing session ID") + } + + url := fmt.Sprintf("%s/agents/sessions/%s", baseCAPIURL, url.PathEscape(id)) + + 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 { + if res.StatusCode == http.StatusNotFound { + return nil, ErrSessionNotFound + } + return nil, fmt.Errorf("failed to get session: %s", res.Status) + } + + var rawSession session + if err := json.NewDecoder(res.Body).Decode(&rawSession); err != nil { + return nil, fmt.Errorf("failed to decode session response: %w", err) + } + + sessions, err := c.hydrateSessionPullRequestsAndUsers([]session{rawSession}) + if err != nil { + return nil, fmt.Errorf("failed to fetch session resources: %w", err) + } + + return sessions[0], nil +} + // hydrateSessionPullRequestsAndUsers hydrates pull request and user information in sessions func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]*Session, error) { if len(sessions) == 0 { From ee8fc060776ef5c2faae5dc03aa8471f3b32352f Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 11:18:58 +0100 Subject: [PATCH 073/208] test(agent-task/capi): add tests for `GetSession` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions_test.go | 207 +++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 94991fb8b..115c7ab9e 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -875,3 +875,210 @@ func TestListSessionsForRepo(t *testing.T) { }) } } + +func TestGetSessionRequiresID(t *testing.T) { + client := &CAPIClient{} + + _, err := client.GetSession(context.Background(), "") + assert.EqualError(t, err, "missing session ID") +} + +func TestGetSession(t *testing.T) { + sampleDateString := "2025-08-29T00:00:00Z" + sampleDate, err := time.Parse(time.RFC3339, sampleDateString) + require.NoError(t, err) + + tests := []struct { + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantErrIs error + wantOut *Session + }{ + { + name: "session not found", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/some-uuid"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(404, "{}"), + ) + }, + wantErrIs: ErrSessionNotFound, + wantErr: "not found", + }, + { + name: "API error", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/some-uuid"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(500, "some error"), + ) + }, + wantErr: "failed to get session:", + }, + { + name: "invalid JSON response", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/some-uuid"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(200, ""), + ) + }, + wantErr: "failed to decode session response: EOF", + }, + { + name: "success", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/some-uuid"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(` + { + "id": "some-uuid", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: &Session{ + ID: "some-uuid", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2000", + Number: 42, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/42", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, + }, + }, + { + name: "API error at hydration", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/some-uuid"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(` + { + "id": "some-uuid", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.StatusStringResponse(500, `{}`), + ) + }, + wantErr: `failed to fetch session resources: non-200 OK status code:`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + defer reg.Verify(t) + + httpClient := &http.Client{Transport: reg} + + cfg := config.NewBlankConfig() + capiClient := NewCAPIClient(httpClient, cfg.Authentication()) + + session, err := capiClient.GetSession(context.Background(), "some-uuid") + + if tt.wantErrIs != nil { + require.ErrorIs(t, err, tt.wantErrIs) + } + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.Nil(t, session) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantOut, session) + }) + } +} From 87b772dc83fa5b133d1374ac6dc9abd253b2c11a Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 11:20:06 +0100 Subject: [PATCH 074/208] feat(agent-task/capi): add `GetSession` method to `CapiClient` interface Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/client.go | 1 + pkg/cmd/agent-task/capi/client_mock.go | 50 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index ecec9a024..15765552b 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -19,6 +19,7 @@ type CapiClient interface { ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) CreateJob(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*Job, error) GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) + GetSession(ctx context.Context, id string) (*Session, error) } // CAPIClient is a client for interacting with the Copilot API diff --git a/pkg/cmd/agent-task/capi/client_mock.go b/pkg/cmd/agent-task/capi/client_mock.go index 621585587..ba7c05ab0 100644 --- a/pkg/cmd/agent-task/capi/client_mock.go +++ b/pkg/cmd/agent-task/capi/client_mock.go @@ -24,6 +24,9 @@ var _ CapiClient = &CapiClientMock{} // GetJobFunc: func(ctx context.Context, owner string, repo string, jobID string) (*Job, error) { // panic("mock out the GetJob method") // }, +// GetSessionFunc: func(ctx context.Context, id string) (*Session, error) { +// panic("mock out the GetSession method") +// }, // ListSessionsForRepoFunc: func(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { // panic("mock out the ListSessionsForRepo method") // }, @@ -43,6 +46,9 @@ type CapiClientMock struct { // GetJobFunc mocks the GetJob method. GetJobFunc func(ctx context.Context, owner string, repo string, jobID string) (*Job, error) + // GetSessionFunc mocks the GetSession method. + GetSessionFunc func(ctx context.Context, id string) (*Session, error) + // ListSessionsForRepoFunc mocks the ListSessionsForRepo method. ListSessionsForRepoFunc func(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) @@ -75,6 +81,13 @@ type CapiClientMock struct { // JobID is the jobID argument value. JobID string } + // GetSession holds details about calls to the GetSession method. + GetSession []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // ID is the id argument value. + ID string + } // ListSessionsForRepo holds details about calls to the ListSessionsForRepo method. ListSessionsForRepo []struct { // Ctx is the ctx argument value. @@ -96,6 +109,7 @@ type CapiClientMock struct { } lockCreateJob sync.RWMutex lockGetJob sync.RWMutex + lockGetSession sync.RWMutex lockListSessionsForRepo sync.RWMutex lockListSessionsForViewer sync.RWMutex } @@ -192,6 +206,42 @@ func (mock *CapiClientMock) GetJobCalls() []struct { return calls } +// GetSession calls GetSessionFunc. +func (mock *CapiClientMock) GetSession(ctx context.Context, id string) (*Session, error) { + if mock.GetSessionFunc == nil { + panic("CapiClientMock.GetSessionFunc: method is nil but CapiClient.GetSession was just called") + } + callInfo := struct { + Ctx context.Context + ID string + }{ + Ctx: ctx, + ID: id, + } + mock.lockGetSession.Lock() + mock.calls.GetSession = append(mock.calls.GetSession, callInfo) + mock.lockGetSession.Unlock() + return mock.GetSessionFunc(ctx, id) +} + +// GetSessionCalls gets all the calls that were made to GetSession. +// Check the length with: +// +// len(mockedCapiClient.GetSessionCalls()) +func (mock *CapiClientMock) GetSessionCalls() []struct { + Ctx context.Context + ID string +} { + var calls []struct { + Ctx context.Context + ID string + } + mock.lockGetSession.RLock() + calls = mock.calls.GetSession + mock.lockGetSession.RUnlock() + return calls +} + // ListSessionsForRepo calls ListSessionsForRepoFunc. func (mock *CapiClientMock) ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { if mock.ListSessionsForRepoFunc == nil { From 476b636810563b68a95e1a3aabddce67aacdc8e9 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 11:30:33 +0100 Subject: [PATCH 075/208] test(agent-task list): remove unused `stubs` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/list/list_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index ce0fd323f..d096ab3be 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -14,7 +14,6 @@ import ( "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/google/shlex" "github.com/stretchr/testify/assert" @@ -114,7 +113,6 @@ func Test_listRun(t *testing.T) { tests := []struct { name string tty bool - stubs func(*httpmock.Registry) capiStubs func(*testing.T, *capi.CapiClientMock) baseRepo ghrepo.Interface baseRepoErr error From 92c7a56b828654720b9089ef3b02d364aa56cdaf Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 12:45:51 +0100 Subject: [PATCH 076/208] feat(agent-task view): add `view` command Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/agent_task.go | 2 + pkg/cmd/agent-task/shared/display.go | 23 ++++++ pkg/cmd/agent-task/view/view.go | 108 +++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 pkg/cmd/agent-task/view/view.go diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index e732e31de..4eeed3719 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -7,6 +7,7 @@ import ( cmdCreate "github.com/cli/cli/v2/pkg/cmd/agent-task/create" cmdList "github.com/cli/cli/v2/pkg/cmd/agent-task/list" + cmdView "github.com/cli/cli/v2/pkg/cmd/agent-task/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" @@ -31,6 +32,7 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command { // register subcommands cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) return cmd } diff --git a/pkg/cmd/agent-task/shared/display.go b/pkg/cmd/agent-task/shared/display.go index 675143140..dd114b049 100644 --- a/pkg/cmd/agent-task/shared/display.go +++ b/pkg/cmd/agent-task/shared/display.go @@ -23,3 +23,26 @@ func ColorFuncForSessionState(s capi.Session, cs *iostreams.ColorScheme) func(st return stateColor } + +func SessionStateString(state string) string { + switch state { + case "queued": + return "Queued" + case "in_progress": + return "In Progress" + case "completed": + return "Completed" + case "failed": + return "Failed" + case "idle": + return "Idle" + case "waiting_for_user": + return "Waiting for User" + case "timed_out": + return "Timed Out" + case "cancelled": + return "Cancelled" + default: + return state + } +} diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go new file mode 100644 index 000000000..f6ce4d468 --- /dev/null +++ b/pkg/cmd/agent-task/view/view.go @@ -0,0 +1,108 @@ +package view + +import ( + "context" + "errors" + "fmt" + "net/url" + "time" + + "github.com/MakeNowJust/heredoc" + "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" +) + +type ViewOptions struct { + IO *iostreams.IOStreams + CapiClient func() (capi.CapiClient, error) + + SelectorArg string +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + CapiClient: shared.CapiClientFunc(f), + } + + cmd := &cobra.Command{ + Use: "view ", + Short: "View an agent task session", + Long: heredoc.Doc(` + View an agent task session. + `), + Args: cmdutil.ExactArgs(1, "a session ID is required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.SelectorArg = args[0] + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + return cmd +} + +func viewRun(opts *ViewOptions) error { + capiClient, err := opts.CapiClient() + if err != nil { + return err + } + + ctx := context.Background() + + opts.IO.StartProgressIndicatorWithLabel("Fetching agent session...") + defer opts.IO.StopProgressIndicator() + + session, err := capiClient.GetSession(ctx, opts.SelectorArg) + opts.IO.StopProgressIndicator() + + if err != nil { + if errors.Is(err, capi.ErrSessionNotFound) { + fmt.Fprintln(opts.IO.ErrOut, "session not found") + return cmdutil.SilentError + } + return err + } + + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + if session.PullRequest != nil { + fmt.Fprintf(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 { + // Should never happen, but we need to cover the path + fmt.Fprintf(out, "%s\n", shared.ColorFuncForSessionState(*session, cs)(shared.SessionStateString(session.State))) + } + + if session.User != nil { + fmt.Fprintf(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(out, "Started %s\n", text.FuzzyAgo(time.Now(), session.CreatedAt)) + } + + // TODO(babakks): uncomment when we have the --logs option ready + // fmt.Fprintln(out, "") + // fmt.Fprintf(out, "For the detailed session logs, try: gh agent-task view '%s' --logs\n", opts.SelectorArg) + + if session.PullRequest != nil { + fmt.Fprintln(out, "") + fmt.Fprintln(out, cs.Muted("View this session on GitHub:")) + fmt.Fprintln(out, cs.Muted(fmt.Sprintf("%s/agent-sessions/%s", session.PullRequest.URL, url.PathEscape(session.ID)))) + } + + return nil +} From d3fa0a70bcaad7f700db7d2bc63ca01a76b8f56b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 12:55:15 +0100 Subject: [PATCH 077/208] test(agent-task view): add tests for the `view` command Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view_test.go | 261 +++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 pkg/cmd/agent-task/view/view_test.go diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go new file mode 100644 index 000000000..97304c399 --- /dev/null +++ b/pkg/cmd/agent-task/view/view_test.go @@ -0,0 +1,261 @@ +package view + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "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/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + args string + wantOpts ViewOptions + wantErr string + }{ + { + name: "no arguments", + wantErr: "a session ID is required", + }, + { + name: "session ID arg", + args: "some-uuid", + wantOpts: ViewOptions{ + SelectorArg: "some-uuid", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + var gotOpts *ViewOptions + cmd := NewCmdView(f, func(opts *ViewOptions) error { gotOpts = opts; return nil }) + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.SelectorArg, gotOpts.SelectorArg) + }) + } +} + +func Test_viewRun(t *testing.T) { + sampleDate := time.Now().Add(-6 * time.Hour) // 6h ago + + tests := []struct { + name string + selectorArg string + tty bool + capiStubs func(*testing.T, *capi.CapiClientMock) + wantOut string + wantErr error + wantStderr string + }{ + { + name: "not found (tty)", + tty: true, + selectorArg: "some-session-id", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + return nil, capi.ErrSessionNotFound + } + }, + wantStderr: "session not found\n", + wantErr: cmdutil.SilentError, + }, + { + name: "not found (nontty)", + selectorArg: "some-session-id", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + return nil, capi.ErrSessionNotFound + } + }, + wantStderr: "session not found\n", + wantErr: cmdutil.SilentError, + }, + { + name: "API error (tty)", + tty: true, + selectorArg: "some-session-id", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + return nil, errors.New("some error") + } + }, + wantErr: errors.New("some error"), + }, + { + name: "API error (nontty)", + selectorArg: "some-session-id", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + return nil, errors.New("some error") + } + }, + wantErr: errors.New("some error"), + }, + { + name: "success, with PR and user data (tty)", + tty: true, + selectorArg: "some-session-id", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + Completed • fix something • OWNER/REPO#101 + Started on behalf of octocat about 6 hours ago + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + name: "success, without user data (tty)", + tty: true, + selectorArg: "some-session-id", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + Completed • fix something • OWNER/REPO#101 + Started about 6 hours ago + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + name: "success, without PR data (tty)", + tty: true, + selectorArg: "some-session-id", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + Completed + Started on behalf of octocat about 6 hours ago + `), + }, + { + name: "success, without PR nor user data (tty)", + tty: true, + selectorArg: "some-session-id", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + }, nil + } + }, + wantOut: heredoc.Doc(` + Completed + Started about 6 hours ago + `), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + capiClientMock := &capi.CapiClientMock{} + if tt.capiStubs != nil { + tt.capiStubs(t, capiClientMock) + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + + opts := &ViewOptions{ + IO: ios, + CapiClient: func() (capi.CapiClient, error) { + return capiClientMock, nil + }, + SelectorArg: tt.selectorArg, + } + + err := viewRun(opts) + if tt.wantErr != nil { + assert.Error(t, err) + require.EqualError(t, err, tt.wantErr.Error()) + } else { + require.NoError(t, err) + } + + got := stdout.String() + require.Equal(t, tt.wantOut, got) + require.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} From a6dd9c167915068108efedbaa0dd8fe5de5ff663 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 16:55:04 +0100 Subject: [PATCH 078/208] feat(agent-task/capi): add `ListSessionsByResourceID` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index e0252a8bb..ad82107e3 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -239,6 +239,66 @@ func (c *CAPIClient) GetSession(ctx context.Context, id string) (*Session, error return sessions[0], nil } +// ListSessionsByResourceID retrieves sessions associated with the given resource type and ID. +func (c *CAPIClient) ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) { + if resourceType == "" || resourceID == 0 { + return nil, fmt.Errorf("missing resource type/ID") + } + + if limit == 0 { + return nil, nil + } + + url := fmt.Sprintf("%s/agents/sessions/resource/%s/%d", baseCAPIURL, url.PathEscape(resourceType), resourceID) + pageSize := defaultSessionsPerPage + + sessions := make([]session, 0, limit+pageSize) + + for page := 1; ; page++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Set("page_size", strconv.Itoa(pageSize)) + q.Set("page_number", strconv.Itoa(page)) + req.URL.RawQuery = q.Encode() + + 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 list sessions: %s", res.Status) + } + var response struct { + Sessions []session `json:"sessions"` + } + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode sessions response: %w", err) + } + + sessions = append(sessions, response.Sessions...) + if len(response.Sessions) < pageSize || len(sessions) >= limit { + break + } + } + + // Drop any above the limit + if len(sessions) > limit { + sessions = sessions[:limit] + } + + // Hydrate the result with pull request data. + result, err := c.hydrateSessionPullRequestsAndUsers(sessions) + if err != nil { + return nil, fmt.Errorf("failed to fetch session resources: %w", err) + } + return result, nil +} + // hydrateSessionPullRequestsAndUsers hydrates pull request and user information in sessions func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]*Session, error) { if len(sessions) == 0 { From 9b2ecf4c0eaa9bd022b156fe2024821430870e46 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 16:55:46 +0100 Subject: [PATCH 079/208] feat(agent-task/capi): add `ListSessionsByResourceID` method to `CapiClient` interface Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/client.go | 1 + pkg/cmd/agent-task/capi/client_mock.go | 72 ++++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index 15765552b..c344393ad 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -20,6 +20,7 @@ type CapiClient interface { CreateJob(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*Job, error) GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) GetSession(ctx context.Context, id string) (*Session, error) + ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) } // CAPIClient is a client for interacting with the Copilot API diff --git a/pkg/cmd/agent-task/capi/client_mock.go b/pkg/cmd/agent-task/capi/client_mock.go index ba7c05ab0..3ad057341 100644 --- a/pkg/cmd/agent-task/capi/client_mock.go +++ b/pkg/cmd/agent-task/capi/client_mock.go @@ -27,6 +27,9 @@ var _ CapiClient = &CapiClientMock{} // GetSessionFunc: func(ctx context.Context, id string) (*Session, error) { // panic("mock out the GetSession method") // }, +// ListSessionsByResourceIDFunc: func(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) { +// panic("mock out the ListSessionsByResourceID method") +// }, // ListSessionsForRepoFunc: func(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { // panic("mock out the ListSessionsForRepo method") // }, @@ -49,6 +52,9 @@ type CapiClientMock struct { // GetSessionFunc mocks the GetSession method. GetSessionFunc func(ctx context.Context, id string) (*Session, error) + // ListSessionsByResourceIDFunc mocks the ListSessionsByResourceID method. + ListSessionsByResourceIDFunc func(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) + // ListSessionsForRepoFunc mocks the ListSessionsForRepo method. ListSessionsForRepoFunc func(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) @@ -88,6 +94,17 @@ type CapiClientMock struct { // ID is the id argument value. ID string } + // ListSessionsByResourceID holds details about calls to the ListSessionsByResourceID method. + ListSessionsByResourceID []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // ResourceType is the resourceType argument value. + ResourceType string + // ResourceID is the resourceID argument value. + ResourceID int64 + // Limit is the limit argument value. + Limit int + } // ListSessionsForRepo holds details about calls to the ListSessionsForRepo method. ListSessionsForRepo []struct { // Ctx is the ctx argument value. @@ -107,11 +124,12 @@ type CapiClientMock struct { Limit int } } - lockCreateJob sync.RWMutex - lockGetJob sync.RWMutex - lockGetSession sync.RWMutex - lockListSessionsForRepo sync.RWMutex - lockListSessionsForViewer sync.RWMutex + lockCreateJob sync.RWMutex + lockGetJob sync.RWMutex + lockGetSession sync.RWMutex + lockListSessionsByResourceID sync.RWMutex + lockListSessionsForRepo sync.RWMutex + lockListSessionsForViewer sync.RWMutex } // CreateJob calls CreateJobFunc. @@ -242,6 +260,50 @@ func (mock *CapiClientMock) GetSessionCalls() []struct { return calls } +// ListSessionsByResourceID calls ListSessionsByResourceIDFunc. +func (mock *CapiClientMock) ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) { + if mock.ListSessionsByResourceIDFunc == nil { + panic("CapiClientMock.ListSessionsByResourceIDFunc: method is nil but CapiClient.ListSessionsByResourceID was just called") + } + callInfo := struct { + Ctx context.Context + ResourceType string + ResourceID int64 + Limit int + }{ + Ctx: ctx, + ResourceType: resourceType, + ResourceID: resourceID, + Limit: limit, + } + mock.lockListSessionsByResourceID.Lock() + mock.calls.ListSessionsByResourceID = append(mock.calls.ListSessionsByResourceID, callInfo) + mock.lockListSessionsByResourceID.Unlock() + return mock.ListSessionsByResourceIDFunc(ctx, resourceType, resourceID, limit) +} + +// ListSessionsByResourceIDCalls gets all the calls that were made to ListSessionsByResourceID. +// Check the length with: +// +// len(mockedCapiClient.ListSessionsByResourceIDCalls()) +func (mock *CapiClientMock) ListSessionsByResourceIDCalls() []struct { + Ctx context.Context + ResourceType string + ResourceID int64 + Limit int +} { + var calls []struct { + Ctx context.Context + ResourceType string + ResourceID int64 + Limit int + } + mock.lockListSessionsByResourceID.RLock() + calls = mock.calls.ListSessionsByResourceID + mock.lockListSessionsByResourceID.RUnlock() + return calls +} + // ListSessionsForRepo calls ListSessionsForRepoFunc. func (mock *CapiClientMock) ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { if mock.ListSessionsForRepoFunc == nil { From fd11b1e8def221b857263af132c95f4640c5675f Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 16:57:21 +0100 Subject: [PATCH 080/208] fix(pr/shared): add `ParseFullReference` func Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/finder.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index df8cc0fd4..cb8237d58 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -328,6 +328,31 @@ func ParseURL(prURL string) (ghrepo.Interface, int, error) { return repo, prNumber, nil } +var fullReferenceRE = regexp.MustCompile(`^(?:([^/]+)/([^/]+))#(\d+)$`) + +// ParseFullReference parses a short issue/pull request reference of the form +// "owner/repo#number", where owner, repo and number are all required. +func ParseFullReference(s string) (ghrepo.Interface, int, error) { + if s == "" { + return nil, 0, errors.New("empty reference") + } + + m := fullReferenceRE.FindStringSubmatch(s) + if m == nil { + return nil, 0, fmt.Errorf("invalid reference: %q", s) + } + + number, err := strconv.Atoi(m[3]) + if err != nil { + return nil, 0, fmt.Errorf("invalid reference: %q", number) + } + + owner := m[1] + repo := m[2] + + return ghrepo.New(owner, repo), number, nil +} + func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.PullRequest, error) { type response struct { Repository struct { From a7691750193a56854585e1a8dff01ff70c24ad57 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 16:57:44 +0100 Subject: [PATCH 081/208] test(pr/shared): add test for `ParseFullReference` Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/finder_test.go | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 0f6da5a6e..5e33ee876 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -78,6 +79,80 @@ func TestParseURL(t *testing.T) { } } +func TestParseFullReference(t *testing.T) { + tests := []struct { + name string + arg string + wantRepo ghrepo.Interface + wantNumber int + wantErr string + }{ + { + name: "number", + arg: "123", + wantErr: `invalid reference: "123"`, + }, + { + name: "number with hash", + arg: "#123", + wantErr: `invalid reference: "#123"`, + }, + { + name: "full form", + arg: "OWNER/REPO#123", + wantNumber: 123, + wantRepo: ghrepo.New("OWNER", "REPO"), + }, + { + name: "empty", + wantErr: "empty reference", + }, + { + name: "invalid full form, without hash", + arg: "OWNER/REPO123", + wantErr: `invalid reference: "OWNER/REPO123"`, + }, + { + name: "invalid full form, empty owner and repo", + arg: "/#123", + wantErr: `invalid reference: "/#123"`, + }, + { + name: "invalid full form, without owner", + arg: "REPO#123", + wantErr: `invalid reference: "REPO#123"`, + }, + { + name: "invalid full form, without repo", + arg: "OWNER/#123", + wantErr: `invalid reference: "OWNER/#123"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, number, err := ParseFullReference(tt.arg) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + assert.Nil(t, repo) + assert.Zero(t, number) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantNumber, number) + + if tt.wantRepo != nil { + require.NotNil(t, repo) + assert.True(t, ghrepo.IsSame(tt.wantRepo, repo)) + } else { + assert.Nil(t, repo) + } + }) + } +} + type args struct { baseRepoFn func() (ghrepo.Interface, error) branchFn func() (string, error) From 5f6189be092cd9dc8e4440e706d826326935e66e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 16:58:18 +0100 Subject: [PATCH 082/208] fix(agent-task/shared): add `IsSessionID` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/capi.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/cmd/agent-task/shared/capi.go b/pkg/cmd/agent-task/shared/capi.go index f23ee86d2..f064eac2e 100644 --- a/pkg/cmd/agent-task/shared/capi.go +++ b/pkg/cmd/agent-task/shared/capi.go @@ -1,10 +1,14 @@ package shared import ( + "regexp" + "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" "github.com/cli/cli/v2/pkg/cmdutil" ) +var uuidRE = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`) + func CapiClientFunc(f *cmdutil.Factory) func() (capi.CapiClient, error) { return func() (capi.CapiClient, error) { cfg, err := f.Config() @@ -21,3 +25,7 @@ func CapiClientFunc(f *cmdutil.Factory) func() (capi.CapiClient, error) { return capi.NewCAPIClient(httpClient, authCfg), nil } } + +func IsSessionID(s string) bool { + return uuidRE.MatchString(s) +} From 09e1f2532d75a7483b2adf0d4d3f209506b4b047 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 17:02:15 +0100 Subject: [PATCH 083/208] test(agent-task/shared): add test for `IsSessionID` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/capi_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 pkg/cmd/agent-task/shared/capi_test.go diff --git a/pkg/cmd/agent-task/shared/capi_test.go b/pkg/cmd/agent-task/shared/capi_test.go new file mode 100644 index 000000000..d6a106d1b --- /dev/null +++ b/pkg/cmd/agent-task/shared/capi_test.go @@ -0,0 +1,20 @@ +package shared + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsSession(t *testing.T) { + assert.True(t, IsSessionID("00000000-0000-0000-0000-000000000000")) + assert.True(t, IsSessionID("e2fa49d2-f164-4a56-ab99-498090b8fcdf")) + assert.True(t, IsSessionID("E2FA49D2-F164-4A56-AB99-498090B8FCDF")) + + assert.False(t, IsSessionID("")) + assert.False(t, IsSessionID(" ")) + assert.False(t, IsSessionID("\n")) + assert.False(t, IsSessionID("not-a-uuid")) + assert.False(t, IsSessionID("000000000000000000000000000000000000")) + assert.False(t, IsSessionID("00000000-0000-0000-0000-000000000000-extra")) +} From e68e28ddf31c2f61121d38fb321f57796cc6cfd6 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 17:02:45 +0100 Subject: [PATCH 084/208] fix(agent-task/shared): add `SessionSymbol` func Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/display.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/cmd/agent-task/shared/display.go b/pkg/cmd/agent-task/shared/display.go index dd114b049..54004c9ad 100644 --- a/pkg/cmd/agent-task/shared/display.go +++ b/pkg/cmd/agent-task/shared/display.go @@ -46,3 +46,17 @@ func SessionStateString(state string) string { return state } } + +type ColorFunc func(string) string + +func SessionSymbol(cs *iostreams.ColorScheme, state string) string { + noColor := func(s string) string { return s } + switch state { + case "completed": + return cs.SuccessIconWithColor(noColor) + case "failed", "timed_out", "cancelled": + return cs.FailureIconWithColor(noColor) + default: + return "-" + } +} From 8482e3d2a45df18e79e148acf98bb1a51997b529 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 5 Sep 2025 17:03:59 +0100 Subject: [PATCH 085/208] feat(agent-task view): support PR arg Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 176 +++++++++++++++++++++++++-- pkg/cmd/agent-task/view/view_test.go | 52 ++++++-- 2 files changed, 209 insertions(+), 19 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index f6ce4d468..687ed86cb 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -4,41 +4,74 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" + "strconv" "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "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/shurcooL/githubv4" "github.com/spf13/cobra" ) +const defaultLimit = 60 + 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 SelectorArg string + PRNumber int + SessionID string } 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, } cmd := &cobra.Command{ - Use: "view ", + Use: "view [ | | | ]", Short: "View an agent task session", Long: heredoc.Doc(` View an agent task session. `), - Args: cmdutil.ExactArgs(1, "a session ID is required"), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.SelectorArg = args[0] + // 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 + } + } + + if opts.SessionID == "" && !opts.IO.CanPrompt() { + return fmt.Errorf("session ID is required when not running interactively") + } + + if opts.Finder == nil { + opts.Finder = prShared.NewFinder(f) + } if runF != nil { return runF(opts) @@ -47,6 +80,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman }, } + cmdutil.EnableRepoOverride(cmd, f) + return cmd } @@ -57,23 +92,117 @@ func viewRun(opts *ViewOptions) error { } ctx := context.Background() + cs := opts.IO.ColorScheme() opts.IO.StartProgressIndicatorWithLabel("Fetching agent session...") defer opts.IO.StopProgressIndicator() - session, err := capiClient.GetSession(ctx, opts.SelectorArg) - opts.IO.StopProgressIndicator() + var session *capi.Session - if err != nil { - if errors.Is(err, capi.ErrSessionNotFound) { - fmt.Fprintln(opts.IO.ErrOut, "session not found") + if opts.SessionID != "" { + if sess, err := capiClient.GetSession(ctx, opts.SessionID); err != nil { + if errors.Is(err, capi.ErrSessionNotFound) { + fmt.Fprintln(opts.IO.ErrOut, "session not found") + return cmdutil.SilentError + } + return err + } else { + session = sess + } + } else { + var resourceID int64 + + 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 { + // We need to check the base repo to get the hostname. + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + hostname := baseRepo.RepoHost() + if repo.RepoHost() != hostname { + return fmt.Errorf("agent tasks are not supported on this host: %s", repo.RepoHost()) + } + + client, err := opts.HttpClient() + if err != nil { + return err + } + + resourceID, err = getPullRequestDatabaseID(ctx, client, hostname, repo, num) + if err != nil { + return fmt.Errorf("failed to get pull request: %w", err) + } + } + } + + if resourceID == 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) + } + + resourceID = databaseID + } + + // 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", resourceID, 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 } - return err + + 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), + )) + } + + opts.IO.StopProgressIndicator() + selected, err := opts.Prompter.Select("Select a session", options[0], options) + if err != nil { + return err + } + + session = sessions[selected] + } } + opts.IO.StopProgressIndicator() + out := opts.IO.Out - cs := opts.IO.ColorScheme() if session.PullRequest != nil { fmt.Fprintf(out, "%s • %s • %s%s\n", @@ -106,3 +235,30 @@ func viewRun(opts *ViewOptions) error { return nil } + +func getPullRequestDatabaseID(ctx context.Context, httpClient *http.Client, hostname string, repo ghrepo.Interface, number int) (int64, error) { + var resp struct { + Repository struct { + PullRequest struct { + FullDatabaseID string `graphql:"fullDatabaseId"` + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "repo": githubv4.String(repo.RepoName()), + "number": githubv4.Int(number), + } + + apiClient := api.NewClientFromHTTP(httpClient) + if err := apiClient.Query(hostname, "GetPullRequestFullDatabaseID", &resp, variables); err != nil { + return 0, err + } + + databaseID, err := strconv.ParseInt(resp.Repository.PullRequest.FullDatabaseID, 10, 64) + if err != nil { + return 0, err + } + return databaseID, nil +} diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 97304c399..5710f3ec3 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "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" @@ -20,20 +21,49 @@ import ( func TestNewCmdList(t *testing.T) { tests := []struct { - name string - args string - wantOpts ViewOptions - wantErr string + name string + tty bool + args string + wantOpts ViewOptions + wantBaseRepo ghrepo.Interface + wantErr string }{ { - name: "no arguments", - wantErr: "a session ID is required", + name: "no arg tty", + tty: true, + args: "", + wantOpts: ViewOptions{}, }, { - name: "session ID arg", - args: "some-uuid", + name: "session ID arg tty", + tty: true, + args: "00000000-0000-0000-0000-000000000000", wantOpts: ViewOptions{ - SelectorArg: "some-uuid", + SelectorArg: "00000000-0000-0000-0000-000000000000", + SessionID: "00000000-0000-0000-0000-000000000000", + }, + }, + { + name: "non-session ID arg tty", + tty: true, + args: "some-arg", + wantOpts: ViewOptions{ + SelectorArg: "some-arg", + }, + }, + { + name: "session ID required if non-tty", + tty: false, + args: "some-arg", + wantErr: "session ID is required when not running interactively", + }, + { + name: "repo override", + tty: true, + args: "some-arg -R OWNER/REPO", + wantBaseRepo: ghrepo.New("OWNER", "REPO"), + wantOpts: ViewOptions{ + SelectorArg: "some-arg", }, }, } @@ -41,6 +71,10 @@ func TestNewCmdList(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + f := &cmdutil.Factory{ IOStreams: ios, } From d80b25fbf29caf39b45abd5ba2572705d84ec3e2 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 11:27:40 +0100 Subject: [PATCH 086/208] test(agent-task/capi): add tests for `GetSessionByResourceID` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions_test.go | 439 +++++++++++++++++++++++ 1 file changed, 439 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 115c7ab9e..d65d7e2a0 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -876,6 +876,445 @@ func TestListSessionsForRepo(t *testing.T) { } } +func TestListSessionsByResourceIDRequiresResource(t *testing.T) { + client := &CAPIClient{} + + _, err := client.ListSessionsByResourceID(context.Background(), "", 999, 0) + assert.EqualError(t, err, "missing resource type/ID") + _, err = client.ListSessionsByResourceID(context.Background(), "only-resource-type", 0, 0) + assert.EqualError(t, err, "missing resource type/ID") + _, err = client.ListSessionsByResourceID(context.Background(), "", 999, 0) + assert.EqualError(t, err, "missing resource type/ID") +} + +func TestListSessionsByResourceID(t *testing.T) { + sampleDateString := "2025-08-29T00:00:00Z" + sampleDate, err := time.Parse(time.RFC3339, sampleDateString) + require.NoError(t, err) + + resourceID := int64(999) + resourceType := "pull" + + tests := []struct { + name string + perPage int + limit int + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantOut []*Session + }{ + { + name: "zero limit", + limit: 0, + wantOut: nil, + }, + { + name: "no sessions", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(`{"sessions":[]}`), + ) + }, + wantOut: nil, + }, + { + name: "single session", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2000", + Number: 42, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/42", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, + }, + }, + }, + { + name: "multiple sessions, paginated", + perPage: 1, // to enforce pagination + limit: 2, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ + "page_number": {"1"}, + "page_size": {"1"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + + // Second page + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ + "page_number": {"2"}, + "page_size": {"1"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess2", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2001, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + }, + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2001", + "number": 43, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/43", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "PR_kwDNA-jNB9E", "U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2000", + Number: 42, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/42", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, + }, + { + ID: "sess2", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2001, + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2001", + Number: 43, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/43", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, + }, + }, + }, + { + name: "API error", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StatusStringResponse(500, "{}"), + ) + }, + wantErr: "failed to list sessions:", + }, { + name: "API error at hydration", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.StatusStringResponse(500, `{}`), + ) + }, + wantErr: `failed to fetch session resources: non-200 OK status code:`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + defer reg.Verify(t) + + httpClient := &http.Client{Transport: reg} + + cfg := config.NewBlankConfig() + capiClient := NewCAPIClient(httpClient, cfg.Authentication()) + + if tt.perPage != 0 { + last := defaultSessionsPerPage + defaultSessionsPerPage = tt.perPage + defer func() { + defaultSessionsPerPage = last + }() + } + + sessions, err := capiClient.ListSessionsByResourceID(context.Background(), resourceType, resourceID, tt.limit) + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.Nil(t, sessions) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantOut, sessions) + }) + } +} + func TestGetSessionRequiresID(t *testing.T) { client := &CAPIClient{} From f57114accc2f20be19b731ca6f00694a6730b470 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 13:48:56 +0100 Subject: [PATCH 087/208] feat(agent-task/capi): add `GetPullRequestDatabaseID` method Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index ad82107e3..1434d9639 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -14,6 +14,7 @@ import ( "time" "github.com/cli/cli/v2/api" + "github.com/shurcooL/githubv4" "github.com/vmihailenco/msgpack/v5" ) @@ -378,6 +379,34 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* return newSessions, nil } +// GetPullRequestDatabaseID retrieves the database ID of a pull request given its number in a repository. +func (c *CAPIClient) GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) { + var resp struct { + Repository struct { + PullRequest struct { + FullDatabaseID string `graphql:"fullDatabaseId"` + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "number": githubv4.Int(number), + } + + apiClient := api.NewClientFromHTTP(c.httpClient) + if err := apiClient.Query(hostname, "GetPullRequestFullDatabaseID", &resp, variables); err != nil { + return 0, err + } + + databaseID, err := strconv.ParseInt(resp.Repository.PullRequest.FullDatabaseID, 10, 64) + if err != nil { + return 0, err + } + return databaseID, nil +} + // generatePullRequestNodeID converts an int64 databaseID and repoID to a GraphQL Node ID format // with the "PR_" prefix for pull requests func generatePullRequestNodeID(repoID, pullRequestID int64) string { From abd2d8a06dc4fb4d30b53e97a8e76c33a3d88041 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 13:49:27 +0100 Subject: [PATCH 088/208] test(agent-task/capi): add tests for `GetPullRequestDatabaseID` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions_test.go | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index d65d7e2a0..4c83fb12a 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -1521,3 +1521,73 @@ func TestGetSession(t *testing.T) { }) } } +func TestGetPullRequestDatabaseID(t *testing.T) { + tests := []struct { + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantOut int64 + }{ + { + name: "graphql error", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.GraphQL(`query GetPullRequestFullDatabaseID\b`), "api.github.com"), + httpmock.StringResponse(`{"data":{}, "errors": [{"message": "some gql error"}]}`), + ) + }, + wantErr: "some gql error", + }, + { + // This never happens in practice and it's just to cover more code path + name: "non-int database ID", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.GraphQL(`query GetPullRequestFullDatabaseID\b`), "api.github.com"), + httpmock.StringResponse(`{"data": {"repository": {"pullRequest": {"fullDatabaseId": "non-int"}}}}`), + ) + }, + wantErr: `strconv.ParseInt: parsing "non-int": invalid syntax`, + }, + { + name: "success", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.GraphQL(`query GetPullRequestFullDatabaseID\b`), "api.github.com"), + httpmock.GraphQLQuery(`{"data": {"repository": {"pullRequest": {"fullDatabaseId": "999"}}}}`, func(s string, m map[string]interface{}) { + assert.Equal(t, "OWNER", m["owner"]) + assert.Equal(t, "REPO", m["repo"]) + assert.Equal(t, float64(42), m["number"]) + }), + ) + }, + wantOut: 999, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + defer reg.Verify(t) + + httpClient := &http.Client{Transport: reg} + + cfg := config.NewBlankConfig() + capiClient := NewCAPIClient(httpClient, cfg.Authentication()) + + databaseID, err := capiClient.GetPullRequestDatabaseID(context.Background(), "github.com", "OWNER", "REPO", 42) + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + require.Zero(t, databaseID) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantOut, databaseID) + }) + } +} From f8f7b0e09b3916c0bfaaf56310457d584256d4ca Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 13:50:09 +0100 Subject: [PATCH 089/208] fix(agent-task/capi): add `GetPullRequestDatabaseID` to `CapiClient` interface Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/client.go | 1 + pkg/cmd/agent-task/capi/client_mock.go | 68 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index c344393ad..3e6d92736 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -21,6 +21,7 @@ type CapiClient interface { GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) GetSession(ctx context.Context, id string) (*Session, error) ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) + GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) } // CAPIClient is a client for interacting with the Copilot API diff --git a/pkg/cmd/agent-task/capi/client_mock.go b/pkg/cmd/agent-task/capi/client_mock.go index 3ad057341..7998f94d8 100644 --- a/pkg/cmd/agent-task/capi/client_mock.go +++ b/pkg/cmd/agent-task/capi/client_mock.go @@ -24,6 +24,9 @@ var _ CapiClient = &CapiClientMock{} // GetJobFunc: func(ctx context.Context, owner string, repo string, jobID string) (*Job, error) { // panic("mock out the GetJob method") // }, +// GetPullRequestDatabaseIDFunc: func(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) { +// panic("mock out the GetPullRequestDatabaseID method") +// }, // GetSessionFunc: func(ctx context.Context, id string) (*Session, error) { // panic("mock out the GetSession method") // }, @@ -49,6 +52,9 @@ type CapiClientMock struct { // GetJobFunc mocks the GetJob method. GetJobFunc func(ctx context.Context, owner string, repo string, jobID string) (*Job, error) + // GetPullRequestDatabaseIDFunc mocks the GetPullRequestDatabaseID method. + GetPullRequestDatabaseIDFunc func(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) + // GetSessionFunc mocks the GetSession method. GetSessionFunc func(ctx context.Context, id string) (*Session, error) @@ -87,6 +93,19 @@ type CapiClientMock struct { // JobID is the jobID argument value. JobID string } + // GetPullRequestDatabaseID holds details about calls to the GetPullRequestDatabaseID method. + GetPullRequestDatabaseID []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Hostname is the hostname argument value. + Hostname string + // Owner is the owner argument value. + Owner string + // Repo is the repo argument value. + Repo string + // Number is the number argument value. + Number int + } // GetSession holds details about calls to the GetSession method. GetSession []struct { // Ctx is the ctx argument value. @@ -126,6 +145,7 @@ type CapiClientMock struct { } lockCreateJob sync.RWMutex lockGetJob sync.RWMutex + lockGetPullRequestDatabaseID sync.RWMutex lockGetSession sync.RWMutex lockListSessionsByResourceID sync.RWMutex lockListSessionsForRepo sync.RWMutex @@ -224,6 +244,54 @@ func (mock *CapiClientMock) GetJobCalls() []struct { return calls } +// GetPullRequestDatabaseID calls GetPullRequestDatabaseIDFunc. +func (mock *CapiClientMock) GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) { + if mock.GetPullRequestDatabaseIDFunc == nil { + panic("CapiClientMock.GetPullRequestDatabaseIDFunc: method is nil but CapiClient.GetPullRequestDatabaseID was just called") + } + callInfo := struct { + Ctx context.Context + Hostname string + Owner string + Repo string + Number int + }{ + Ctx: ctx, + Hostname: hostname, + Owner: owner, + Repo: repo, + Number: number, + } + mock.lockGetPullRequestDatabaseID.Lock() + mock.calls.GetPullRequestDatabaseID = append(mock.calls.GetPullRequestDatabaseID, callInfo) + mock.lockGetPullRequestDatabaseID.Unlock() + return mock.GetPullRequestDatabaseIDFunc(ctx, hostname, owner, repo, number) +} + +// GetPullRequestDatabaseIDCalls gets all the calls that were made to GetPullRequestDatabaseID. +// Check the length with: +// +// len(mockedCapiClient.GetPullRequestDatabaseIDCalls()) +func (mock *CapiClientMock) GetPullRequestDatabaseIDCalls() []struct { + Ctx context.Context + Hostname string + Owner string + Repo string + Number int +} { + var calls []struct { + Ctx context.Context + Hostname string + Owner string + Repo string + Number int + } + mock.lockGetPullRequestDatabaseID.RLock() + calls = mock.calls.GetPullRequestDatabaseID + mock.lockGetPullRequestDatabaseID.RUnlock() + return calls +} + // GetSession calls GetSessionFunc. func (mock *CapiClientMock) GetSession(ctx context.Context, id string) (*Session, error) { if mock.GetSessionFunc == nil { From c1beeb5d53520b772e6073759c393c4051967a33 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 13:50:47 +0100 Subject: [PATCH 090/208] fix(agent-task view): use `CapiClient.GetPullRequestDatabaseID` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 45 +++++---------------------------- 1 file changed, 6 insertions(+), 39 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 687ed86cb..2b550d81e 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -10,7 +10,6 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" @@ -20,7 +19,6 @@ import ( 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/shurcooL/githubv4" "github.com/spf13/cobra" ) @@ -117,25 +115,21 @@ func viewRun(opts *ViewOptions) error { // 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 { - // We need to check the base repo to get the hostname. + // 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 repo.RepoHost() != hostname { - return fmt.Errorf("agent tasks are not supported on this host: %s", repo.RepoHost()) + if hostname != ghinstance.Default() { + return fmt.Errorf("agent tasks are not supported on this host: %s", hostname) } - client, err := opts.HttpClient() + resourceID, err = capiClient.GetPullRequestDatabaseID(ctx, hostname, repo.RepoOwner(), repo.RepoName(), num) if err != nil { - return err - } - - resourceID, err = getPullRequestDatabaseID(ctx, client, hostname, repo, num) - if err != nil { - return fmt.Errorf("failed to get pull request: %w", err) + return fmt.Errorf("failed to fetch pull request: %w", err) } } } @@ -235,30 +229,3 @@ func viewRun(opts *ViewOptions) error { return nil } - -func getPullRequestDatabaseID(ctx context.Context, httpClient *http.Client, hostname string, repo ghrepo.Interface, number int) (int64, error) { - var resp struct { - Repository struct { - PullRequest struct { - FullDatabaseID string `graphql:"fullDatabaseId"` - } `graphql:"pullRequest(number: $number)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } - - variables := map[string]interface{}{ - "owner": githubv4.String(repo.RepoOwner()), - "repo": githubv4.String(repo.RepoName()), - "number": githubv4.Int(number), - } - - apiClient := api.NewClientFromHTTP(httpClient) - if err := apiClient.Query(hostname, "GetPullRequestFullDatabaseID", &resp, variables); err != nil { - return 0, err - } - - databaseID, err := strconv.ParseInt(resp.Repository.PullRequest.FullDatabaseID, 10, 64) - if err != nil { - return 0, err - } - return databaseID, nil -} From d06d0262e6aaa7ea14aa92673574db6084e5484d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 13:51:14 +0100 Subject: [PATCH 091/208] fix(agent-task view): use lower limit for fetching sessions Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 2b550d81e..e537c498b 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -22,7 +22,7 @@ import ( "github.com/spf13/cobra" ) -const defaultLimit = 60 +const defaultLimit = 40 type ViewOptions struct { IO *iostreams.IOStreams From 6e78b8d68fbc856362eabc44cba001e4fa8ed342 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 8 Sep 2025 13:51:38 +0100 Subject: [PATCH 092/208] test(agent-task view): update tests Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view_test.go | 382 +++++++++++++++++++++++---- 1 file changed, 329 insertions(+), 53 deletions(-) diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 5710f3ec3..4fcb4c66d 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -11,7 +11,9 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + 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/google/shlex" @@ -99,6 +101,13 @@ func TestNewCmdList(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantOpts.SelectorArg, gotOpts.SelectorArg) + assert.Equal(t, tt.wantOpts.SessionID, gotOpts.SessionID) + + if tt.wantBaseRepo != nil { + baseRepo, err := gotOpts.BaseRepo() + require.NoError(t, err) + assert.True(t, ghrepo.IsSame(tt.wantBaseRepo, baseRepo)) + } }) } } @@ -108,19 +117,23 @@ func Test_viewRun(t *testing.T) { tests := []struct { name string - selectorArg string tty bool + opts ViewOptions + promptStubs func(*testing.T, *prompter.MockPrompter) capiStubs func(*testing.T, *capi.CapiClientMock) wantOut string wantErr error wantStderr string }{ { - name: "not found (tty)", - tty: true, - selectorArg: "some-session-id", + name: "with session id, not found (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + m.GetSessionFunc = func(_ context.Context, _ string) (*capi.Session, error) { return nil, capi.ErrSessionNotFound } }, @@ -128,43 +141,29 @@ func Test_viewRun(t *testing.T) { wantErr: cmdutil.SilentError, }, { - name: "not found (nontty)", - selectorArg: "some-session-id", - capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { - return nil, capi.ErrSessionNotFound - } + name: "with session id, api error (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", }, - wantStderr: "session not found\n", - wantErr: cmdutil.SilentError, - }, - { - name: "API error (tty)", - tty: true, - selectorArg: "some-session-id", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + m.GetSessionFunc = func(_ context.Context, _ string) (*capi.Session, error) { return nil, errors.New("some error") } }, wantErr: errors.New("some error"), }, { - name: "API error (nontty)", - selectorArg: "some-session-id", - capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { - return nil, errors.New("some error") - } + name: "with session id, success, with pr and user data (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", }, - wantErr: errors.New("some error"), - }, - { - name: "success, with PR and user data (tty)", - tty: true, - selectorArg: "some-session-id", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) return &capi.Session{ ID: "some-session-id", State: "completed", @@ -186,17 +185,21 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Completed • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago - + View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), }, { - name: "success, without user data (tty)", - tty: true, - selectorArg: "some-session-id", + name: "with session id, success, without user data (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) return &capi.Session{ ID: "some-session-id", State: "completed", @@ -215,17 +218,21 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Completed • fix something • OWNER/REPO#101 Started about 6 hours ago - + View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), }, { - name: "success, without PR data (tty)", - tty: true, - selectorArg: "some-session-id", + name: "with session id, success, without pr data (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) return &capi.Session{ ID: "some-session-id", State: "completed", @@ -242,11 +249,15 @@ func Test_viewRun(t *testing.T) { `), }, { - name: "success, without PR nor user data (tty)", - tty: true, - selectorArg: "some-session-id", + name: "with session id, success, without pr nor user data (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetSessionFunc = func(ctx context.Context, selector string) (*capi.Session, error) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) return &capi.Session{ ID: "some-session-id", State: "completed", @@ -259,6 +270,267 @@ func Test_viewRun(t *testing.T) { Started about 6 hours ago `), }, + { + name: "with pr number, api error (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "pr-number", + Finder: prShared.NewMockFinder( + "pr-number", + &api.PullRequest{FullDatabaseID: "999999"}, + ghrepo.New("OWNER", "REPO"), + ), + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsByResourceIDFunc = func(_ context.Context, _ string, _ int64, _ int) ([]*capi.Session, error) { + return nil, errors.New("some error") + } + }, + wantErr: errors.New("failed to list sessions for pull request: some error"), + }, + { + name: "with pr reference, unsupported hostname (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "OWNER/REPO#999", + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.NewWithHost("OWNER", "REPO", "foo.com"), nil + }, + }, + wantErr: errors.New("agent tasks are not supported on this host: foo.com"), + }, + { + name: "with pr reference, api error when fetching pr database ID (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "OWNER/REPO#999", + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetPullRequestDatabaseIDFunc = func(_ context.Context, _ string, _ string, _ string, _ int) (int64, error) { + return 0, errors.New("some error") + } + }, + wantErr: errors.New("failed to fetch pull request: some error"), + }, + { + name: "with pr reference, api error when fetching session (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "OWNER/REPO#999", + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetPullRequestDatabaseIDFunc = func(_ context.Context, _ string, _ string, _ string, _ int) (int64, error) { + return 999999, nil + } + m.ListSessionsByResourceIDFunc = func(_ context.Context, _ string, _ int64, _ int) ([]*capi.Session, error) { + return nil, errors.New("some error") + } + }, + wantErr: errors.New("failed to list sessions for pull request: some error"), + }, + { + name: "with pr number, success, single session with pr and user data (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "pr-number", + Finder: prShared.NewMockFinder( + "pr-number", + &api.PullRequest{FullDatabaseID: "999999"}, + ghrepo.New("OWNER", "REPO"), + ), + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { + assert.Equal(t, "pull", resourceType) + assert.Equal(t, int64(999999), resourceID) + assert.Equal(t, defaultLimit, limit) + return []*capi.Session{ + { + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + Completed • fix something • OWNER/REPO#101 + Started on behalf of octocat about 6 hours ago + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + name: "with pr number, success, multiple sessions with pr and user data (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "pr-number", + Finder: prShared.NewMockFinder( + "pr-number", + &api.PullRequest{FullDatabaseID: "999999"}, + ghrepo.New("OWNER", "REPO"), + ), + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { + assert.Equal(t, "pull", resourceType) + assert.Equal(t, int64(999999), resourceID) + assert.Equal(t, defaultLimit, limit) + return []*capi.Session{ + { + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, + { + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something else", + Number: 102, + URL: "https://github.com/OWNER/REPO/pull/102", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, + }, nil + } + }, + promptStubs: func(t *testing.T, pm *prompter.MockPrompter) { + pm.RegisterSelect( + "Select a session", + []string{ + "✓ session one • about 6 hours ago", + "✓ session two • about 6 hours ago", + }, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ session one • about 6 hours ago") + }, + ) + }, + wantOut: heredoc.Doc(` + Completed • fix something • OWNER/REPO#101 + Started on behalf of octocat about 6 hours ago + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + name: "with pr reference, success, multiple sessions with pr and user data (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "OWNER/REPO#999", + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetPullRequestDatabaseIDFunc = func(_ context.Context, hostname string, owner string, repo string, number int) (int64, error) { + assert.Equal(t, "github.com", hostname) + assert.Equal(t, "OWNER", owner) + assert.Equal(t, "REPO", repo) + assert.Equal(t, 999, number) + return 999999, nil + } + m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { + assert.Equal(t, "pull", resourceType) + assert.Equal(t, int64(999999), resourceID) + assert.Equal(t, defaultLimit, limit) + return []*capi.Session{ + { + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, + { + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something else", + Number: 102, + URL: "https://github.com/OWNER/REPO/pull/102", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, + }, nil + } + }, + promptStubs: func(t *testing.T, pm *prompter.MockPrompter) { + pm.RegisterSelect( + "Select a session", + []string{ + "✓ session one • about 6 hours ago", + "✓ session two • about 6 hours ago", + }, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ session one • about 6 hours ago") + }, + ) + }, + wantOut: heredoc.Doc(` + Completed • fix something • OWNER/REPO#101 + Started on behalf of octocat about 6 hours ago + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, } for _, tt := range tests { @@ -268,18 +540,22 @@ func Test_viewRun(t *testing.T) { tt.capiStubs(t, capiClientMock) } + prompter := prompter.NewMockPrompter(t) + if tt.promptStubs != nil { + tt.promptStubs(t, prompter) + } + ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) - opts := &ViewOptions{ - IO: ios, - CapiClient: func() (capi.CapiClient, error) { - return capiClientMock, nil - }, - SelectorArg: tt.selectorArg, + opts := tt.opts + opts.IO = ios + opts.Prompter = prompter + opts.CapiClient = func() (capi.CapiClient, error) { + return capiClientMock, nil } - err := viewRun(opts) + err := viewRun(&opts) if tt.wantErr != nil { assert.Error(t, err) require.EqualError(t, err, tt.wantErr.Error()) From 922843301dc37bdef6d51da8d631e1d0ac544fa0 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 9 Sep 2025 10:57:51 +0100 Subject: [PATCH 093/208] docs(agent-task/shared): add godoc to `SessionStateString` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/display.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/agent-task/shared/display.go b/pkg/cmd/agent-task/shared/display.go index 54004c9ad..e841f4c41 100644 --- a/pkg/cmd/agent-task/shared/display.go +++ b/pkg/cmd/agent-task/shared/display.go @@ -24,6 +24,7 @@ func ColorFuncForSessionState(s capi.Session, cs *iostreams.ColorScheme) func(st return stateColor } +// SessionStateString returns the humane/capitalised form of the given session state. func SessionStateString(state string) string { switch state { case "queued": From d52502b63833011153d868609ce0c81a26d86326 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 9 Sep 2025 11:37:28 +0100 Subject: [PATCH 094/208] fix(agent-task/capi): skip unpopulated resource ID when hydrating Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 8 +- pkg/cmd/agent-task/capi/sessions_test.go | 220 +++++++++++++++++++++++ 2 files changed, 225 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 1434d9639..805ef9481 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -309,9 +309,11 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* prNodeIds := make([]string, 0, len(sessions)) userNodeIds := make([]string, 0, len(sessions)) for _, session := range sessions { - prNodeID := generatePullRequestNodeID(int64(session.RepoID), session.ResourceID) - if !slices.Contains(prNodeIds, prNodeID) { - prNodeIds = append(prNodeIds, prNodeID) + if session.ResourceType == "pull" { + prNodeID := generatePullRequestNodeID(int64(session.RepoID), session.ResourceID) + if !slices.Contains(prNodeIds, prNodeID) { + prNodeIds = append(prNodeIds, prNodeID) + } } userNodeId := generateUserNodeID(session.UserID) diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 4c83fb12a..2b49da473 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -159,6 +159,84 @@ func TestListSessionsForViewer(t *testing.T) { }, }, }, + { + // This happens at the early moments of a session lifecycle, before a PR is created and associated with it. + name: "single session, no pull request resource", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "", + "resource_id": 0, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "", + ResourceID: 0, + CreatedAt: sampleDate, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, + }, + }, + }, { name: "multiple sessions, paginated", perPage: 1, // to enforce pagination @@ -594,6 +672,84 @@ func TestListSessionsForRepo(t *testing.T) { }, }, }, + { + // This happens at the early moments of a session lifecycle, before a PR is created and associated with it. + name: "single session, no pull request resource", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "", + "resource_id": 0, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "", + ResourceID: 0, + CreatedAt: sampleDate, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, + }, + }, + }, { name: "multiple sessions, paginated", perPage: 1, // to enforce pagination @@ -1459,6 +1615,70 @@ func TestGetSession(t *testing.T) { }, }, }, + { + // This happens at the early moments of a session lifecycle, before a PR is created and associated with it. + name: "success, but no pull request resource", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/sessions/some-uuid"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(` + { + "id": "some-uuid", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "", + "resource_id": 0, + "created_at": "%[1]s" + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: &Session{ + ID: "some-uuid", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "", + ResourceID: 0, + CreatedAt: sampleDate, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, + }, + }, { name: "API error at hydration", httpStubs: func(t *testing.T, reg *httpmock.Registry) { From 0a9a4a425010980cfe4711f59782ede9cfcb8fa4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 9 Sep 2025 11:37:49 +0100 Subject: [PATCH 095/208] test(agent-task/capi): fix test case args Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 2b49da473..1b750f56b 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -1039,7 +1039,7 @@ func TestListSessionsByResourceIDRequiresResource(t *testing.T) { assert.EqualError(t, err, "missing resource type/ID") _, err = client.ListSessionsByResourceID(context.Background(), "only-resource-type", 0, 0) assert.EqualError(t, err, "missing resource type/ID") - _, err = client.ListSessionsByResourceID(context.Background(), "", 999, 0) + _, err = client.ListSessionsByResourceID(context.Background(), "", 0, 0) assert.EqualError(t, err, "missing resource type/ID") } From 8b1f722911c6098f55c1c7b96dcc0f0eb86efe42 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 9 Sep 2025 12:09:34 +0100 Subject: [PATCH 096/208] docs(agent-task/capi): remove redundant comment Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 805ef9481..f6c5d7856 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -132,7 +132,6 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S sessions = sessions[:limit] } - // Hydrate the result with pull request data. result, err := c.hydrateSessionPullRequestsAndUsers(sessions) if err != nil { return nil, fmt.Errorf("failed to fetch session resources: %w", err) @@ -193,7 +192,6 @@ func (c *CAPIClient) ListSessionsForRepo(ctx context.Context, owner string, repo sessions = sessions[:limit] } - // Hydrate the result with pull request data. result, err := c.hydrateSessionPullRequestsAndUsers(sessions) if err != nil { return nil, fmt.Errorf("failed to fetch session resources: %w", err) @@ -292,7 +290,6 @@ func (c *CAPIClient) ListSessionsByResourceID(ctx context.Context, resourceType sessions = sessions[:limit] } - // Hydrate the result with pull request data. result, err := c.hydrateSessionPullRequestsAndUsers(sessions) if err != nil { return nil, fmt.Errorf("failed to fetch session resources: %w", err) From f8ff42a3f1b917b6b0a860a1e6d22baa2ba29bc1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 9 Sep 2025 12:22:21 +0100 Subject: [PATCH 097/208] docs(agent-task view): explain when condition is met Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index e537c498b..6dc5b6cf9 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -206,7 +206,7 @@ func viewRun(opts *ViewOptions) error { cs.ColorFromString(prShared.ColorForPRState(*session.PullRequest))(fmt.Sprintf("#%d", session.PullRequest.Number)), ) } else { - // Should never happen, but we need to cover the path + // This can happen when the session is just created and a PR is not yet available for it fmt.Fprintf(out, "%s\n", shared.ColorFuncForSessionState(*session, cs)(shared.SessionStateString(session.State))) } From ec8f412cca677ff2378b8862d975c4ca7fc650a0 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 9 Sep 2025 12:24:09 +0100 Subject: [PATCH 098/208] docs(agent-task view): explain test case rationale Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 4fcb4c66d..64b63df5b 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -191,6 +191,7 @@ func Test_viewRun(t *testing.T) { `), }, { + // The user data should always be there, but we need to cover the code path. name: "with session id, success, without user data (tty)", tty: true, opts: ViewOptions{ @@ -224,6 +225,7 @@ func Test_viewRun(t *testing.T) { `), }, { + // This can happen when the session is just created and a PR is not yet available for it. name: "with session id, success, without pr data (tty)", tty: true, opts: ViewOptions{ @@ -249,6 +251,7 @@ func Test_viewRun(t *testing.T) { `), }, { + // The user data should always be there, but we need to cover the code path. name: "with session id, success, without pr nor user data (tty)", tty: true, opts: ViewOptions{ From bbf7586578de78e463eae400989fd64b9cac9d4c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:02:49 -0600 Subject: [PATCH 099/208] Show session name instead of ID in agent-task list Updated the agent-task list command to display the session name instead of the session ID in the table output and header. Adjusted tests to reflect this change by using the Name field for display and updating expected outputs accordingly. --- pkg/cmd/agent-task/list/list.go | 6 +- pkg/cmd/agent-task/list/list_test.go | 84 +++++++++++++++++----------- 2 files changed, 53 insertions(+), 37 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index b9f892a63..bff4ed835 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -122,7 +122,7 @@ func listRun(opts *ListOptions) error { } cs := opts.IO.ColorScheme() - tp := tableprinter.New(opts.IO, tableprinter.WithHeader("Session ID", "Pull Request", "Repo", "Session State", "Created")) + tp := tableprinter.New(opts.IO, tableprinter.WithHeader("Session Name", "Pull Request", "Repo", "Session State", "Created")) for _, s := range sessions { if s.ResourceType != "pull" || s.PullRequest == nil || s.PullRequest.Repository == nil { // Skip these sessions in case they happen, for now. @@ -132,8 +132,8 @@ func listRun(opts *ListOptions) error { pr := fmt.Sprintf("#%d", s.PullRequest.Number) repo := s.PullRequest.Repository.NameWithOwner - // ID - tp.AddField(s.ID) + // Name + tp.AddField(s.Name) if tp.IsTTY() { tp.AddField(pr, tableprinter.WithColor(cs.ColorFromString(prShared.ColorForPRState(*s.PullRequest)))) } else { diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index d096ab3be..9b981f28a 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -152,7 +152,8 @@ func Test_listRun(t *testing.T) { m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { return []*capi.Session{ { - ID: "s1", + ID: "id1", + Name: "s1", State: "completed", CreatedAt: sampleDate, ResourceType: "pull", @@ -167,8 +168,8 @@ func Test_listRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - SESSION ID PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago + SESSION NAME PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago `), }, { @@ -178,7 +179,8 @@ func Test_listRun(t *testing.T) { m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { return []*capi.Session{ { - ID: "s1", + ID: "id1", + Name: "s1", State: "completed", ResourceType: "pull", CreatedAt: sampleDate, @@ -201,7 +203,8 @@ func Test_listRun(t *testing.T) { m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { return []*capi.Session{ { - ID: "s1", + ID: "id1", + Name: "s1", State: "completed", CreatedAt: sampleDate, ResourceType: "pull", @@ -213,7 +216,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s2", + ID: "id2", + Name: "s2", State: "failed", CreatedAt: sampleDate, ResourceType: "pull", @@ -225,7 +229,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s3", + ID: "id3", + Name: "s3", State: "in_progress", CreatedAt: sampleDate, ResourceType: "pull", @@ -237,7 +242,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s4", + ID: "id4", + Name: "s4", State: "queued", CreatedAt: sampleDate, ResourceType: "pull", @@ -249,7 +255,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s5", + ID: "id5", + Name: "s5", State: "canceled", CreatedAt: sampleDate, ResourceType: "pull", @@ -261,7 +268,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s6", + ID: "id6", + Name: "s6", State: "mystery", CreatedAt: sampleDate, ResourceType: "pull", @@ -276,13 +284,13 @@ func Test_listRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - SESSION ID PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago - s2 #102 OWNER/REPO failed about 6 hours ago - s3 #103 OWNER/REPO in_progress about 6 hours ago - s4 #104 OWNER/REPO queued about 6 hours ago - s5 #105 OWNER/REPO canceled about 6 hours ago - s6 #106 OWNER/REPO mystery about 6 hours ago + SESSION NAME PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago + s2 #102 OWNER/REPO failed about 6 hours ago + s3 #103 OWNER/REPO in_progress about 6 hours ago + s4 #104 OWNER/REPO queued about 6 hours ago + s5 #105 OWNER/REPO canceled about 6 hours ago + s6 #106 OWNER/REPO mystery about 6 hours ago `), }, { @@ -319,7 +327,8 @@ func Test_listRun(t *testing.T) { m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { return []*capi.Session{ { - ID: "s1", + ID: "id1", + Name: "s1", State: "completed", CreatedAt: sampleDate, ResourceType: "pull", @@ -334,8 +343,8 @@ func Test_listRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - SESSION ID PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago + SESSION NAME PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago `), }, { @@ -346,7 +355,8 @@ func Test_listRun(t *testing.T) { m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { return []*capi.Session{ { - ID: "s1", + ID: "id1", + Name: "s1", State: "completed", ResourceType: "pull", CreatedAt: sampleDate, @@ -370,7 +380,8 @@ func Test_listRun(t *testing.T) { m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { return []*capi.Session{ { - ID: "s1", + ID: "id1", + Name: "s1", State: "completed", CreatedAt: sampleDate, ResourceType: "pull", @@ -382,7 +393,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s2", + ID: "id2", + Name: "s2", State: "failed", CreatedAt: sampleDate, ResourceType: "pull", @@ -394,7 +406,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s3", + ID: "id3", + Name: "s3", State: "in_progress", CreatedAt: sampleDate, ResourceType: "pull", @@ -406,7 +419,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s4", + ID: "id4", + Name: "s4", State: "queued", CreatedAt: sampleDate, ResourceType: "pull", @@ -418,7 +432,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s5", + ID: "id5", + Name: "s5", State: "canceled", CreatedAt: sampleDate, ResourceType: "pull", @@ -430,7 +445,8 @@ func Test_listRun(t *testing.T) { }, }, { - ID: "s6", + ID: "id6", + Name: "s6", State: "mystery", CreatedAt: sampleDate, ResourceType: "pull", @@ -445,13 +461,13 @@ func Test_listRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - SESSION ID PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago - s2 #102 OWNER/REPO failed about 6 hours ago - s3 #103 OWNER/REPO in_progress about 6 hours ago - s4 #104 OWNER/REPO queued about 6 hours ago - s5 #105 OWNER/REPO canceled about 6 hours ago - s6 #106 OWNER/REPO mystery about 6 hours ago + SESSION NAME PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO completed about 6 hours ago + s2 #102 OWNER/REPO failed about 6 hours ago + s3 #103 OWNER/REPO in_progress about 6 hours ago + s4 #104 OWNER/REPO queued about 6 hours ago + s5 #105 OWNER/REPO canceled about 6 hours ago + s6 #106 OWNER/REPO mystery about 6 hours ago `), }, { From 1f107cec4fc024e4ffff94ec6b599f02d59ac5b6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:15:10 -0600 Subject: [PATCH 100/208] Fix default value in session selection prompt Updated the call to Prompter.Select to use an empty string as the default value instead of options[0], ensuring no session is preselected by default. --- pkg/cmd/agent-task/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 6dc5b6cf9..c501f28b3 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -185,7 +185,7 @@ func viewRun(opts *ViewOptions) error { } opts.IO.StopProgressIndicator() - selected, err := opts.Prompter.Select("Select a session", options[0], options) + selected, err := opts.Prompter.Select("Select a session", "", options) if err != nil { return err } From 563809362b00fe826ddac99b853eb9160f1153ce Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:30:23 -0600 Subject: [PATCH 101/208] Add interactive prompt for task description in agent-task create Enhances the agent-task create command to prompt users for a task description interactively if none is provided and the terminal supports prompting. Updates tests to cover interactive and non-interactive scenarios, including error handling for empty input and prompt failures. --- pkg/cmd/agent-task/create/create.go | 33 ++++++-- pkg/cmd/agent-task/create/create_test.go | 101 +++++++++++++++++------ 2 files changed, 105 insertions(+), 29 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 028740612..58f18751d 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -13,6 +13,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" "github.com/cli/cli/v2/pkg/cmd/agent-task/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -29,12 +30,14 @@ type CreateOptions struct { ProblemStatement string BackOff backoff.BackOff BaseBranch string + Prompter prompter.Prompter } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ IO: f.IOStreams, CapiClient: shared.CapiClientFunc(f), + Prompter: f.Prompter, } var fromFileName string @@ -51,7 +54,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return err } - // Populate ProblemStatement from either arg or file + // Gather arg inputs for ProblemStatement if len(args) > 0 { opts.ProblemStatement = args[0] } else if fromFileName != "" { @@ -66,10 +69,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts.ProblemStatement = trimmed } - if opts.ProblemStatement == "" { - return cmdutil.FlagErrorf("a task description is required") - } - + opts.Config = f.Config if runF != nil { return runF(opts) } @@ -85,6 +85,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co # Create a task with problem statement from stdin $ echo "build me a new app" | gh agent-task create -F - + # Create a task with an editor prompt (interactive) + $ gh agent-task create + # Select a different base branch for the PR $ gh agent-task create "fix errors" --base branch `), @@ -106,6 +109,26 @@ func createRun(opts *CreateOptions) error { return fmt.Errorf("a repository is required; re-run in a repository or supply one with --repo owner/name") } + // Prompt for ProblemStatement if not provided non-interactively + if opts.ProblemStatement == "" && opts.IO.CanPrompt() { + if opts.Prompter == nil { + return cmdutil.FlagErrorf("interactive prompting is not available") + } + desc, err := opts.Prompter.MarkdownEditor("Enter the task description", "", false) + if err != nil { + return err + } + trimmed := strings.TrimSpace(desc) + if trimmed == "" { + return cmdutil.FlagErrorf("a task description is required") + } + opts.ProblemStatement = trimmed + } + + if opts.ProblemStatement == "" { + return cmdutil.FlagErrorf("a task description or -F is required when running non-interactively") + } + client, err := opts.CapiClient() if err != nil { return err diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 00134792f..edf03f5c9 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -12,6 +12,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -38,8 +39,7 @@ func TestNewCmdCreate(t *testing.T) { wantErr string }{ { - name: "no args nor file", - wantErr: "a task description is required", + name: "no args nor file returns no error (prompting path)", }, { name: "arg only success", @@ -157,13 +157,16 @@ func Test_createRun(t *testing.T) { } tests := []struct { - name string - capiStubs func(*testing.T, *capi.CapiClientMock) - baseRepoFunc func() (ghrepo.Interface, error) - baseBranch string - wantStdout string - wantStdErr string - wantErr string + name string + capiStubs func(*testing.T, *capi.CapiClientMock) + baseRepoFunc func() (ghrepo.Interface, error) + baseBranch string + isTTY bool + prompterMock *prompter.PrompterMock + problemStatement string + wantStdout string + wantStdErr string + wantErr string }{ { name: "missing repo returns error", @@ -171,9 +174,47 @@ func Test_createRun(t *testing.T) { wantErr: "a repository is required; re-run in a repository or supply one with --repo owner/name", }, { - name: "base branch included in create payload", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - baseBranch: "feature", + name: "non-interactive empty description returns error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "", + wantErr: "a task description or -F is required when running non-interactively", + }, + { + name: "interactive prompt success", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + isTTY: true, + problemStatement: "", + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "From editor", problemStatement) + return &createdJobSuccessWithPR, nil + } + }, + prompterMock: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + require.Equal(t, "Enter the task description", prompt) + return "From editor", nil + }, + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + name: "interactive prompt empty returns error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + isTTY: true, + problemStatement: "", + prompterMock: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + return " ", nil + }, + }, + wantErr: "a task description is required", + }, + { + name: "base branch included in create payload", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + baseBranch: "feature", + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -192,8 +233,9 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "create task API failure returns error", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + name: "create task API failure returns error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -206,8 +248,9 @@ func Test_createRun(t *testing.T) { wantErr: "some error", }, { - name: "get job API failure surfaces error", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + name: "get job API failure surfaces error", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -224,8 +267,9 @@ func Test_createRun(t *testing.T) { wantStdout: "job job123 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 }, + name: "success with immediate PR", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -238,8 +282,9 @@ func Test_createRun(t *testing.T) { 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 }, + name: "success with delayed PR after polling", + baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + problemStatement: "Do the thing", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -258,8 +303,9 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "fallback after timeout returns link to global agents page", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + 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", capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -289,17 +335,24 @@ func Test_createRun(t *testing.T) { } ios, _, stdout, stderr := iostreams.Test() + if tt.isTTY { + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + ios.SetStdoutTTY(true) + } + opts := &CreateOptions{ IO: ios, - ProblemStatement: "Do the thing", + ProblemStatement: tt.problemStatement, BaseRepo: tt.baseRepoFunc, BaseBranch: tt.baseBranch, + Prompter: tt.prompterMock, CapiClient: func() (capi.CapiClient, error) { return capiClientMock, nil }, } - // A backoff with no internal between retries to keep tests fast, + // A backoff with no interval 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) From 6945fc018385e928c5c1ea81d0f1b6d53a96f9ef Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:08:04 -0600 Subject: [PATCH 102/208] Still prompt for task desc with -F --- pkg/cmd/agent-task/create/create.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 58f18751d..799847bf4 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -110,11 +110,8 @@ func createRun(opts *CreateOptions) error { } // Prompt for ProblemStatement if not provided non-interactively - if opts.ProblemStatement == "" && opts.IO.CanPrompt() { - if opts.Prompter == nil { - return cmdutil.FlagErrorf("interactive prompting is not available") - } - desc, err := opts.Prompter.MarkdownEditor("Enter the task description", "", false) + if opts.Prompter != nil && opts.IO.CanPrompt() { + desc, err := opts.Prompter.MarkdownEditor("Enter the task description", opts.ProblemStatement, false) if err != nil { return err } From 6b29c2905c7bcd589a1365d24be056daabc4b5c7 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 10:51:29 +0100 Subject: [PATCH 103/208] fix(agent-task/capi): also return PR URL from `GetPullRequestDatabaseID` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/client.go | 2 +- pkg/cmd/agent-task/capi/client_mock.go | 6 +++--- pkg/cmd/agent-task/capi/sessions.go | 11 ++++++----- pkg/cmd/agent-task/capi/sessions_test.go | 22 +++++++++++++--------- pkg/cmd/agent-task/view/view.go | 4 +++- 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index 3e6d92736..45c214ea2 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -21,7 +21,7 @@ type CapiClient interface { GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) GetSession(ctx context.Context, id string) (*Session, error) ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) - GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) + GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, string, error) } // CAPIClient is a client for interacting with the Copilot API diff --git a/pkg/cmd/agent-task/capi/client_mock.go b/pkg/cmd/agent-task/capi/client_mock.go index 7998f94d8..85b757943 100644 --- a/pkg/cmd/agent-task/capi/client_mock.go +++ b/pkg/cmd/agent-task/capi/client_mock.go @@ -24,7 +24,7 @@ var _ CapiClient = &CapiClientMock{} // GetJobFunc: func(ctx context.Context, owner string, repo string, jobID string) (*Job, error) { // panic("mock out the GetJob method") // }, -// GetPullRequestDatabaseIDFunc: func(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) { +// GetPullRequestDatabaseIDFunc: func(ctx context.Context, hostname string, owner string, repo string, number int) (int64, string, error) { // panic("mock out the GetPullRequestDatabaseID method") // }, // GetSessionFunc: func(ctx context.Context, id string) (*Session, error) { @@ -53,7 +53,7 @@ type CapiClientMock struct { GetJobFunc func(ctx context.Context, owner string, repo string, jobID string) (*Job, error) // GetPullRequestDatabaseIDFunc mocks the GetPullRequestDatabaseID method. - GetPullRequestDatabaseIDFunc func(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) + GetPullRequestDatabaseIDFunc func(ctx context.Context, hostname string, owner string, repo string, number int) (int64, string, error) // GetSessionFunc mocks the GetSession method. GetSessionFunc func(ctx context.Context, id string) (*Session, error) @@ -245,7 +245,7 @@ func (mock *CapiClientMock) GetJobCalls() []struct { } // GetPullRequestDatabaseID calls GetPullRequestDatabaseIDFunc. -func (mock *CapiClientMock) GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) { +func (mock *CapiClientMock) GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, string, error) { if mock.GetPullRequestDatabaseIDFunc == nil { panic("CapiClientMock.GetPullRequestDatabaseIDFunc: method is nil but CapiClient.GetPullRequestDatabaseID was just called") } diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index f6c5d7856..f28914f80 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -378,12 +378,13 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* return newSessions, nil } -// GetPullRequestDatabaseID retrieves the database ID of a pull request given its number in a repository. -func (c *CAPIClient) GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, error) { +// GetPullRequestDatabaseID retrieves the database ID and URL of a pull request given its number in a repository. +func (c *CAPIClient) GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, string, error) { var resp struct { Repository struct { PullRequest struct { FullDatabaseID string `graphql:"fullDatabaseId"` + URL string `graphql:"url"` } `graphql:"pullRequest(number: $number)"` } `graphql:"repository(owner: $owner, name: $repo)"` } @@ -396,14 +397,14 @@ func (c *CAPIClient) GetPullRequestDatabaseID(ctx context.Context, hostname stri apiClient := api.NewClientFromHTTP(c.httpClient) if err := apiClient.Query(hostname, "GetPullRequestFullDatabaseID", &resp, variables); err != nil { - return 0, err + return 0, "", err } databaseID, err := strconv.ParseInt(resp.Repository.PullRequest.FullDatabaseID, 10, 64) if err != nil { - return 0, err + return 0, "", err } - return databaseID, nil + return databaseID, resp.Repository.PullRequest.URL, nil } // generatePullRequestNodeID converts an int64 databaseID and repoID to a GraphQL Node ID format diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 1b750f56b..e67ed2ab1 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -1743,10 +1743,11 @@ func TestGetSession(t *testing.T) { } func TestGetPullRequestDatabaseID(t *testing.T) { tests := []struct { - name string - httpStubs func(*testing.T, *httpmock.Registry) - wantErr string - wantOut int64 + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantDatabaseID int64 + wantURL string }{ { name: "graphql error", @@ -1764,24 +1765,26 @@ func TestGetPullRequestDatabaseID(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.WithHost(httpmock.GraphQL(`query GetPullRequestFullDatabaseID\b`), "api.github.com"), - httpmock.StringResponse(`{"data": {"repository": {"pullRequest": {"fullDatabaseId": "non-int"}}}}`), + httpmock.StringResponse(`{"data": {"repository": {"pullRequest": {"fullDatabaseId": "non-int", "url": "some-url"}}}}`), ) }, wantErr: `strconv.ParseInt: parsing "non-int": invalid syntax`, + wantURL: "some-url", }, { name: "success", httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.WithHost(httpmock.GraphQL(`query GetPullRequestFullDatabaseID\b`), "api.github.com"), - httpmock.GraphQLQuery(`{"data": {"repository": {"pullRequest": {"fullDatabaseId": "999"}}}}`, func(s string, m map[string]interface{}) { + httpmock.GraphQLQuery(`{"data": {"repository": {"pullRequest": {"fullDatabaseId": "999", "url": "some-url"}}}}`, func(s string, m map[string]interface{}) { assert.Equal(t, "OWNER", m["owner"]) assert.Equal(t, "REPO", m["repo"]) assert.Equal(t, float64(42), m["number"]) }), ) }, - wantOut: 999, + wantDatabaseID: 999, + wantURL: "some-url", }, } @@ -1798,7 +1801,7 @@ func TestGetPullRequestDatabaseID(t *testing.T) { cfg := config.NewBlankConfig() capiClient := NewCAPIClient(httpClient, cfg.Authentication()) - databaseID, err := capiClient.GetPullRequestDatabaseID(context.Background(), "github.com", "OWNER", "REPO", 42) + databaseID, url, err := capiClient.GetPullRequestDatabaseID(context.Background(), "github.com", "OWNER", "REPO", 42) if tt.wantErr != "" { require.ErrorContains(t, err, tt.wantErr) @@ -1807,7 +1810,8 @@ func TestGetPullRequestDatabaseID(t *testing.T) { } require.NoError(t, err) - require.Equal(t, tt.wantOut, databaseID) + require.Equal(t, tt.wantDatabaseID, databaseID) + require.Equal(t, tt.wantURL, url) }) } } diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index c501f28b3..bac6ee936 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -109,6 +109,7 @@ func viewRun(opts *ViewOptions) error { } } else { var resourceID int64 + var prURL string if opts.SelectorArg != "" { // Finder does not support the PR/issue reference format (e.g. owner/repo#123) @@ -127,7 +128,7 @@ func viewRun(opts *ViewOptions) error { return fmt.Errorf("agent tasks are not supported on this host: %s", hostname) } - resourceID, err = capiClient.GetPullRequestDatabaseID(ctx, hostname, repo.RepoOwner(), repo.RepoName(), num) + resourceID, prURL, err = capiClient.GetPullRequestDatabaseID(ctx, hostname, repo.RepoOwner(), repo.RepoName(), num) if err != nil { return fmt.Errorf("failed to fetch pull request: %w", err) } @@ -155,6 +156,7 @@ func viewRun(opts *ViewOptions) error { } resourceID = databaseID + prURL = pr.URL } // TODO(babakks): currently we just fetch a pre-defined number of From ab7e4039daa1994b1dca60413636a92d5d72d448 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 10:54:40 +0100 Subject: [PATCH 104/208] refactor: move Copilot Agents home URL to capi Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 2 ++ pkg/cmd/agent-task/list/list.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index f28914f80..aa7deabaf 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -18,6 +18,8 @@ import ( "github.com/vmihailenco/msgpack/v5" ) +const AgentsHomeURL = "https://github.com/copilot/agents" + var defaultSessionsPerPage = 50 var ErrSessionNotFound = errors.New("not found") diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index bff4ed835..5886955e6 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -70,7 +70,7 @@ func listRun(opts *ListOptions) error { // based on repo, so we just open the agents dashboard with no args. // If that page is ever added in the future, we should route to that // page instead of the global one when --repo is set. - const webURL = "https://github.com/copilot/agents" + webURL := capi.AgentsHomeURL if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) } From 57d9f1ff0e26f340377586ee40e421bceb53e583 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 10:55:05 +0100 Subject: [PATCH 105/208] feat(agent-task view): add `--web` mode Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 44 ++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index bac6ee936..d02b8e710 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -10,6 +10,7 @@ import ( "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" @@ -31,10 +32,12 @@ type ViewOptions struct { HttpClient func() (*http.Client, error) Finder prShared.PRFinder Prompter prompter.Prompter + Browser browser.Browser SelectorArg string PRNumber int SessionID string + Web bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -43,6 +46,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman HttpClient: f.HttpClient, CapiClient: shared.CapiClientFunc(f), Prompter: f.Prompter, + Browser: f.Browser, } cmd := &cobra.Command{ @@ -80,6 +84,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmdutil.EnableRepoOverride(cmd, f) + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent task in the browser") + return cmd } @@ -98,15 +104,32 @@ func viewRun(opts *ViewOptions) error { var session *capi.Session if opts.SessionID != "" { - if sess, err := capiClient.GetSession(ctx, opts.SessionID); err != nil { + 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 - } else { - session = sess } + + 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 resourceID int64 var prURL string @@ -173,6 +196,21 @@ func viewRun(opts *ViewOptions) error { return cmdutil.SilentError } + 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() From ecbad7ecb9d537fe55640bc5d462ff747230f90b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 10:55:54 +0100 Subject: [PATCH 106/208] test(agent-task view): add `--web` mode tests Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view_test.go | 330 ++++++++++++++++++++++++--- 1 file changed, 302 insertions(+), 28 deletions(-) diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 64b63df5b..d825e996d 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" @@ -68,6 +69,15 @@ func TestNewCmdList(t *testing.T) { SelectorArg: "some-arg", }, }, + { + name: "web mode", + tty: true, + args: "some-arg -w", + wantOpts: ViewOptions{ + SelectorArg: "some-arg", + Web: true, + }, + }, } for _, tt := range tests { @@ -116,14 +126,15 @@ func Test_viewRun(t *testing.T) { sampleDate := time.Now().Add(-6 * time.Hour) // 6h ago tests := []struct { - name string - tty bool - opts ViewOptions - promptStubs func(*testing.T, *prompter.MockPrompter) - capiStubs func(*testing.T, *capi.CapiClientMock) - wantOut string - wantErr error - wantStderr string + name string + tty bool + opts ViewOptions + promptStubs func(*testing.T, *prompter.MockPrompter) + capiStubs func(*testing.T, *capi.CapiClientMock) + wantOut string + wantErr error + wantStderr string + wantBrowserURL string }{ { name: "with session id, not found (tty)", @@ -273,6 +284,74 @@ func Test_viewRun(t *testing.T) { Started about 6 hours ago `), }, + { + name: "with session id, not found, web mode (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + Web: true, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, _ string) (*capi.Session, error) { + return nil, capi.ErrSessionNotFound + } + }, + wantStderr: "session not found\n", + wantErr: cmdutil.SilentError, + }, + { + name: "with session id, without pr data, web mode (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + Web: true, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + // User data is irrelevant in this case + }, nil + } + }, + wantBrowserURL: "https://github.com/copilot/agents", + wantStderr: "Opening https://github.com/copilot/agents in your browser.\n", + }, + { + name: "with session id, with pr data, web mode (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + Web: true, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + // User data is irrelevant in this case + }, nil + } + }, + wantBrowserURL: "https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id", + wantStderr: "Opening https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id in your browser.\n", + }, { name: "with pr number, api error (tty)", tty: true, @@ -280,7 +359,10 @@ func Test_viewRun(t *testing.T) { SelectorArg: "pr-number", Finder: prShared.NewMockFinder( "pr-number", - &api.PullRequest{FullDatabaseID: "999999"}, + &api.PullRequest{ + FullDatabaseID: "999999", + URL: "https://github.com/OWNER/REPO/pull/101", + }, ghrepo.New("OWNER", "REPO"), ), }, @@ -312,8 +394,8 @@ func Test_viewRun(t *testing.T) { }, }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetPullRequestDatabaseIDFunc = func(_ context.Context, _ string, _ string, _ string, _ int) (int64, error) { - return 0, errors.New("some error") + m.GetPullRequestDatabaseIDFunc = func(_ context.Context, _ string, _ string, _ string, _ int) (int64, string, error) { + return 0, "", errors.New("some error") } }, wantErr: errors.New("failed to fetch pull request: some error"), @@ -328,8 +410,8 @@ func Test_viewRun(t *testing.T) { }, }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetPullRequestDatabaseIDFunc = func(_ context.Context, _ string, _ string, _ string, _ int) (int64, error) { - return 999999, nil + m.GetPullRequestDatabaseIDFunc = func(_ context.Context, _ string, _ string, _ string, _ int) (int64, string, error) { + return 999999, "some-url", nil } m.ListSessionsByResourceIDFunc = func(_ context.Context, _ string, _ int64, _ int) ([]*capi.Session, error) { return nil, errors.New("some error") @@ -344,7 +426,10 @@ func Test_viewRun(t *testing.T) { SelectorArg: "pr-number", Finder: prShared.NewMockFinder( "pr-number", - &api.PullRequest{FullDatabaseID: "999999"}, + &api.PullRequest{ + FullDatabaseID: "999999", + URL: "https://github.com/OWNER/REPO/pull/101", + }, ghrepo.New("OWNER", "REPO"), ), }, @@ -388,7 +473,10 @@ func Test_viewRun(t *testing.T) { SelectorArg: "pr-number", Finder: prShared.NewMockFinder( "pr-number", - &api.PullRequest{FullDatabaseID: "999999"}, + &api.PullRequest{ + FullDatabaseID: "999999", + URL: "https://github.com/OWNER/REPO/pull/101", + }, ghrepo.New("OWNER", "REPO"), ), }, @@ -421,9 +509,9 @@ func Test_viewRun(t *testing.T) { State: "completed", CreatedAt: sampleDate, PullRequest: &api.PullRequest{ - Title: "fix something else", - Number: 102, - URL: "https://github.com/OWNER/REPO/pull/102", + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, @@ -459,18 +547,18 @@ func Test_viewRun(t *testing.T) { name: "with pr reference, success, multiple sessions with pr and user data (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "OWNER/REPO#999", + SelectorArg: "OWNER/REPO#101", BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.GetPullRequestDatabaseIDFunc = func(_ context.Context, hostname string, owner string, repo string, number int) (int64, error) { + m.GetPullRequestDatabaseIDFunc = func(_ context.Context, hostname string, owner string, repo string, number int) (int64, string, error) { assert.Equal(t, "github.com", hostname) assert.Equal(t, "OWNER", owner) assert.Equal(t, "REPO", repo) - assert.Equal(t, 999, number) - return 999999, nil + assert.Equal(t, 101, number) + return 999999, "https://github.com/OWNER/REPO/pull/101", nil } m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { assert.Equal(t, "pull", resourceType) @@ -500,9 +588,9 @@ func Test_viewRun(t *testing.T) { State: "completed", CreatedAt: sampleDate, PullRequest: &api.PullRequest{ - Title: "fix something else", - Number: 102, - URL: "https://github.com/OWNER/REPO/pull/102", + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", Repository: &api.PRRepository{ NameWithOwner: "OWNER/REPO", }, @@ -534,6 +622,189 @@ func Test_viewRun(t *testing.T) { https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), }, + { + name: "with pr number, api error, web mode (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "pr-number", + Finder: prShared.NewMockFinder( + "pr-number", + &api.PullRequest{ + FullDatabaseID: "999999", + URL: "https://github.com/OWNER/REPO/pull/101", + }, + ghrepo.New("OWNER", "REPO"), + ), + Web: true, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsByResourceIDFunc = func(_ context.Context, _ string, _ int64, _ int) ([]*capi.Session, error) { + return nil, errors.New("some error") + } + }, + wantErr: errors.New("failed to list sessions for pull request: some error"), + }, + { + name: "with pr number, single session with pr data, web mode (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "pr-number", + Finder: prShared.NewMockFinder( + "pr-number", + &api.PullRequest{ + FullDatabaseID: "999999", + URL: "https://github.com/OWNER/REPO/pull/101", + }, + ghrepo.New("OWNER", "REPO"), + ), + Web: true, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { + assert.Equal(t, "pull", resourceType) + assert.Equal(t, int64(999999), resourceID) + assert.Equal(t, defaultLimit, limit) + return []*capi.Session{ + { + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + // User data is irrelevant in this case + }, + }, nil + } + }, + wantBrowserURL: "https://github.com/OWNER/REPO/pull/101/agent-sessions", + wantStderr: "Opening https://github.com/OWNER/REPO/pull/101/agent-sessions in your browser.\n", + }, + { + name: "with pr number, multiple session with pr data, web mode (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "pr-number", + Finder: prShared.NewMockFinder( + "pr-number", + &api.PullRequest{ + FullDatabaseID: "999999", + URL: "https://github.com/OWNER/REPO/pull/101", + }, + ghrepo.New("OWNER", "REPO"), + ), + Web: true, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { + assert.Equal(t, "pull", resourceType) + assert.Equal(t, int64(999999), resourceID) + assert.Equal(t, defaultLimit, limit) + return []*capi.Session{ + { + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + // User data is irrelevant in this case + }, + { + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + // User data is irrelevant in this case + }, + }, nil + } + }, + wantBrowserURL: "https://github.com/OWNER/REPO/pull/101/agent-sessions", + wantStderr: "Opening https://github.com/OWNER/REPO/pull/101/agent-sessions in your browser.\n", + }, + { + name: "with pr reference, multiple sessions with pr and user data, web mode (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "OWNER/REPO#101", + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Web: true, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetPullRequestDatabaseIDFunc = func(_ context.Context, hostname string, owner string, repo string, number int) (int64, string, error) { + assert.Equal(t, "github.com", hostname) + assert.Equal(t, "OWNER", owner) + assert.Equal(t, "REPO", repo) + assert.Equal(t, 101, number) + return 999999, "https://github.com/OWNER/REPO/pull/101", nil + } + m.ListSessionsByResourceIDFunc = func(_ context.Context, resourceType string, resourceID int64, limit int) ([]*capi.Session, error) { + assert.Equal(t, "pull", resourceType) + assert.Equal(t, int64(999999), resourceID) + assert.Equal(t, defaultLimit, limit) + return []*capi.Session{ + { + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, + { + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, + }, nil + } + }, + wantBrowserURL: "https://github.com/OWNER/REPO/pull/101/agent-sessions", + wantStderr: "Opening https://github.com/OWNER/REPO/pull/101/agent-sessions in your browser.\n", + }, } for _, tt := range tests { @@ -551,9 +822,12 @@ func Test_viewRun(t *testing.T) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) + browser := &browser.Stub{} + opts := tt.opts opts.IO = ios opts.Prompter = prompter + opts.Browser = browser opts.CapiClient = func() (capi.CapiClient, error) { return capiClientMock, nil } @@ -566,9 +840,9 @@ func Test_viewRun(t *testing.T) { require.NoError(t, err) } - got := stdout.String() - require.Equal(t, tt.wantOut, got) - require.Equal(t, tt.wantStderr, stderr.String()) + assert.Equal(t, tt.wantOut, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + assert.Equal(t, tt.wantBrowserURL, browser.BrowsedURL()) }) } } From b3b8697cf34cad96617bd97adbcc59d762e4e580 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 11:04:44 +0100 Subject: [PATCH 107/208] test(agent-task view): enhance naming and stubs Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view_test.go | 50 +++++++++++++--------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index d825e996d..a4636c369 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -356,9 +356,9 @@ func Test_viewRun(t *testing.T) { name: "with pr number, api error (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "pr-number", + SelectorArg: "101", Finder: prShared.NewMockFinder( - "pr-number", + "101", &api.PullRequest{ FullDatabaseID: "999999", URL: "https://github.com/OWNER/REPO/pull/101", @@ -377,7 +377,7 @@ func Test_viewRun(t *testing.T) { name: "with pr reference, unsupported hostname (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "OWNER/REPO#999", + SelectorArg: "OWNER/REPO#101", BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.NewWithHost("OWNER", "REPO", "foo.com"), nil }, @@ -388,7 +388,7 @@ func Test_viewRun(t *testing.T) { name: "with pr reference, api error when fetching pr database ID (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "OWNER/REPO#999", + SelectorArg: "OWNER/REPO#101", BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -404,7 +404,7 @@ func Test_viewRun(t *testing.T) { name: "with pr reference, api error when fetching session (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "OWNER/REPO#999", + SelectorArg: "OWNER/REPO#101", BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -420,12 +420,12 @@ func Test_viewRun(t *testing.T) { wantErr: errors.New("failed to list sessions for pull request: some error"), }, { - name: "with pr number, success, single session with pr and user data (tty)", + name: "with pr number, success, single session (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "pr-number", + SelectorArg: "101", Finder: prShared.NewMockFinder( - "pr-number", + "101", &api.PullRequest{ FullDatabaseID: "999999", URL: "https://github.com/OWNER/REPO/pull/101", @@ -467,12 +467,12 @@ func Test_viewRun(t *testing.T) { `), }, { - name: "with pr number, success, multiple sessions with pr and user data (tty)", + name: "with pr number, success, multiple sessions (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "pr-number", + SelectorArg: "101", Finder: prShared.NewMockFinder( - "pr-number", + "101", &api.PullRequest{ FullDatabaseID: "999999", URL: "https://github.com/OWNER/REPO/pull/101", @@ -544,7 +544,7 @@ func Test_viewRun(t *testing.T) { `), }, { - name: "with pr reference, success, multiple sessions with pr and user data (tty)", + name: "with pr reference, success, multiple sessions (tty)", tty: true, opts: ViewOptions{ SelectorArg: "OWNER/REPO#101", @@ -626,9 +626,9 @@ func Test_viewRun(t *testing.T) { name: "with pr number, api error, web mode (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "pr-number", + SelectorArg: "101", Finder: prShared.NewMockFinder( - "pr-number", + "101", &api.PullRequest{ FullDatabaseID: "999999", URL: "https://github.com/OWNER/REPO/pull/101", @@ -645,12 +645,12 @@ func Test_viewRun(t *testing.T) { wantErr: errors.New("failed to list sessions for pull request: some error"), }, { - name: "with pr number, single session with pr data, web mode (tty)", + name: "with pr number, single session, web mode (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "pr-number", + SelectorArg: "101", Finder: prShared.NewMockFinder( - "pr-number", + "101", &api.PullRequest{ FullDatabaseID: "999999", URL: "https://github.com/OWNER/REPO/pull/101", @@ -686,12 +686,12 @@ func Test_viewRun(t *testing.T) { wantStderr: "Opening https://github.com/OWNER/REPO/pull/101/agent-sessions in your browser.\n", }, { - name: "with pr number, multiple session with pr data, web mode (tty)", + name: "with pr number, multiple sessions, web mode (tty)", tty: true, opts: ViewOptions{ - SelectorArg: "pr-number", + SelectorArg: "101", Finder: prShared.NewMockFinder( - "pr-number", + "101", &api.PullRequest{ FullDatabaseID: "999999", URL: "https://github.com/OWNER/REPO/pull/101", @@ -743,7 +743,7 @@ func Test_viewRun(t *testing.T) { wantStderr: "Opening https://github.com/OWNER/REPO/pull/101/agent-sessions in your browser.\n", }, { - name: "with pr reference, multiple sessions with pr and user data, web mode (tty)", + name: "with pr reference, multiple sessions, web mode (tty)", tty: true, opts: ViewOptions{ SelectorArg: "OWNER/REPO#101", @@ -778,9 +778,7 @@ func Test_viewRun(t *testing.T) { NameWithOwner: "OWNER/REPO", }, }, - User: &api.GitHubUser{ - Login: "octocat", - }, + // User data is irrelevant in this case }, { ID: "some-other-session-id", @@ -795,9 +793,7 @@ func Test_viewRun(t *testing.T) { NameWithOwner: "OWNER/REPO", }, }, - User: &api.GitHubUser{ - Login: "octocat", - }, + // User data is irrelevant in this case }, }, nil } From 88e2d0d7d9343ef4491a84c5eded2827cd19b727 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 11:41:49 +0100 Subject: [PATCH 108/208] fix(agent-task view): stop progress indicator before opening browser Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index d02b8e710..b592af7cf 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -113,6 +113,8 @@ func viewRun(opts *ViewOptions) error { return err } + opts.IO.StopProgressIndicator() + if opts.Web { var webURL string if sess.PullRequest != nil { @@ -196,6 +198,8 @@ func viewRun(opts *ViewOptions) error { 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 @@ -224,7 +228,6 @@ func viewRun(opts *ViewOptions) error { )) } - opts.IO.StopProgressIndicator() selected, err := opts.Prompter.Select("Select a session", "", options) if err != nil { return err @@ -234,8 +237,6 @@ func viewRun(opts *ViewOptions) error { } } - opts.IO.StopProgressIndicator() - out := opts.IO.Out if session.PullRequest != nil { From b463395d481b736f1a121ea18d687e77d6c7a2ad Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 12:45:34 +0100 Subject: [PATCH 109/208] fix(agent-task create): only prompt for problem statement if not provided Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 799847bf4..7c1d04efc 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -109,12 +109,16 @@ func createRun(opts *CreateOptions) error { return fmt.Errorf("a repository is required; re-run in a repository or supply one with --repo owner/name") } - // Prompt for ProblemStatement if not provided non-interactively - if opts.Prompter != nil && opts.IO.CanPrompt() { + if opts.ProblemStatement == "" { + if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("a task description or -F is required when running non-interactively") + } + desc, err := opts.Prompter.MarkdownEditor("Enter the task description", opts.ProblemStatement, false) if err != nil { return err } + trimmed := strings.TrimSpace(desc) if trimmed == "" { return cmdutil.FlagErrorf("a task description is required") @@ -122,10 +126,6 @@ func createRun(opts *CreateOptions) error { opts.ProblemStatement = trimmed } - if opts.ProblemStatement == "" { - return cmdutil.FlagErrorf("a task description or -F is required when running non-interactively") - } - client, err := opts.CapiClient() if err != nil { return err From 4cebd35791631dc5f862b6b78b7c1e58c53d78c1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 12:48:24 +0100 Subject: [PATCH 110/208] refactor(agent-task create): assign `Config` at instantiation Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 7c1d04efc..c0d1c292e 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -37,6 +37,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, CapiClient: shared.CapiClientFunc(f), + Config: f.Config, Prompter: f.Prompter, } @@ -69,7 +70,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts.ProblemStatement = trimmed } - opts.Config = f.Config if runF != nil { return runF(opts) } From 6a5481368f1db90897adfc03a3b9e77cc33ba3bf Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 13:53:45 +0100 Subject: [PATCH 111/208] docs(agent-task view): improve `--help` docs Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index b592af7cf..787157ffe 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -51,10 +51,26 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "view [ | | | ]", - Short: "View an agent task session", + 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 From 78b09e55e5f8fe3a4c061c53b495b7a847c5bf85 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 18:43:40 +0100 Subject: [PATCH 112/208] fix(pr/shared): add rest return value to `ParseURL` Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/edit/edit.go | 2 +- pkg/cmd/pr/shared/finder.go | 23 +++++++++++++--------- pkg/cmd/pr/shared/finder_test.go | 33 +++++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index c01364d24..b196546cd 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -97,7 +97,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman // needs to know the API host. If the command is run outside of // a git repo, we cannot instantiate the detector unless we have // already parsed the URL. - if baseRepo, _, err := shared.ParseURL(opts.SelectorArg); err == nil { + if baseRepo, _, _, err := shared.ParseURL(opts.SelectorArg); err == nil { opts.BaseRepo = func() (ghrepo.Interface, error) { return baseRepo, nil } diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index cb8237d58..ac9289486 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -112,7 +112,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return nil, nil, errors.New("Find error: no fields specified") } - if repo, prNumber, err := ParseURL(opts.Selector); err == nil { + if repo, prNumber, _, err := ParseURL(opts.Selector); err == nil { f.prNumber = prNumber f.baseRefRepo = repo } @@ -300,32 +300,37 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return pr, f.baseRefRepo, g.Wait() } -var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`) +var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)(/.*)?$`) // ParseURL parses a pull request URL and returns the repository and pull -// request number. -func ParseURL(prURL string) (ghrepo.Interface, int, error) { +// request number. If there is no error, the returned repo is not nil and will +// have non-empty hostname. +// +// Any path components (not query params or fragments) after the pull request +// number are also returned. +func ParseURL(prURL string) (ghrepo.Interface, int, string, error) { if prURL == "" { - return nil, 0, fmt.Errorf("invalid URL: %q", prURL) + return nil, 0, "", fmt.Errorf("invalid URL: %q", prURL) } u, err := url.Parse(prURL) if err != nil { - return nil, 0, err + return nil, 0, "", err } if u.Scheme != "https" && u.Scheme != "http" { - return nil, 0, fmt.Errorf("invalid scheme: %s", u.Scheme) + return nil, 0, "", fmt.Errorf("invalid scheme: %s", u.Scheme) } m := pullURLRE.FindStringSubmatch(u.Path) if m == nil { - return nil, 0, fmt.Errorf("not a pull request URL: %s", prURL) + return nil, 0, "", fmt.Errorf("not a pull request URL: %s", prURL) } repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) prNumber, _ := strconv.Atoi(m[3]) - return repo, prNumber, nil + rest := m[4] + return repo, prNumber, rest, nil } var fullReferenceRE = regexp.MustCompile(`^(?:([^/]+)/([^/]+))#(\d+)$`) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 5e33ee876..61acc892b 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -21,6 +21,7 @@ func TestParseURL(t *testing.T) { arg string wantRepo ghrepo.Interface wantNum int + wantRest string wantErr string }{ { @@ -35,15 +36,39 @@ func TestParseURL(t *testing.T) { wantRepo: ghrepo.NewWithHost("owner", "repo", "example.com"), wantNum: 123, }, + { + name: "valid HTTP URL with rest", + arg: "http://example.com/owner/repo/pull/123/foo/bar", + wantRepo: ghrepo.NewWithHost("owner", "repo", "example.com"), + wantNum: 123, + wantRest: "/foo/bar", + }, + { + name: "valid HTTP URL with a trailing slash", + arg: "http://example.com/owner/repo/pull/123/", + wantRepo: ghrepo.NewWithHost("owner", "repo", "example.com"), + wantNum: 123, + wantRest: "/", + }, { name: "empty URL", wantErr: "invalid URL: \"\"", }, + { + name: "no scheme", + arg: "github.com/owner/repo/pull/123", + wantErr: "invalid scheme: ", + }, { name: "invalid scheme", arg: "ftp://github.com/owner/repo/pull/123", wantErr: "invalid scheme: ftp", }, + { + name: "no hostname", + arg: "/owner/repo/pull/123", + wantErr: "invalid scheme: ", + }, { name: "incorrect path", arg: "https://github.com/owner/repo/issues/123", @@ -59,11 +84,16 @@ func TestParseURL(t *testing.T) { arg: "https://github.com/owner/repo/pull/foo", wantErr: "not a pull request URL: https://github.com/owner/repo/pull/foo", }, + { + name: "invalid PR number, non-numeric suffix", + arg: "https://github.com/owner/repo/pull/123foo", + wantErr: "not a pull request URL: https://github.com/owner/repo/pull/123foo", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - repo, num, err := ParseURL(tt.arg) + repo, num, rest, err := ParseURL(tt.arg) if tt.wantErr != "" { require.Error(t, err) @@ -73,6 +103,7 @@ func TestParseURL(t *testing.T) { require.NoError(t, err) require.Equal(t, tt.wantNum, num) + require.Equal(t, tt.wantRest, rest) require.NotNil(t, repo) require.True(t, ghrepo.IsSame(tt.wantRepo, repo)) }) From 7e21837f3d91553861cf759f2374022000424913 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 18:44:24 +0100 Subject: [PATCH 113/208] fix(agent-task/shared): add `ParsePullRequestAgentSessionURL` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/capi.go | 20 +++++++++ pkg/cmd/agent-task/shared/capi_test.go | 56 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/pkg/cmd/agent-task/shared/capi.go b/pkg/cmd/agent-task/shared/capi.go index f064eac2e..36f206e34 100644 --- a/pkg/cmd/agent-task/shared/capi.go +++ b/pkg/cmd/agent-task/shared/capi.go @@ -1,13 +1,16 @@ package shared import ( + "errors" "regexp" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" ) var uuidRE = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`) +var agentSessionsPathRE = regexp.MustCompile(`^/agent-sessions/([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$`) func CapiClientFunc(f *cmdutil.Factory) func() (capi.CapiClient, error) { return func() (capi.CapiClient, error) { @@ -29,3 +32,20 @@ func CapiClientFunc(f *cmdutil.Factory) func() (capi.CapiClient, error) { func IsSessionID(s string) bool { return uuidRE.MatchString(s) } + +// ParsePullRequestAgentSessionURL parses session ID from a pull request's agent +// session URL, which is of the form: +// +// https://github.com/OWNER/REPO/pull/NUMBER/agent-sessions/SESSION-ID +func ParsePullRequestAgentSessionURL(u string) (string, error) { + _, _, rest, err := prShared.ParseURL(u) + if err != nil { + return "", err + } + + match := agentSessionsPathRE.FindStringSubmatch(rest) + if match == nil { + return "", errors.New("not a valid agent session URL") + } + return match[1], nil +} diff --git a/pkg/cmd/agent-task/shared/capi_test.go b/pkg/cmd/agent-task/shared/capi_test.go index d6a106d1b..d29f50624 100644 --- a/pkg/cmd/agent-task/shared/capi_test.go +++ b/pkg/cmd/agent-task/shared/capi_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsSession(t *testing.T) { @@ -18,3 +19,58 @@ func TestIsSession(t *testing.T) { assert.False(t, IsSessionID("000000000000000000000000000000000000")) assert.False(t, IsSessionID("00000000-0000-0000-0000-000000000000-extra")) } + +func TestParsePullRequestAgentSessionURL(t *testing.T) { + tests := []struct { + name string + url string + wantSessionID string + wantErr bool + }{ + { + name: "valid", + url: "https://github.com/OWNER/REPO/pull/123/agent-sessions/e2fa49d2-f164-4a56-ab99-498090b8fcdf", + wantSessionID: "e2fa49d2-f164-4a56-ab99-498090b8fcdf", + }, + { + name: "invalid session id", + url: "https://github.com/OWNER/REPO/pull/123/agent-sessions/fff", + wantErr: true, + }, + { + name: "no session id, trailing slash", + url: "https://github.com/OWNER/REPO/pull/123/agent-sessions/", + wantErr: true, + }, + { + name: "no session id", + url: "https://github.com/OWNER/REPO/pull/123/agent-sessions", + wantErr: true, + }, + { + name: "invalid pr url", + url: "https://github.com/OWNER/REPO/issues/123", + wantErr: true, + }, + { + name: "empty", + url: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sessionID, err := ParsePullRequestAgentSessionURL(tt.url) + + if tt.wantErr { + require.Error(t, err) + assert.Zero(t, sessionID) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantSessionID, sessionID) + }) + } +} From 3798d98969fb7cc3bbf45d93118713775805c69e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 18:45:06 +0100 Subject: [PATCH 114/208] feat(agent-task view): support PR `/agent-sessions/*` URL as argument Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 2 ++ pkg/cmd/agent-task/view/view_test.go | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 787157ffe..4f9d6dc3a 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -80,6 +80,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts.SelectorArg = args[0] if shared.IsSessionID(opts.SelectorArg) { opts.SessionID = opts.SelectorArg + } else if sessionID, err := shared.ParsePullRequestAgentSessionURL(opts.SelectorArg); err == nil { + opts.SessionID = sessionID } } diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index a4636c369..bdff45793 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -46,6 +46,15 @@ func TestNewCmdList(t *testing.T) { SessionID: "00000000-0000-0000-0000-000000000000", }, }, + { + name: "PR agent-session URL arg tty", + tty: true, + args: "https://github.com/OWNER/REPO/pull/101/agent-sessions/00000000-0000-0000-0000-000000000000", + wantOpts: ViewOptions{ + SelectorArg: "https://github.com/OWNER/REPO/pull/101/agent-sessions/00000000-0000-0000-0000-000000000000", + SessionID: "00000000-0000-0000-0000-000000000000", + }, + }, { name: "non-session ID arg tty", tty: true, From 6b872923ea58f7ac824b6dd86a3e6125666b6073 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 18:57:11 +0100 Subject: [PATCH 115/208] refactor(agent-task view): rename `resourceID` to `prID` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 4f9d6dc3a..0f8c9f794 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -151,7 +151,7 @@ func viewRun(opts *ViewOptions) error { session = sess } else { - var resourceID int64 + var prID int64 var prURL string if opts.SelectorArg != "" { @@ -171,14 +171,14 @@ func viewRun(opts *ViewOptions) error { return fmt.Errorf("agent tasks are not supported on this host: %s", hostname) } - resourceID, prURL, err = capiClient.GetPullRequestDatabaseID(ctx, hostname, repo.RepoOwner(), repo.RepoName(), num) + 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 resourceID == 0 { + if prID == 0 { findOptions := prShared.FindOptions{ Selector: opts.SelectorArg, Fields: []string{"id", "url", "fullDatabaseId"}, @@ -198,7 +198,7 @@ func viewRun(opts *ViewOptions) error { return fmt.Errorf("failed to parse pull request: %w", err) } - resourceID = databaseID + prID = databaseID prURL = pr.URL } @@ -206,7 +206,7 @@ func viewRun(opts *ViewOptions) error { // 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", resourceID, defaultLimit) + sessions, err := capiClient.ListSessionsByResourceID(ctx, "pull", prID, defaultLimit) if err != nil { return fmt.Errorf("failed to list sessions for pull request: %w", err) } From b079ea82e516d67237531422c3dd62307d05b1ef Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 19:07:03 +0100 Subject: [PATCH 116/208] refactor(agent-task/shared): extract uuid pattern Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/capi.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/shared/capi.go b/pkg/cmd/agent-task/shared/capi.go index 36f206e34..656538c80 100644 --- a/pkg/cmd/agent-task/shared/capi.go +++ b/pkg/cmd/agent-task/shared/capi.go @@ -2,6 +2,7 @@ package shared import ( "errors" + "fmt" "regexp" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" @@ -9,8 +10,10 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" ) -var uuidRE = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`) -var agentSessionsPathRE = regexp.MustCompile(`^/agent-sessions/([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$`) +const uuidPattern = `[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}` + +var uuidRE = regexp.MustCompile(fmt.Sprintf("^%s$", uuidPattern)) +var agentSessionsPathRE = regexp.MustCompile(fmt.Sprintf("^/agent-sessions/(%s)$", uuidPattern)) func CapiClientFunc(f *cmdutil.Factory) func() (capi.CapiClient, error) { return func() (capi.CapiClient, error) { From 2311a046886df4274b83bd9b684582073b74c299 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 10 Sep 2025 19:07:52 +0100 Subject: [PATCH 117/208] fix(pr/shared): ensure `ParseURL` regexp is backward-compatible Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/finder.go | 2 +- pkg/cmd/pr/shared/finder_test.go | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index ac9289486..786704fbb 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -300,7 +300,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return pr, f.baseRefRepo, g.Wait() } -var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)(/.*)?$`) +var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)(.*$)`) // ParseURL parses a pull request URL and returns the repository and pull // request number. If there is no error, the returned repo is not nil and will diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 61acc892b..470709480 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -43,6 +43,13 @@ func TestParseURL(t *testing.T) { wantNum: 123, wantRest: "/foo/bar", }, + { + name: "valid HTTP URL with .patch as rest", + arg: "http://example.com/owner/repo/pull/123.patch", + wantRepo: ghrepo.NewWithHost("owner", "repo", "example.com"), + wantNum: 123, + wantRest: ".patch", + }, { name: "valid HTTP URL with a trailing slash", arg: "http://example.com/owner/repo/pull/123/", @@ -84,11 +91,6 @@ func TestParseURL(t *testing.T) { arg: "https://github.com/owner/repo/pull/foo", wantErr: "not a pull request URL: https://github.com/owner/repo/pull/foo", }, - { - name: "invalid PR number, non-numeric suffix", - arg: "https://github.com/owner/repo/pull/123foo", - wantErr: "not a pull request URL: https://github.com/owner/repo/pull/123foo", - }, } for _, tt := range tests { From c0a5b9aced943d6be73fad752241ce9fb30567a0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 10 Sep 2025 18:03:42 -0600 Subject: [PATCH 118/208] Refactor agent-task create to improve file input handling Moves file reading for the problem statement from argument parsing to execution, storing the file path in CreateOptions. Updates error handling and user prompts to better distinguish between interactive and non-interactive modes. Refactors and expands tests to match the new logic and improve coverage for file and prompt scenarios. --- pkg/cmd/agent-task/create/create.go | 70 ++--- pkg/cmd/agent-task/create/create_test.go | 321 +++++++++++++++-------- 2 files changed, 255 insertions(+), 136 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index c0d1c292e..6ebbbc938 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -23,14 +23,15 @@ import ( // 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 - BaseBranch string - Prompter prompter.Prompter + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + CapiClient func() (capi.CapiClient, error) + Config func() (gh.Config, error) + ProblemStatement string + BackOff backoff.BackOff + BaseBranch string + Prompter prompter.Prompter + ProblemStatementFile string } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -41,8 +42,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co Prompter: f.Prompter, } - var fromFileName string - cmd := &cobra.Command{ Use: "create [] [flags]", Short: "Create an agent task (preview)", @@ -51,23 +50,15 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co // Support -R/--repo override opts.BaseRepo = f.BaseRepo - if err := cmdutil.MutuallyExclusive("only one of -F or arg can be provided", len(args) > 0, fromFileName != ""); err != nil { + if err := cmdutil.MutuallyExclusive("only one of -F or arg can be provided", len(args) > 0, opts.ProblemStatementFile != ""); err != nil { return err } - // Gather arg inputs for ProblemStatement + // Populate ProblemStatement from arg if len(args) > 0 { opts.ProblemStatement = args[0] - } else if fromFileName != "" { - fileContent, err := cmdutil.ReadFile(fromFileName, opts.IO.In) - if err != nil { - return cmdutil.FlagErrorf("could not read task description file: %v", err) - } - trimmed := strings.TrimSpace(string(fileContent)) - if trimmed == "" { - return cmdutil.FlagErrorf("task description file is empty") - } - opts.ProblemStatement = trimmed + } else if opts.ProblemStatementFile == "" && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("a task description or -F is required when running non-interactively") } if runF != nil { @@ -95,7 +86,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmdutil.EnableRepoOverride(cmd, f) - cmd.Flags().StringVarP(&fromFileName, "from-file", "F", "", "Read task description from `file` (use \"-\" to read from standard input)") + cmd.Flags().StringVarP(&opts.ProblemStatementFile, "from-file", "F", "", "Read task description from `file` (use \"-\" to read from standard input)") cmd.Flags().StringVarP(&opts.BaseBranch, "base", "b", "", "Base branch for the pull request (use default branch if not provided)") return cmd @@ -110,20 +101,37 @@ func createRun(opts *CreateOptions) error { } if opts.ProblemStatement == "" { - if !opts.IO.CanPrompt() { - return cmdutil.FlagErrorf("a task description or -F is required when running non-interactively") + // Load initial problem statement from file, if provided + if opts.ProblemStatementFile != "" { + fileContent, err := cmdutil.ReadFile(opts.ProblemStatementFile, opts.IO.In) + if err != nil { + return cmdutil.FlagErrorf("could not read task description file: %v", err) + } + opts.ProblemStatement = strings.TrimSpace(string(fileContent)) } - desc, err := opts.Prompter.MarkdownEditor("Enter the task description", opts.ProblemStatement, false) + if opts.IO.CanPrompt() { + desc, err := opts.Prompter.MarkdownEditor("Enter the task description", opts.ProblemStatement, false) + if err != nil { + return err + } + opts.ProblemStatement = strings.TrimSpace(desc) + } + } + + if opts.ProblemStatement == "" { + fmt.Fprintf(opts.IO.ErrOut, "a task description is required.\n") + return cmdutil.SilentError + } + + if opts.IO.CanPrompt() { + confirm, err := opts.Prompter.Confirm("Submit agent task", true) if err != nil { return err } - - trimmed := strings.TrimSpace(desc) - if trimmed == "" { - return cmdutil.FlagErrorf("a task description is required") + if !confirm { + return cmdutil.SilentError } - opts.ProblemStatement = trimmed } client, err := opts.CapiClient() diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index edf03f5c9..2969bdcc7 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -3,7 +3,6 @@ package create import ( "context" "errors" - "fmt" "io" "os" "path/filepath" @@ -21,38 +20,36 @@ import ( ) func TestNewCmdCreate(t *testing.T) { - tmpDir := t.TempDir() - - tmpEmptyFile := filepath.Join(tmpDir, "empty-task-description.md") - err := os.WriteFile(tmpEmptyFile, []byte(" \n\n"), 0600) - require.NoError(t, err) - - tmpFile := filepath.Join(tmpDir, "task-description.md") - err = os.WriteFile(tmpFile, []byte("task description from file"), 0600) - require.NoError(t, err) - tests := []struct { name string args string stdin string - wantOpts *CreateOptions // nil when expecting error + tty bool + wantOpts *CreateOptions wantErr string }{ { name: "no args nor file returns no error (prompting path)", + tty: true, + wantOpts: &CreateOptions{ + ProblemStatement: "", + ProblemStatementFile: "", + }, }, { name: "arg only success", args: "'task description from args'", wantOpts: &CreateOptions{ - ProblemStatement: "task description from args", + ProblemStatement: "task description from args", + ProblemStatementFile: "", }, }, { - name: "from-file success", - args: fmt.Sprintf("-F '%s'", tmpFile), + name: "from-file succes", + args: "-F task-description.md", wantOpts: &CreateOptions{ - ProblemStatement: "task description from file", + ProblemStatement: "", + ProblemStatementFile: "task-description.md", }, }, { @@ -60,7 +57,8 @@ func TestNewCmdCreate(t *testing.T) { args: "-F -", stdin: "task description from stdin", wantOpts: &CreateOptions{ - ProblemStatement: "task description from stdin", + ProblemStatement: "", + ProblemStatementFile: "-", }, }, { @@ -69,29 +67,41 @@ func TestNewCmdCreate(t *testing.T) { wantErr: "only one of -F or arg can be provided", }, { - name: "missing file path", - args: "-F does-not-exist.md", - wantErr: "could not read task description file: open does-not-exist.md:", + name: "missing file path is accepted", + args: "-F does-not-exist.md", + wantOpts: &CreateOptions{ + ProblemStatement: "", + ProblemStatementFile: "does-not-exist.md", + }, }, { - name: "empty file", - args: fmt.Sprintf("-F '%s'", tmpEmptyFile), - wantErr: "task description file is empty", + name: "empty file accepted at", + args: "-F empty-task-description.md", + wantOpts: &CreateOptions{ + ProblemStatement: "", + ProblemStatementFile: "empty-task-description.md", + }, }, { - name: "empty from stdin", - args: "-F -", - stdin: " \n\n", - wantErr: "task description file is empty", + name: "empty from stdin accepted", + args: "-F -", + stdin: " \n\n", + wantOpts: &CreateOptions{ + ProblemStatement: "", + ProblemStatementFile: "-", + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, stdin, _, _ := iostreams.Test() - f := &cmdutil.Factory{ - IOStreams: ios, + if tt.tty { + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) } + f := &cmdutil.Factory{IOStreams: ios} var gotOpts *CreateOptions cmd := NewCmdCreate(f, func(o *CreateOptions) error { @@ -102,31 +112,35 @@ func TestNewCmdCreate(t *testing.T) { argv, err := shlex.Split(tt.args) require.NoError(t, err) cmd.SetArgs(argv) - cmd.SetIn(stdin) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - if tt.stdin != "" { stdin.WriteString(tt.stdin) } _, err = cmd.ExecuteC() - if tt.wantErr != "" { - require.ErrorContains(t, err, tt.wantErr) - return + require.Error(t, err, tt.wantErr) + } else { + require.NoError(t, err) } - require.NoError(t, err) if tt.wantOpts != nil { require.Equal(t, tt.wantOpts.ProblemStatement, gotOpts.ProblemStatement) + require.Equal(t, tt.wantOpts.ProblemStatementFile, gotOpts.ProblemStatementFile) } }) } } func Test_createRun(t *testing.T) { + tmpDir := t.TempDir() + taskDescFile := filepath.Join(tmpDir, "task-description.md") + emptyTaskDescFile := filepath.Join(tmpDir, "empty-task-description.md") + require.NoError(t, os.WriteFile(taskDescFile, []byte("task description from file"), 0600)) + require.NoError(t, os.WriteFile(emptyTaskDescFile, []byte(" \n\n"), 0600)) + sampleDateString := "2025-08-29T00:00:00Z" sampleDate, err := time.Parse(time.RFC3339, sampleDateString) require.NoError(t, err) @@ -157,64 +171,149 @@ func Test_createRun(t *testing.T) { } tests := []struct { - name string - capiStubs func(*testing.T, *capi.CapiClientMock) - baseRepoFunc func() (ghrepo.Interface, error) - baseBranch string - isTTY bool - prompterMock *prompter.PrompterMock - problemStatement string - wantStdout string - wantStdErr string - wantErr string + name string + isTTY bool + capiStubs func(*testing.T, *capi.CapiClientMock) + opts *CreateOptions // input options (IO & BackOff set later) + wantStdout string + wantStdErr string + wantErr string }{ { - name: "missing repo returns error", - 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: "interactive with file prompts to edit with file contents", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + ProblemStatement: "", + ProblemStatementFile: taskDescFile, + Prompter: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + require.Equal(t, "Enter the task description", prompt) + require.Equal(t, "task description from file", defaultValue) + return prompt + "+From editor", nil + }, + ConfirmFunc: func(message string, defaultValue bool) (bool, error) { + require.Equal(t, "Submit agent task", message) + return true, nil + }, + }, + }, + isTTY: true, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "Enter the task description+From editor", problemStatement) + return &createdJobSuccessWithPR, nil + } + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "non-interactive empty description returns error", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - problemStatement: "", - wantErr: "a task description or -F is required when running non-interactively", + name: "interactively rejecting confirmation prompt aborts task creation", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + ProblemStatement: "", + Prompter: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + require.Equal(t, "Enter the task description", prompt) + return "From editor", nil + }, + ConfirmFunc: func(message string, defaultValue bool) (bool, error) { + require.Equal(t, "Submit agent task", message) + return false, nil + }, + }, + }, + isTTY: true, + wantErr: "SilentError", + wantStdErr: "", }, { - name: "interactive prompt success", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - isTTY: true, - problemStatement: "", + name: "interactively entering task description with editor, no file", + isTTY: true, + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + ProblemStatement: "", + Prompter: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + require.Equal(t, "Enter the task description", prompt) + return "From editor", nil + }, + ConfirmFunc: func(message string, defaultValue bool) (bool, error) { + require.Equal(t, "Submit agent task", message) + return true, nil + }, + }, + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "From editor", problemStatement) return &createdJobSuccessWithPR, nil } }, - prompterMock: &prompter.PrompterMock{ - MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { - require.Equal(t, "Enter the task description", prompt) - return "From editor", nil + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + name: "empty task description from interactive prompt returns error", + isTTY: true, + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil }, + Prompter: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + return " ", nil + }, + }, + }, + wantErr: "SilentError", + wantStdErr: "a task description is required.\n", + }, + { + name: "problem statement loaded from file non-interactively doesn't prompt or return error", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + ProblemStatement: "", + ProblemStatementFile: taskDescFile, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "task description from file", problemStatement) + return &createdJobSuccessWithPR, nil + } }, wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "interactive prompt empty returns error", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - isTTY: true, - problemStatement: "", - prompterMock: &prompter.PrompterMock{ - MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { - return " ", nil - }, - }, - wantErr: "a task description is required", + name: "missing repo returns error", + opts: &CreateOptions{ + BaseRepo: 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: "base branch included in create payload", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - baseBranch: "feature", - problemStatement: "Do the thing", + name: "non-interactive empty description returns error", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + ProblemStatement: "", + }, + wantErr: "SilentError", + wantStdErr: "a task description is required.\n", + }, + { + name: "base branch included in create payload", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + ProblemStatement: "Do the thing", + BaseBranch: "feature", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -233,24 +332,32 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "create task API failure returns error", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - problemStatement: "Do the thing", + name: "create task API failure returns error", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + ProblemStatement: "Do the thing", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) require.Equal(t, "REPO", repo) require.Equal(t, "Do the thing", problemStatement) require.Equal(t, "", baseBranch) - return nil, errors.New("some error") + return nil, errors.New("some API error") } }, - wantErr: "some error", + wantErr: "some API error", }, { - name: "get job API failure surfaces error", - baseRepoFunc: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - problemStatement: "Do the thing", + name: "get job API failure surfaces error", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + ProblemStatement: "Do the thing", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -267,9 +374,13 @@ func Test_createRun(t *testing.T) { wantStdout: "job job123 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", + name: "success with immediate PR", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + ProblemStatement: "Do the thing", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -282,9 +393,13 @@ func Test_createRun(t *testing.T) { 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", + name: "success with delayed PR after polling", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + ProblemStatement: "Do the thing", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -303,9 +418,13 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\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", + name: "fallback after polling timeout returns link to global agents page", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + ProblemStatement: "Do the thing", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) @@ -341,23 +460,15 @@ func Test_createRun(t *testing.T) { ios.SetStdoutTTY(true) } - opts := &CreateOptions{ - IO: ios, - ProblemStatement: tt.problemStatement, - BaseRepo: tt.baseRepoFunc, - BaseBranch: tt.baseBranch, - Prompter: tt.prompterMock, - CapiClient: func() (capi.CapiClient, error) { - return capiClientMock, nil - }, + tt.opts.IO = ios + tt.opts.CapiClient = func() (capi.CapiClient, error) { + return capiClientMock, nil } - // A backoff with no interval 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) - - err := createRun(opts) + // fast backoff + tt.opts.BackOff = backoff.WithMaxRetries(&backoff.ZeroBackOff{}, 3) + err := createRun(tt.opts) if tt.wantErr != "" { require.Error(t, err) require.Equal(t, tt.wantErr, err.Error()) From deee0c61eda2a897dc25faa21a2828611d5748e9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 10 Sep 2025 18:39:35 -0600 Subject: [PATCH 119/208] Update command examples for agent-task create Improves the help text for the 'gh agent-task create' command by clarifying the editor usage example and adding an example for using a file as a template. --- pkg/cmd/agent-task/create/create.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 6ebbbc938..08c1f8c02 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -76,9 +76,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co # Create a task with problem statement from stdin $ echo "build me a new app" | gh agent-task create -F - - # Create a task with an editor prompt (interactive) + # Create a task with an editor $ gh agent-task create + # Create a task with an editor and a file as a template + $ gh agent-task create -F task-desc.md + # Select a different base branch for the PR $ gh agent-task create "fix errors" --base branch `), From 1c37ab94537a64028e032914980ddf7299dbcfd1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 11 Sep 2025 09:03:26 +0100 Subject: [PATCH 120/208] refactor(agent-task/shared): enhance symbol names Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/capi.go | 16 ++++++++-------- pkg/cmd/agent-task/shared/capi_test.go | 2 +- pkg/cmd/agent-task/view/view.go | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/agent-task/shared/capi.go b/pkg/cmd/agent-task/shared/capi.go index 656538c80..c61aa13a7 100644 --- a/pkg/cmd/agent-task/shared/capi.go +++ b/pkg/cmd/agent-task/shared/capi.go @@ -12,8 +12,8 @@ import ( const uuidPattern = `[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}` -var uuidRE = regexp.MustCompile(fmt.Sprintf("^%s$", uuidPattern)) -var agentSessionsPathRE = regexp.MustCompile(fmt.Sprintf("^/agent-sessions/(%s)$", uuidPattern)) +var sessionIDRegexp = regexp.MustCompile(fmt.Sprintf("^%s$", uuidPattern)) +var agentSessionURLRegexp = regexp.MustCompile(fmt.Sprintf("^/agent-sessions/(%s)$", uuidPattern)) func CapiClientFunc(f *cmdutil.Factory) func() (capi.CapiClient, error) { return func() (capi.CapiClient, error) { @@ -33,20 +33,20 @@ func CapiClientFunc(f *cmdutil.Factory) func() (capi.CapiClient, error) { } func IsSessionID(s string) bool { - return uuidRE.MatchString(s) + return sessionIDRegexp.MatchString(s) } -// ParsePullRequestAgentSessionURL parses session ID from a pull request's agent -// session URL, which is of the form: +// ParseSessionIDFromURL parses session ID from a pull request's agent session +// URL, which is of the form: // -// https://github.com/OWNER/REPO/pull/NUMBER/agent-sessions/SESSION-ID -func ParsePullRequestAgentSessionURL(u string) (string, error) { +// `https://github.com/OWNER/REPO/pull/NUMBER/agent-sessions/SESSION-ID` +func ParseSessionIDFromURL(u string) (string, error) { _, _, rest, err := prShared.ParseURL(u) if err != nil { return "", err } - match := agentSessionsPathRE.FindStringSubmatch(rest) + match := agentSessionURLRegexp.FindStringSubmatch(rest) if match == nil { return "", errors.New("not a valid agent session URL") } diff --git a/pkg/cmd/agent-task/shared/capi_test.go b/pkg/cmd/agent-task/shared/capi_test.go index d29f50624..205d881c8 100644 --- a/pkg/cmd/agent-task/shared/capi_test.go +++ b/pkg/cmd/agent-task/shared/capi_test.go @@ -61,7 +61,7 @@ func TestParsePullRequestAgentSessionURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sessionID, err := ParsePullRequestAgentSessionURL(tt.url) + sessionID, err := ParseSessionIDFromURL(tt.url) if tt.wantErr { require.Error(t, err) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 0f8c9f794..024101b0e 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -80,7 +80,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts.SelectorArg = args[0] if shared.IsSessionID(opts.SelectorArg) { opts.SessionID = opts.SelectorArg - } else if sessionID, err := shared.ParsePullRequestAgentSessionURL(opts.SelectorArg); err == nil { + } else if sessionID, err := shared.ParseSessionIDFromURL(opts.SelectorArg); err == nil { opts.SessionID = sessionID } } From 10e4abc51545e3485b88b7486aa568510bb022d3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 11 Sep 2025 09:04:17 +0100 Subject: [PATCH 121/208] refactor(pr/shared): rename `rest` to `tail` Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/finder.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 786704fbb..7d66d60f3 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -302,12 +302,9 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)(.*$)`) -// ParseURL parses a pull request URL and returns the repository and pull -// request number. If there is no error, the returned repo is not nil and will -// have non-empty hostname. -// -// Any path components (not query params or fragments) after the pull request -// number are also returned. +// ParseURL parses a pull request URL and returns the repository, pull request +// number, and any tailing path components. If there is no error, the returned +// repo is not nil and will have non-empty hostname. func ParseURL(prURL string) (ghrepo.Interface, int, string, error) { if prURL == "" { return nil, 0, "", fmt.Errorf("invalid URL: %q", prURL) @@ -329,8 +326,8 @@ func ParseURL(prURL string) (ghrepo.Interface, int, string, error) { repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) prNumber, _ := strconv.Atoi(m[3]) - rest := m[4] - return repo, prNumber, rest, nil + tail := m[4] + return repo, prNumber, tail, nil } var fullReferenceRE = regexp.MustCompile(`^(?:([^/]+)/([^/]+))#(\d+)$`) From 872cf495c24be303183a182d64b2b22e15a0c735 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 11 Sep 2025 09:19:52 -0600 Subject: [PATCH 122/208] Refactor create command tests and add base branch case Removed redundant test cases for file input and stdin in TestNewCmdCreate, and added a new test to verify that the base branch argument sets the BaseBranch field correctly. --- pkg/cmd/agent-task/create/create_test.go | 44 ++++-------------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 2969bdcc7..5780b70b3 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -44,51 +44,18 @@ func TestNewCmdCreate(t *testing.T) { ProblemStatementFile: "", }, }, - { - name: "from-file succes", - args: "-F task-description.md", - wantOpts: &CreateOptions{ - ProblemStatement: "", - ProblemStatementFile: "task-description.md", - }, - }, - { - name: "file content from stdin success", - args: "-F -", - stdin: "task description from stdin", - wantOpts: &CreateOptions{ - ProblemStatement: "", - ProblemStatementFile: "-", - }, - }, { name: "mutually exclusive arg and file", args: "'some task inline' -F foo.md", wantErr: "only one of -F or arg can be provided", }, { - name: "missing file path is accepted", - args: "-F does-not-exist.md", + name: "base branch sets baseBranch field", + args: "'task description' -b feature", wantOpts: &CreateOptions{ - ProblemStatement: "", - ProblemStatementFile: "does-not-exist.md", - }, - }, - { - name: "empty file accepted at", - args: "-F empty-task-description.md", - wantOpts: &CreateOptions{ - ProblemStatement: "", - ProblemStatementFile: "empty-task-description.md", - }, - }, - { - name: "empty from stdin accepted", - args: "-F -", - stdin: " \n\n", - wantOpts: &CreateOptions{ - ProblemStatement: "", - ProblemStatementFile: "-", + ProblemStatement: "task description", + ProblemStatementFile: "", + BaseBranch: "feature", }, }, } @@ -129,6 +96,7 @@ func TestNewCmdCreate(t *testing.T) { if tt.wantOpts != nil { require.Equal(t, tt.wantOpts.ProblemStatement, gotOpts.ProblemStatement) require.Equal(t, tt.wantOpts.ProblemStatementFile, gotOpts.ProblemStatementFile) + require.Equal(t, tt.wantOpts.BaseBranch, gotOpts.BaseBranch) } }) } From 666f5574e4f8074063370c54c5e0cf2cf7d2ac3c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 11 Sep 2025 09:20:48 -0600 Subject: [PATCH 123/208] Remove unused stdin field from create command tests The 'stdin' field and related code were removed from TestNewCmdCreate as they were no longer used. This simplifies the test structure and eliminates unnecessary code. --- pkg/cmd/agent-task/create/create_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 5780b70b3..06e9c7c4e 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -23,7 +23,6 @@ func TestNewCmdCreate(t *testing.T) { tests := []struct { name string args string - stdin string tty bool wantOpts *CreateOptions wantErr string @@ -82,9 +81,6 @@ func TestNewCmdCreate(t *testing.T) { cmd.SetIn(stdin) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - if tt.stdin != "" { - stdin.WriteString(tt.stdin) - } _, err = cmd.ExecuteC() if tt.wantErr != "" { From f22dc9271b19c31f41b43bb400eaa06a3b69ac45 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 11 Sep 2025 09:22:27 -0600 Subject: [PATCH 124/208] Update test to use edited task description Modified the test in create_test.go to return and expect 'edited task description' instead of concatenating the prompt string. This clarifies the test's intent and expected behavior for the MarkdownEditorFunc and CreateJobFunc. --- pkg/cmd/agent-task/create/create_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 06e9c7c4e..638559e74 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -153,7 +153,7 @@ func Test_createRun(t *testing.T) { MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { require.Equal(t, "Enter the task description", prompt) require.Equal(t, "task description from file", defaultValue) - return prompt + "+From editor", nil + return "edited task description", nil }, ConfirmFunc: func(message string, defaultValue bool) (bool, error) { require.Equal(t, "Submit agent task", message) @@ -166,7 +166,7 @@ func Test_createRun(t *testing.T) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) require.Equal(t, "REPO", repo) - require.Equal(t, "Enter the task description+From editor", problemStatement) + require.Equal(t, "edited task description", problemStatement) return &createdJobSuccessWithPR, nil } }, From 1dbb694790ba59c12958a254b78e8030adb755a2 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 11 Sep 2025 09:24:42 -0600 Subject: [PATCH 125/208] Add test for non-interactive problem statement input Adds a test case to ensure that when a problem statement is provided as an argument non-interactively, the command does not prompt or return an error, and the correct job is created. --- pkg/cmd/agent-task/create/create_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 638559e74..36653374a 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -271,6 +271,22 @@ func Test_createRun(t *testing.T) { wantErr: "SilentError", wantStdErr: "a task description is required.\n", }, + { + name: "problem statement loaded from arg non-interactively doesn't prompt or return error", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + ProblemStatement: "task description", + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "task description", problemStatement) + return &createdJobSuccessWithPR, nil + } + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, { name: "base branch included in create payload", opts: &CreateOptions{ From 3831380d131bd5fde42186a051f10e584e341e84 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 11 Sep 2025 11:23:18 -0600 Subject: [PATCH 126/208] Add error type assertion to createRun tests Introduces a new 'wantErrIs' field to test cases in Test_createRun to assert specific error types using require.ErrorIs. This enhances test coverage by verifying not only error messages but also error types. --- pkg/cmd/agent-task/create/create_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 36653374a..b855ce687 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -142,6 +142,7 @@ func Test_createRun(t *testing.T) { wantStdout string wantStdErr string wantErr string + wantErrIs error }{ { name: "interactive with file prompts to edit with file contents", @@ -190,6 +191,7 @@ func Test_createRun(t *testing.T) { }, isTTY: true, wantErr: "SilentError", + wantErrIs: cmdutil.SilentError, wantStdErr: "", }, { @@ -233,6 +235,7 @@ func Test_createRun(t *testing.T) { }, }, wantErr: "SilentError", + wantErrIs: cmdutil.SilentError, wantStdErr: "a task description is required.\n", }, { @@ -269,6 +272,7 @@ func Test_createRun(t *testing.T) { ProblemStatement: "", }, wantErr: "SilentError", + wantErrIs: cmdutil.SilentError, wantStdErr: "a task description is required.\n", }, { @@ -449,10 +453,13 @@ func Test_createRun(t *testing.T) { tt.opts.BackOff = backoff.WithMaxRetries(&backoff.ZeroBackOff{}, 3) err := createRun(tt.opts) + if tt.wantErrIs != nil { + require.ErrorIs(t, err, tt.wantErrIs) + } if tt.wantErr != "" { require.Error(t, err) require.Equal(t, tt.wantErr, err.Error()) - } else { + } else if tt.wantErrIs == nil { require.NoError(t, err) } From fbcdeed41fd59e7e4f2bb7f987a14e6114ac6ce0 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 11 Sep 2025 15:18:07 +0100 Subject: [PATCH 127/208] feat(agent-task/capi): add `GetSessionLogs` method Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/client.go | 1 + pkg/cmd/agent-task/capi/client_mock.go | 50 ++++++++++++++++++++++++++ pkg/cmd/agent-task/capi/sessions.go | 30 ++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index 45c214ea2..6d35f11c2 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -20,6 +20,7 @@ type CapiClient interface { CreateJob(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*Job, error) GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) GetSession(ctx context.Context, id string) (*Session, error) + GetSessionLogs(ctx context.Context, id string) ([]byte, error) ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, string, error) } diff --git a/pkg/cmd/agent-task/capi/client_mock.go b/pkg/cmd/agent-task/capi/client_mock.go index 85b757943..fa59ffab7 100644 --- a/pkg/cmd/agent-task/capi/client_mock.go +++ b/pkg/cmd/agent-task/capi/client_mock.go @@ -30,6 +30,9 @@ var _ CapiClient = &CapiClientMock{} // GetSessionFunc: func(ctx context.Context, id string) (*Session, error) { // panic("mock out the GetSession method") // }, +// GetSessionLogsFunc: func(ctx context.Context, id string) ([]byte, error) { +// panic("mock out the GetSessionLogs method") +// }, // ListSessionsByResourceIDFunc: func(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) { // panic("mock out the ListSessionsByResourceID method") // }, @@ -58,6 +61,9 @@ type CapiClientMock struct { // GetSessionFunc mocks the GetSession method. GetSessionFunc func(ctx context.Context, id string) (*Session, error) + // GetSessionLogsFunc mocks the GetSessionLogs method. + GetSessionLogsFunc func(ctx context.Context, id string) ([]byte, error) + // ListSessionsByResourceIDFunc mocks the ListSessionsByResourceID method. ListSessionsByResourceIDFunc func(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) @@ -113,6 +119,13 @@ type CapiClientMock struct { // ID is the id argument value. ID string } + // GetSessionLogs holds details about calls to the GetSessionLogs method. + GetSessionLogs []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // ID is the id argument value. + ID string + } // ListSessionsByResourceID holds details about calls to the ListSessionsByResourceID method. ListSessionsByResourceID []struct { // Ctx is the ctx argument value. @@ -147,6 +160,7 @@ type CapiClientMock struct { lockGetJob sync.RWMutex lockGetPullRequestDatabaseID sync.RWMutex lockGetSession sync.RWMutex + lockGetSessionLogs sync.RWMutex lockListSessionsByResourceID sync.RWMutex lockListSessionsForRepo sync.RWMutex lockListSessionsForViewer sync.RWMutex @@ -328,6 +342,42 @@ func (mock *CapiClientMock) GetSessionCalls() []struct { return calls } +// GetSessionLogs calls GetSessionLogsFunc. +func (mock *CapiClientMock) GetSessionLogs(ctx context.Context, id string) ([]byte, error) { + if mock.GetSessionLogsFunc == nil { + panic("CapiClientMock.GetSessionLogsFunc: method is nil but CapiClient.GetSessionLogs was just called") + } + callInfo := struct { + Ctx context.Context + ID string + }{ + Ctx: ctx, + ID: id, + } + mock.lockGetSessionLogs.Lock() + mock.calls.GetSessionLogs = append(mock.calls.GetSessionLogs, callInfo) + mock.lockGetSessionLogs.Unlock() + return mock.GetSessionLogsFunc(ctx, id) +} + +// GetSessionLogsCalls gets all the calls that were made to GetSessionLogs. +// Check the length with: +// +// len(mockedCapiClient.GetSessionLogsCalls()) +func (mock *CapiClientMock) GetSessionLogsCalls() []struct { + Ctx context.Context + ID string +} { + var calls []struct { + Ctx context.Context + ID string + } + mock.lockGetSessionLogs.RLock() + calls = mock.calls.GetSessionLogs + mock.lockGetSessionLogs.RUnlock() + return calls +} + // ListSessionsByResourceID calls ListSessionsByResourceIDFunc. func (mock *CapiClientMock) ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) { if mock.ListSessionsByResourceIDFunc == nil { diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index aa7deabaf..0c9de2722 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "slices" @@ -240,6 +241,35 @@ func (c *CAPIClient) GetSession(ctx context.Context, id string) (*Session, error return sessions[0], nil } +// GetSession retrieves logs of an agent session identified by ID. +func (c *CAPIClient) GetSessionLogs(ctx context.Context, id string) ([]byte, error) { + if id == "" { + return nil, fmt.Errorf("missing session ID") + } + + url := fmt.Sprintf("%s/agents/sessions/%s/logs", baseCAPIURL, url.PathEscape(id)) + + 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 { + if res.StatusCode == http.StatusNotFound { + return nil, ErrSessionNotFound + } + return nil, fmt.Errorf("failed to get session: %s", res.Status) + } + + return io.ReadAll(res.Body) +} + // ListSessionsByResourceID retrieves sessions associated with the given resource type and ID. func (c *CAPIClient) ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) { if resourceType == "" || resourceID == 0 { From 05e609c5b3ae8cff5404e4ca44df251054c21aa3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 12 Sep 2025 13:33:28 +0100 Subject: [PATCH 128/208] fix(agent-task/shared): add log renderer Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/log.go | 223 ++++++++++++++++++ pkg/cmd/agent-task/shared/log_mock.go | 144 +++++++++++ pkg/cmd/agent-task/shared/log_test.go | 64 +++++ .../shared/testdata/sample-log-1.txt | 64 +++++ .../shared/testdata/sample-log-1.want.txt | 117 +++++++++ .../shared/testdata/sample-log-2.txt | 62 +++++ .../shared/testdata/sample-log-2.want.txt | 111 +++++++++ 7 files changed, 785 insertions(+) create mode 100644 pkg/cmd/agent-task/shared/log.go create mode 100644 pkg/cmd/agent-task/shared/log_mock.go create mode 100644 pkg/cmd/agent-task/shared/log_test.go create mode 100644 pkg/cmd/agent-task/shared/testdata/sample-log-1.txt create mode 100644 pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt create mode 100644 pkg/cmd/agent-task/shared/testdata/sample-log-2.txt create mode 100644 pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go new file mode 100644 index 000000000..6b121380e --- /dev/null +++ b/pkg/cmd/agent-task/shared/log.go @@ -0,0 +1,223 @@ +package shared + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + + "github.com/cli/cli/v2/pkg/iostreams" +) + +//go:generate moq -rm -out log_mock.go . LogRenderer + +type LogRenderer interface { + Follow(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error + Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (stop bool, err error) +} + +type logRenderer struct{} + +func NewLogRenderer() LogRenderer { + return &logRenderer{} +} + +func (r *logRenderer) Follow(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error { + var last string + for { + raw, err := fetcher() + if err != nil { + return err + } + + logs := string(raw) + if logs == last { + continue + } + + diff := strings.TrimSpace(logs[len(last):]) + + if stop, err := r.Render([]byte(diff), w, cs); err != nil { + return err + } else if stop { + return nil + } + + last = logs + } +} + +func (r *logRenderer) Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { + lines := slices.DeleteFunc(strings.Split(string(logs), "\n"), func(line string) bool { + return line == "" + }) + + for _, line := range lines { + raw, found := strings.CutPrefix(line, "data: ") + if !found { + return false, errors.New("unexpected log format") + } + + // TODO: should ignore the error since the entries can be different. + var entry logEntry + err := json.Unmarshal([]byte(raw), &entry) + if err != nil { + return false, fmt.Errorf("unexpected log entry: %w", err) + } + + if stop, err := renderLogEntry(entry, w, cs); err != nil { + return false, fmt.Errorf("failed to process log entry: %w", err) + } else if stop { + return true, nil + } + } + + return false, nil +} + +func renderLogEntry(entry logEntry, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { + var stop bool + for _, choice := range entry.Choices { + if choice.FinishReason == "stop" { + stop = true + } + + if choice.Delta.Content == "" { + continue + } + + if choice.Delta.Role != "" && choice.Delta.Role != "assistant" { + // Because... + continue + } + + if choice.Delta.ToolCalls == nil { + // message + fmt.Fprintln(w, "") + if _, err := fmt.Fprintf(w, "> %s\n", choice.Delta.Content); err != nil { + return false, err + } + continue + } + + for _, tc := range choice.Delta.ToolCalls { + fmt.Fprintln(w, "") + switch tc.Function.Name { + case "run_setup": + args := toolCallRunSetup{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + return false, fmt.Errorf("failed to parse 'run_setup' tool call arguments: %w", err) + } + fmt.Fprintf(w, "- %s\n", cs.Bold(args.Name)) + case "view": + args := toolCallView{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + return false, fmt.Errorf("failed to parse 'view' tool call arguments: %w", err) + } + // TODO: detect if it's the repository root or just a file to show the right message + // NOTE: omit the output since it's a git diff + fmt.Fprintf(w, "- View %s\n", cs.Bold(args.Path)) + case "bash": + args := toolCallBash{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + return false, fmt.Errorf("failed to parse 'bash' tool call arguments: %w", err) + } + // NOTE: omit the delta.content to reduce noise + fmt.Fprintf(w, "- Bash: %s\n", cs.Bold(args.Description)) + case "think": + args := toolCallThink{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + return false, fmt.Errorf("failed to parse 'think' tool call arguments: %w", err) + } + // NOTE: omit the delta.content since it's the same as thought + fmt.Fprintf(w, "? %s: %s\n", cs.Bold("Thought:"), args.Thought) + case "report_progress": + args := toolCallReportProgress{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + return false, fmt.Errorf("failed to parse 'report_progress' tool call arguments: %w", err) + } + // NOTE: omit the delta.content to reduce noise + fmt.Fprintf(w, "! Progress update: %s\n", cs.Bold(args.CommitMessage)) + case "create": + args := toolCallCreate{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + return false, fmt.Errorf("failed to parse 'create' tool call arguments: %w", err) + } + // NOTE: omit the delta.content since it's a diff + fmt.Fprintf(w, "- Create %s\n", cs.Bold(args.Path)) + case "str_replace": + args := toolCallStrReplace{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + return false, fmt.Errorf("failed to parse 'str_replace' tool call arguments: %w", err) + } + // NOTE: omit the delta.content since it's a diff + fmt.Fprintf(w, "- Edit %s\n", cs.Bold(args.Path)) + default: + // Unknown tool call. For example for "codeql_checker": + // NOTE: omit the delta.content since we don't know how large could that be + fmt.Fprintf(w, "- Call to %s\n", cs.Bold(tc.Function.Name)) + } + } + } + return stop, nil +} + +type logEntry struct { + ID string `json:"id"` + Created int64 `json:"created"` + Model string `json:"model"` + Object string `json:"object"` + Choices []struct { + Delta struct { + Content string `json:"content"` + Role string `json:"role"` + ToolCalls []struct { + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + Index int `json:"index"` + ID string `json:"id"` + } `json:"tool_calls"` + } `json:"delta"` + FinishReason string `json:"finish_reason"` + Index int `json:"index"` + } `json:"choices"` +} + +type toolCallRunSetup struct { + Name string `json:"name"` +} + +type toolCallView struct { + Path string `json:"path"` +} + +type toolCallBash struct { + Async bool `json:"async"` + Command string `json:"command"` + Description string `json:"description"` + SessionID string `json:"sessionId"` +} + +type toolCallThink struct { + Thought string `json:"thought"` +} + +type toolCallReportProgress struct { + CommitMessage string `json:"commitMessage"` + PrDescription string `json:"prDescription"` +} + +type toolCallCreate struct { + FileText string `json:"file_text"` + Path string `json:"path"` +} + +type toolCallStrReplace struct { + NewStr string `json:"new_str"` + OldStr string `json:"old_str"` + Path string `json:"path"` +} diff --git a/pkg/cmd/agent-task/shared/log_mock.go b/pkg/cmd/agent-task/shared/log_mock.go new file mode 100644 index 000000000..7307e7821 --- /dev/null +++ b/pkg/cmd/agent-task/shared/log_mock.go @@ -0,0 +1,144 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package shared + +import ( + "github.com/cli/cli/v2/pkg/iostreams" + "io" + "sync" +) + +// Ensure, that LogRendererMock does implement LogRenderer. +// If this is not the case, regenerate this file with moq. +var _ LogRenderer = &LogRendererMock{} + +// LogRendererMock is a mock implementation of LogRenderer. +// +// func TestSomethingThatUsesLogRenderer(t *testing.T) { +// +// // make and configure a mocked LogRenderer +// mockedLogRenderer := &LogRendererMock{ +// FollowFunc: func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error { +// panic("mock out the Follow method") +// }, +// RenderFunc: func(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { +// panic("mock out the Render method") +// }, +// } +// +// // use mockedLogRenderer in code that requires LogRenderer +// // and then make assertions. +// +// } +type LogRendererMock struct { + // FollowFunc mocks the Follow method. + FollowFunc func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error + + // RenderFunc mocks the Render method. + RenderFunc func(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) + + // calls tracks calls to the methods. + calls struct { + // Follow holds details about calls to the Follow method. + Follow []struct { + // Fetcher is the fetcher argument value. + Fetcher func() ([]byte, error) + // W is the w argument value. + W io.Writer + // Cs is the cs argument value. + Cs *iostreams.ColorScheme + } + // Render holds details about calls to the Render method. + Render []struct { + // Logs is the logs argument value. + Logs []byte + // W is the w argument value. + W io.Writer + // Cs is the cs argument value. + Cs *iostreams.ColorScheme + } + } + lockFollow sync.RWMutex + lockRender sync.RWMutex +} + +// Follow calls FollowFunc. +func (mock *LogRendererMock) Follow(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error { + if mock.FollowFunc == nil { + panic("LogRendererMock.FollowFunc: method is nil but LogRenderer.Follow was just called") + } + callInfo := struct { + Fetcher func() ([]byte, error) + W io.Writer + Cs *iostreams.ColorScheme + }{ + Fetcher: fetcher, + W: w, + Cs: cs, + } + mock.lockFollow.Lock() + mock.calls.Follow = append(mock.calls.Follow, callInfo) + mock.lockFollow.Unlock() + return mock.FollowFunc(fetcher, w, cs) +} + +// FollowCalls gets all the calls that were made to Follow. +// Check the length with: +// +// len(mockedLogRenderer.FollowCalls()) +func (mock *LogRendererMock) FollowCalls() []struct { + Fetcher func() ([]byte, error) + W io.Writer + Cs *iostreams.ColorScheme +} { + var calls []struct { + Fetcher func() ([]byte, error) + W io.Writer + Cs *iostreams.ColorScheme + } + mock.lockFollow.RLock() + calls = mock.calls.Follow + mock.lockFollow.RUnlock() + return calls +} + +// Render calls RenderFunc. +func (mock *LogRendererMock) Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { + if mock.RenderFunc == nil { + panic("LogRendererMock.RenderFunc: method is nil but LogRenderer.Render was just called") + } + callInfo := struct { + Logs []byte + W io.Writer + Cs *iostreams.ColorScheme + }{ + Logs: logs, + W: w, + Cs: cs, + } + mock.lockRender.Lock() + mock.calls.Render = append(mock.calls.Render, callInfo) + mock.lockRender.Unlock() + return mock.RenderFunc(logs, w, cs) +} + +// RenderCalls gets all the calls that were made to Render. +// Check the length with: +// +// len(mockedLogRenderer.RenderCalls()) +func (mock *LogRendererMock) RenderCalls() []struct { + Logs []byte + W io.Writer + Cs *iostreams.ColorScheme +} { + var calls []struct { + Logs []byte + W io.Writer + Cs *iostreams.ColorScheme + } + mock.lockRender.RLock() + calls = mock.calls.Render + mock.lockRender.RUnlock() + return calls +} diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go new file mode 100644 index 000000000..a713b4ced --- /dev/null +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -0,0 +1,64 @@ +package shared + +import ( + "os" + "slices" + "strings" + "testing" + + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFollow(t *testing.T) { + tests := []struct { + name string + log string + want string + }{ + { + name: "sample log 1", + log: "testdata/sample-log-1.txt", + want: "testdata/sample-log-1.want.txt", + }, + { + name: "sample log 2", + log: "testdata/sample-log-2.txt", + want: "testdata/sample-log-2.want.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := os.ReadFile(tt.log) + require.NoError(t, err) + + lines := slices.DeleteFunc(strings.Split(string(raw), "\n"), func(line string) bool { + return line == "" + }) + + var hits int + fetcher := func() ([]byte, error) { + hits++ + if hits > len(lines) { + require.FailNow(t, "too many API calls") + } + return []byte(strings.Join(lines[0:hits], "\n\n")), nil + } + + ios, _, stdout, _ := iostreams.Test() + + err = NewLogRenderer().Follow(fetcher, stdout, ios.ColorScheme()) + require.NoError(t, err) + + want, err := os.ReadFile(tt.want) + require.NoError(t, err) + + // // Temp for updating tests + // os.WriteFile(tt.log+".got", stdout.Bytes(), 0644) + + assert.Equal(t, string(want), stdout.String()) + }) + } +} diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-1.txt b/pkg/cmd/agent-task/shared/testdata/sample-log-1.txt new file mode 100644 index 000000000..19fdb5dfd --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/sample-log-1.txt @@ -0,0 +1,64 @@ +data: {"id":"f85420df-3bbf-4ba9-bebd-7a17536cd61f","choices":[{"delta":{"content":"MCP server started successfully (version github-mcp-server/remote-1644693e4126d8c37794e77b2e09c6800709985e) with 39 tools - for the full output, see the verbose logs\n\n- download_workflow_run_artifact\n- get_code_scanning_alert\n- get_commit\n- get_file_contents\n- get_issue\n- get_issue_comments\n- get_job_logs\n- get_latest_release\n- get_pull_request\n- get_pull_request_comments\n- get_pull_request_diff\n- get_pull_request_files\n- get_pull_request_reviews\n- get_pull_request_status\n- get_release_by_tag\n- get_secret_scanning_alert\n- get_tag\n- get_workflow_run\n- get_workflow_run_logs\n- get_workflow_run_usage\n- list_branches\n- list_code_scanning_alerts\n- list_commits\n- list_issue_types\n- list_issues\n- list_pull_requests\n- list_releases\n- list_secret_scanning_alerts\n- list_sub_issues\n- list_tags\n- list_workflow_jobs\n- list_workflow_run_artifacts\n- list_workflow_runs\n- list_workflows\n- search_code\n- search_issues\n- search_pull_requests\n- search_repositories\n- search_users","role":"assistant","tool_calls":[{"function":{"name":"run_setup","arguments":"{\"name\":\"Start 'github-mcp-server' MCP server\"}"},"index":0,"id":"f85420df-3bbf-4ba9-bebd-7a17536cd61f"}]},"finish_reason":"tool_calls","index":0}],"created":1757413024352,"model":"","object":"chat.completion.chunk"} + +data: {"id":"c1ca2441-69a3-477a-bbc4-8544fd62630c","choices":[{"delta":{"content":"MCP server started successfully (version 0.0.35) with 21 tools - for the full output, see the verbose logs\n\n- browser_close\n- browser_resize\n- browser_console_messages\n- browser_handle_dialog\n- browser_evaluate\n- browser_file_upload\n- browser_fill_form\n- browser_install\n- browser_press_key\n- browser_type\n- browser_navigate\n- browser_navigate_back\n- browser_network_requests\n- browser_take_screenshot\n- browser_snapshot\n- browser_click\n- browser_drag\n- browser_hover\n- browser_select_option\n- browser_tabs\n- browser_wait_for","role":"assistant","tool_calls":[{"function":{"name":"run_setup","arguments":"{\"name\":\"Start 'playwright' MCP server\"}"},"index":0,"id":"c1ca2441-69a3-477a-bbc4-8544fd62630c"}]},"finish_reason":"tool_calls","index":0}],"created":1757413024832,"model":"","object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"I'll help you implement a shell script to print OS information. Let me start by exploring the repository structure to understand the codebase and then create a plan."},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo\"}","name":"view"},"id":"tooluse_HgRy3bczRe2bIxIR3T5deQ","type":"function","index":0}]},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && pwd && ls -la\",\"description\":\"Check current directory and list all files\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_p0U-8yIaTv2QbgGB4Z--hw","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413049,"id":"96e471e6-9a97-443c-b28d-a0d465406336","usage":{"completion_tokens":202,"prompt_tokens":18109,"prompt_tokens_details":{"cached_tokens":17458},"total_tokens":18311},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"96e471e6-9a97-443c-b28d-a0d465406336","created":1757413052,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo\"}","name":"view"},"id":"tooluse_HgRy3bczRe2bIxIR3T5deQ","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo b/home/runner/work/foo/foo\ncreate file mode 100644\nindex 0000000..0000000\n--- a/dev/null\n+++ b/home/runner/work/foo/foo\n@@ -1,0 +1,3 @@\n .git\n .github\n README.md\n"}}],"object":"chat.completion.chunk"} + +data: {"id":"96e471e6-9a97-443c-b28d-a0d465406336","created":1757413054,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && pwd && ls -la\",\"description\":\"Check current directory and list all files\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_p0U-8yIaTv2QbgGB4Z--hw","type":"function","index":0}],"content":"/home/runner/work/foo/foo\ntotal 20\ndrwxr-xr-x 4 runner docker 4096 Sep 9 10:17 .\ndrwxr-xr-x 3 runner docker 4096 Sep 9 10:16 ..\ndrwxr-xr-x 7 runner docker 4096 Sep 9 10:17 .git\ndrwxr-xr-x 4 runner docker 4096 Sep 9 10:17 .github\n-rw-r--r-- 1 runner docker 6 Sep 9 10:17 README.md\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Let me check the README and any existing workflow files to understand the repository structure better:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/README.md\"}","name":"view"},"id":"tooluse_HLjtr9JtSwCGSkyehEZn8A","type":"function","index":0}]},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/.github\"}","name":"view"},"id":"tooluse_GPlnrEqIQ226gXGgF934Dw","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413061,"id":"d8d65b32-47b5-47ad-b114-d1d9d626be6c","usage":{"completion_tokens":127,"prompt_tokens":18542,"prompt_tokens_details":{"cached_tokens":17458},"total_tokens":18669},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"d8d65b32-47b5-47ad-b114-d1d9d626be6c","created":1757413063,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/.github\"}","name":"view"},"id":"tooluse_GPlnrEqIQ226gXGgF934Dw","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo/.github b/home/runner/work/foo/foo/.github\ncreate file mode 100644\nindex 0000000..0000000\n--- a/dev/null\n+++ b/home/runner/work/foo/foo/.github\n@@ -1,0 +1,4 @@\n PULL_REQUEST_TEMPLATE\n dependabot.yml\n pull_request_template.md\n workflows\n"}}],"object":"chat.completion.chunk"} + +data: {"id":"d8d65b32-47b5-47ad-b114-d1d9d626be6c","created":1757413065,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/README.md\"}","name":"view"},"id":"tooluse_HLjtr9JtSwCGSkyehEZn8A","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo/README.md b/home/runner/work/foo/foo/README.md\nindex 0000000..0000000 100644\n--- a/home/runner/work/foo/foo/README.md\n+++ b/home/runner/work/foo/foo/README.md\n@@ -1,2 +1,2 @@\n # foo\n \n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/.github/workflows\"}","name":"view"},"id":"tooluse_m_FcAEbrTkWWktSyzBMt3w","type":"function","index":0}]},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && find . -name \\\"*.sh\\\" -o -name \\\"Makefile\\\" -o -name \\\"package.json\\\" -o -name \\\"*.yml\\\" -o -name \\\"*.yaml\\\"\",\"description\":\"Check if there are any existing scripts or build files\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_fOOqjjkiTDSDFUOvikXLxQ","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413072,"id":"0e4bb34a-fe12-462c-9be9-5daa0ff398d8","usage":{"completion_tokens":212,"prompt_tokens":18757,"prompt_tokens_details":{"cached_tokens":18301},"total_tokens":18969},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"0e4bb34a-fe12-462c-9be9-5daa0ff398d8","created":1757413074,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && find . -name \\\"*.sh\\\" -o -name \\\"Makefile\\\" -o -name \\\"package.json\\\" -o -name \\\"*.yml\\\" -o -name \\\"*.yaml\\\"\",\"description\":\"Check if there are any existing scripts or build files\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_fOOqjjkiTDSDFUOvikXLxQ","type":"function","index":0}],"content":"./.github/workflows/upload-artifact.yml\n./.github/workflows/manual-dispatch.yml\n./.github/workflows/cache.yml\n./.github/dependabot.yml\n"}}],"object":"chat.completion.chunk"} + +data: {"id":"0e4bb34a-fe12-462c-9be9-5daa0ff398d8","created":1757413075,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/.github/workflows\"}","name":"view"},"id":"tooluse_m_FcAEbrTkWWktSyzBMt3w","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo/.github/workflows b/home/runner/work/foo/foo/.github/workflows\ncreate file mode 100644\nindex 0000000..0000000\n--- a/dev/null\n+++ b/home/runner/work/foo/foo/.github/workflows\n@@ -1,0 +1,3 @@\n cache.yml\n manual-dispatch.yml\n upload-artifact.yml\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"thought\":\"Looking at the repository structure, I can see:\\n\\n1. This is a minimal repository with just a README.md and some GitHub workflows\\n2. There are no existing shell scripts or build infrastructure\\n3. The repository appears to be a simple project without any specific language or framework constraints\\n4. There are GitHub workflows but they seem to be for general CI/CD purposes\\n\\nFor the task \\\"add a shell script to print os information\\\", I need to:\\n1. Create a shell script that displays relevant OS information\\n2. Make it executable\\n3. Consider where to place it in the repository structure\\n4. Since this is a minimal repository, I should keep the script simple and place it in the root or a scripts directory\\n5. The script should display useful OS information like:\\n - Operating system name and version\\n - Kernel version\\n - Architecture\\n - CPU information\\n - Memory information\\n - Disk space\\n - Uptime\\n\\nSince there's no existing test infrastructure and the instructions mention I can skip tests if there's no existing test infrastructure, I won't need to create tests.\\n\\nLet me plan the minimal changes:\\n1. Create a shell script (os_info.sh) in the root directory\\n2. Make it executable\\n3. Test it works correctly\\n4. Update README if appropriate\"}","name":"think"},"id":"tooluse_4z3OdYgpSra5wIGGLfmr6Q","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413085,"id":"e462d281-1592-4450-86af-87f173ff938a","usage":{"completion_tokens":331,"prompt_tokens":19094,"prompt_tokens_details":{"cached_tokens":18659},"total_tokens":19425},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"e462d281-1592-4450-86af-87f173ff938a","created":1757413086,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"thought\":\"Looking at the repository structure, I can see:\\n\\n1. This is a minimal repository with just a README.md and some GitHub workflows\\n2. There are no existing shell scripts or build infrastructure\\n3. The repository appears to be a simple project without any specific language or framework constraints\\n4. There are GitHub workflows but they seem to be for general CI/CD purposes\\n\\nFor the task \\\"add a shell script to print os information\\\", I need to:\\n1. Create a shell script that displays relevant OS information\\n2. Make it executable\\n3. Consider where to place it in the repository structure\\n4. Since this is a minimal repository, I should keep the script simple and place it in the root or a scripts directory\\n5. The script should display useful OS information like:\\n - Operating system name and version\\n - Kernel version\\n - Architecture\\n - CPU information\\n - Memory information\\n - Disk space\\n - Uptime\\n\\nSince there's no existing test infrastructure and the instructions mention I can skip tests if there's no existing test infrastructure, I won't need to create tests.\\n\\nLet me plan the minimal changes:\\n1. Create a shell script (os_info.sh) in the root directory\\n2. Make it executable\\n3. Test it works correctly\\n4. Update README if appropriate\"}","name":"think"},"id":"tooluse_4z3OdYgpSra5wIGGLfmr6Q","type":"function","index":0}],"content":"Looking at the repository structure, I can see:\n\n1. This is a minimal repository with just a README.md and some GitHub workflows\n2. There are no existing shell scripts or build infrastructure\n3. The repository appears to be a simple project without any specific language or framework constraints\n4. There are GitHub workflows but they seem to be for general CI/CD purposes\n\nFor the task \"add a shell script to print os information\", I need to:\n1. Create a shell script that displays relevant OS information\n2. Make it executable\n3. Consider where to place it in the repository structure\n4. Since this is a minimal repository, I should keep the script simple and place it in the root or a scripts directory\n5. The script should display useful OS information like:\n - Operating system name and version\n - Kernel version\n - Architecture\n - CPU information\n - Memory information\n - Disk space\n - Uptime\n\nSince there's no existing test infrastructure and the instructions mention I can skip tests if there's no existing test infrastructure, I won't need to create tests.\n\nLet me plan the minimal changes:\n1. Create a shell script (os_info.sh) in the root directory\n2. Make it executable\n3. Test it works correctly\n4. Update README if appropriate"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Now let me report my plan and create the shell script:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"commitMessage\":\"Initial plan: Add shell script to print OS information\",\"prDescription\":\"Adding a shell script to print OS information as requested.\\n\\n**Plan:**\\n- [ ] Create shell script `os_info.sh` in root directory that displays comprehensive OS information\\n- [ ] Include OS name/version, kernel, architecture, CPU, memory, disk space, and uptime information \\n- [ ] Make script executable\\n- [ ] Test script functionality on current system\\n- [ ] Update README.md to document the new script\\n- [ ] Verify all changes work as expected\"}","name":"report_progress"},"id":"tooluse_70Lb3coESd-wlmpX6RwxPQ","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413094,"id":"05b5de29-2408-46f6-8c9c-7c8d07952a52","usage":{"completion_tokens":197,"prompt_tokens":19439,"prompt_tokens_details":{"cached_tokens":18959},"total_tokens":19636},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"05b5de29-2408-46f6-8c9c-7c8d07952a52","created":1757413098,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"commitMessage\":\"Initial plan: Add shell script to print OS information\",\"prDescription\":\"Adding a shell script to print OS information as requested.\\n\\n**Plan:**\\n- [ ] Create shell script `os_info.sh` in root directory that displays comprehensive OS information\\n- [ ] Include OS name/version, kernel, architecture, CPU, memory, disk space, and uptime information \\n- [ ] Make script executable\\n- [ ] Test script functionality on current system\\n- [ ] Update README.md to document the new script\\n- [ ] Verify all changes work as expected\"}","name":"report_progress"},"id":"tooluse_70Lb3coESd-wlmpX6RwxPQ","type":"function","index":0}],"content":"$ git push -v origin copilot/add-os-information-shell-script-3\nPushing to https://github.com/gh-babakks/foo\nTo https://github.com/gh-babakks/foo\n = [up to date] copilot/add-os-information-shell-script-3 -> copilot/add-os-information-shell-script-3\nupdating local tracking ref 'refs/remotes/origin/copilot/add-os-information-shell-script-3'\nEverything up-to-date\n\n\n\nProgress reported successfully."}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Now let me create the shell script with comprehensive OS information:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"file_text\":\"#!/bin/bash\\n\\n# OS Information Script\\n# This script displays comprehensive operating system information\\n\\necho \\\"==================================================\\\"\\necho \\\" OPERATING SYSTEM INFORMATION\\\"\\necho \\\"==================================================\\\"\\necho\\n\\n# OS Name and Version\\necho \\\"📋 Operating System:\\\"\\nif command -v lsb_release &> /dev/null; then\\n lsb_release -d | cut -d: -f2 | sed 's/^\\\\s*//'\\nelif [ -f /etc/os-release ]; then\\n grep PRETTY_NAME /etc/os-release | cut -d= -f2 | sed 's/\\\"//g'\\nelif [ -f /etc/redhat-release ]; then\\n cat /etc/redhat-release\\nelse\\n uname -s\\nfi\\necho\\n\\n# Kernel Information\\necho \\\"🔧 Kernel Information:\\\"\\necho \\\" Version: $(uname -r)\\\"\\necho \\\" Architecture: $(uname -m)\\\"\\necho\\n\\n# CPU Information\\necho \\\"💻 CPU Information:\\\"\\nif [ -f /proc/cpuinfo ]; then\\n cpu_model=$(grep \\\"model name\\\" /proc/cpuinfo | head -1 | cut -d: -f2 | sed 's/^\\\\s*//')\\n cpu_cores=$(grep -c \\\"processor\\\" /proc/cpuinfo)\\n echo \\\" Model: $cpu_model\\\"\\n echo \\\" Cores: $cpu_cores\\\"\\nelse\\n echo \\\" Architecture: $(uname -m)\\\"\\nfi\\necho\\n\\n# Memory Information\\necho \\\"💾 Memory Information:\\\"\\nif [ -f /proc/meminfo ]; then\\n total_mem=$(grep MemTotal /proc/meminfo | awk '{print $2}')\\n available_mem=$(grep MemAvailable /proc/meminfo | awk '{print $2}')\\n if [ -n \\\"$total_mem\\\" ]; then\\n total_gb=$((total_mem / 1024 / 1024))\\n available_gb=$((available_mem / 1024 / 1024))\\n echo \\\" Total: ${total_gb}GB\\\"\\n echo \\\" Available: ${available_gb}GB\\\"\\n fi\\nelse\\n echo \\\" Memory information not available\\\"\\nfi\\necho\\n\\n# Disk Space Information\\necho \\\"💽 Disk Space Information:\\\"\\ndf -h / 2>/dev/null | awk 'NR==2 {print \\\" Root filesystem: \\\" $2 \\\" total, \\\" $3 \\\" used, \\\" $4 \\\" available (\\\" $5 \\\" used)\\\"}' || echo \\\" Disk information not available\\\"\\necho\\n\\n# System Uptime\\necho \\\"⏰ System Uptime:\\\"\\nif command -v uptime &> /dev/null; then\\n uptime -p 2>/dev/null || uptime | cut -d, -f1 | sed 's/.*up //'\\nelse\\n echo \\\" Uptime information not available\\\"\\nfi\\necho\\n\\n# Current Date and Time\\necho \\\"📅 Current Date and Time:\\\"\\ndate\\necho\\n\\necho \\\"==================================================\\\"\",\"path\":\"/home/runner/work/foo/foo/os_info.sh\"}","name":"create"},"id":"tooluse_1bYfGs6DTV6XMP6BZO7FUg","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413111,"id":"0d3a07d1-6edf-4838-9e26-82cbadbf314e","usage":{"completion_tokens":825,"prompt_tokens":19783,"prompt_tokens_details":{"cached_tokens":19414},"total_tokens":20608},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"0d3a07d1-6edf-4838-9e26-82cbadbf314e","created":1757413113,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"file_text\":\"#!/bin/bash\\n\\n# OS Information Script\\n# This script displays comprehensive operating system information\\n\\necho \\\"==================================================\\\"\\necho \\\" OPERATING SYSTEM INFORMATION\\\"\\necho \\\"==================================================\\\"\\necho\\n\\n# OS Name and Version\\necho \\\"📋 Operating System:\\\"\\nif command -v lsb_release &> /dev/null; then\\n lsb_release -d | cut -d: -f2 | sed 's/^\\\\s*//'\\nelif [ -f /etc/os-release ]; then\\n grep PRETTY_NAME /etc/os-release | cut -d= -f2 | sed 's/\\\"//g'\\nelif [ -f /etc/redhat-release ]; then\\n cat /etc/redhat-release\\nelse\\n uname -s\\nfi\\necho\\n\\n# Kernel Information\\necho \\\"🔧 Kernel Information:\\\"\\necho \\\" Version: $(uname -r)\\\"\\necho \\\" Architecture: $(uname -m)\\\"\\necho\\n\\n# CPU Information\\necho \\\"💻 CPU Information:\\\"\\nif [ -f /proc/cpuinfo ]; then\\n cpu_model=$(grep \\\"model name\\\" /proc/cpuinfo | head -1 | cut -d: -f2 | sed 's/^\\\\s*//')\\n cpu_cores=$(grep -c \\\"processor\\\" /proc/cpuinfo)\\n echo \\\" Model: $cpu_model\\\"\\n echo \\\" Cores: $cpu_cores\\\"\\nelse\\n echo \\\" Architecture: $(uname -m)\\\"\\nfi\\necho\\n\\n# Memory Information\\necho \\\"💾 Memory Information:\\\"\\nif [ -f /proc/meminfo ]; then\\n total_mem=$(grep MemTotal /proc/meminfo | awk '{print $2}')\\n available_mem=$(grep MemAvailable /proc/meminfo | awk '{print $2}')\\n if [ -n \\\"$total_mem\\\" ]; then\\n total_gb=$((total_mem / 1024 / 1024))\\n available_gb=$((available_mem / 1024 / 1024))\\n echo \\\" Total: ${total_gb}GB\\\"\\n echo \\\" Available: ${available_gb}GB\\\"\\n fi\\nelse\\n echo \\\" Memory information not available\\\"\\nfi\\necho\\n\\n# Disk Space Information\\necho \\\"💽 Disk Space Information:\\\"\\ndf -h / 2>/dev/null | awk 'NR==2 {print \\\" Root filesystem: \\\" $2 \\\" total, \\\" $3 \\\" used, \\\" $4 \\\" available (\\\" $5 \\\" used)\\\"}' || echo \\\" Disk information not available\\\"\\necho\\n\\n# System Uptime\\necho \\\"⏰ System Uptime:\\\"\\nif command -v uptime &> /dev/null; then\\n uptime -p 2>/dev/null || uptime | cut -d, -f1 | sed 's/.*up //'\\nelse\\n echo \\\" Uptime information not available\\\"\\nfi\\necho\\n\\n# Current Date and Time\\necho \\\"📅 Current Date and Time:\\\"\\ndate\\necho\\n\\necho \\\"==================================================\\\"\",\"path\":\"/home/runner/work/foo/foo/os_info.sh\"}","name":"create"},"id":"tooluse_1bYfGs6DTV6XMP6BZO7FUg","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo/os_info.sh b/home/runner/work/foo/foo/os_info.sh\ncreate file mode 100644\nindex 0000000..0000000\n--- a/dev/null\n+++ b/home/runner/work/foo/foo/os_info.sh\n@@ -1,0 +1,77 @@\n+#!/bin/bash\n+\n+# OS Information Script\n+# This script displays comprehensive operating system information\n+\n+echo \"==================================================\"\n+echo \" OPERATING SYSTEM INFORMATION\"\n+echo \"==================================================\"\n+echo\n+\n+# OS Name and Version\n+echo \"📋 Operating System:\"\n+if command -v lsb_release &> /dev/null; then\n+ lsb_release -d | cut -d: -f2 | sed 's/^\\s*//'\n+elif [ -f /etc/os-release ]; then\n+ grep PRETTY_NAME /etc/os-release | cut -d= -f2 | sed 's/\"//g'\n+elif [ -f /etc/redhat-release ]; then\n+ cat /etc/redhat-release\n+else\n+ uname -s\n+fi\n+echo\n+\n+# Kernel Information\n+echo \"🔧 Kernel Information:\"\n+echo \" Version: $(uname -r)\"\n+echo \" Architecture: $(uname -m)\"\n+echo\n+\n+# CPU Information\n+echo \"💻 CPU Information:\"\n+if [ -f /proc/cpuinfo ]; then\n+ cpu_model=$(grep \"model name\" /proc/cpuinfo | head -1 | cut -d: -f2 | sed 's/^\\s*//')\n+ cpu_cores=$(grep -c \"processor\" /proc/cpuinfo)\n+ echo \" Model: $cpu_model\"\n+ echo \" Cores: $cpu_cores\"\n+else\n+ echo \" Architecture: $(uname -m)\"\n+fi\n+echo\n+\n+# Memory Information\n+echo \"💾 Memory Information:\"\n+if [ -f /proc/meminfo ]; then\n+ total_mem=$(grep MemTotal /proc/meminfo | awk '{print $2}')\n+ available_mem=$(grep MemAvailable /proc/meminfo | awk '{print $2}')\n+ if [ -n \"$total_mem\" ]; then\n+ total_gb=$((total_mem / 1024 / 1024))\n+ available_gb=$((available_mem / 1024 / 1024))\n+ echo \" Total: ${total_gb}GB\"\n+ echo \" Available: ${available_gb}GB\"\n+ fi\n+else\n+ echo \" Memory information not available\"\n+fi\n+echo\n+\n+# Disk Space Information\n+echo \"💽 Disk Space Information:\"\n+df -h / 2>/dev/null | awk 'NR==2 {print \" Root filesystem: \" $2 \" total, \" $3 \" used, \" $4 \" available (\" $5 \" used)\"}' || echo \" Disk information not available\"\n+echo\n+\n+# System Uptime\n+echo \"⏰ System Uptime:\"\n+if command -v uptime &> /dev/null; then\n+ uptime -p 2>/dev/null || uptime | cut -d, -f1 | sed 's/.*up //'\n+else\n+ echo \" Uptime information not available\"\n+fi\n+echo\n+\n+# Current Date and Time\n+echo \"📅 Current Date and Time:\"\n+date\n+echo\n+\n+echo \"==================================================\"\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Now let me make the script executable and test it:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && chmod +x os_info.sh && ./os_info.sh\",\"description\":\"Make script executable and test it\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_glSi0wzWSvWs0ceTDwh4JQ","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413120,"id":"127409e7-00c7-4682-a28f-f6e13c65dc78","usage":{"completion_tokens":146,"prompt_tokens":20643,"prompt_tokens_details":{"cached_tokens":19626},"total_tokens":20789},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"127409e7-00c7-4682-a28f-f6e13c65dc78","created":1757413122,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && chmod +x os_info.sh && ./os_info.sh\",\"description\":\"Make script executable and test it\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_glSi0wzWSvWs0ceTDwh4JQ","type":"function","index":0}],"content":"==================================================\n OPERATING SYSTEM INFORMATION\n==================================================\n\n📋 Operating System:\nUbuntu 24.04.3 LTS\n\n🔧 Kernel Information:\n Version: 6.11.0-1018-azure\n Architecture: x86_64\n\n💻 CPU Information:\n Model: AMD EPYC 7763 64-Core Processor\n Cores: 4\n\n💾 Memory Information:\n Total: 15GB\n Available: 14GB\n\n💽 Disk Space Information:\n Root filesystem: 72G total, 46G used, 27G available (64% used)\n\n⏰ System Uptime:\nup 2 minutes\n\n📅 Current Date and Time:\nTue Sep 9 10:18:42 UTC 2025\n\n==================================================\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Great! The script works perfectly. Now let me update the README to document the new script:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"new_str\":\"# foo\\n\\n## OS Information Script\\n\\nThis repository contains a shell script to display comprehensive operating system information.\\n\\n### Usage\\n\\n```bash\\n./os_info.sh\\n```\\n\\nThe script displays:\\n- Operating system name and version\\n- Kernel version and architecture \\n- CPU model and core count\\n- Memory usage information\\n- Disk space usage\\n- System uptime\\n- Current date and time\\n\\n### Requirements\\n\\n- Bash shell\\n- Standard Unix/Linux utilities (works on most Linux distributions)\",\"old_str\":\"# foo\",\"path\":\"/home/runner/work/foo/foo/README.md\"}","name":"str_replace"},"id":"tooluse_xmZ_Tz5ySNCQtfhYBEkhYQ","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413130,"id":"24ccb760-8b7e-4067-b59f-73a3d6894bb9","usage":{"completion_tokens":230,"prompt_tokens":21004,"prompt_tokens_details":{"cached_tokens":20597},"total_tokens":21234},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"24ccb760-8b7e-4067-b59f-73a3d6894bb9","created":1757413132,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"new_str\":\"# foo\\n\\n## OS Information Script\\n\\nThis repository contains a shell script to display comprehensive operating system information.\\n\\n### Usage\\n\\n```bash\\n./os_info.sh\\n```\\n\\nThe script displays:\\n- Operating system name and version\\n- Kernel version and architecture \\n- CPU model and core count\\n- Memory usage information\\n- Disk space usage\\n- System uptime\\n- Current date and time\\n\\n### Requirements\\n\\n- Bash shell\\n- Standard Unix/Linux utilities (works on most Linux distributions)\",\"old_str\":\"# foo\",\"path\":\"/home/runner/work/foo/foo/README.md\"}","name":"str_replace"},"id":"tooluse_xmZ_Tz5ySNCQtfhYBEkhYQ","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo/README.md b/home/runner/work/foo/foo/README.md\nindex 0000000..0000000 100644\n--- a/home/runner/work/foo/foo/README.md\n+++ b/home/runner/work/foo/foo/README.md\n@@ -1,2 +1,26 @@\n # foo\n \n+## OS Information Script\n+\n+This repository contains a shell script to display comprehensive operating system information.\n+\n+### Usage\n+\n+```bash\n+./os_info.sh\n+```\n+\n+The script displays:\n+- Operating system name and version\n+- Kernel version and architecture \n+- CPU model and core count\n+- Memory usage information\n+- Disk space usage\n+- System uptime\n+- Current date and time\n+\n+### Requirements\n+\n+- Bash shell\n+- Standard Unix/Linux utilities (works on most Linux distributions)\n+\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Let me verify the changes look good:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/README.md\"}","name":"view"},"id":"tooluse_dkzXcB1VSxCFAxRW6gWN4A","type":"function","index":0}]},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && ls -la os_info.sh && echo \\\"---\\\" && git status\",\"description\":\"Check file permissions and final status\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_nYQtQ-HZQeuL58w3q3DaXA","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413139,"id":"bdc3491a-a50e-4f31-b3c8-fc909d0b4322","usage":{"completion_tokens":191,"prompt_tokens":21264,"prompt_tokens_details":{"cached_tokens":20779},"total_tokens":21455},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"bdc3491a-a50e-4f31-b3c8-fc909d0b4322","created":1757413142,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && ls -la os_info.sh && echo \\\"---\\\" && git status\",\"description\":\"Check file permissions and final status\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_nYQtQ-HZQeuL58w3q3DaXA","type":"function","index":0}],"content":"-rwxr-xr-x 1 runner docker 2139 Sep 9 10:18 os_info.sh\n---\nOn branch copilot/add-os-information-shell-script-3\nYour branch is up to date with 'origin/copilot/add-os-information-shell-script-3'.\n\nChanges not staged for commit:\n (use \"git add ...\" to update what will be committed)\n (use \"git restore ...\" to discard changes in working directory)\n\tmodified: README.md\n\nUntracked files:\n (use \"git add ...\" to include in what will be committed)\n\tos_info.sh\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n"}}],"object":"chat.completion.chunk"} + +data: {"id":"bdc3491a-a50e-4f31-b3c8-fc909d0b4322","created":1757413143,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/README.md\"}","name":"view"},"id":"tooluse_dkzXcB1VSxCFAxRW6gWN4A","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo/README.md b/home/runner/work/foo/foo/README.md\nindex 0000000..0000000 100644\n--- a/home/runner/work/foo/foo/README.md\n+++ b/home/runner/work/foo/foo/README.md\n@@ -1,26 +1,26 @@\n # foo\n \n ## OS Information Script\n \n This repository contains a shell script to display comprehensive operating system information.\n \n ### Usage\n \n ```bash\n ./os_info.sh\n ```\n \n The script displays:\n - Operating system name and version\n - Kernel version and architecture \n - CPU model and core count\n - Memory usage information\n - Disk space usage\n - System uptime\n - Current date and time\n \n ### Requirements\n \n - Bash shell\n - Standard Unix/Linux utilities (works on most Linux distributions)\n \n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Perfect! Let me test the script one more time to make sure everything is working correctly and commit the changes:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && echo \\\"Testing script execution:\\\" && ./os_info.sh | head -10\",\"description\":\"Final test of the script\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_ndAHpcXkSTm2_CAMXhE0qw","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413149,"id":"ca7380c9-bb6c-4375-8a8f-4c4199af2bfb","usage":{"completion_tokens":158,"prompt_tokens":21864,"prompt_tokens_details":{"cached_tokens":21224},"total_tokens":22022},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"ca7380c9-bb6c-4375-8a8f-4c4199af2bfb","created":1757413152,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && echo \\\"Testing script execution:\\\" && ./os_info.sh | head -10\",\"description\":\"Final test of the script\",\"sessionId\":\"main\"}","name":"bash"},"id":"tooluse_ndAHpcXkSTm2_CAMXhE0qw","type":"function","index":0}],"content":"Testing script execution:\n==================================================\n OPERATING SYSTEM INFORMATION\n==================================================\n\n📋 Operating System:\nUbuntu 24.04.3 LTS\n\n🔧 Kernel Information:\n Version: 6.11.0-1018-azure\n Architecture: x86_64\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Excellent! Now let me commit all the changes:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"commitMessage\":\"Add os_info.sh shell script to display OS information\",\"prDescription\":\"Adding a shell script to print OS information as requested.\\n\\n**Plan:**\\n- [x] Create shell script `os_info.sh` in root directory that displays comprehensive OS information\\n- [x] Include OS name/version, kernel, architecture, CPU, memory, disk space, and uptime information \\n- [x] Make script executable\\n- [x] Test script functionality on current system\\n- [x] Update README.md to document the new script\\n- [x] Verify all changes work as expected\\n\\n**Changes Made:**\\n- Created `os_info.sh` - executable shell script that displays comprehensive OS information including:\\n - Operating system name and version (with fallbacks for different distros)\\n - Kernel version and architecture\\n - CPU model and core count\\n - Memory usage (total and available)\\n - Disk space usage for root filesystem\\n - System uptime\\n - Current date and time\\n- Updated README.md with usage instructions and script documentation\\n- Script includes proper error handling and fallbacks for different systems\"}","name":"report_progress"},"id":"tooluse_484Da3MSRnGoeHdag1y3yA","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413161,"id":"2237873a-8d78-4c98-a75d-e37c077723f1","usage":{"completion_tokens":321,"prompt_tokens":22111,"prompt_tokens_details":{"cached_tokens":21445},"total_tokens":22432},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"2237873a-8d78-4c98-a75d-e37c077723f1","created":1757413165,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"commitMessage\":\"Add os_info.sh shell script to display OS information\",\"prDescription\":\"Adding a shell script to print OS information as requested.\\n\\n**Plan:**\\n- [x] Create shell script `os_info.sh` in root directory that displays comprehensive OS information\\n- [x] Include OS name/version, kernel, architecture, CPU, memory, disk space, and uptime information \\n- [x] Make script executable\\n- [x] Test script functionality on current system\\n- [x] Update README.md to document the new script\\n- [x] Verify all changes work as expected\\n\\n**Changes Made:**\\n- Created `os_info.sh` - executable shell script that displays comprehensive OS information including:\\n - Operating system name and version (with fallbacks for different distros)\\n - Kernel version and architecture\\n - CPU model and core count\\n - Memory usage (total and available)\\n - Disk space usage for root filesystem\\n - System uptime\\n - Current date and time\\n- Updated README.md with usage instructions and script documentation\\n- Script includes proper error handling and fallbacks for different systems\"}","name":"report_progress"},"id":"tooluse_484Da3MSRnGoeHdag1y3yA","type":"function","index":0}],"content":"$ git add . -v\nadd 'README.md'\nadd 'os_info.sh'\n\n$ git commit -m Add os_info.sh shell script to display OS information\n\nCo-authored-by: babakks <36728931+babakks@users.noreply.github.com>\n[copilot/add-os-information-shell-script-3 4bbc3e5] Add os_info.sh shell script to display OS information\n 2 files changed, 101 insertions(+)\n create mode 100755 os_info.sh\n\n$ git push -v origin copilot/add-os-information-shell-script-3\nPushing to https://github.com/gh-babakks/foo\nPOST git-receive-pack (1732 bytes)\nTo https://github.com/gh-babakks/foo\n 4e83749..4bbc3e5 copilot/add-os-information-shell-script-3 -> copilot/add-os-information-shell-script-3\nupdating local tracking ref 'refs/remotes/origin/copilot/add-os-information-shell-script-3'\n\n\n\nProgress reported successfully."}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Perfect! I have successfully implemented the shell script to print OS information. Let me run the CodeQL security checker to ensure there are no security vulnerabilities:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"codeql_checker"},"id":"tooluse_euKLZhSPTu-QqOjtapqgTw","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757413171,"id":"ef3f53a9-54fa-4c5b-a3ac-400638090a14","usage":{"completion_tokens":70,"prompt_tokens":22727,"prompt_tokens_details":{"cached_tokens":22012},"total_tokens":22797},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"id":"ef3f53a9-54fa-4c5b-a3ac-400638090a14","created":1757413174,"model":"Claude Sonnet 4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{}","name":"codeql_checker"},"id":"tooluse_euKLZhSPTu-QqOjtapqgTw","type":"function","index":0}],"content":"Analysis completed successfully for ."}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Excellent! The security analysis shows no vulnerabilities. \n\n## Summary\n\nI have successfully completed the task by adding a comprehensive shell script to print OS information. Here's what was implemented:\n\n### ✅ **Completed Changes:**\n\n1. **Created `os_info.sh`** - A robust, executable shell script that displays:\n - Operating system name and version (with smart detection for different Linux distros)\n - Kernel version and architecture\n - CPU model and core count\n - Memory usage (total and available)\n - Disk space usage for root filesystem\n - System uptime\n - Current date and time\n\n2. **Updated README.md** - Added comprehensive documentation including:\n - Usage instructions\n - Description of information displayed\n - System requirements\n\n3. **Made the script executable** and thoroughly tested its functionality\n\n### 🔧 **Key Features:**\n\n- **Cross-platform compatibility**: Works on various Linux distributions with fallback mechanisms\n- **Error handling**: Gracefully handles missing commands or files\n- **User-friendly output**: Clean formatting with emoji icons and organized sections\n- **Comprehensive information**: Covers all major system information categories\n- **Executable permissions**: Properly set for immediate use\n\nThe script is ready to use and provides a complete overview of the operating system information as requested. It has been tested and works correctly on the current Ubuntu system, displaying all relevant OS details in an organized and readable format."},"finish_reason":"stop"}],"created":1757413184,"id":"f62df9bf-33fe-41a1-a76b-1142bba8ae01","usage":{"completion_tokens":319,"prompt_tokens":22814,"prompt_tokens_details":{"cached_tokens":22422},"total_tokens":23133},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"\nAdd shell script to display comprehensive OS information\n\n\n\nThis PR adds a new shell script `os_info.sh` that displays comprehensive operating system information in a user-friendly format.\n\n## What's Added\n\nThe script provides detailed system information including:\n- **Operating System**: Name and version with fallback support for different Linux distributions\n- **Kernel Information**: Version and architecture\n- **CPU Details**: Model name and core count\n- **Memory Usage**: Total and available memory in GB\n- **Disk Space**: Root filesystem usage statistics\n- **System Uptime**: How long the system has been running\n- **Current Date/Time**: Timestamp of when the script was executed\n\n## Features\n\n- **Cross-platform compatibility**: Works on most Linux distributions with appropriate fallbacks\n- **Error handling**: Gracefully handles missing system information\n- **Clean output**: Formatted with emojis and clear sections for easy reading\n- **Executable**: Script is properly set with execute permissions\n\n## Usage\n\n```bash\n./os_info.sh\n```\n\nExample output:\n```\n==================================================\n OPERATING SYSTEM INFORMATION\n==================================================\n\n📋 Operating System:\nUbuntu 24.04.3 LTS\n\n🔧 Kernel Information:\n Version: 6.11.0-1018-azure\n Architecture: x86_64\n\n💻 CPU Information:\n Model: AMD EPYC 7763 64-Core Processor\n Cores: 4\n```\n\nThe README has been updated with usage instructions and requirements documentation.\n"},"finish_reason":"stop"}],"created":1757413195,"id":"0a4ae0d9-60d3-422f-a2c2-c78d583ee142","usage":{"completion_tokens":365,"prompt_tokens":23069,"prompt_tokens_details":{"cached_tokens":17458},"total_tokens":23434},"model":"Claude Sonnet 4","object":"chat.completion.chunk"} + diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt b/pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt new file mode 100644 index 000000000..26ecab588 --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt @@ -0,0 +1,117 @@ + +- Start 'github-mcp-server' MCP server + +- Start 'playwright' MCP server + +> I'll help you implement a shell script to print OS information. Let me start by exploring the repository structure to understand the codebase and then create a plan. + +- View /home/runner/work/foo/foo + +- Bash: Check current directory and list all files + +> Let me check the README and any existing workflow files to understand the repository structure better: + +- View /home/runner/work/foo/foo/.github + +- View /home/runner/work/foo/foo/README.md + +- Bash: Check if there are any existing scripts or build files + +- View /home/runner/work/foo/foo/.github/workflows + +? Thought:: Looking at the repository structure, I can see: + +1. This is a minimal repository with just a README.md and some GitHub workflows +2. There are no existing shell scripts or build infrastructure +3. The repository appears to be a simple project without any specific language or framework constraints +4. There are GitHub workflows but they seem to be for general CI/CD purposes + +For the task "add a shell script to print os information", I need to: +1. Create a shell script that displays relevant OS information +2. Make it executable +3. Consider where to place it in the repository structure +4. Since this is a minimal repository, I should keep the script simple and place it in the root or a scripts directory +5. The script should display useful OS information like: + - Operating system name and version + - Kernel version + - Architecture + - CPU information + - Memory information + - Disk space + - Uptime + +Since there's no existing test infrastructure and the instructions mention I can skip tests if there's no existing test infrastructure, I won't need to create tests. + +Let me plan the minimal changes: +1. Create a shell script (os_info.sh) in the root directory +2. Make it executable +3. Test it works correctly +4. Update README if appropriate + +> Now let me report my plan and create the shell script: + +! Progress update: Initial plan: Add shell script to print OS information + +> Now let me create the shell script with comprehensive OS information: + +- Create /home/runner/work/foo/foo/os_info.sh + +> Now let me make the script executable and test it: + +- Bash: Make script executable and test it + +> Great! The script works perfectly. Now let me update the README to document the new script: + +- Edit /home/runner/work/foo/foo/README.md + +> Let me verify the changes look good: + +- Bash: Check file permissions and final status + +- View /home/runner/work/foo/foo/README.md + +> Perfect! Let me test the script one more time to make sure everything is working correctly and commit the changes: + +- Bash: Final test of the script + +> Excellent! Now let me commit all the changes: + +! Progress update: Add os_info.sh shell script to display OS information + +> Perfect! I have successfully implemented the shell script to print OS information. Let me run the CodeQL security checker to ensure there are no security vulnerabilities: + +- Call to codeql_checker + +> Excellent! The security analysis shows no vulnerabilities. + +## Summary + +I have successfully completed the task by adding a comprehensive shell script to print OS information. Here's what was implemented: + +### ✅ **Completed Changes:** + +1. **Created `os_info.sh`** - A robust, executable shell script that displays: + - Operating system name and version (with smart detection for different Linux distros) + - Kernel version and architecture + - CPU model and core count + - Memory usage (total and available) + - Disk space usage for root filesystem + - System uptime + - Current date and time + +2. **Updated README.md** - Added comprehensive documentation including: + - Usage instructions + - Description of information displayed + - System requirements + +3. **Made the script executable** and thoroughly tested its functionality + +### 🔧 **Key Features:** + +- **Cross-platform compatibility**: Works on various Linux distributions with fallback mechanisms +- **Error handling**: Gracefully handles missing commands or files +- **User-friendly output**: Clean formatting with emoji icons and organized sections +- **Comprehensive information**: Covers all major system information categories +- **Executable permissions**: Properly set for immediate use + +The script is ready to use and provides a complete overview of the operating system information as requested. It has been tested and works correctly on the current Ubuntu system, displaying all relevant OS details in an organized and readable format. diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-2.txt b/pkg/cmd/agent-task/shared/testdata/sample-log-2.txt new file mode 100644 index 000000000..4a77bbbac --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/sample-log-2.txt @@ -0,0 +1,62 @@ +data: {"id":"fe0942b2-814c-455e-a930-738b1c006e21","choices":[{"delta":{"content":"MCP server started successfully (version github-mcp-server/remote-84df50f6d2a32c75816994627b178853b0fdfc7b) with 39 tools - for the full output, see the verbose logs\n\n- download_workflow_run_artifact\n- get_code_scanning_alert\n- get_commit\n- get_file_contents\n- get_issue\n- get_issue_comments\n- get_job_logs\n- get_latest_release\n- get_pull_request\n- get_pull_request_comments\n- get_pull_request_diff\n- get_pull_request_files\n- get_pull_request_reviews\n- get_pull_request_status\n- get_release_by_tag\n- get_secret_scanning_alert\n- get_tag\n- get_workflow_run\n- get_workflow_run_logs\n- get_workflow_run_usage\n- list_branches\n- list_code_scanning_alerts\n- list_commits\n- list_issue_types\n- list_issues\n- list_pull_requests\n- list_releases\n- list_secret_scanning_alerts\n- list_sub_issues\n- list_tags\n- list_workflow_jobs\n- list_workflow_run_artifacts\n- list_workflow_runs\n- list_workflows\n- search_code\n- search_issues\n- search_pull_requests\n- search_repositories\n- search_users","role":"assistant","tool_calls":[{"function":{"name":"run_setup","arguments":"{\"name\":\"Start 'github-mcp-server' MCP server\"}"},"index":0,"id":"fe0942b2-814c-455e-a930-738b1c006e21"}]},"finish_reason":"tool_calls","index":0}],"created":1757597711559,"model":"","object":"chat.completion.chunk"} + +data: {"id":"505e4c5b-1b3b-4daa-9482-7d56ec62481c","choices":[{"delta":{"content":"MCP server started successfully (version 0.0.35) with 21 tools - for the full output, see the verbose logs\n\n- browser_close\n- browser_resize\n- browser_console_messages\n- browser_handle_dialog\n- browser_evaluate\n- browser_file_upload\n- browser_fill_form\n- browser_install\n- browser_press_key\n- browser_type\n- browser_navigate\n- browser_navigate_back\n- browser_network_requests\n- browser_take_screenshot\n- browser_snapshot\n- browser_click\n- browser_drag\n- browser_hover\n- browser_select_option\n- browser_tabs\n- browser_wait_for","role":"assistant","tool_calls":[{"function":{"name":"run_setup","arguments":"{\"name\":\"Start 'playwright' MCP server\"}"},"index":0,"id":"505e4c5b-1b3b-4daa-9482-7d56ec62481c"}]},"finish_reason":"tool_calls","index":0}],"created":1757597712436,"model":"","object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"I'll help you create a .yaml file with random fields in the repository. Let me start by exploring the repository structure to understand the codebase and then create a plan."},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo\"}","name":"view"},"id":"toolu_vrtx_01DtzuF1J9HqaLLmhjftXXuC","type":"function","index":0}]},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"command\":\"cd /home/runner/work/foo/foo && find . -type f -name \\\"*.md\\\" -o -name \\\"*.txt\\\" -o -name \\\"*.json\\\" -o -name \\\"*.yaml\\\" -o -name \\\"*.yml\\\" | head -20\",\"description\":\"Find documentation and config files to understand the repository\",\"sessionId\":\"main\",\"async\":false}","name":"bash"},"id":"toolu_vrtx_013ghLNCYV22MWdnhXD3Adfm","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597734,"id":"msg_vrtx_013f7aQC1EKiyT6zNgEwFA2A","usage":{"completion_tokens":249,"prompt_tokens":17583,"prompt_tokens_details":{"cached_tokens":12861},"total_tokens":17832},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_013f7aQC1EKiyT6zNgEwFA2A","created":1757597736,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"command\":\"cd /home/runner/work/foo/foo && find . -type f -name \\\"*.md\\\" -o -name \\\"*.txt\\\" -o -name \\\"*.json\\\" -o -name \\\"*.yaml\\\" -o -name \\\"*.yml\\\" | head -20\",\"description\":\"Find documentation and config files to understand the repository\",\"sessionId\":\"main\",\"async\":false}","name":"bash"},"id":"toolu_vrtx_013ghLNCYV22MWdnhXD3Adfm","type":"function","index":0}],"content":"./README.md\n./.github/workflows/upload-artifact.yml\n./.github/workflows/manual-dispatch.yml\n./.github/workflows/cache.yml\n./.github/dependabot.yml\n./.github/pull_request_template.md\n./.github/PULL_REQUEST_TEMPLATE/my-template.md\n"}}],"object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_013f7aQC1EKiyT6zNgEwFA2A","created":1757597737,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo\"}","name":"view"},"id":"toolu_vrtx_01DtzuF1J9HqaLLmhjftXXuC","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo b/home/runner/work/foo/foo\ncreate file mode 100644\nindex 0000000..0000000\n--- a/dev/null\n+++ b/home/runner/work/foo/foo\n@@ -1,0 +1,3 @@\n .git\n .github\n README.md\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/README.md\"}","name":"view"},"id":"toolu_vrtx_01GNSQBPRnAVwF5QbnJt1A22","type":"function","index":0}]},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && ls -la\",\"description\":\"List all files in the root directory\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_01RS1dKXeq1SP8cV3jkmsYbR","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597743,"id":"msg_vrtx_01UA6ZjdcvUKcnJUDJRk5wsv","usage":{"completion_tokens":170,"prompt_tokens":17986,"prompt_tokens_details":{"cached_tokens":16932},"total_tokens":18156},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_01UA6ZjdcvUKcnJUDJRk5wsv","created":1757597746,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && ls -la\",\"description\":\"List all files in the root directory\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_01RS1dKXeq1SP8cV3jkmsYbR","type":"function","index":0}],"content":"total 20\ndrwxr-xr-x 4 runner runner 4096 Sep 11 13:35 .\ndrwxr-xr-x 3 runner runner 4096 Sep 11 13:34 ..\ndrwxrwxr-x 7 runner runner 4096 Sep 11 13:35 .git\ndrwxrwxr-x 4 runner runner 4096 Sep 11 13:35 .github\n-rw-rw-r-- 1 runner runner 6 Sep 11 13:35 README.md\n"}}],"object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_01UA6ZjdcvUKcnJUDJRk5wsv","created":1757597747,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/README.md\"}","name":"view"},"id":"toolu_vrtx_01GNSQBPRnAVwF5QbnJt1A22","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo/README.md b/home/runner/work/foo/foo/README.md\nindex 0000000..0000000 100644\n--- a/home/runner/work/foo/foo/README.md\n+++ b/home/runner/work/foo/foo/README.md\n@@ -1,2 +1,2 @@\n # foo\n \n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && git --no-pager status\",\"description\":\"Check git status to understand current state\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_01VbQmGc46Ad34LghdzqTE8J","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597752,"id":"msg_vrtx_016S6MeX5RYpWcRFAV4Vna4p","usage":{"completion_tokens":127,"prompt_tokens":18376,"prompt_tokens_details":{"cached_tokens":17868},"total_tokens":18503},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_016S6MeX5RYpWcRFAV4Vna4p","created":1757597754,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && git --no-pager status\",\"description\":\"Check git status to understand current state\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_01VbQmGc46Ad34LghdzqTE8J","type":"function","index":0}],"content":"On branch copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25\nYour branch is up to date with 'origin/copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25'.\n\nnothing to commit, working tree clean\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"thought\":\"Based on my exploration of the repository, I can see that:\\n\\n1. This is a simple repository called \\\"foo\\\" with minimal content\\n2. It contains:\\n - A basic README.md file with just \\\"# foo\\\"\\n - A .github directory with some workflow files and templates\\n - No existing source code, build system, or test infrastructure\\n - Some existing YAML files in .github/workflows/ and .github/dependabot.yml\\n\\nThe problem statement is straightforward: \\\"create a .yaml file with random fields\\\". Since this is a minimal repository with no specific build/test infrastructure, I need to:\\n\\n1. Create a YAML file with some random fields\\n2. Since there's no test infrastructure, I don't need to add tests\\n3. Since there's no build system, there's nothing to build\\n4. This is a very simple change that just involves creating a new file\\n\\nLet me plan this out:\\n\\nPlan:\\n1. Create a YAML file with random fields in the root directory\\n2. Choose meaningful random fields that demonstrate various YAML data types (strings, numbers, booleans, arrays, objects)\\n3. Report progress with the changes\\n\\nThe change will be minimal - just adding one new file.\"}","name":"think"},"id":"toolu_vrtx_01Sia5awHBmbinDaVCfWt6i6","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597765,"id":"msg_vrtx_01FKTNJ9MshfGB82vC7TgAGk","usage":{"completion_tokens":317,"prompt_tokens":18609,"prompt_tokens_details":{"cached_tokens":18190},"total_tokens":18926},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_01FKTNJ9MshfGB82vC7TgAGk","created":1757597766,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"thought\":\"Based on my exploration of the repository, I can see that:\\n\\n1. This is a simple repository called \\\"foo\\\" with minimal content\\n2. It contains:\\n - A basic README.md file with just \\\"# foo\\\"\\n - A .github directory with some workflow files and templates\\n - No existing source code, build system, or test infrastructure\\n - Some existing YAML files in .github/workflows/ and .github/dependabot.yml\\n\\nThe problem statement is straightforward: \\\"create a .yaml file with random fields\\\". Since this is a minimal repository with no specific build/test infrastructure, I need to:\\n\\n1. Create a YAML file with some random fields\\n2. Since there's no test infrastructure, I don't need to add tests\\n3. Since there's no build system, there's nothing to build\\n4. This is a very simple change that just involves creating a new file\\n\\nLet me plan this out:\\n\\nPlan:\\n1. Create a YAML file with random fields in the root directory\\n2. Choose meaningful random fields that demonstrate various YAML data types (strings, numbers, booleans, arrays, objects)\\n3. Report progress with the changes\\n\\nThe change will be minimal - just adding one new file.\"}","name":"think"},"id":"toolu_vrtx_01Sia5awHBmbinDaVCfWt6i6","type":"function","index":0}],"content":"Based on my exploration of the repository, I can see that:\n\n1. This is a simple repository called \"foo\" with minimal content\n2. It contains:\n - A basic README.md file with just \"# foo\"\n - A .github directory with some workflow files and templates\n - No existing source code, build system, or test infrastructure\n - Some existing YAML files in .github/workflows/ and .github/dependabot.yml\n\nThe problem statement is straightforward: \"create a .yaml file with random fields\". Since this is a minimal repository with no specific build/test infrastructure, I need to:\n\n1. Create a YAML file with some random fields\n2. Since there's no test infrastructure, I don't need to add tests\n3. Since there's no build system, there's nothing to build\n4. This is a very simple change that just involves creating a new file\n\nLet me plan this out:\n\nPlan:\n1. Create a YAML file with random fields in the root directory\n2. Choose meaningful random fields that demonstrate various YAML data types (strings, numbers, booleans, arrays, objects)\n3. Report progress with the changes\n\nThe change will be minimal - just adding one new file."}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Now I'll outline my plan and create the YAML file with random fields."},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"commitMessage\":\"Initial plan: Create YAML file with random fields\",\"prDescription\":\"Creating a .yaml file with random fields as requested.\\n\\n## Plan:\\n- [x] Explore repository structure and understand current state\\n- [ ] Create a .yaml file with various random fields demonstrating different YAML data types\\n- [ ] Verify the YAML file is valid\\n- [ ] Complete the implementation\\n\\n## Details:\\nThe repository is minimal with just a README and some GitHub workflow files. I'll create a new YAML file in the root directory with random fields including strings, numbers, booleans, arrays, and nested objects to demonstrate various YAML capabilities.\"}","name":"report_progress"},"id":"toolu_vrtx_019gXZoL1p4iQ2rWGYHVsxuF","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597775,"id":"msg_vrtx_01NaCn7ycmvQft1NL27xNjvW","usage":{"completion_tokens":218,"prompt_tokens":18941,"prompt_tokens_details":{"cached_tokens":18603},"total_tokens":19159},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_01NaCn7ycmvQft1NL27xNjvW","created":1757597779,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"commitMessage\":\"Initial plan: Create YAML file with random fields\",\"prDescription\":\"Creating a .yaml file with random fields as requested.\\n\\n## Plan:\\n- [x] Explore repository structure and understand current state\\n- [ ] Create a .yaml file with various random fields demonstrating different YAML data types\\n- [ ] Verify the YAML file is valid\\n- [ ] Complete the implementation\\n\\n## Details:\\nThe repository is minimal with just a README and some GitHub workflow files. I'll create a new YAML file in the root directory with random fields including strings, numbers, booleans, arrays, and nested objects to demonstrate various YAML capabilities.\"}","name":"report_progress"},"id":"toolu_vrtx_019gXZoL1p4iQ2rWGYHVsxuF","type":"function","index":0}],"content":"$ git push -v origin copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25\nPushing to https://github.com/gh-babakks/foo\nTo https://github.com/gh-babakks/foo\n = [up to date] copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25 -> copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25\nupdating local tracking ref 'refs/remotes/origin/copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25'\nEverything up-to-date\n\n\n\nProgress reported successfully."}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Now I'll create a YAML file with random fields that demonstrates various YAML data types and structures."},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/sample-data.yaml\",\"file_text\":\"# Sample YAML file with random fields demonstrating various data types\\napplication:\\n name: \\\"RandomApp\\\"\\n version: \\\"2.4.7\\\"\\n environment: \\\"production\\\"\\n debug_mode: false\\n max_connections: 1000\\n timeout_seconds: 30.5\\n\\ndatabase:\\n host: \\\"db.example.com\\\"\\n port: 5432\\n username: \\\"app_user\\\"\\n ssl_enabled: true\\n connection_pool:\\n min_size: 5\\n max_size: 20\\n idle_timeout: 300\\n\\nfeatures:\\n - name: \\\"user_authentication\\\"\\n enabled: true\\n priority: 1\\n - name: \\\"file_upload\\\"\\n enabled: false\\n priority: 3\\n - name: \\\"real_time_notifications\\\"\\n enabled: true\\n priority: 2\\n\\nregions:\\n - us-east-1\\n - us-west-2\\n - eu-central-1\\n - ap-southeast-1\\n\\nmetadata:\\n created_at: \\\"2023-09-15T10:30:00Z\\\"\\n updated_at: \\\"2024-01-20T14:45:30Z\\\"\\n tags:\\n team: \\\"backend\\\"\\n cost_center: \\\"engineering\\\"\\n criticality: \\\"high\\\"\\n \\napi:\\n base_url: \\\"https://api.example.com/v1\\\"\\n rate_limit: 1000\\n retry_attempts: 3\\n endpoints:\\n users: \\\"/users\\\"\\n orders: \\\"/orders\\\"\\n analytics: \\\"/analytics\\\"\\n\\nmonitoring:\\n health_check:\\n path: \\\"/health\\\"\\n interval: 60\\n timeout: 10\\n metrics:\\n enabled: true\\n export_format: \\\"prometheus\\\"\\n retention_days: 30\\n\\nrandom_numbers:\\n - 42\\n - 3.14159\\n - -17\\n - 0.001\\n - 9999999\\n\\nrandom_booleans:\\n feature_flag_a: true\\n feature_flag_b: false\\n experimental_mode: true\\n maintenance_mode: false\\n\\nnested_config:\\n level1:\\n level2:\\n level3:\\n deep_value: \\\"Found me!\\\"\\n treasure: 12345\\n coordinates:\\n x: 10.5\\n y: -20.3\\n z: 100\"}","name":"create"},"id":"toolu_vrtx_012kkrKRu2UMXFSC9CmYohUC","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597793,"id":"msg_vrtx_01VLNz4KXQM3No1cEkdyixvC","usage":{"completion_tokens":676,"prompt_tokens":19370,"prompt_tokens_details":{"cached_tokens":18935},"total_tokens":20046},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_01VLNz4KXQM3No1cEkdyixvC","created":1757597794,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/sample-data.yaml\",\"file_text\":\"# Sample YAML file with random fields demonstrating various data types\\napplication:\\n name: \\\"RandomApp\\\"\\n version: \\\"2.4.7\\\"\\n environment: \\\"production\\\"\\n debug_mode: false\\n max_connections: 1000\\n timeout_seconds: 30.5\\n\\ndatabase:\\n host: \\\"db.example.com\\\"\\n port: 5432\\n username: \\\"app_user\\\"\\n ssl_enabled: true\\n connection_pool:\\n min_size: 5\\n max_size: 20\\n idle_timeout: 300\\n\\nfeatures:\\n - name: \\\"user_authentication\\\"\\n enabled: true\\n priority: 1\\n - name: \\\"file_upload\\\"\\n enabled: false\\n priority: 3\\n - name: \\\"real_time_notifications\\\"\\n enabled: true\\n priority: 2\\n\\nregions:\\n - us-east-1\\n - us-west-2\\n - eu-central-1\\n - ap-southeast-1\\n\\nmetadata:\\n created_at: \\\"2023-09-15T10:30:00Z\\\"\\n updated_at: \\\"2024-01-20T14:45:30Z\\\"\\n tags:\\n team: \\\"backend\\\"\\n cost_center: \\\"engineering\\\"\\n criticality: \\\"high\\\"\\n \\napi:\\n base_url: \\\"https://api.example.com/v1\\\"\\n rate_limit: 1000\\n retry_attempts: 3\\n endpoints:\\n users: \\\"/users\\\"\\n orders: \\\"/orders\\\"\\n analytics: \\\"/analytics\\\"\\n\\nmonitoring:\\n health_check:\\n path: \\\"/health\\\"\\n interval: 60\\n timeout: 10\\n metrics:\\n enabled: true\\n export_format: \\\"prometheus\\\"\\n retention_days: 30\\n\\nrandom_numbers:\\n - 42\\n - 3.14159\\n - -17\\n - 0.001\\n - 9999999\\n\\nrandom_booleans:\\n feature_flag_a: true\\n feature_flag_b: false\\n experimental_mode: true\\n maintenance_mode: false\\n\\nnested_config:\\n level1:\\n level2:\\n level3:\\n deep_value: \\\"Found me!\\\"\\n treasure: 12345\\n coordinates:\\n x: 10.5\\n y: -20.3\\n z: 100\"}","name":"create"},"id":"toolu_vrtx_012kkrKRu2UMXFSC9CmYohUC","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo/sample-data.yaml b/home/runner/work/foo/foo/sample-data.yaml\ncreate file mode 100644\nindex 0000000..0000000\n--- a/dev/null\n+++ b/home/runner/work/foo/foo/sample-data.yaml\n@@ -1,0 +1,86 @@\n+# Sample YAML file with random fields demonstrating various data types\n+application:\n+ name: \"RandomApp\"\n+ version: \"2.4.7\"\n+ environment: \"production\"\n+ debug_mode: false\n+ max_connections: 1000\n+ timeout_seconds: 30.5\n+\n+database:\n+ host: \"db.example.com\"\n+ port: 5432\n+ username: \"app_user\"\n+ ssl_enabled: true\n+ connection_pool:\n+ min_size: 5\n+ max_size: 20\n+ idle_timeout: 300\n+\n+features:\n+ - name: \"user_authentication\"\n+ enabled: true\n+ priority: 1\n+ - name: \"file_upload\"\n+ enabled: false\n+ priority: 3\n+ - name: \"real_time_notifications\"\n+ enabled: true\n+ priority: 2\n+\n+regions:\n+ - us-east-1\n+ - us-west-2\n+ - eu-central-1\n+ - ap-southeast-1\n+\n+metadata:\n+ created_at: \"2023-09-15T10:30:00Z\"\n+ updated_at: \"2024-01-20T14:45:30Z\"\n+ tags:\n+ team: \"backend\"\n+ cost_center: \"engineering\"\n+ criticality: \"high\"\n+ \n+api:\n+ base_url: \"https://api.example.com/v1\"\n+ rate_limit: 1000\n+ retry_attempts: 3\n+ endpoints:\n+ users: \"/users\"\n+ orders: \"/orders\"\n+ analytics: \"/analytics\"\n+\n+monitoring:\n+ health_check:\n+ path: \"/health\"\n+ interval: 60\n+ timeout: 10\n+ metrics:\n+ enabled: true\n+ export_format: \"prometheus\"\n+ retention_days: 30\n+\n+random_numbers:\n+ - 42\n+ - 3.14159\n+ - -17\n+ - 0.001\n+ - 9999999\n+\n+random_booleans:\n+ feature_flag_a: true\n+ feature_flag_b: false\n+ experimental_mode: true\n+ maintenance_mode: false\n+\n+nested_config:\n+ level1:\n+ level2:\n+ level3:\n+ deep_value: \"Found me!\"\n+ treasure: 12345\n+ coordinates:\n+ x: 10.5\n+ y: -20.3\n+ z: 100\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Now let me verify that the YAML file is valid by testing it with a YAML parser:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && python3 -c \\\"import yaml; yaml.safe_load(open('sample-data.yaml', 'r')); print('YAML file is valid!')\\\"\",\"description\":\"Validate the YAML file syntax\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_016xFTskoAmX34uHxg8m6rmq","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597802,"id":"msg_vrtx_01HRQQGQeEXaLKRAnpLcvWof","usage":{"completion_tokens":178,"prompt_tokens":20081,"prompt_tokens_details":{"cached_tokens":19365},"total_tokens":20259},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_01HRQQGQeEXaLKRAnpLcvWof","created":1757597803,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && python3 -c \\\"import yaml; yaml.safe_load(open('sample-data.yaml', 'r')); print('YAML file is valid!')\\\"\",\"description\":\"Validate the YAML file syntax\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_016xFTskoAmX34uHxg8m6rmq","type":"function","index":0}],"content":"\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && python3 -c \\\"import yaml; data = yaml.safe_load(open('sample-data.yaml', 'r')); print('YAML file is valid!'); print(f'Loaded {len(data)} top-level keys: {list(data.keys())}')\\\"\",\"description\":\"Validate YAML and show structure\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_014g7kzLWv5BEZ4BWNbm5bzp","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597809,"id":"msg_vrtx_01SVMUS4b3UegHDN3yaqnBQd","usage":{"completion_tokens":184,"prompt_tokens":20280,"prompt_tokens_details":{"cached_tokens":20076},"total_tokens":20464},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_01SVMUS4b3UegHDN3yaqnBQd","created":1757597811,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && python3 -c \\\"import yaml; data = yaml.safe_load(open('sample-data.yaml', 'r')); print('YAML file is valid!'); print(f'Loaded {len(data)} top-level keys: {list(data.keys())}')\\\"\",\"description\":\"Validate YAML and show structure\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_014g7kzLWv5BEZ4BWNbm5bzp","type":"function","index":0}],"content":"\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Let me try a different approach to verify the YAML:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && python3 -c \\\"\\nimport yaml\\ntry:\\n with open('sample-data.yaml', 'r') as f:\\n data = yaml.safe_load(f)\\n print('YAML file is valid!')\\n print(f'Loaded {len(data)} top-level keys: {list(data.keys())}')\\n print(f'Application name: {data[\\\\\\\"application\\\\\\\"][\\\\\\\"name\\\\\\\"]}')\\n print(f'Number of features: {len(data[\\\\\\\"features\\\\\\\"])}')\\nexcept Exception as e:\\n print(f'Error: {e}')\\n\\\"\",\"description\":\"Detailed YAML validation with content verification\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_017EMDdJCqTE8tBwzM9PsYWb","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597818,"id":"msg_vrtx_01GbFRhuSh5bH1VPVEAMD8L9","usage":{"completion_tokens":275,"prompt_tokens":20485,"prompt_tokens_details":{"cached_tokens":20276},"total_tokens":20760},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_01GbFRhuSh5bH1VPVEAMD8L9","created":1757597820,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && python3 -c \\\"\\nimport yaml\\ntry:\\n with open('sample-data.yaml', 'r') as f:\\n data = yaml.safe_load(f)\\n print('YAML file is valid!')\\n print(f'Loaded {len(data)} top-level keys: {list(data.keys())}')\\n print(f'Application name: {data[\\\\\\\"application\\\\\\\"][\\\\\\\"name\\\\\\\"]}')\\n print(f'Number of features: {len(data[\\\\\\\"features\\\\\\\"])}')\\nexcept Exception as e:\\n print(f'Error: {e}')\\n\\\"\",\"description\":\"Detailed YAML validation with content verification\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_017EMDdJCqTE8tBwzM9PsYWb","type":"function","index":0}],"content":"YAML file is valid!\nLoaded 10 top-level keys: ['application', 'database', 'features', 'regions', 'metadata', 'api', 'monitoring', 'random_numbers', 'random_booleans', 'nested_config']\nApplication name: RandomApp\nNumber of features: 3\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Perfect! Let me also check the file contents and verify the current status:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/sample-data.yaml\",\"view_range\":[1,15]}","name":"view"},"id":"toolu_vrtx_019W2ACdpfZPudVbMfa3GwyY","type":"function","index":0}]},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && git --no-pager status\",\"description\":\"Check git status to see the new file\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_01CRcLvPHzuU6zEzP5C6D1op","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597828,"id":"msg_vrtx_016erDHLVsq6EnRF6RAQSi9p","usage":{"completion_tokens":217,"prompt_tokens":20858,"prompt_tokens_details":{"cached_tokens":20482},"total_tokens":21075},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_016erDHLVsq6EnRF6RAQSi9p","created":1757597830,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"async\":false,\"command\":\"cd /home/runner/work/foo/foo && git --no-pager status\",\"description\":\"Check git status to see the new file\",\"sessionId\":\"main\"}","name":"bash"},"id":"toolu_vrtx_01CRcLvPHzuU6zEzP5C6D1op","type":"function","index":0}],"content":"On branch copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25\nYour branch is up to date with 'origin/copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25'.\n\nUntracked files:\n (use \"git add ...\" to include in what will be committed)\n\tsample-data.yaml\n\nnothing added to commit but untracked files present (use \"git add\" to track)\n"}}],"object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_016erDHLVsq6EnRF6RAQSi9p","created":1757597832,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"path\":\"/home/runner/work/foo/foo/sample-data.yaml\",\"view_range\":[1,15]}","name":"view"},"id":"toolu_vrtx_019W2ACdpfZPudVbMfa3GwyY","type":"function","index":0}],"content":"\ndiff --git a/home/runner/work/foo/foo/sample-data.yaml b/home/runner/work/foo/foo/sample-data.yaml\nindex 0000000..0000000 100644\n--- a/home/runner/work/foo/foo/sample-data.yaml\n+++ b/home/runner/work/foo/foo/sample-data.yaml\n@@ -1,15 +1,15 @@\n # Sample YAML file with random fields demonstrating various data types\n application:\n name: \"RandomApp\"\n version: \"2.4.7\"\n environment: \"production\"\n debug_mode: false\n max_connections: 1000\n timeout_seconds: 30.5\n \n database:\n host: \"db.example.com\"\n port: 5432\n username: \"app_user\"\n ssl_enabled: true\n connection_pool:\n"}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Excellent! Now let me report the completion of this task:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{\"commitMessage\":\"Add sample-data.yaml with random fields demonstrating various YAML data types\",\"prDescription\":\"Creating a .yaml file with random fields as requested.\\n\\n## Plan:\\n- [x] Explore repository structure and understand current state\\n- [x] Create a .yaml file with various random fields demonstrating different YAML data types\\n- [x] Verify the YAML file is valid\\n- [x] Complete the implementation\\n\\n## Implementation Details:\\nCreated `sample-data.yaml` with the following random fields and data types:\\n\\n### Data Types Demonstrated:\\n- **Strings**: Application names, URLs, usernames\\n- **Numbers**: Integers (ports, timeouts) and floats (version numbers, coordinates)\\n- **Booleans**: Feature flags and configuration options\\n- **Arrays**: Lists of regions, features, and random numbers\\n- **Objects**: Nested configuration structures\\n- **Mixed Arrays**: Arrays containing objects with multiple properties\\n\\n### Structure Includes:\\n- Application configuration (name, version, environment settings)\\n- Database connection details\\n- Feature flag configurations\\n- Regional deployment settings\\n- API endpoint mappings\\n- Monitoring configuration\\n- Deeply nested structures (3+ levels)\\n- Various random number examples\\n- Boolean flag examples\\n\\nThe YAML file has been validated and contains 10 top-level sections with diverse random data to demonstrate YAML capabilities.\"}","name":"report_progress"},"id":"toolu_vrtx_01X21uE7N4uwbUajy5Y1ou5n","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597844,"id":"msg_vrtx_012X2S9mbgHA4X7sGvGUr8oY","usage":{"completion_tokens":375,"prompt_tokens":21414,"prompt_tokens_details":{"cached_tokens":20856},"total_tokens":21789},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_012X2S9mbgHA4X7sGvGUr8oY","created":1757597849,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{\"commitMessage\":\"Add sample-data.yaml with random fields demonstrating various YAML data types\",\"prDescription\":\"Creating a .yaml file with random fields as requested.\\n\\n## Plan:\\n- [x] Explore repository structure and understand current state\\n- [x] Create a .yaml file with various random fields demonstrating different YAML data types\\n- [x] Verify the YAML file is valid\\n- [x] Complete the implementation\\n\\n## Implementation Details:\\nCreated `sample-data.yaml` with the following random fields and data types:\\n\\n### Data Types Demonstrated:\\n- **Strings**: Application names, URLs, usernames\\n- **Numbers**: Integers (ports, timeouts) and floats (version numbers, coordinates)\\n- **Booleans**: Feature flags and configuration options\\n- **Arrays**: Lists of regions, features, and random numbers\\n- **Objects**: Nested configuration structures\\n- **Mixed Arrays**: Arrays containing objects with multiple properties\\n\\n### Structure Includes:\\n- Application configuration (name, version, environment settings)\\n- Database connection details\\n- Feature flag configurations\\n- Regional deployment settings\\n- API endpoint mappings\\n- Monitoring configuration\\n- Deeply nested structures (3+ levels)\\n- Various random number examples\\n- Boolean flag examples\\n\\nThe YAML file has been validated and contains 10 top-level sections with diverse random data to demonstrate YAML capabilities.\"}","name":"report_progress"},"id":"toolu_vrtx_01X21uE7N4uwbUajy5Y1ou5n","type":"function","index":0}],"content":"$ git add . -v\nadd 'sample-data.yaml'\n\n$ git commit -m Add sample-data.yaml with random fields demonstrating various YAML data types\n\nCo-authored-by: babakks <36728931+babakks@users.noreply.github.com>\n[copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25 e9e29a7] Add sample-data.yaml with random fields demonstrating various YAML data types\n 1 file changed, 86 insertions(+)\n create mode 100644 sample-data.yaml\n\n$ git push -v origin copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25\nPushing to https://github.com/gh-babakks/foo\nPOST git-receive-pack (1462 bytes)\nTo https://github.com/gh-babakks/foo\n 830cc87..e9e29a7 copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25 -> copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25\nupdating local tracking ref 'refs/remotes/origin/copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25'\n\n\n\nProgress reported successfully."}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Perfect! I have successfully completed the task. Let me also run a security check to ensure there are no vulnerabilities in the changes:"},"finish_reason":"tool_calls"},{"delta":{"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"codeql_checker"},"id":"toolu_vrtx_01CJZG4x83H1U2e1HkKMHEGi","type":"function","index":0}]},"finish_reason":"tool_calls"}],"created":1757597855,"id":"msg_vrtx_01DWsg7fF2gjbRZ4Gr7y6RjK","usage":{"completion_tokens":66,"prompt_tokens":22163,"prompt_tokens_details":{"cached_tokens":21249},"total_tokens":22229},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"id":"msg_vrtx_01DWsg7fF2gjbRZ4Gr7y6RjK","created":1757597860,"model":"claude-sonnet-4","choices":[{"finish_reason":"tool_calls","delta":{"tool_calls":[{"function":{"arguments":"{}","name":"codeql_checker"},"id":"toolu_vrtx_01CJZG4x83H1U2e1HkKMHEGi","type":"function","index":0}],"content":"Analysis completed successfully for ."}}],"object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"Great! The security check came back clean with no vulnerabilities detected.\n\n## Summary\n\nI have successfully implemented the requested changes to create a `.yaml` file with random fields. Here's what was accomplished:\n\n### ✅ **Task Completed Successfully**\n\n**Created**: `sample-data.yaml` - A comprehensive YAML file with diverse random fields\n\n### 📋 **What the YAML file contains:**\n\n1. **Application Configuration**: Name, version, environment settings, connection limits\n2. **Database Settings**: Host, port, credentials, connection pooling\n3. **Feature Management**: Array of features with enabled/disabled states and priorities\n4. **Regional Data**: List of AWS regions\n5. **Metadata**: Timestamps and organizational tags\n6. **API Configuration**: Base URLs, rate limits, endpoint mappings\n7. **Monitoring Setup**: Health checks and metrics configuration\n8. **Random Data Examples**: Numbers, booleans, and deeply nested structures\n\n### 🔧 **Data Types Demonstrated:**\n- Strings (quoted and unquoted)\n- Integers and floating-point numbers\n- Booleans (true/false)\n- Arrays/Lists\n- Objects/Maps\n- Nested structures (up to 4 levels deep)\n- Mixed data type arrays\n\n### ✅ **Validation:**\n- The YAML file syntax has been validated using Python's yaml parser\n- Successfully loads with 10 top-level keys\n- No security vulnerabilities detected\n- File properly committed and pushed to the repository\n\nThe implementation is minimal and focused, adding exactly what was requested - a single YAML file with random fields that demonstrates the full range of YAML data types and structures."},"finish_reason":"stop"}],"created":1757597872,"id":"msg_vrtx_015a4ikH7i829nZuNkAEiXPs","usage":{"completion_tokens":374,"prompt_tokens":22246,"prompt_tokens_details":{"cached_tokens":22162},"total_tokens":22620},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + +data: {"choices":[{"delta":{"role":"assistant","content":"\nAdd sample YAML file with diverse random fields and data types\n\n\n\nThis PR adds a comprehensive YAML file (`sample-data.yaml`) containing various random fields that demonstrate different YAML data types and structures.\n\n## What's Added\n\nThe new `sample-data.yaml` file includes:\n\n- **Application Configuration**: Name, version, environment settings with mixed data types\n- **Database Settings**: Connection details with nested objects and boolean flags\n- **Feature Management**: Array of objects with different properties and priorities\n- **Regional Data**: Simple string arrays for deployment regions\n- **API Configuration**: Nested endpoint mappings and rate limiting settings\n- **Monitoring Setup**: Health check parameters and metrics configuration\n- **Random Data Examples**: Arrays of numbers, boolean flags, and deeply nested structures\n\n## Data Types Demonstrated\n\nThe file showcases all major YAML data types:\n- Strings (quoted and unquoted)\n- Integers and floating-point numbers\n- Booleans (true/false)\n- Arrays (simple lists and complex object arrays)\n- Nested objects (up to 4 levels deep)\n- Mixed data structures\n\n## Example Structure\n\n```yaml\napplication:\n name: \"RandomApp\"\n version: \"2.4.7\"\n debug_mode: false\n max_connections: 1000\n\nfeatures:\n - name: \"user_authentication\"\n enabled: true\n priority: 1\n - name: \"file_upload\"\n enabled: false\n priority: 3\n\nnested_config:\n level1:\n level2:\n level3:\n deep_value: \"Found me!\"\n coordinates:\n x: 10.5\n y: -20.3\n```\n\nThe YAML file has been validated for proper syntax and contains 86 lines of diverse random data across 10 top-level sections.\n"},"finish_reason":"stop"}],"created":1757597887,"id":"msg_vrtx_01So1PEUFXUoEDB1SeuHeDzK","usage":{"completion_tokens":440,"prompt_tokens":22501,"prompt_tokens_details":{"cached_tokens":16932},"total_tokens":22941},"model":"claude-sonnet-4","object":"chat.completion.chunk"} + diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt b/pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt new file mode 100644 index 000000000..196c5d69a --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt @@ -0,0 +1,111 @@ + +- Start 'github-mcp-server' MCP server + +- Start 'playwright' MCP server + +> I'll help you create a .yaml file with random fields in the repository. Let me start by exploring the repository structure to understand the codebase and then create a plan. + +- Bash: Find documentation and config files to understand the repository + +- View /home/runner/work/foo/foo + +- Bash: List all files in the root directory + +- View /home/runner/work/foo/foo/README.md + +- Bash: Check git status to understand current state + +? Thought:: Based on my exploration of the repository, I can see that: + +1. This is a simple repository called "foo" with minimal content +2. It contains: + - A basic README.md file with just "# foo" + - A .github directory with some workflow files and templates + - No existing source code, build system, or test infrastructure + - Some existing YAML files in .github/workflows/ and .github/dependabot.yml + +The problem statement is straightforward: "create a .yaml file with random fields". Since this is a minimal repository with no specific build/test infrastructure, I need to: + +1. Create a YAML file with some random fields +2. Since there's no test infrastructure, I don't need to add tests +3. Since there's no build system, there's nothing to build +4. This is a very simple change that just involves creating a new file + +Let me plan this out: + +Plan: +1. Create a YAML file with random fields in the root directory +2. Choose meaningful random fields that demonstrate various YAML data types (strings, numbers, booleans, arrays, objects) +3. Report progress with the changes + +The change will be minimal - just adding one new file. + +> Now I'll outline my plan and create the YAML file with random fields. + +! Progress update: Initial plan: Create YAML file with random fields + +> Now I'll create a YAML file with random fields that demonstrates various YAML data types and structures. + +- Create /home/runner/work/foo/foo/sample-data.yaml + +> Now let me verify that the YAML file is valid by testing it with a YAML parser: + +- Bash: Validate the YAML file syntax + +- Bash: Validate YAML and show structure + +> Let me try a different approach to verify the YAML: + +- Bash: Detailed YAML validation with content verification + +> Perfect! Let me also check the file contents and verify the current status: + +- Bash: Check git status to see the new file + +- View /home/runner/work/foo/foo/sample-data.yaml + +> Excellent! Now let me report the completion of this task: + +! Progress update: Add sample-data.yaml with random fields demonstrating various YAML data types + +> Perfect! I have successfully completed the task. Let me also run a security check to ensure there are no vulnerabilities in the changes: + +- Call to codeql_checker + +> Great! The security check came back clean with no vulnerabilities detected. + +## Summary + +I have successfully implemented the requested changes to create a `.yaml` file with random fields. Here's what was accomplished: + +### ✅ **Task Completed Successfully** + +**Created**: `sample-data.yaml` - A comprehensive YAML file with diverse random fields + +### 📋 **What the YAML file contains:** + +1. **Application Configuration**: Name, version, environment settings, connection limits +2. **Database Settings**: Host, port, credentials, connection pooling +3. **Feature Management**: Array of features with enabled/disabled states and priorities +4. **Regional Data**: List of AWS regions +5. **Metadata**: Timestamps and organizational tags +6. **API Configuration**: Base URLs, rate limits, endpoint mappings +7. **Monitoring Setup**: Health checks and metrics configuration +8. **Random Data Examples**: Numbers, booleans, and deeply nested structures + +### 🔧 **Data Types Demonstrated:** +- Strings (quoted and unquoted) +- Integers and floating-point numbers +- Booleans (true/false) +- Arrays/Lists +- Objects/Maps +- Nested structures (up to 4 levels deep) +- Mixed data type arrays + +### ✅ **Validation:** +- The YAML file syntax has been validated using Python's yaml parser +- Successfully loads with 10 top-level keys +- No security vulnerabilities detected +- File properly committed and pushed to the repository + +The implementation is minimal and focused, adding exactly what was requested - a single YAML file with random fields that demonstrates the full range of YAML data types and structures. From 1ccbb0af83dc3d0316a6a69b10e60b6e2ae4bb7e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 12 Sep 2025 13:34:18 +0100 Subject: [PATCH 129/208] feat(agent-task view): add `--log` and `--follow` flags Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 101 +++++++++++++--- pkg/cmd/agent-task/view/view_test.go | 170 +++++++++++++++++++++++++-- 2 files changed, 244 insertions(+), 27 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 024101b0e..9058ec83e 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -23,7 +23,10 @@ import ( "github.com/spf13/cobra" ) -const defaultLimit = 40 +const ( + defaultLimit = 40 + defaultLogPollInterval = 5 * time.Second +) type ViewOptions struct { IO *iostreams.IOStreams @@ -34,19 +37,30 @@ type ViewOptions struct { 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, + IO: f.IOStreams, + HttpClient: f.HttpClient, + CapiClient: shared.CapiClientFunc(f), + Prompter: f.Prompter, + Browser: f.Browser, + LogRenderer: defaultLogRenderer, + Sleep: time.Sleep, } cmd := &cobra.Command{ @@ -89,6 +103,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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) } @@ -103,6 +121,8 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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 } @@ -255,10 +275,19 @@ func viewRun(opts *ViewOptions) error { } } - out := opts.IO.Out + 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(out, "%s • %s • %s%s\n", + 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, @@ -266,25 +295,61 @@ func viewRun(opts *ViewOptions) error { ) } else { // This can happen when the session is just created and a PR is not yet available for it - fmt.Fprintf(out, "%s\n", shared.ColorFuncForSessionState(*session, cs)(shared.SessionStateString(session.State))) + fmt.Fprintf(opts.IO.Out, "%s\n", shared.ColorFuncForSessionState(*session, cs)(shared.SessionStateString(session.State))) } if session.User != nil { - fmt.Fprintf(out, "Started on behalf of %s %s\n", session.User.Login, text.FuzzyAgo(time.Now(), session.CreatedAt)) + 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(out, "Started %s\n", text.FuzzyAgo(time.Now(), session.CreatedAt)) + fmt.Fprintf(opts.IO.Out, "Started %s\n", text.FuzzyAgo(time.Now(), session.CreatedAt)) } - // TODO(babakks): uncomment when we have the --logs option ready - // fmt.Fprintln(out, "") - // fmt.Fprintf(out, "For the detailed session logs, try: gh agent-task view '%s' --logs\n", opts.SelectorArg) + 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(out, "") - fmt.Fprintln(out, cs.Muted("View this session on GitHub:")) - fmt.Fprintln(out, cs.Muted(fmt.Sprintf("%s/agent-sessions/%s", session.PullRequest.URL, url.PathEscape(session.ID)))) + 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() + + cs := opts.IO.ColorScheme() + 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, cs) } - return nil + 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, cs) + return err } diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index bdff45793..dd361f919 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "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" @@ -78,6 +79,31 @@ func TestNewCmdList(t *testing.T) { SelectorArg: "some-arg", }, }, + { + name: "with --log", + tty: true, + args: "some-arg --log", + wantOpts: ViewOptions{ + SelectorArg: "some-arg", + Log: true, + }, + }, + { + name: "with --log and --follow", + tty: true, + args: "some-arg --log --follow", + wantOpts: ViewOptions{ + SelectorArg: "some-arg", + Log: true, + Follow: true, + }, + }, + { + name: "--follow requires --log", + tty: true, + args: "some-arg --follow", + wantErr: "--log is required when providing --follow", + }, { name: "web mode", tty: true, @@ -135,15 +161,16 @@ func Test_viewRun(t *testing.T) { sampleDate := time.Now().Add(-6 * time.Hour) // 6h ago tests := []struct { - name string - tty bool - opts ViewOptions - promptStubs func(*testing.T, *prompter.MockPrompter) - capiStubs func(*testing.T, *capi.CapiClientMock) - wantOut string - wantErr error - wantStderr string - wantBrowserURL string + name string + tty bool + opts ViewOptions + promptStubs func(*testing.T, *prompter.MockPrompter) + capiStubs func(*testing.T, *capi.CapiClientMock) + logRendererStubs func(*testing.T, *shared.LogRendererMock) + wantOut string + wantErr error + wantStderr string + wantBrowserURL string }{ { name: "with session id, not found (tty)", @@ -206,6 +233,9 @@ func Test_viewRun(t *testing.T) { Completed • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), @@ -240,6 +270,9 @@ func Test_viewRun(t *testing.T) { Completed • fix something • OWNER/REPO#101 Started about 6 hours ago + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), @@ -268,6 +301,9 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Completed Started on behalf of octocat about 6 hours ago + + For detailed session logs, try: + gh agent-task view 'some-session-id' --log `), }, { @@ -291,6 +327,9 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Completed Started about 6 hours ago + + For detailed session logs, try: + gh agent-task view 'some-session-id' --log `), }, { @@ -471,6 +510,9 @@ func Test_viewRun(t *testing.T) { Completed • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), @@ -548,6 +590,9 @@ func Test_viewRun(t *testing.T) { Completed • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), @@ -627,6 +672,9 @@ func Test_viewRun(t *testing.T) { Completed • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + View this session on GitHub: https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), @@ -810,6 +858,102 @@ func Test_viewRun(t *testing.T) { wantBrowserURL: "https://github.com/OWNER/REPO/pull/101/agent-sessions", wantStderr: "Opening https://github.com/OWNER/REPO/pull/101/agent-sessions in your browser.\n", }, + { + name: "with log (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + Log: true, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, nil + } + m.GetSessionLogsFunc = func(_ context.Context, id string) ([]byte, error) { + assert.Equal(t, "some-session-id", id) + return []byte(""), nil + } + }, + logRendererStubs: func(t *testing.T, m *shared.LogRendererMock) { + m.RenderFunc = func(raw []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { + w.Write([]byte("(rendered:) " + string(raw) + "\n")) + return false, nil + } + }, + wantOut: heredoc.Doc(` + Completed + Started on behalf of octocat about 6 hours ago + + To follow session logs, try: + gh agent-task view 'some-session-id' --log --follow + + (rendered:) + `), + }, + { + name: "with log and follow (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + Log: true, + Follow: true, + Sleep: func(_ time.Duration) {}, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, nil + } + + var count int + m.GetSessionLogsFunc = func(_ context.Context, id string) ([]byte, error) { + assert.Equal(t, "some-session-id", id) + + count++ + require.Less(t, count, 3, "too many calls to fetch logs") + if count == 1 { + return []byte(""), nil + } + return []byte(""), nil + } + }, + logRendererStubs: func(t *testing.T, m *shared.LogRendererMock) { + m.FollowFunc = func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error { + raw, err := fetcher() + require.NoError(t, err) + w.Write([]byte("(rendered:) " + string(raw) + "\n")) + + raw, err = fetcher() + require.NoError(t, err) + w.Write([]byte("(rendered:) " + string(raw) + "\n")) + return nil + } + }, + wantOut: heredoc.Doc(` + Completed + Started on behalf of octocat about 6 hours ago + + (rendered:) + (rendered:) + `), + }, } for _, tt := range tests { @@ -824,6 +968,11 @@ func Test_viewRun(t *testing.T) { tt.promptStubs(t, prompter) } + logRenderer := &shared.LogRendererMock{} + if tt.logRendererStubs != nil { + tt.logRendererStubs(t, logRenderer) + } + ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) @@ -836,6 +985,9 @@ func Test_viewRun(t *testing.T) { opts.CapiClient = func() (capi.CapiClient, error) { return capiClientMock, nil } + opts.LogRenderer = func() shared.LogRenderer { + return logRenderer + } err := viewRun(&opts) if tt.wantErr != nil { From 1155e8307019fb96fcac4bd5ef23bc6670356d73 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 12 Sep 2025 20:40:47 +0100 Subject: [PATCH 130/208] feat(agent-task view): add `--log` and `--follow` flags Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/log.go | 213 +++++++++++++++++++++++-------- 1 file changed, 158 insertions(+), 55 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 6b121380e..567a97c27 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -60,11 +60,16 @@ func (r *logRenderer) Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme return false, errors.New("unexpected log format") } - // TODO: should ignore the error since the entries can be different. - var entry logEntry + // The only log entry type we're interested in is a chat completion chunk, + // which can be verified by a successful unmarshal into the corresponding + // type AND the Object field being equal to "chat.completion.chunk". The + // latter is to avoid accepting an empty JSON object (i.e. "{}"). Also, + // if the entry is not what we expect, we should just skip and avoid + // returning an error. + var entry chatCompletionChunkEntry err := json.Unmarshal([]byte(raw), &entry) - if err != nil { - return false, fmt.Errorf("unexpected log entry: %w", err) + if err != nil || entry.Object != "chat.completion.chunk" { + continue } if stop, err := renderLogEntry(entry, w, cs); err != nil { @@ -77,42 +82,53 @@ func (r *logRenderer) Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme return false, nil } -func renderLogEntry(entry logEntry, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { +func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { var stop bool for _, choice := range entry.Choices { if choice.FinishReason == "stop" { stop = true } - if choice.Delta.Content == "" { - continue - } - - if choice.Delta.Role != "" && choice.Delta.Role != "assistant" { - // Because... - continue - } - - if choice.Delta.ToolCalls == nil { - // message - fmt.Fprintln(w, "") - if _, err := fmt.Fprintf(w, "> %s\n", choice.Delta.Content); err != nil { - return false, err + if len(choice.Delta.ToolCalls) == 0 { + if choice.Delta.Content != "" && choice.Delta.Role == "assistant" { + // Copilot message and we should display. + renderCopilotMessage(w, cs, choice.Delta.Content) } continue } + // Since we don't want to clear-and-reprint live progress of events, we + // need to only process entries that correspond to a finished tool call. + // Such entries have a non-empty Content field. + if choice.Delta.Content == "" { + continue + } + + if choice.Delta.ReasoningText != "" { + // Note that this should be formatted as a normal Copilot message. + renderCopilotMessage(w, cs, choice.Delta.ReasoningText) + } + + render := func(s string) { + _, _ = fmt.Fprintf(w, "%s\n", s) + } + for _, tc := range choice.Delta.ToolCalls { - fmt.Fprintln(w, "") - switch tc.Function.Name { + name := tc.Function.Name + if name == "" { + continue + } + + args := tc.Function.Arguments + + switch name { case "run_setup": - args := toolCallRunSetup{} - if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - return false, fmt.Errorf("failed to parse 'run_setup' tool call arguments: %w", err) + if v := unmarshal[runSetupToolArgs](args); v != nil { + render(v.Name) // e.g. "Start 'github-mcp-server' MCP server" + continue } - fmt.Fprintf(w, "- %s\n", cs.Bold(args.Name)) case "view": - args := toolCallView{} + args := viewToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'view' tool call arguments: %w", err) } @@ -120,60 +136,124 @@ func renderLogEntry(entry logEntry, w io.Writer, cs *iostreams.ColorScheme) (boo // NOTE: omit the output since it's a git diff fmt.Fprintf(w, "- View %s\n", cs.Bold(args.Path)) case "bash": - args := toolCallBash{} - if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - return false, fmt.Errorf("failed to parse 'bash' tool call arguments: %w", err) + if v := unmarshal[bashToolArgs](args); v != nil { + if v.Description != "" { + render("Bash: " + cs.Bold(v.Description)) + } else { + render("Run Bash command") + } + continue + } + case "write_bash": + if v := unmarshal[writeBashToolArgs](args); v != nil { + render("Send input to Bash session " + v.SessionID) + continue + } + case "read_bash": + if v := unmarshal[readBashToolArgs](args); v != nil { + render("Read logs from Bash session " + v.SessionID) + continue + } + case "stop_bash": + if v := unmarshal[stopBashToolArgs](args); v != nil { + render("Stop Bash session " + v.SessionID) + continue + } + case "async_bash": + if v := unmarshal[asyncBashToolArgs](args); v != nil { + render("Start or send input to long-running Bash session " + v.SessionID) + continue + } + case "read_async_bash": + if v := unmarshal[readAsyncBashToolArgs](args); v != nil { + render("View logs from long-running Bash session " + v.SessionID) + continue + } + case "stop_async_bash": + if v := unmarshal[stopAsyncBashToolArgs](args); v != nil { + render("Stop long-running Bash session " + v.SessionID) + continue } - // NOTE: omit the delta.content to reduce noise - fmt.Fprintf(w, "- Bash: %s\n", cs.Bold(args.Description)) case "think": - args := toolCallThink{} + if v := unmarshal[thinkToolArgs](args); v != nil { + render("Stop long-running Bash session " + v.SessionID) + continue + } + + args := thinkToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'think' tool call arguments: %w", err) } // NOTE: omit the delta.content since it's the same as thought fmt.Fprintf(w, "? %s: %s\n", cs.Bold("Thought:"), args.Thought) case "report_progress": - args := toolCallReportProgress{} + args := reportProgressToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'report_progress' tool call arguments: %w", err) } // NOTE: omit the delta.content to reduce noise fmt.Fprintf(w, "! Progress update: %s\n", cs.Bold(args.CommitMessage)) case "create": - args := toolCallCreate{} + args := createToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'create' tool call arguments: %w", err) } // NOTE: omit the delta.content since it's a diff fmt.Fprintf(w, "- Create %s\n", cs.Bold(args.Path)) case "str_replace": - args := toolCallStrReplace{} + args := strReplaceToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'str_replace' tool call arguments: %w", err) } // NOTE: omit the delta.content since it's a diff fmt.Fprintf(w, "- Edit %s\n", cs.Bold(args.Path)) - default: - // Unknown tool call. For example for "codeql_checker": - // NOTE: omit the delta.content since we don't know how large could that be - fmt.Fprintf(w, "- Call to %s\n", cs.Bold(tc.Function.Name)) } + + // Unknown tool call. For example for "codeql_checker": + // NOTE: omit the delta.content since we don't know how large could that be + renderGenericToolCall(w, name) } } return stop, nil } -type logEntry struct { +func unmarshal[T any](raw string) *T { + var t T + if err := json.Unmarshal([]byte(raw), &t); err != nil { + return nil + } + return &t +} + +func renderCopilotMessage(w io.Writer, message string) { + _, _ = fmt.Fprintf(w, "%s\n", message) +} + +func renderToolCall(w io.Writer, name, title string) { + if name != "" && title != "" { + _, _ = fmt.Fprintf(w, "%s %s\n", name, title) + } else if title == "" { + _, _ = fmt.Fprintf(w, "%s\n", name) + } else { + _, _ = fmt.Fprintf(w, "%s\n", title) + } +} + +func renderGenericToolCall(w io.Writer, name string) { + _, _ = fmt.Fprintf(w, "Call to %s\n", name) +} + +type chatCompletionChunkEntry struct { ID string `json:"id"` Created int64 `json:"created"` Model string `json:"model"` Object string `json:"object"` Choices []struct { Delta struct { - Content string `json:"content"` - Role string `json:"role"` - ToolCalls []struct { + ReasoningText string `json:"reasoning_text"` + Content string `json:"content"` + Role string `json:"role"` + ToolCalls []struct { Function struct { Name string `json:"name"` Arguments string `json:"arguments"` @@ -187,36 +267,59 @@ type logEntry struct { } `json:"choices"` } -type toolCallRunSetup struct { +type runSetupToolArgs struct { Name string `json:"name"` } -type toolCallView struct { - Path string `json:"path"` -} - -type toolCallBash struct { - Async bool `json:"async"` +type bashToolArgs struct { Command string `json:"command"` Description string `json:"description"` - SessionID string `json:"sessionId"` } -type toolCallThink struct { +type readBashToolArgs struct { + SessionID string `json:"sessionId"` +} + +type writeBashToolArgs struct { + SessionID string `json:"sessionId"` + Input string `json:"input"` +} + +type stopBashToolArgs struct { + SessionID string `json:"sessionId"` +} + +type asyncBashToolArgs struct { + Command string `json:"command"` + SessionID string `json:"sessionId"` +} + +type readAsyncBashToolArgs struct { + SessionID string `json:"sessionId"` +} + +type stopAsyncBashToolArgs struct { + SessionID string `json:"sessionId"` +} + +type viewToolArgs struct { + Path string `json:"path"` +} +type thinkToolArgs struct { Thought string `json:"thought"` } -type toolCallReportProgress struct { +type reportProgressToolArgs struct { CommitMessage string `json:"commitMessage"` PrDescription string `json:"prDescription"` } -type toolCallCreate struct { +type createToolArgs struct { FileText string `json:"file_text"` Path string `json:"path"` } -type toolCallStrReplace struct { +type strReplaceToolArgs struct { NewStr string `json:"new_str"` OldStr string `json:"old_str"` Path string `json:"path"` From 0fb10fca7d1337c3a630726a1048615c284c0edd Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:24:16 -0600 Subject: [PATCH 131/208] 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. --- pkg/cmd/agent-task/shared/log.go | 302 ++++++++++++++++++-------- pkg/cmd/agent-task/shared/log_mock.go | 28 +-- pkg/cmd/agent-task/shared/log_test.go | 2 +- pkg/cmd/agent-task/view/view.go | 5 +- pkg/cmd/agent-task/view/view_test.go | 4 +- 5 files changed, 225 insertions(+), 116 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 567a97c27..73a5a8a5c 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -9,13 +9,14 @@ import ( "strings" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" ) //go:generate moq -rm -out log_mock.go . LogRenderer type LogRenderer interface { - Follow(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error - Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (stop bool, err error) + Follow(fetcher func() ([]byte, error), w io.Writer, io *iostreams.IOStreams) error + Render(logs []byte, w io.Writer, io *iostreams.IOStreams) (stop bool, err error) } type logRenderer struct{} @@ -24,7 +25,7 @@ func NewLogRenderer() LogRenderer { return &logRenderer{} } -func (r *logRenderer) Follow(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error { +func (r *logRenderer) Follow(fetcher func() ([]byte, error), w io.Writer, io *iostreams.IOStreams) error { var last string for { raw, err := fetcher() @@ -39,7 +40,7 @@ func (r *logRenderer) Follow(fetcher func() ([]byte, error), w io.Writer, cs *io diff := strings.TrimSpace(logs[len(last):]) - if stop, err := r.Render([]byte(diff), w, cs); err != nil { + if stop, err := r.Render([]byte(diff), w, io); err != nil { return err } else if stop { return nil @@ -49,7 +50,7 @@ func (r *logRenderer) Follow(fetcher func() ([]byte, error), w io.Writer, cs *io } } -func (r *logRenderer) Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { +func (r *logRenderer) Render(logs []byte, w io.Writer, io *iostreams.IOStreams) (bool, error) { lines := slices.DeleteFunc(strings.Split(string(logs), "\n"), func(line string) bool { return line == "" }) @@ -72,7 +73,7 @@ func (r *logRenderer) Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme continue } - if stop, err := renderLogEntry(entry, w, cs); err != nil { + if stop, err := renderLogEntry(entry, w, io); err != nil { return false, fmt.Errorf("failed to process log entry: %w", err) } else if stop { return true, nil @@ -82,7 +83,8 @@ func (r *logRenderer) Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme return false, nil } -func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { +func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.IOStreams) (bool, error) { + cs := io.ColorScheme() var stop bool for _, choice := range entry.Choices { if choice.FinishReason == "stop" { @@ -92,7 +94,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, cs *iostreams.C if len(choice.Delta.ToolCalls) == 0 { if choice.Delta.Content != "" && choice.Delta.Role == "assistant" { // Copilot message and we should display. - renderCopilotMessage(w, cs, choice.Delta.Content) + renderRawMarkdown(choice.Delta.Content, w, io) } continue } @@ -106,11 +108,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, cs *iostreams.C if choice.Delta.ReasoningText != "" { // Note that this should be formatted as a normal Copilot message. - renderCopilotMessage(w, cs, choice.Delta.ReasoningText) - } - - render := func(s string) { - _, _ = fmt.Fprintf(w, "%s\n", s) + renderRawMarkdown(choice.Delta.ReasoningText, w, io) } for _, tc := range choice.Delta.ToolCalls { @@ -124,7 +122,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, cs *iostreams.C switch name { case "run_setup": if v := unmarshal[runSetupToolArgs](args); v != nil { - render(v.Name) // e.g. "Start 'github-mcp-server' MCP server" + renderToolCall(w, cs, "Start "+v.Name+" MCP server", "") continue } case "view": @@ -132,91 +130,191 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, cs *iostreams.C if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'view' tool call arguments: %w", err) } - // TODO: detect if it's the repository root or just a file to show the right message - // NOTE: omit the output since it's a git diff - fmt.Fprintf(w, "- View %s\n", cs.Bold(args.Path)) + fmt.Fprintf(w, "View %s\n", cs.Bold(relativePath(args.Path))) + + // TODO: Strip the diff formatting from this, but for now render as it is. + if err := renderFileContentAsMarkdown("output.diff", choice.Delta.Content, w, io); err != nil { + return false, fmt.Errorf("failed to render viewed file content: %w", err) + } case "bash": if v := unmarshal[bashToolArgs](args); v != nil { if v.Description != "" { - render("Bash: " + cs.Bold(v.Description)) + renderToolCall(w, cs, "Bash", v.Description) } else { - render("Run Bash command") + renderToolCall(w, cs, "Run Bash command", "") + } + + contentWithCommand := choice.Delta.Content + if v.Command != "" { + contentWithCommand = fmt.Sprintf("%s\n%s", v.Command, choice.Delta.Content) + } + if err := renderFileContentAsMarkdown("commands.sh", contentWithCommand, w, io); err != nil { + return false, fmt.Errorf("failed to render bash command output: %w", err) } - continue - } - case "write_bash": - if v := unmarshal[writeBashToolArgs](args); v != nil { - render("Send input to Bash session " + v.SessionID) - continue - } - case "read_bash": - if v := unmarshal[readBashToolArgs](args); v != nil { - render("Read logs from Bash session " + v.SessionID) - continue - } - case "stop_bash": - if v := unmarshal[stopBashToolArgs](args); v != nil { - render("Stop Bash session " + v.SessionID) - continue - } - case "async_bash": - if v := unmarshal[asyncBashToolArgs](args); v != nil { - render("Start or send input to long-running Bash session " + v.SessionID) - continue - } - case "read_async_bash": - if v := unmarshal[readAsyncBashToolArgs](args); v != nil { - render("View logs from long-running Bash session " + v.SessionID) - continue - } - case "stop_async_bash": - if v := unmarshal[stopAsyncBashToolArgs](args); v != nil { - render("Stop long-running Bash session " + v.SessionID) - continue - } - case "think": - if v := unmarshal[thinkToolArgs](args); v != nil { - render("Stop long-running Bash session " + v.SessionID) - continue } + // GUI does not support these. + // case "write_bash": + // if v := unmarshal[writeBashToolArgs](args); v != nil { + // renderToolCallTitle("Send input to Bash session " + v.SessionID) + // continue + // } + // case "read_bash": + // if v := unmarshal[readBashToolArgs](args); v != nil { + // renderToolCallTitle("Read logs from Bash session " + v.SessionID) + // continue + // } + // case "stop_bash": + // if v := unmarshal[stopBashToolArgs](args); v != nil { + // renderToolCallTitle("Stop Bash session " + v.SessionID) + // continue + // } + // case "async_bash": + // if v := unmarshal[asyncBashToolArgs](args); v != nil { + // renderToolCallTitle("Start or send input to long-running Bash session " + v.SessionID) + // continue + // } + // case "read_async_bash": + // if v := unmarshal[readAsyncBashToolArgs](args); v != nil { + // renderToolCallTitle("View logs from long-running Bash session " + v.SessionID) + // continue + // } + // case "stop_async_bash": + // if v := unmarshal[stopAsyncBashToolArgs](args); v != nil { + // renderToolCallTitle("Stop long-running Bash session " + v.SessionID) + // continue + // } + case "think": args := thinkToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'think' tool call arguments: %w", err) } + // NOTE: omit the delta.content since it's the same as thought - fmt.Fprintf(w, "? %s: %s\n", cs.Bold("Thought:"), args.Thought) + renderToolCall(w, cs, "Thought", "") + if err := renderRawMarkdown(args.Thought, w, io); err != nil { + return false, fmt.Errorf("failed to render thought: %w", err) + } case "report_progress": args := reportProgressToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'report_progress' tool call arguments: %w", err) } // NOTE: omit the delta.content to reduce noise - fmt.Fprintf(w, "! Progress update: %s\n", cs.Bold(args.CommitMessage)) + renderToolCall(w, cs, "Progress update", cs.Bold(args.CommitMessage)) case "create": args := createToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'create' tool call arguments: %w", err) } - // NOTE: omit the delta.content since it's a diff - fmt.Fprintf(w, "- Create %s\n", cs.Bold(args.Path)) + renderToolCall(w, cs, "Create", cs.Bold(relativePath(args.Path))) + + if err := renderFileContentAsMarkdown(args.Path, args.FileText, w, io); err != nil { + return false, fmt.Errorf("failed to render created file content: %w", err) + } case "str_replace": args := strReplaceToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'str_replace' tool call arguments: %w", err) } - // NOTE: omit the delta.content since it's a diff - fmt.Fprintf(w, "- Edit %s\n", cs.Bold(args.Path)) - } - // Unknown tool call. For example for "codeql_checker": - // NOTE: omit the delta.content since we don't know how large could that be - renderGenericToolCall(w, name) + renderToolCall(w, cs, "Edit", cs.Bold(relativePath(args.Path))) + if err := renderFileContentAsMarkdown("output.diff", choice.Delta.Content, w, io); err != nil { + return false, fmt.Errorf("failed to render str_replace diff: %w", err) + } + default: + // Unknown tool call. For example for "codeql_checker": + // NOTE: omit the delta.content since we don't know how large could that be + renderGenericToolCall(w, cs, name) + + // If it's JSON, treat it as such, otherwise we skip whatever the content is. + var contentAsJSON any + if err := json.Unmarshal([]byte(choice.Delta.Content), &contentAsJSON); err == nil { + marshaled, err := json.MarshalIndent(contentAsJSON, "", " ") + if err == nil { + choice.Delta.Content = string(marshaled) + } + + if err := renderFileContentAsMarkdown("output.json", string(marshaled), w, io); err != nil { + return false, fmt.Errorf("failed to render output.json: %w", err) + } + } + } } } return stop, nil } +func renderRawMarkdown(md string, w io.Writer, io *iostreams.IOStreams) error { + // Glamour doesn't add leading newlines when content is a complete + // markdown document. So, we have to add the leading newline. + paddingFunc := func(s string) string { + return fmt.Sprintf("\n%s\n\n", s) + } + + return renderMarkdownWithPadding(md, w, io, paddingFunc) +} + +// renderMarkdownWithPadding renders the given markdown string to the given writer. +// If a paddingFunc is provided, the md string is ran through it before +// rendering. This can be used to add newlines before and after the content. +func renderMarkdownWithPadding(md string, w io.Writer, io *iostreams.IOStreams, paddingFunc func(string) string) error { + rendered, err := markdown.Render(md, + markdown.WithTheme(io.TerminalTheme()), + markdown.WithWrap(io.TerminalWidth()), + ) + + if err != nil { + return fmt.Errorf("failed to render markdown: %w", err) + } + + rendered = strings.TrimSpace(rendered) + if paddingFunc != nil { + rendered = paddingFunc(rendered) + } + + fmt.Fprint(w, rendered) + + return nil +} + +// renderFileContentAsMarkdown renders the given content as markdown +// based on the file extension of the path. +func renderFileContentAsMarkdown(path, content string, w io.Writer, io *iostreams.IOStreams) error { + parts := strings.Split(path, ".") + lang := parts[len(parts)-1] + content = strings.TrimSpace(content) + + if lang == "md" { + return renderMarkdownWithPadding(content, w, io, nil) + } + + md := fmt.Sprintf("```%s\n%s\n```", lang, content) + // Glamour adds leading newlines when content is only a code block, + // so we only want to add a trailing newline. + paddingFunc := func(s string) string { + return fmt.Sprintf("%s\n\n", s) + } + + return renderMarkdownWithPadding(md, w, io, paddingFunc) +} + +func relativePath(absPath string) string { + relPath := strings.TrimPrefix(absPath, "/home/runner/work/") + + parts := strings.Split(relPath, "/") + + // The last two parts of the path are the + // repo name and the repo owner. + // If that's all we have (or less), + // we return a friendly name "repository". + if len(parts) > 2 { + // Drop the repo owner and name, returning the remaining path. + return strings.Join(parts[2:], "/") + } + return "repository" +} + func unmarshal[T any](raw string) *T { var t T if err := json.Unmarshal([]byte(raw), &t); err != nil { @@ -225,22 +323,33 @@ func unmarshal[T any](raw string) *T { return &t } -func renderCopilotMessage(w io.Writer, message string) { - _, _ = fmt.Fprintf(w, "%s\n", message) -} +func renderToolCall(w io.Writer, cs *iostreams.ColorScheme, descriptor, title string) { + if title != "" { + title = cs.Bold(title) + } -func renderToolCall(w io.Writer, name, title string) { - if name != "" && title != "" { - _, _ = fmt.Fprintf(w, "%s %s\n", name, title) + if descriptor != "" && title != "" { + fmt.Fprintf(w, "%s: %s\n", descriptor, title) } else if title == "" { - _, _ = fmt.Fprintf(w, "%s\n", name) + fmt.Fprintf(w, "%s\n", descriptor) } else { - _, _ = fmt.Fprintf(w, "%s\n", title) + fmt.Fprintf(w, "%s\n", title) } } -func renderGenericToolCall(w io.Writer, name string) { - _, _ = fmt.Fprintf(w, "Call to %s\n", name) +func renderGenericToolCall(w io.Writer, cs *iostreams.ColorScheme, name string) { + genericToolCallTitles := map[string]string{ + "codeql_checker": "Run CodeQL analysis", + "github-mcp-server-list_issues": "List issues on GitHub", + "github-mcp-server-list_pull_requests": "List pull requests on GitHub", + } + + descriptor, ok := genericToolCallTitles[name] + if !ok { + descriptor = fmt.Sprintf("Call to %s", name) + } + + renderToolCall(w, cs, descriptor, "") } type chatCompletionChunkEntry struct { @@ -276,37 +385,38 @@ type bashToolArgs struct { Description string `json:"description"` } -type readBashToolArgs struct { - SessionID string `json:"sessionId"` -} +// type readBashToolArgs struct { +// SessionID string `json:"sessionId"` +// } -type writeBashToolArgs struct { - SessionID string `json:"sessionId"` - Input string `json:"input"` -} +// type writeBashToolArgs struct { +// SessionID string `json:"sessionId"` +// Input string `json:"input"` +// } -type stopBashToolArgs struct { - SessionID string `json:"sessionId"` -} +// type stopBashToolArgs struct { +// SessionID string `json:"sessionId"` +// } -type asyncBashToolArgs struct { - Command string `json:"command"` - SessionID string `json:"sessionId"` -} +// type asyncBashToolArgs struct { +// Command string `json:"command"` +// SessionID string `json:"sessionId"` +// } -type readAsyncBashToolArgs struct { - SessionID string `json:"sessionId"` -} +// type readAsyncBashToolArgs struct { +// SessionID string `json:"sessionId"` +// } -type stopAsyncBashToolArgs struct { - SessionID string `json:"sessionId"` -} +// type stopAsyncBashToolArgs struct { +// SessionID string `json:"sessionId"` +// } type viewToolArgs struct { Path string `json:"path"` } type thinkToolArgs struct { - Thought string `json:"thought"` + SessionID string `json:"sessionId"` + Thought string `json:"thought"` } type reportProgressToolArgs struct { diff --git a/pkg/cmd/agent-task/shared/log_mock.go b/pkg/cmd/agent-task/shared/log_mock.go index 7307e7821..e4096e273 100644 --- a/pkg/cmd/agent-task/shared/log_mock.go +++ b/pkg/cmd/agent-task/shared/log_mock.go @@ -19,10 +19,10 @@ var _ LogRenderer = &LogRendererMock{} // // // make and configure a mocked LogRenderer // mockedLogRenderer := &LogRendererMock{ -// FollowFunc: func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error { +// FollowFunc: func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.IOStreams) error { // panic("mock out the Follow method") // }, -// RenderFunc: func(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { +// RenderFunc: func(logs []byte, w io.Writer, cs *iostreams.IOStreams) (bool, error) { // panic("mock out the Render method") // }, // } @@ -33,10 +33,10 @@ var _ LogRenderer = &LogRendererMock{} // } type LogRendererMock struct { // FollowFunc mocks the Follow method. - FollowFunc func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error + FollowFunc func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.IOStreams) error // RenderFunc mocks the Render method. - RenderFunc func(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) + RenderFunc func(logs []byte, w io.Writer, cs *iostreams.IOStreams) (bool, error) // calls tracks calls to the methods. calls struct { @@ -47,7 +47,7 @@ type LogRendererMock struct { // W is the w argument value. W io.Writer // Cs is the cs argument value. - Cs *iostreams.ColorScheme + Cs *iostreams.IOStreams } // Render holds details about calls to the Render method. Render []struct { @@ -56,7 +56,7 @@ type LogRendererMock struct { // W is the w argument value. W io.Writer // Cs is the cs argument value. - Cs *iostreams.ColorScheme + Cs *iostreams.IOStreams } } lockFollow sync.RWMutex @@ -64,14 +64,14 @@ type LogRendererMock struct { } // Follow calls FollowFunc. -func (mock *LogRendererMock) Follow(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error { +func (mock *LogRendererMock) Follow(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.IOStreams) error { if mock.FollowFunc == nil { panic("LogRendererMock.FollowFunc: method is nil but LogRenderer.Follow was just called") } callInfo := struct { Fetcher func() ([]byte, error) W io.Writer - Cs *iostreams.ColorScheme + Cs *iostreams.IOStreams }{ Fetcher: fetcher, W: w, @@ -90,12 +90,12 @@ func (mock *LogRendererMock) Follow(fetcher func() ([]byte, error), w io.Writer, func (mock *LogRendererMock) FollowCalls() []struct { Fetcher func() ([]byte, error) W io.Writer - Cs *iostreams.ColorScheme + Cs *iostreams.IOStreams } { var calls []struct { Fetcher func() ([]byte, error) W io.Writer - Cs *iostreams.ColorScheme + Cs *iostreams.IOStreams } mock.lockFollow.RLock() calls = mock.calls.Follow @@ -104,14 +104,14 @@ func (mock *LogRendererMock) FollowCalls() []struct { } // Render calls RenderFunc. -func (mock *LogRendererMock) Render(logs []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { +func (mock *LogRendererMock) Render(logs []byte, w io.Writer, cs *iostreams.IOStreams) (bool, error) { if mock.RenderFunc == nil { panic("LogRendererMock.RenderFunc: method is nil but LogRenderer.Render was just called") } callInfo := struct { Logs []byte W io.Writer - Cs *iostreams.ColorScheme + Cs *iostreams.IOStreams }{ Logs: logs, W: w, @@ -130,12 +130,12 @@ func (mock *LogRendererMock) Render(logs []byte, w io.Writer, cs *iostreams.Colo func (mock *LogRendererMock) RenderCalls() []struct { Logs []byte W io.Writer - Cs *iostreams.ColorScheme + Cs *iostreams.IOStreams } { var calls []struct { Logs []byte W io.Writer - Cs *iostreams.ColorScheme + Cs *iostreams.IOStreams } mock.lockRender.RLock() calls = mock.calls.Render diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go index a713b4ced..ebaeeafc9 100644 --- a/pkg/cmd/agent-task/shared/log_test.go +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -49,7 +49,7 @@ func TestFollow(t *testing.T) { ios, _, stdout, _ := iostreams.Test() - err = NewLogRenderer().Follow(fetcher, stdout, ios.ColorScheme()) + err = NewLogRenderer().Follow(fetcher, stdout, ios) require.NoError(t, err) want, err := os.ReadFile(tt.want) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 9058ec83e..8d43b1f7e 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -323,7 +323,6 @@ func printSession(opts *ViewOptions, session *capi.Session) { func printLogs(opts *ViewOptions, capiClient capi.CapiClient, sessionID string) error { ctx := context.Background() - cs := opts.IO.ColorScheme() renderer := opts.LogRenderer() if opts.Follow { @@ -341,7 +340,7 @@ func printLogs(opts *ViewOptions, capiClient capi.CapiClient, sessionID string) } fmt.Fprintln(opts.IO.Out, "") - return renderer.Follow(fetcher, opts.IO.Out, cs) + return renderer.Follow(fetcher, opts.IO.Out, opts.IO) } raw, err := capiClient.GetSessionLogs(ctx, sessionID) @@ -350,6 +349,6 @@ func printLogs(opts *ViewOptions, capiClient capi.CapiClient, sessionID string) } fmt.Fprintln(opts.IO.Out, "") - _, err = renderer.Render(raw, opts.IO.Out, cs) + _, err = renderer.Render(raw, opts.IO.Out, opts.IO) return err } diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index dd361f919..1d4a8b2cc 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -884,7 +884,7 @@ func Test_viewRun(t *testing.T) { } }, logRendererStubs: func(t *testing.T, m *shared.LogRendererMock) { - m.RenderFunc = func(raw []byte, w io.Writer, cs *iostreams.ColorScheme) (bool, error) { + m.RenderFunc = func(raw []byte, w io.Writer, ios *iostreams.IOStreams) (bool, error) { w.Write([]byte("(rendered:) " + string(raw) + "\n")) return false, nil } @@ -935,7 +935,7 @@ func Test_viewRun(t *testing.T) { } }, logRendererStubs: func(t *testing.T, m *shared.LogRendererMock) { - m.FollowFunc = func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.ColorScheme) error { + m.FollowFunc = func(fetcher func() ([]byte, error), w io.Writer, ios *iostreams.IOStreams) error { raw, err := fetcher() require.NoError(t, err) w.Write([]byte("(rendered:) " + string(raw) + "\n")) From 9e16a82e5e61a8a4a085a732beddd65580d530a8 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:40:08 -0600 Subject: [PATCH 132/208] Refactor JSON content rendering in log entries Extracted JSON content rendering into a new helper function renderContentAsJSONMarkdown for reuse. Updated 'report_progress' and 'create' tool call handling to use this helper, improving code clarity and reducing duplication. Also added support for rendering PR descriptions in 'report_progress' tool calls. --- pkg/cmd/agent-task/shared/log.go | 45 +++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 73a5a8a5c..0729dbf3c 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -153,7 +153,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } } - // GUI does not support these. + // GUI does not currently support these. // case "write_bash": // if v := unmarshal[writeBashToolArgs](args); v != nil { // renderToolCallTitle("Send input to Bash session " + v.SessionID) @@ -200,8 +200,22 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'report_progress' tool call arguments: %w", err) } - // NOTE: omit the delta.content to reduce noise + renderToolCall(w, cs, "Progress update", cs.Bold(args.CommitMessage)) + if args.PrDescription != "" { + if err := renderRawMarkdown(args.PrDescription, w, io); err != nil { + return false, fmt.Errorf("failed to render PR description: %w", err) + } + } + + // TODO: KW I wasn't able to get this to populate. + if choice.Delta.Content != "" { + // Try to treat this as JSON + if err := renderContentAsJSONMarkdown(choice.Delta.Content, w, io); err != nil { + return false, fmt.Errorf("failed to render progress update content: %w", err) + } + } + case "create": args := createToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { @@ -228,23 +242,28 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I renderGenericToolCall(w, cs, name) // If it's JSON, treat it as such, otherwise we skip whatever the content is. - var contentAsJSON any - if err := json.Unmarshal([]byte(choice.Delta.Content), &contentAsJSON); err == nil { - marshaled, err := json.MarshalIndent(contentAsJSON, "", " ") - if err == nil { - choice.Delta.Content = string(marshaled) - } - - if err := renderFileContentAsMarkdown("output.json", string(marshaled), w, io); err != nil { - return false, fmt.Errorf("failed to render output.json: %w", err) - } - } + _ = renderContentAsJSONMarkdown(choice.Delta.Content, w, io) } } } return stop, nil } +func renderContentAsJSONMarkdown(content string, w io.Writer, io *iostreams.IOStreams) error { + var contentAsJSON any + if err := json.Unmarshal([]byte(content), &contentAsJSON); err == nil { + marshaled, err := json.MarshalIndent(contentAsJSON, "", " ") + if err == nil { + content = string(marshaled) + } + + if err := renderFileContentAsMarkdown("output.json", string(marshaled), w, io); err != nil { + return fmt.Errorf("failed to render JSON: %w", err) + } + } + return nil +} + func renderRawMarkdown(md string, w io.Writer, io *iostreams.IOStreams) error { // Glamour doesn't add leading newlines when content is a complete // markdown document. So, we have to add the leading newline. From ec3fce0dfb0649714f678abc5785e07286a8136f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 12 Sep 2025 17:24:04 -0600 Subject: [PATCH 133/208] Improve log rendering and tool call handling Enhances log rendering by stripping diff formatting from viewed file content and improving JSON rendering with optional labels. Expands the list of recognized tool calls with descriptive titles, especially for Playwright and GitHub MCP server tools. Refactors related helper functions for clarity and robustness. --- pkg/cmd/agent-task/shared/log.go | 116 +++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 0729dbf3c..1dddd6c76 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -122,7 +122,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I switch name { case "run_setup": if v := unmarshal[runSetupToolArgs](args); v != nil { - renderToolCall(w, cs, "Start "+v.Name+" MCP server", "") + renderToolCall(w, cs, v.Name, "") continue } case "view": @@ -132,8 +132,10 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } fmt.Fprintf(w, "View %s\n", cs.Bold(relativePath(args.Path))) + content := stripDiffFormat(choice.Delta.Content) + // TODO: Strip the diff formatting from this, but for now render as it is. - if err := renderFileContentAsMarkdown("output.diff", choice.Delta.Content, w, io); err != nil { + if err := renderFileContentAsMarkdown(args.Path, content, w, io); err != nil { return false, fmt.Errorf("failed to render viewed file content: %w", err) } case "bash": @@ -211,7 +213,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I // TODO: KW I wasn't able to get this to populate. if choice.Delta.Content != "" { // Try to treat this as JSON - if err := renderContentAsJSONMarkdown(choice.Delta.Content, w, io); err != nil { + if err := renderContentAsJSONMarkdown("", choice.Delta.Content, w, io); err != nil { return false, fmt.Errorf("failed to render progress update content: %w", err) } } @@ -242,19 +244,29 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I renderGenericToolCall(w, cs, name) // If it's JSON, treat it as such, otherwise we skip whatever the content is. - _ = renderContentAsJSONMarkdown(choice.Delta.Content, w, io) + _ = renderContentAsJSONMarkdown("Output:", choice.Delta.Content, w, io) + + // The entirety of the args can be treated as "input" to the tool call. + // We try to render it as JSON, but if that fails, just skip it. + _ = renderContentAsJSONMarkdown("Input:", args, w, io) } } } return stop, nil } -func renderContentAsJSONMarkdown(content string, w io.Writer, io *iostreams.IOStreams) error { +func renderContentAsJSONMarkdown(label, content string, w io.Writer, io *iostreams.IOStreams) error { var contentAsJSON any if err := json.Unmarshal([]byte(content), &contentAsJSON); err == nil { marshaled, err := json.MarshalIndent(contentAsJSON, "", " ") - if err == nil { - content = string(marshaled) + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + if label != "" { + if err := renderRawMarkdown(label, w, io); err != nil { + return fmt.Errorf("failed to render label: %w", err) + } } if err := renderFileContentAsMarkdown("output.json", string(marshaled), w, io); err != nil { @@ -297,15 +309,46 @@ func renderMarkdownWithPadding(md string, w io.Writer, io *iostreams.IOStreams, return nil } +func stripDiffFormat(diff string) string { + lines := strings.Split(diff, "\n") + + // Find where the hunk header ends. + hunkEndIndex := -1 + for i, line := range lines { + if strings.HasPrefix(line, "@@") { + hunkEndIndex = i + break + } + } + + // If we found the hunk header end, we strip everything before it. + if hunkEndIndex != -1 { + lines = lines[hunkEndIndex+1:] + } else { + // This isn't a diff, so we defensively just return the original string. + return diff + } + + // Now we strip the leading + and - from lines. + var stripped []string + for _, line := range lines { + if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") { + stripped = append(stripped, line[1:]) + } else { + stripped = append(stripped, line) + } + } + return strings.Join(stripped, "\n") +} + // renderFileContentAsMarkdown renders the given content as markdown // based on the file extension of the path. func renderFileContentAsMarkdown(path, content string, w io.Writer, io *iostreams.IOStreams) error { parts := strings.Split(path, ".") lang := parts[len(parts)-1] - content = strings.TrimSpace(content) if lang == "md" { - return renderMarkdownWithPadding(content, w, io, nil) + return renderRawMarkdown(content, w, io) } md := fmt.Sprintf("```%s\n%s\n```", lang, content) @@ -358,9 +401,58 @@ func renderToolCall(w io.Writer, cs *iostreams.ColorScheme, descriptor, title st func renderGenericToolCall(w io.Writer, cs *iostreams.ColorScheme, name string) { genericToolCallTitles := map[string]string{ - "codeql_checker": "Run CodeQL analysis", - "github-mcp-server-list_issues": "List issues on GitHub", - "github-mcp-server-list_pull_requests": "List pull requests on GitHub", + // Custom tools, the GitHub UI doesn't currently have these. + "codeql_checker": "Run CodeQL analysis", + + // Playwright tools. + "playwright-browser_navigate": "Navigate Playwright web browser to a URL", + "playwright-browser_navigate_back": "Navigate back in Playwright web browser", + "playwright-browser_navigate_forward": "Navigate forward in Playwright web browser", + "playwright-browser_click": "Click element in Playwright web browser", + "playwright-browser_take_screenshot": "Take screenshot of Playwright web browser", + "playwright-browser_type": "Type in Playwright web browser", + "playwright-browser_wait_for": "Wait for text to appear/disappear in Playwright web browser", + "playwright-browser_evaluate": "Run JavaScript in Playwright web browser", + "playwright-browser_snapshot": "Take snapshot of page in Playwright web browser", + "playwright-browser_resize": "Resize Playwright web browser window", + "playwright-browser_close": "Close Playwright web browser", + "playwright-browser_press_key": "Press key in Playwright web browser", + "playwright-browser_select_option": "Select option in Playwright web browser", + "playwright-browser_handle_dialog": "Interact with dialog in Playwright web browser", + "playwright-browser_console_messages": "Get console messages from Playwright web browser", + "playwright-browser_drag": "Drag mouse between elements in Playwright web browser", + "playwright-browser_file_upload": "Upload file in Playwright web browser", + "playwright-browser_hover": "Hover mouse over element in Playwright web browser", + "playwright-browser_network_requests": "Get network requests from Playwright web browser", + + // GitHub MCP server common tools + "github-mcp-server-get_file_contents": "Get file contents from GitHub", + "github-mcp-server-get_pull_request": "Get pull request from GitHub", + "github-mcp-server-get_issue": "Get issue from GitHub", + "github-mcp-server-get_pull_request_files": "Get pull request changed files from GitHub", + "github-mcp-server-list_pull_requests": "List pull requests on GitHub", + "github-mcp-server-list_branches": "List branches on GitHub", + "github-mcp-server-get_pull_request_diff": "Get pull request diff from GitHub", + "github-mcp-server-get_pull_request_comments": "Get pull request comments from GitHub", + "github-mcp-server-get_commit": "Get commit from GitHub", + "github-mcp-server-search_repositories": "Search repositories on GitHub", + "github-mcp-server-search_code": "Search code on GitHub", + "github-mcp-server-get_issue_comments": "Get issue comments from GitHub", + "github-mcp-server-list_issues": "List issues on GitHub", + "github-mcp-server-search_pull_requests": "Search pull requests on GitHub", + "github-mcp-server-list_commits": "List commits on GitHub", + "github-mcp-server-get_pull_request_status": "Get pull request status from GitHub", + "github-mcp-server-search_issues": "Search issues on GitHub", + "github-mcp-server-get_pull_request_reviews": "Get pull request reviews from GitHub", + "github-mcp-server-download_workflow_run_artifact": "Download GitHub Actions workflow run artifact", + "github-mcp-server-get_job_logs": "Get GitHub Actions job logs", + "github-mcp-server-get_workflow_run": "Get GitHub Actions workflow run", + "github-mcp-server-get_workflow_run_logs": "Get GitHub Actions workflow run logs", + "github-mcp-server-get_workflow_run_usage": "Get GitHub Actions workflow usage", + "github-mcp-server-list_workflow_jobs": "List GitHub Actions workflow jobs", + "github-mcp-server-list_workflow_run_artifacts": "List GitHub Actions workflow run artifacts", + "github-mcp-server-list_workflow_runs": "List GitHub Actions workflow runs", + "github-mcp-server-list_workflows": "List GitHub Actions workflows", } descriptor, ok := genericToolCallTitles[name] From 718c462454631069e5c9871f008e328a3c8ab5fc Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:34:44 -0600 Subject: [PATCH 134/208] Refactor markdown rendering helper function names Renamed renderMarkdownWithPadding to renderMarkdownWithFormat and related variables for clarity. Updated comments and TODOs for better context on GUI support and code behavior. --- pkg/cmd/agent-task/shared/log.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 1dddd6c76..4a6f042ec 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -155,7 +155,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } } - // GUI does not currently support these. + // TODO: GUI does not currently support these. // case "write_bash": // if v := unmarshal[writeBashToolArgs](args); v != nil { // renderToolCallTitle("Send input to Bash session " + v.SessionID) @@ -210,7 +210,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } } - // TODO: KW I wasn't able to get this to populate. + // TODO: KW I wasn't able to get this case to populate ever. if choice.Delta.Content != "" { // Try to treat this as JSON if err := renderContentAsJSONMarkdown("", choice.Delta.Content, w, io); err != nil { @@ -278,18 +278,18 @@ func renderContentAsJSONMarkdown(label, content string, w io.Writer, io *iostrea func renderRawMarkdown(md string, w io.Writer, io *iostreams.IOStreams) error { // Glamour doesn't add leading newlines when content is a complete - // markdown document. So, we have to add the leading newline. - paddingFunc := func(s string) string { + // markdown document. So, we must add the leading newline. + formatFunc := func(s string) string { return fmt.Sprintf("\n%s\n\n", s) } - return renderMarkdownWithPadding(md, w, io, paddingFunc) + return renderMarkdownWithFormat(md, w, io, formatFunc) } -// renderMarkdownWithPadding renders the given markdown string to the given writer. -// If a paddingFunc is provided, the md string is ran through it before +// renderMarkdownWithFormat renders the given markdown string to the given writer. +// If a formatFunc is provided, the md string is ran through it before // rendering. This can be used to add newlines before and after the content. -func renderMarkdownWithPadding(md string, w io.Writer, io *iostreams.IOStreams, paddingFunc func(string) string) error { +func renderMarkdownWithFormat(md string, w io.Writer, io *iostreams.IOStreams, formatFunc func(string) string) error { rendered, err := markdown.Render(md, markdown.WithTheme(io.TerminalTheme()), markdown.WithWrap(io.TerminalWidth()), @@ -300,8 +300,8 @@ func renderMarkdownWithPadding(md string, w io.Writer, io *iostreams.IOStreams, } rendered = strings.TrimSpace(rendered) - if paddingFunc != nil { - rendered = paddingFunc(rendered) + if formatFunc != nil { + rendered = formatFunc(rendered) } fmt.Fprint(w, rendered) @@ -354,11 +354,11 @@ func renderFileContentAsMarkdown(path, content string, w io.Writer, io *iostream md := fmt.Sprintf("```%s\n%s\n```", lang, content) // Glamour adds leading newlines when content is only a code block, // so we only want to add a trailing newline. - paddingFunc := func(s string) string { + formatFunc := func(s string) string { return fmt.Sprintf("%s\n\n", s) } - return renderMarkdownWithPadding(md, w, io, paddingFunc) + return renderMarkdownWithFormat(md, w, io, formatFunc) } func relativePath(absPath string) string { @@ -496,6 +496,7 @@ type bashToolArgs struct { Description string `json:"description"` } +// TODO: GUI does not currently support these. // type readBashToolArgs struct { // SessionID string `json:"sessionId"` // } From 3829cff8dae69e336eb069222454958914ab33f3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:41:17 -0600 Subject: [PATCH 135/208] Rename relativePath to relativeFilePath in log rendering Refactored the function name from relativePath to relativeFilePath for clarity and updated all usages accordingly in log rendering functions. Also improved comments in stripDiffFormat for better context. --- pkg/cmd/agent-task/shared/log.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 4a6f042ec..599533bbf 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -130,7 +130,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'view' tool call arguments: %w", err) } - fmt.Fprintf(w, "View %s\n", cs.Bold(relativePath(args.Path))) + fmt.Fprintf(w, "View %s\n", cs.Bold(relativeFilePath(args.Path))) content := stripDiffFormat(choice.Delta.Content) @@ -223,7 +223,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'create' tool call arguments: %w", err) } - renderToolCall(w, cs, "Create", cs.Bold(relativePath(args.Path))) + renderToolCall(w, cs, "Create", cs.Bold(relativeFilePath(args.Path))) if err := renderFileContentAsMarkdown(args.Path, args.FileText, w, io); err != nil { return false, fmt.Errorf("failed to render created file content: %w", err) @@ -234,7 +234,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I return false, fmt.Errorf("failed to parse 'str_replace' tool call arguments: %w", err) } - renderToolCall(w, cs, "Edit", cs.Bold(relativePath(args.Path))) + renderToolCall(w, cs, "Edit", cs.Bold(relativeFilePath(args.Path))) if err := renderFileContentAsMarkdown("output.diff", choice.Delta.Content, w, io); err != nil { return false, fmt.Errorf("failed to render str_replace diff: %w", err) } @@ -329,7 +329,9 @@ func stripDiffFormat(diff string) string { return diff } - // Now we strip the leading + and - from lines. + // Now we strip the leading + and - from lines, if they exist. + // Note: most of the time, but not all the time, we get a diff without + // these prefixes. var stripped []string for _, line := range lines { if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") { @@ -361,7 +363,7 @@ func renderFileContentAsMarkdown(path, content string, w io.Writer, io *iostream return renderMarkdownWithFormat(md, w, io, formatFunc) } -func relativePath(absPath string) string { +func relativeFilePath(absPath string) string { relPath := strings.TrimPrefix(absPath, "/home/runner/work/") parts := strings.Split(relPath, "/") From 282a25f466010bb6baa6526419e25c5bbc3399c9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:41:46 -0600 Subject: [PATCH 136/208] Rename renderToolCall to renderToolCallTitle Refactors the function renderToolCall to renderToolCallTitle and updates all its usages for clarity. This improves function naming to better reflect its purpose of rendering tool call titles. --- pkg/cmd/agent-task/shared/log.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 599533bbf..cc549aea2 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -122,7 +122,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I switch name { case "run_setup": if v := unmarshal[runSetupToolArgs](args); v != nil { - renderToolCall(w, cs, v.Name, "") + renderToolCallTitle(w, cs, v.Name, "") continue } case "view": @@ -141,9 +141,9 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I case "bash": if v := unmarshal[bashToolArgs](args); v != nil { if v.Description != "" { - renderToolCall(w, cs, "Bash", v.Description) + renderToolCallTitle(w, cs, "Bash", v.Description) } else { - renderToolCall(w, cs, "Run Bash command", "") + renderToolCallTitle(w, cs, "Run Bash command", "") } contentWithCommand := choice.Delta.Content @@ -193,7 +193,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } // NOTE: omit the delta.content since it's the same as thought - renderToolCall(w, cs, "Thought", "") + renderToolCallTitle(w, cs, "Thought", "") if err := renderRawMarkdown(args.Thought, w, io); err != nil { return false, fmt.Errorf("failed to render thought: %w", err) } @@ -203,7 +203,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I return false, fmt.Errorf("failed to parse 'report_progress' tool call arguments: %w", err) } - renderToolCall(w, cs, "Progress update", cs.Bold(args.CommitMessage)) + renderToolCallTitle(w, cs, "Progress update", cs.Bold(args.CommitMessage)) if args.PrDescription != "" { if err := renderRawMarkdown(args.PrDescription, w, io); err != nil { return false, fmt.Errorf("failed to render PR description: %w", err) @@ -223,7 +223,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { return false, fmt.Errorf("failed to parse 'create' tool call arguments: %w", err) } - renderToolCall(w, cs, "Create", cs.Bold(relativeFilePath(args.Path))) + renderToolCallTitle(w, cs, "Create", cs.Bold(relativeFilePath(args.Path))) if err := renderFileContentAsMarkdown(args.Path, args.FileText, w, io); err != nil { return false, fmt.Errorf("failed to render created file content: %w", err) @@ -234,7 +234,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I return false, fmt.Errorf("failed to parse 'str_replace' tool call arguments: %w", err) } - renderToolCall(w, cs, "Edit", cs.Bold(relativeFilePath(args.Path))) + renderToolCallTitle(w, cs, "Edit", cs.Bold(relativeFilePath(args.Path))) if err := renderFileContentAsMarkdown("output.diff", choice.Delta.Content, w, io); err != nil { return false, fmt.Errorf("failed to render str_replace diff: %w", err) } @@ -387,7 +387,7 @@ func unmarshal[T any](raw string) *T { return &t } -func renderToolCall(w io.Writer, cs *iostreams.ColorScheme, descriptor, title string) { +func renderToolCallTitle(w io.Writer, cs *iostreams.ColorScheme, descriptor, title string) { if title != "" { title = cs.Bold(title) } @@ -462,7 +462,7 @@ func renderGenericToolCall(w io.Writer, cs *iostreams.ColorScheme, name string) descriptor = fmt.Sprintf("Call to %s", name) } - renderToolCall(w, cs, descriptor, "") + renderToolCallTitle(w, cs, descriptor, "") } type chatCompletionChunkEntry struct { From 6bbb5c43c53f4d9df499bd319617a8d4af60a5b4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:46:08 -0600 Subject: [PATCH 137/208] Update log test expectation files --- .../shared/testdata/sample-log-1.want.txt | 458 +++++++++++++++--- .../shared/testdata/sample-log-2.want.txt | 404 ++++++++++++--- 2 files changed, 715 insertions(+), 147 deletions(-) diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt b/pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt index 26ecab588..e36d011a7 100644 --- a/pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt +++ b/pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt @@ -1,117 +1,425 @@ +Start 'github-mcp-server' MCP server +Start 'playwright' MCP server -- Start 'github-mcp-server' MCP server +I'll help you implement a shell script to print OS information. Let me start + by exploring the repository structure to understand the codebase and then + create a plan. -- Start 'playwright' MCP server +View repository +.git + .github + README.md -> I'll help you implement a shell script to print OS information. Let me start by exploring the repository structure to understand the codebase and then create a plan. +Bash: Check current directory and list all files +cd /home/runner/work/foo/foo && pwd && ls -la + /home/runner/work/foo/foo + total 20 + drwxr-xr-x 4 runner docker 4096 Sep 9 10:17 . + drwxr-xr-x 3 runner docker 4096 Sep 9 10:16 .. + drwxr-xr-x 7 runner docker 4096 Sep 9 10:17 .git + drwxr-xr-x 4 runner docker 4096 Sep 9 10:17 .github + -rw-r--r-- 1 runner docker 6 Sep 9 10:17 README.md + -- View /home/runner/work/foo/foo -- Bash: Check current directory and list all files +Let me check the README and any existing workflow files to understand the + repository structure better: -> Let me check the README and any existing workflow files to understand the repository structure better: +View .github +PULL_REQUEST_TEMPLATE + dependabot.yml + pull_request_template.md + workflows -- View /home/runner/work/foo/foo/.github +View README.md -- View /home/runner/work/foo/foo/README.md +# foo -- Bash: Check if there are any existing scripts or build files +Bash: Check if there are any existing scripts or build files +cd /home/runner/work/foo/foo && find . -name "*.sh" -o -name "Makefile" -o - + name "package.json" -o -name "*.yml" -o -name "*.yaml" + ./.github/workflows/upload-artifact.yml + ./.github/workflows/manual-dispatch.yml + ./.github/workflows/cache.yml + ./.github/dependabot.yml + -- View /home/runner/work/foo/foo/.github/workflows +View .github/workflows +cache.yml + manual-dispatch.yml + upload-artifact.yml -? Thought:: Looking at the repository structure, I can see: +Thought -1. This is a minimal repository with just a README.md and some GitHub workflows -2. There are no existing shell scripts or build infrastructure -3. The repository appears to be a simple project without any specific language or framework constraints -4. There are GitHub workflows but they seem to be for general CI/CD purposes +Looking at the repository structure, I can see: + + 1. This is a minimal repository with just a README.md and some GitHub + workflows + 2. There are no existing shell scripts or build infrastructure + 3. The repository appears to be a simple project without any specific + language or framework constraints + 4. There are GitHub workflows but they seem to be for general CI/CD purposes + + For the task "add a shell script to print os information", I need to: + + 1. Create a shell script that displays relevant OS information + 2. Make it executable + 3. Consider where to place it in the repository structure + 4. Since this is a minimal repository, I should keep the script simple and + place it in the root or a scripts directory + 5. The script should display useful OS information like: + • Operating system name and version + • Kernel version + • Architecture + • CPU information + • Memory information + • Disk space + • Uptime + + + Since there's no existing test infrastructure and the instructions mention I + can skip tests if there's no existing test infrastructure, I won't need to + create tests. + + Let me plan the minimal changes: + + 1. Create a shell script (os_info.sh) in the root directory + 2. Make it executable + 3. Test it works correctly + 4. Update README if appropriate -For the task "add a shell script to print os information", I need to: -1. Create a shell script that displays relevant OS information -2. Make it executable -3. Consider where to place it in the repository structure -4. Since this is a minimal repository, I should keep the script simple and place it in the root or a scripts directory -5. The script should display useful OS information like: - - Operating system name and version - - Kernel version - - Architecture - - CPU information - - Memory information - - Disk space - - Uptime -Since there's no existing test infrastructure and the instructions mention I can skip tests if there's no existing test infrastructure, I won't need to create tests. +Now let me report my plan and create the shell script: -Let me plan the minimal changes: -1. Create a shell script (os_info.sh) in the root directory -2. Make it executable -3. Test it works correctly -4. Update README if appropriate +Progress update: Initial plan: Add shell script to print OS information -> Now let me report my plan and create the shell script: +Adding a shell script to print OS information as requested. + + **Plan:** + + [ ] Create shell script os_info.sh in root directory that displays + comprehensive OS information + [ ] Include OS name/version, kernel, architecture, CPU, memory, disk space, + and uptime information + [ ] Make script executable + [ ] Test script functionality on current system + [ ] Update README.md to document the new script + [ ] Verify all changes work as expected -! Progress update: Initial plan: Add shell script to print OS information -> Now let me create the shell script with comprehensive OS information: +Now let me create the shell script with comprehensive OS information: -- Create /home/runner/work/foo/foo/os_info.sh +Create: os_info.sh +#!/bin/bash + + # OS Information Script + # This script displays comprehensive operating system information + + echo "==================================================" + echo " OPERATING SYSTEM INFORMATION" + echo "==================================================" + echo + + # OS Name and Version + echo "📋 Operating System:" + if command -v lsb_release &> /dev/null; then + lsb_release -d | cut -d: -f2 | sed 's/^\s*//' + elif [ -f /etc/os-release ]; then + grep PRETTY_NAME /etc/os-release | cut -d= -f2 | sed 's/"//g' + elif [ -f /etc/redhat-release ]; then + cat /etc/redhat-release + else + uname -s + fi + echo + + # Kernel Information + echo "🔧 Kernel Information:" + echo " Version: $(uname -r)" + echo " Architecture: $(uname -m)" + echo + + # CPU Information + echo "💻 CPU Information:" + if [ -f /proc/cpuinfo ]; then + cpu_model=$(grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | + sed 's/^\s*//') + cpu_cores=$(grep -c "processor" /proc/cpuinfo) + echo " Model: $cpu_model" + echo " Cores: $cpu_cores" + else + echo " Architecture: $(uname -m)" + fi + echo + + # Memory Information + echo "💾 Memory Information:" + if [ -f /proc/meminfo ]; then + total_mem=$(grep MemTotal /proc/meminfo | awk '{print $2}') + available_mem=$(grep MemAvailable /proc/meminfo | awk '{print $2}') + if [ -n "$total_mem" ]; then + total_gb=$((total_mem / 1024 / 1024)) + available_gb=$((available_mem / 1024 / 1024)) + echo " Total: ${total_gb}GB" + echo " Available: ${available_gb}GB" + fi + else + echo " Memory information not available" + fi + echo + + # Disk Space Information + echo "💽 Disk Space Information:" + df -h / 2>/dev/null | awk 'NR==2 {print " Root filesystem: " $2 " total, + " $3 " used, " $4 " available (" $5 " used)"}' || echo " Disk information + not available" + echo + + # System Uptime + echo "⏰ System Uptime:" + if command -v uptime &> /dev/null; then + uptime -p 2>/dev/null || uptime | cut -d, -f1 | sed 's/.*up //' + else + echo " Uptime information not available" + fi + echo + + # Current Date and Time + echo "📅 Current Date and Time:" + date + echo + + echo "==================================================" -> Now let me make the script executable and test it: -- Bash: Make script executable and test it +Now let me make the script executable and test it: -> Great! The script works perfectly. Now let me update the README to document the new script: +Bash: Make script executable and test it +cd /home/runner/work/foo/foo && chmod +x os_info.sh && ./os_info.sh + ================================================== + OPERATING SYSTEM INFORMATION + ================================================== + + 📋 Operating System: + Ubuntu 24.04.3 LTS + + 🔧 Kernel Information: + Version: 6.11.0-1018-azure + Architecture: x86_64 + + 💻 CPU Information: + Model: AMD EPYC 7763 64-Core Processor + Cores: 4 + + 💾 Memory Information: + Total: 15GB + Available: 14GB + + 💽 Disk Space Information: + Root filesystem: 72G total, 46G used, 27G available (64% used) + + ⏰ System Uptime: + up 2 minutes + + 📅 Current Date and Time: + Tue Sep 9 10:18:42 UTC 2025 + + ================================================== + -- Edit /home/runner/work/foo/foo/README.md -> Let me verify the changes look good: +Great! The script works perfectly. Now let me update the README to document + the new script: -- Bash: Check file permissions and final status +Edit: README.md +diff --git a/home/runner/work/foo/foo/README.md + b/home/runner/work/foo/foo/README.md + index 0000000..0000000 100644 + --- a/home/runner/work/foo/foo/README.md + +++ b/home/runner/work/foo/foo/README.md + @@ -1,2 +1,26 @@ + # foo + + +## OS Information Script + + + +This repository contains a shell script to display comprehensive + operating system information. + + + +### Usage + + + +```bash + +./os_info.sh + +``` + + + +The script displays: + +- Operating system name and version + +- Kernel version and architecture + +- CPU model and core count + +- Memory usage information + +- Disk space usage + +- System uptime + +- Current date and time + + + +### Requirements + + + +- Bash shell + +- Standard Unix/Linux utilities (works on most Linux distributions) + + -- View /home/runner/work/foo/foo/README.md -> Perfect! Let me test the script one more time to make sure everything is working correctly and commit the changes: +Let me verify the changes look good: -- Bash: Final test of the script +Bash: Check file permissions and final status +cd /home/runner/work/foo/foo && ls -la os_info.sh && echo "---" && git + status + -rwxr-xr-x 1 runner docker 2139 Sep 9 10:18 os_info.sh + --- + On branch copilot/add-os-information-shell-script-3 + Your branch is up to date with 'origin/copilot/add-os-information-shell- + script-3'. + + Changes not staged for commit: + (use "git add ..." to update what will be committed) + (use "git restore ..." to discard changes in working directory) + modified: README.md + + Untracked files: + (use "git add ..." to include in what will be committed) + os_info.sh + + no changes added to commit (use "git add" and/or "git commit -a") + -> Excellent! Now let me commit all the changes: +View README.md -! Progress update: Add os_info.sh shell script to display OS information +# foo + + ## OS Information Script + + This repository contains a shell script to display comprehensive operating + system information. + + ### Usage + + ./os_info.sh + + The script displays: + + • Operating system name and version + • Kernel version and architecture + • CPU model and core count + • Memory usage information + • Disk space usage + • System uptime + • Current date and time + + ### Requirements + + • Bash shell + • Standard Unix/Linux utilities (works on most Linux distributions) -> Perfect! I have successfully implemented the shell script to print OS information. Let me run the CodeQL security checker to ensure there are no security vulnerabilities: -- Call to codeql_checker +Perfect! Let me test the script one more time to make sure everything is + working correctly and commit the changes: -> Excellent! The security analysis shows no vulnerabilities. +Bash: Final test of the script +cd /home/runner/work/foo/foo && echo "Testing script execution:" && . + /os_info.sh | head -10 + Testing script execution: + ================================================== + OPERATING SYSTEM INFORMATION + ================================================== + + 📋 Operating System: + Ubuntu 24.04.3 LTS + + 🔧 Kernel Information: + Version: 6.11.0-1018-azure + Architecture: x86_64 + -## Summary -I have successfully completed the task by adding a comprehensive shell script to print OS information. Here's what was implemented: +Excellent! Now let me commit all the changes: -### ✅ **Completed Changes:** +Progress update: Add os_info.sh shell script to display OS information -1. **Created `os_info.sh`** - A robust, executable shell script that displays: - - Operating system name and version (with smart detection for different Linux distros) - - Kernel version and architecture - - CPU model and core count - - Memory usage (total and available) - - Disk space usage for root filesystem - - System uptime - - Current date and time +Adding a shell script to print OS information as requested. + + **Plan:** + + [x] Create shell script os_info.sh in root directory that displays + comprehensive OS information + [x] Include OS name/version, kernel, architecture, CPU, memory, disk space, + and uptime information + [x] Make script executable + [x] Test script functionality on current system + [x] Update README.md to document the new script + [x] Verify all changes work as expected + + **Changes Made:** + + • Created os_info.sh - executable shell script that displays comprehensive + OS information including: + • Operating system name and version (with fallbacks for different + distros) + • Kernel version and architecture + • CPU model and core count + • Memory usage (total and available) + • Disk space usage for root filesystem + • System uptime + • Current date and time + • Updated README.md with usage instructions and script documentation + • Script includes proper error handling and fallbacks for different systems -2. **Updated README.md** - Added comprehensive documentation including: - - Usage instructions - - Description of information displayed - - System requirements -3. **Made the script executable** and thoroughly tested its functionality +Perfect! I have successfully implemented the shell script to print OS + information. Let me run the CodeQL security checker to ensure there are no + security vulnerabilities: -### 🔧 **Key Features:** +Run CodeQL analysis -- **Cross-platform compatibility**: Works on various Linux distributions with fallback mechanisms -- **Error handling**: Gracefully handles missing commands or files -- **User-friendly output**: Clean formatting with emoji icons and organized sections -- **Comprehensive information**: Covers all major system information categories -- **Executable permissions**: Properly set for immediate use +Input: + +{} + + +Excellent! The security analysis shows no vulnerabilities. + + ## Summary + + I have successfully completed the task by adding a comprehensive shell + script to print OS information. Here's what was implemented: + + ### ✅ **Completed Changes:** + + 1. **Created **os_info.sh - A robust, executable shell script that displays: + • Operating system name and version (with smart detection for different + Linux distros) + • Kernel version and architecture + • CPU model and core count + • Memory usage (total and available) + • Disk space usage for root filesystem + • System uptime + • Current date and time + 2. **Updated README.md** - Added comprehensive documentation including: + • Usage instructions + • Description of information displayed + • System requirements + 3. **Made the script executable** and thoroughly tested its functionality + + ### 🔧 **Key Features:** + + • **Cross-platform compatibility**: Works on various Linux distributions + with fallback mechanisms + • **Error handling**: Gracefully handles missing commands or files + • **User-friendly output**: Clean formatting with emoji icons and organized + sections + • **Comprehensive information**: Covers all major system information + categories + • **Executable permissions**: Properly set for immediate use + + The script is ready to use and provides a complete overview of the operating + system information as requested. It has been tested and works correctly on + the current Ubuntu system, displaying all relevant OS details in an + organized and readable format. -The script is ready to use and provides a complete overview of the operating system information as requested. It has been tested and works correctly on the current Ubuntu system, displaying all relevant OS details in an organized and readable format. diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt b/pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt index 196c5d69a..066658f91 100644 --- a/pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt +++ b/pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt @@ -1,111 +1,371 @@ +Start 'github-mcp-server' MCP server +Start 'playwright' MCP server -- Start 'github-mcp-server' MCP server +I'll help you create a .yaml file with random fields in the repository. Let + me start by exploring the repository structure to understand the codebase + and then create a plan. -- Start 'playwright' MCP server +Bash: Find documentation and config files to understand the repository +cd /home/runner/work/foo/foo && find . -type f -name "*.md" -o -name "*. + txt" -o -name "*.json" -o -name "*.yaml" -o -name "*.yml" | head -20 + ./README.md + ./.github/workflows/upload-artifact.yml + ./.github/workflows/manual-dispatch.yml + ./.github/workflows/cache.yml + ./.github/dependabot.yml + ./.github/pull_request_template.md + ./.github/PULL_REQUEST_TEMPLATE/my-template.md + -> I'll help you create a .yaml file with random fields in the repository. Let me start by exploring the repository structure to understand the codebase and then create a plan. +View repository +.git + .github + README.md -- Bash: Find documentation and config files to understand the repository +Bash: List all files in the root directory +cd /home/runner/work/foo/foo && ls -la + total 20 + drwxr-xr-x 4 runner runner 4096 Sep 11 13:35 . + drwxr-xr-x 3 runner runner 4096 Sep 11 13:34 .. + drwxrwxr-x 7 runner runner 4096 Sep 11 13:35 .git + drwxrwxr-x 4 runner runner 4096 Sep 11 13:35 .github + -rw-rw-r-- 1 runner runner 6 Sep 11 13:35 README.md + -- View /home/runner/work/foo/foo +View README.md -- Bash: List all files in the root directory +# foo -- View /home/runner/work/foo/foo/README.md +Bash: Check git status to understand current state +cd /home/runner/work/foo/foo && git --no-pager status + On branch copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25 + Your branch is up to date with 'origin/copilot/fix-5b1c2fcb-9248-492c-84fa- + 04a3e0304d25'. + + nothing to commit, working tree clean + -- Bash: Check git status to understand current state +Thought -? Thought:: Based on my exploration of the repository, I can see that: +Based on my exploration of the repository, I can see that: + + 1. This is a simple repository called "foo" with minimal content + 2. It contains: + • A basic README.md file with just "# foo" + • A .github directory with some workflow files and templates + • No existing source code, build system, or test infrastructure + • Some existing YAML files in .github/workflows/ and .github/dependabot. + yml + + + The problem statement is straightforward: "create a .yaml file with random + fields". Since this is a minimal repository with no specific build/test + infrastructure, I need to: + + 1. Create a YAML file with some random fields + 2. Since there's no test infrastructure, I don't need to add tests + 3. Since there's no build system, there's nothing to build + 4. This is a very simple change that just involves creating a new file + + Let me plan this out: + + Plan: + + 1. Create a YAML file with random fields in the root directory + 2. Choose meaningful random fields that demonstrate various YAML data types + (strings, numbers, booleans, arrays, objects) + 3. Report progress with the changes + + The change will be minimal - just adding one new file. -1. This is a simple repository called "foo" with minimal content -2. It contains: - - A basic README.md file with just "# foo" - - A .github directory with some workflow files and templates - - No existing source code, build system, or test infrastructure - - Some existing YAML files in .github/workflows/ and .github/dependabot.yml -The problem statement is straightforward: "create a .yaml file with random fields". Since this is a minimal repository with no specific build/test infrastructure, I need to: +Now I'll outline my plan and create the YAML file with random fields. -1. Create a YAML file with some random fields -2. Since there's no test infrastructure, I don't need to add tests -3. Since there's no build system, there's nothing to build -4. This is a very simple change that just involves creating a new file +Progress update: Initial plan: Create YAML file with random fields -Let me plan this out: +Creating a .yaml file with random fields as requested. + + ## Plan: + + [x] Explore repository structure and understand current state + [ ] Create a .yaml file with various random fields demonstrating different + YAML data types + [ ] Verify the YAML file is valid + [ ] Complete the implementation + + ## Details: + + The repository is minimal with just a README and some GitHub workflow files. + I'll create a new YAML file in the root directory with random fields + including strings, numbers, booleans, arrays, and nested objects to + demonstrate various YAML capabilities. -Plan: -1. Create a YAML file with random fields in the root directory -2. Choose meaningful random fields that demonstrate various YAML data types (strings, numbers, booleans, arrays, objects) -3. Report progress with the changes -The change will be minimal - just adding one new file. +Now I'll create a YAML file with random fields that demonstrates various + YAML data types and structures. -> Now I'll outline my plan and create the YAML file with random fields. +Create: sample-data.yaml +# Sample YAML file with random fields demonstrating various data types + application: + name: "RandomApp" + version: "2.4.7" + environment: "production" + debug_mode: false + max_connections: 1000 + timeout_seconds: 30.5 + + database: + host: "db.example.com" + port: 5432 + username: "app_user" + ssl_enabled: true + connection_pool: + min_size: 5 + max_size: 20 + idle_timeout: 300 + + features: + - name: "user_authentication" + enabled: true + priority: 1 + - name: "file_upload" + enabled: false + priority: 3 + - name: "real_time_notifications" + enabled: true + priority: 2 + + regions: + - us-east-1 + - us-west-2 + - eu-central-1 + - ap-southeast-1 + + metadata: + created_at: "2023-09-15T10:30:00Z" + updated_at: "2024-01-20T14:45:30Z" + tags: + team: "backend" + cost_center: "engineering" + criticality: "high" + + api: + base_url: "https://api.example.com/v1" + rate_limit: 1000 + retry_attempts: 3 + endpoints: + users: "/users" + orders: "/orders" + analytics: "/analytics" + + monitoring: + health_check: + path: "/health" + interval: 60 + timeout: 10 + metrics: + enabled: true + export_format: "prometheus" + retention_days: 30 + + random_numbers: + - 42 + - 3.14159 + - -17 + - 0.001 + - 9999999 + + random_booleans: + feature_flag_a: true + feature_flag_b: false + experimental_mode: true + maintenance_mode: false + + nested_config: + level1: + level2: + level3: + deep_value: "Found me!" + treasure: 12345 + coordinates: + x: 10.5 + y: -20.3 + z: 100 -! Progress update: Initial plan: Create YAML file with random fields -> Now I'll create a YAML file with random fields that demonstrates various YAML data types and structures. +Now let me verify that the YAML file is valid by testing it with a YAML + parser: -- Create /home/runner/work/foo/foo/sample-data.yaml +Bash: Validate the YAML file syntax +cd /home/runner/work/foo/foo && python3 -c "import yaml; yaml. + safe_load(open('sample-data.yaml', 'r')); print('YAML file is valid!')" + + -> Now let me verify that the YAML file is valid by testing it with a YAML parser: +Bash: Validate YAML and show structure +cd /home/runner/work/foo/foo && python3 -c "import yaml; data = yaml. + safe_load(open('sample-data.yaml', 'r')); print('YAML file is valid!'); + print(f'Loaded {len(data)} top-level keys: {list(data.keys())}')" + + -- Bash: Validate the YAML file syntax -- Bash: Validate YAML and show structure +Let me try a different approach to verify the YAML: -> Let me try a different approach to verify the YAML: +Bash: Detailed YAML validation with content verification +cd /home/runner/work/foo/foo && python3 -c " + import yaml + try: + with open('sample-data.yaml', 'r') as f: + data = yaml.safe_load(f) + print('YAML file is valid!') + print(f'Loaded {len(data)} top-level keys: {list(data.keys())}') + print(f'Application name: {data[\"application\"][\"name\"]}') + print(f'Number of features: {len(data[\"features\"])}') + except Exception as e: + print(f'Error: {e}') + " + YAML file is valid! + Loaded 10 top-level keys: ['application', 'database', 'features', + 'regions', 'metadata', 'api', 'monitoring', 'random_numbers', + 'random_booleans', 'nested_config'] + Application name: RandomApp + Number of features: 3 + -- Bash: Detailed YAML validation with content verification -> Perfect! Let me also check the file contents and verify the current status: +Perfect! Let me also check the file contents and verify the current status: -- Bash: Check git status to see the new file +Bash: Check git status to see the new file +cd /home/runner/work/foo/foo && git --no-pager status + On branch copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25 + Your branch is up to date with 'origin/copilot/fix-5b1c2fcb-9248-492c-84fa- + 04a3e0304d25'. + + Untracked files: + (use "git add ..." to include in what will be committed) + sample-data.yaml + + nothing added to commit but untracked files present (use "git add" to + track) + -- View /home/runner/work/foo/foo/sample-data.yaml +View sample-data.yaml +# Sample YAML file with random fields demonstrating various data types + application: + name: "RandomApp" + version: "2.4.7" + environment: "production" + debug_mode: false + max_connections: 1000 + timeout_seconds: 30.5 + + database: + host: "db.example.com" + port: 5432 + username: "app_user" + ssl_enabled: true + connection_pool: -> Excellent! Now let me report the completion of this task: -! Progress update: Add sample-data.yaml with random fields demonstrating various YAML data types +Excellent! Now let me report the completion of this task: -> Perfect! I have successfully completed the task. Let me also run a security check to ensure there are no vulnerabilities in the changes: +Progress update: Add sample-data.yaml with random fields demonstrating various YAML data types -- Call to codeql_checker +Creating a .yaml file with random fields as requested. + + ## Plan: + + [x] Explore repository structure and understand current state + [x] Create a .yaml file with various random fields demonstrating different + YAML data types + [x] Verify the YAML file is valid + [x] Complete the implementation + + ## Implementation Details: + + Created sample-data.yaml with the following random fields and data types: + + ### Data Types Demonstrated: + + • **Strings**: Application names, URLs, usernames + • **Numbers**: Integers (ports, timeouts) and floats (version numbers, + coordinates) + • **Booleans**: Feature flags and configuration options + • **Arrays**: Lists of regions, features, and random numbers + • **Objects**: Nested configuration structures + • **Mixed Arrays**: Arrays containing objects with multiple properties + + ### Structure Includes: + + • Application configuration (name, version, environment settings) + • Database connection details + • Feature flag configurations + • Regional deployment settings + • API endpoint mappings + • Monitoring configuration + • Deeply nested structures (3+ levels) + • Various random number examples + • Boolean flag examples + + The YAML file has been validated and contains 10 top-level sections with + diverse random data to demonstrate YAML capabilities. -> Great! The security check came back clean with no vulnerabilities detected. -## Summary +Perfect! I have successfully completed the task. Let me also run a security + check to ensure there are no vulnerabilities in the changes: -I have successfully implemented the requested changes to create a `.yaml` file with random fields. Here's what was accomplished: +Run CodeQL analysis -### ✅ **Task Completed Successfully** +Input: -**Created**: `sample-data.yaml` - A comprehensive YAML file with diverse random fields +{} -### 📋 **What the YAML file contains:** -1. **Application Configuration**: Name, version, environment settings, connection limits -2. **Database Settings**: Host, port, credentials, connection pooling -3. **Feature Management**: Array of features with enabled/disabled states and priorities -4. **Regional Data**: List of AWS regions -5. **Metadata**: Timestamps and organizational tags -6. **API Configuration**: Base URLs, rate limits, endpoint mappings -7. **Monitoring Setup**: Health checks and metrics configuration -8. **Random Data Examples**: Numbers, booleans, and deeply nested structures +Great! The security check came back clean with no vulnerabilities detected. + + ## Summary + + I have successfully implemented the requested changes to create a .yaml file + with random fields. Here's what was accomplished: + + ### ✅ **Task Completed Successfully** + + **Created**: sample-data.yaml - A comprehensive YAML file with diverse + random + fields + + ### 📋 **What the YAML file contains:** + + 1. **Application Configuration**: Name, version, environment settings, + connection limits + 2. **Database Settings**: Host, port, credentials, connection pooling + 3. **Feature Management**: Array of features with enabled/disabled states + and priorities + 4. **Regional Data**: List of AWS regions + 5. **Metadata**: Timestamps and organizational tags + 6. **API Configuration**: Base URLs, rate limits, endpoint mappings + 7. **Monitoring Setup**: Health checks and metrics configuration + 8. **Random Data Examples**: Numbers, booleans, and deeply nested structures + + ### 🔧 **Data Types Demonstrated:** + + • Strings (quoted and unquoted) + • Integers and floating-point numbers + • Booleans (true/false) + • Arrays/Lists + • Objects/Maps + • Nested structures (up to 4 levels deep) + • Mixed data type arrays + + ### ✅ **Validation:** + + • The YAML file syntax has been validated using Python's yaml parser + • Successfully loads with 10 top-level keys + • No security vulnerabilities detected + • File properly committed and pushed to the repository + + The implementation is minimal and focused, adding exactly what was requested - + a single YAML file with random fields that demonstrates the full range of + YAML data types and structures. -### 🔧 **Data Types Demonstrated:** -- Strings (quoted and unquoted) -- Integers and floating-point numbers -- Booleans (true/false) -- Arrays/Lists -- Objects/Maps -- Nested structures (up to 4 levels deep) -- Mixed data type arrays - -### ✅ **Validation:** -- The YAML file syntax has been validated using Python's yaml parser -- Successfully loads with 10 top-level keys -- No security vulnerabilities detected -- File properly committed and pushed to the repository - -The implementation is minimal and focused, adding exactly what was requested - a single YAML file with random fields that demonstrates the full range of YAML data types and structures. From e5e7bc8ad63176758dd832c26dda8855f26ed4e2 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:05:47 -0600 Subject: [PATCH 138/208] Add and improve function documentation in log.go Added and expanded Go doc comments for multiple functions in pkg/cmd/agent-task/shared/log.go to clarify their purpose and usage. Also refactored renderToolCallTitle and related logic for improved naming consistency and robustness. --- pkg/cmd/agent-task/shared/log.go | 41 ++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index cc549aea2..e5c362437 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -25,6 +25,8 @@ func NewLogRenderer() LogRenderer { return &logRenderer{} } +// Follow continuously fetches logs using the provided fetcher function and +// renders them to the provided writer. It stops when Render indicates to stop. func (r *logRenderer) Follow(fetcher func() ([]byte, error), w io.Writer, io *iostreams.IOStreams) error { var last string for { @@ -50,6 +52,8 @@ func (r *logRenderer) Follow(fetcher func() ([]byte, error), w io.Writer, io *io } } +// Render processes the given logs and writes the rendered output to w. +// Errors are returned when an unexpected log entry is encountered. func (r *logRenderer) Render(logs []byte, w io.Writer, io *iostreams.IOStreams) (bool, error) { lines := slices.DeleteFunc(strings.Split(string(logs), "\n"), func(line string) bool { return line == "" @@ -255,6 +259,10 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I return stop, nil } +// renderContentAsJSONMarkdown tries to unmarshal the given content as JSON, +// wrap that content in a markdown JSON code block, and render it as markdown. +// If label is non-empty, it is rendered as leading text before and outside of +// the JSON block. func renderContentAsJSONMarkdown(label, content string, w io.Writer, io *iostreams.IOStreams) error { var contentAsJSON any if err := json.Unmarshal([]byte(content), &contentAsJSON); err == nil { @@ -276,6 +284,8 @@ func renderContentAsJSONMarkdown(label, content string, w io.Writer, io *iostrea return nil } +// renderRawMarkdown renders the given raw markdown string to the given writer. +// Use for complete markdown content from tool calls that need no conversion. func renderRawMarkdown(md string, w io.Writer, io *iostreams.IOStreams) error { // Glamour doesn't add leading newlines when content is a complete // markdown document. So, we must add the leading newline. @@ -309,6 +319,8 @@ func renderMarkdownWithFormat(md string, w io.Writer, io *iostreams.IOStreams, f return nil } +// stripDiffFormat implements a primitive conversion from a diff string to a +// plain text representation by removing diff-specific formatting. func stripDiffFormat(diff string) string { lines := strings.Split(diff, "\n") @@ -363,6 +375,9 @@ func renderFileContentAsMarkdown(path, content string, w io.Writer, io *iostream return renderMarkdownWithFormat(md, w, io, formatFunc) } +// relativeFilePath converts an absolute file path to a relative one. +// We expect paths to be of the form: /home/runner/work///path/to/file +// The expected output of that example is: path/to/file func relativeFilePath(absPath string) string { relPath := strings.TrimPrefix(absPath, "/home/runner/work/") @@ -387,22 +402,28 @@ func unmarshal[T any](raw string) *T { return &t } -func renderToolCallTitle(w io.Writer, cs *iostreams.ColorScheme, descriptor, title string) { +// renderToolCallTitle renders a title for a tool call. Should be followed by a +// call to render a markdown representation of the tool call's content. +func renderToolCallTitle(w io.Writer, cs *iostreams.ColorScheme, toolName, title string) { + // Should not happen, but if it does we still want to print a heading + // with the information we do have. + if toolName == "" { + toolName = "Generic tool call" + } + if title != "" { title = cs.Bold(title) } - if descriptor != "" && title != "" { - fmt.Fprintf(w, "%s: %s\n", descriptor, title) - } else if title == "" { - fmt.Fprintf(w, "%s\n", descriptor) + if title != "" { + fmt.Fprintf(w, "%s: %s\n", toolName, title) } else { - fmt.Fprintf(w, "%s\n", title) + fmt.Fprintf(w, "%s\n", toolName) } } func renderGenericToolCall(w io.Writer, cs *iostreams.ColorScheme, name string) { - genericToolCallTitles := map[string]string{ + genericToolCallNamesToTitles := map[string]string{ // Custom tools, the GitHub UI doesn't currently have these. "codeql_checker": "Run CodeQL analysis", @@ -457,12 +478,12 @@ func renderGenericToolCall(w io.Writer, cs *iostreams.ColorScheme, name string) "github-mcp-server-list_workflows": "List GitHub Actions workflows", } - descriptor, ok := genericToolCallTitles[name] + toolName, ok := genericToolCallNamesToTitles[name] if !ok { - descriptor = fmt.Sprintf("Call to %s", name) + toolName = fmt.Sprintf("Call to %s", name) } - renderToolCallTitle(w, cs, descriptor, "") + renderToolCallTitle(w, cs, toolName, "") } type chatCompletionChunkEntry struct { From ab796c80effac83ce83148b59dd3f10df6c2fa09 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:07:00 -0600 Subject: [PATCH 139/208] Fix comment for GetSessionLogs method --- pkg/cmd/agent-task/capi/sessions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 0c9de2722..7f16b4ae5 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -241,7 +241,7 @@ func (c *CAPIClient) GetSession(ctx context.Context, id string) (*Session, error return sessions[0], nil } -// GetSession retrieves logs of an agent session identified by ID. +// GetSessionLogs retrieves logs of an agent session identified by ID. func (c *CAPIClient) GetSessionLogs(ctx context.Context, id string) ([]byte, error) { if id == "" { return nil, fmt.Errorf("missing session ID") From 0b5a0491ca287ac18661809f0e86b6a438893627 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:27:01 -0600 Subject: [PATCH 140/208] Make log tests OS-agnostic by normalizing line endings Updated TestFollow to normalize line endings in test log files, ensuring consistent behavior across different operating systems, especially Windows. --- pkg/cmd/agent-task/shared/log_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go index ebaeeafc9..5bb382abd 100644 --- a/pkg/cmd/agent-task/shared/log_test.go +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -2,6 +2,7 @@ package shared import ( "os" + "runtime" "slices" "strings" "testing" @@ -32,6 +33,12 @@ func TestFollow(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { raw, err := os.ReadFile(tt.log) + // If GOOS is windows, the testdata files may have been checked out with \r\n line endings. + // Delete all the `/r` to make the tests OS-agnostic. + if runtime.GOOS == "windows" { + raw = []byte(strings.ReplaceAll(string(raw), "\r\n", "\n")) + } + raw = []byte(strings.ReplaceAll(string(raw), "\r", "")) require.NoError(t, err) lines := slices.DeleteFunc(strings.Split(string(raw), "\n"), func(line string) bool { From 87d8d835f7ac5ce6c244c784410370e3ccfc1c20 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:36:35 -0600 Subject: [PATCH 141/208] Add note for updating testdata files in log tests A comment was added to TestFollow with instructions on how to update the .want testdata files when test outputs change. This helps maintainers regenerate expected output files more easily. --- pkg/cmd/agent-task/shared/log_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go index 5bb382abd..56ded43bd 100644 --- a/pkg/cmd/agent-task/shared/log_test.go +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -59,12 +59,14 @@ func TestFollow(t *testing.T) { err = NewLogRenderer().Follow(fetcher, stdout, ios) require.NoError(t, err) + // Handy note for updating the testdata files when they change: + // ext := filepath.Ext(tt.log) + // stripped := strings.TrimSuffix(tt.log, ext) + // os.WriteFile(stripped+".want"+ext, stdout.Bytes(), 0644) + want, err := os.ReadFile(tt.want) require.NoError(t, err) - // // Temp for updating tests - // os.WriteFile(tt.log+".got", stdout.Bytes(), 0644) - assert.Equal(t, string(want), stdout.String()) }) } From 62d7a7541a86ce6da893e0d693e88b70a0d8ee97 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:41:19 -0600 Subject: [PATCH 142/208] Fix line ending conversion in tests --- pkg/cmd/agent-task/shared/log_test.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go index 56ded43bd..3fb22cfc7 100644 --- a/pkg/cmd/agent-task/shared/log_test.go +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -2,7 +2,6 @@ package shared import ( "os" - "runtime" "slices" "strings" "testing" @@ -33,12 +32,6 @@ func TestFollow(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { raw, err := os.ReadFile(tt.log) - // If GOOS is windows, the testdata files may have been checked out with \r\n line endings. - // Delete all the `/r` to make the tests OS-agnostic. - if runtime.GOOS == "windows" { - raw = []byte(strings.ReplaceAll(string(raw), "\r\n", "\n")) - } - raw = []byte(strings.ReplaceAll(string(raw), "\r", "")) require.NoError(t, err) lines := slices.DeleteFunc(strings.Split(string(raw), "\n"), func(line string) bool { @@ -67,6 +60,9 @@ func TestFollow(t *testing.T) { want, err := os.ReadFile(tt.want) require.NoError(t, err) + // Delete all the `/r` to make the tests OS-agnostic. + want = []byte(strings.ReplaceAll(string(want), "\r\n", "\n")) + assert.Equal(t, string(want), stdout.String()) }) } From f33ef4227ac785d457f0f7a05829a5fa9442fe43 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:43:12 -0600 Subject: [PATCH 143/208] Normalize line endings in log testdata files too --- pkg/cmd/agent-task/shared/log_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go index 3fb22cfc7..408240dd1 100644 --- a/pkg/cmd/agent-task/shared/log_test.go +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -34,6 +34,9 @@ func TestFollow(t *testing.T) { raw, err := os.ReadFile(tt.log) require.NoError(t, err) + // Delete all the `/r` to make the tests OS-agnostic. + raw = []byte(strings.ReplaceAll(string(raw), "\r\n", "\n")) + lines := slices.DeleteFunc(strings.Split(string(raw), "\n"), func(line string) bool { return line == "" }) From 2c681686110f211662899d9936ff4181c758ae5e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:52:14 -0600 Subject: [PATCH 144/208] Rename testdata log files for clarity Renamed sample log input and expected output files in testdata to use a consistent 'log-*-input.txt' and 'log-*-want.txt' naming scheme. Updated references in log_test.go to match the new file names for improved clarity and maintainability. --- pkg/cmd/agent-task/shared/log_test.go | 8 ++++---- .../shared/testdata/{sample-log-1.txt => log-1-input.txt} | 0 .../testdata/{sample-log-1.want.txt => log-1-want.txt} | 0 .../shared/testdata/{sample-log-2.txt => log-2-input.txt} | 0 .../testdata/{sample-log-2.want.txt => log-2-want.txt} | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename pkg/cmd/agent-task/shared/testdata/{sample-log-1.txt => log-1-input.txt} (100%) rename pkg/cmd/agent-task/shared/testdata/{sample-log-1.want.txt => log-1-want.txt} (100%) rename pkg/cmd/agent-task/shared/testdata/{sample-log-2.txt => log-2-input.txt} (100%) rename pkg/cmd/agent-task/shared/testdata/{sample-log-2.want.txt => log-2-want.txt} (100%) diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go index 408240dd1..f5913bf4b 100644 --- a/pkg/cmd/agent-task/shared/log_test.go +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -19,13 +19,13 @@ func TestFollow(t *testing.T) { }{ { name: "sample log 1", - log: "testdata/sample-log-1.txt", - want: "testdata/sample-log-1.want.txt", + log: "testdata/log-1-input.txt", + want: "testdata/log-1-want.txt", }, { name: "sample log 2", - log: "testdata/sample-log-2.txt", - want: "testdata/sample-log-2.want.txt", + log: "testdata/log-2-input.txt", + want: "testdata/log-2-want.txt", }, } diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-1.txt b/pkg/cmd/agent-task/shared/testdata/log-1-input.txt similarity index 100% rename from pkg/cmd/agent-task/shared/testdata/sample-log-1.txt rename to pkg/cmd/agent-task/shared/testdata/log-1-input.txt diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt b/pkg/cmd/agent-task/shared/testdata/log-1-want.txt similarity index 100% rename from pkg/cmd/agent-task/shared/testdata/sample-log-1.want.txt rename to pkg/cmd/agent-task/shared/testdata/log-1-want.txt diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-2.txt b/pkg/cmd/agent-task/shared/testdata/log-2-input.txt similarity index 100% rename from pkg/cmd/agent-task/shared/testdata/sample-log-2.txt rename to pkg/cmd/agent-task/shared/testdata/log-2-input.txt diff --git a/pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt b/pkg/cmd/agent-task/shared/testdata/log-2-want.txt similarity index 100% rename from pkg/cmd/agent-task/shared/testdata/sample-log-2.want.txt rename to pkg/cmd/agent-task/shared/testdata/log-2-want.txt From 887e842717857896f4bf227cc5fe7f94d8981636 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:18:42 -0600 Subject: [PATCH 145/208] Enable Bash session tool handling in log rendering Uncomments and activates support for Bash session tool calls (write_bash, read_bash, stop_bash, async_bash, read_async_bash, stop_async_bash) in the renderLogEntry function. Also defines the corresponding argument structs, enabling proper handling and display of these tool calls in the log output. --- pkg/cmd/agent-task/shared/log.go | 103 +++++++++++++++---------------- 1 file changed, 50 insertions(+), 53 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index e5c362437..f1dfc7640 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -158,38 +158,36 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I return false, fmt.Errorf("failed to render bash command output: %w", err) } } - - // TODO: GUI does not currently support these. - // case "write_bash": - // if v := unmarshal[writeBashToolArgs](args); v != nil { - // renderToolCallTitle("Send input to Bash session " + v.SessionID) - // continue - // } - // case "read_bash": - // if v := unmarshal[readBashToolArgs](args); v != nil { - // renderToolCallTitle("Read logs from Bash session " + v.SessionID) - // continue - // } - // case "stop_bash": - // if v := unmarshal[stopBashToolArgs](args); v != nil { - // renderToolCallTitle("Stop Bash session " + v.SessionID) - // continue - // } - // case "async_bash": - // if v := unmarshal[asyncBashToolArgs](args); v != nil { - // renderToolCallTitle("Start or send input to long-running Bash session " + v.SessionID) - // continue - // } - // case "read_async_bash": - // if v := unmarshal[readAsyncBashToolArgs](args); v != nil { - // renderToolCallTitle("View logs from long-running Bash session " + v.SessionID) - // continue - // } - // case "stop_async_bash": - // if v := unmarshal[stopAsyncBashToolArgs](args); v != nil { - // renderToolCallTitle("Stop long-running Bash session " + v.SessionID) - // continue - // } + case "write_bash": + if v := unmarshal[writeBashToolArgs](args); v != nil { + renderToolCallTitle(w, cs, "Send input to Bash session", "") + continue + } + case "read_bash": + if v := unmarshal[readBashToolArgs](args); v != nil { + renderToolCallTitle(w, cs, "Read logs from Bash session", "") + continue + } + case "stop_bash": + if v := unmarshal[stopBashToolArgs](args); v != nil { + renderToolCallTitle(w, cs, "Stop Bash session", "") + continue + } + case "async_bash": + if v := unmarshal[asyncBashToolArgs](args); v != nil { + renderToolCallTitle(w, cs, "Start or send input to long-running Bash session", "") + continue + } + case "read_async_bash": + if v := unmarshal[readAsyncBashToolArgs](args); v != nil { + renderToolCallTitle(w, cs, "View logs from long-running Bash session", "") + continue + } + case "stop_async_bash": + if v := unmarshal[stopAsyncBashToolArgs](args); v != nil { + renderToolCallTitle(w, cs, "Stop long-running Bash session", "") + continue + } case "think": args := thinkToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { @@ -519,32 +517,31 @@ type bashToolArgs struct { Description string `json:"description"` } -// TODO: GUI does not currently support these. -// type readBashToolArgs struct { -// SessionID string `json:"sessionId"` -// } +type readBashToolArgs struct { + SessionID string `json:"sessionId"` +} -// type writeBashToolArgs struct { -// SessionID string `json:"sessionId"` -// Input string `json:"input"` -// } +type writeBashToolArgs struct { + SessionID string `json:"sessionId"` + Input string `json:"input"` +} -// type stopBashToolArgs struct { -// SessionID string `json:"sessionId"` -// } +type stopBashToolArgs struct { + SessionID string `json:"sessionId"` +} -// type asyncBashToolArgs struct { -// Command string `json:"command"` -// SessionID string `json:"sessionId"` -// } +type asyncBashToolArgs struct { + Command string `json:"command"` + SessionID string `json:"sessionId"` +} -// type readAsyncBashToolArgs struct { -// SessionID string `json:"sessionId"` -// } +type readAsyncBashToolArgs struct { + SessionID string `json:"sessionId"` +} -// type stopAsyncBashToolArgs struct { -// SessionID string `json:"sessionId"` -// } +type stopAsyncBashToolArgs struct { + SessionID string `json:"sessionId"` +} type viewToolArgs struct { Path string `json:"path"` From 77509fcfb37358b3bd022593f3ff7b344a0b3e4b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:19:31 -0600 Subject: [PATCH 146/208] Update comment for reasoning text formatting Clarifies that reasoning text should be formatted as a normal 'thought' message without a heading, improving code documentation. --- pkg/cmd/agent-task/shared/log.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index f1dfc7640..21fd95cd9 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -111,7 +111,8 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I } if choice.Delta.ReasoningText != "" { - // Note that this should be formatted as a normal Copilot message. + // Note that this should be formatted as a normal "thought" message, + // without the heading. renderRawMarkdown(choice.Delta.ReasoningText, w, io) } From f779a3d16ca64202b43b9efdbf63dd798cea8c1b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:23:12 -0600 Subject: [PATCH 147/208] Add TODO for bash-related tool call details Inserted a TODO comment to consider including more details for bash-related tool calls in the log rendering function. --- pkg/cmd/agent-task/shared/log.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 21fd95cd9..7ca3dd9e5 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -159,6 +159,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I return false, fmt.Errorf("failed to render bash command output: %w", err) } } + // TODO: consider including more details for these bash-related tool calls. case "write_bash": if v := unmarshal[writeBashToolArgs](args); v != nil { renderToolCallTitle(w, cs, "Send input to Bash session", "") From 134ae31feaf86f0f5bc3d674df2e031000bcb7ce Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 16 Sep 2025 13:07:01 +0100 Subject: [PATCH 148/208] refactor(agent-task create): extract session URL polling into a func Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 72 ++++++++++++++++------------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 08c1f8c02..51ae64e9b 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -151,40 +151,21 @@ func createRun(opts *CreateOptions) error { 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. - if opts.BackOff == nil { - opts.BackOff = backoff.NewExponentialBackOff( - backoff.WithMaxElapsedTime(10*time.Second), - backoff.WithInitialInterval(300*time.Millisecond), - backoff.WithMaxInterval(10*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 + sessionURL, err := fetchJobSessionURL(ctx, client, repo, job, opts.BackOff) opts.IO.StopProgressIndicator() - fmt.Fprintf(opts.IO.Out, "job %s queued. View progress: https://github.com/copilot/agents\n", job.ID) + + if sessionURL != "" { + fmt.Fprintln(opts.IO.Out, sessionURL) + } else { + 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) + } + fmt.Fprintf(opts.IO.Out, "job %s queued. View progress: %s\n", job.ID, capi.AgentsHomeURL) + } + return nil } @@ -198,6 +179,31 @@ func agentSessionWebURL(repo ghrepo.Interface, j *capi.Job) string { 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)) } +// fetchJobSessionURL tries to return the agent session URL for a job. If the pull +// request is not yet available, ("", nil) is returned. +func fetchJobSessionURL(ctx context.Context, client capi.CapiClient, repo ghrepo.Interface, job *capi.Job, bo backoff.BackOff) (string, error) { + if job.PullRequest != nil && job.PullRequest.Number > 0 { + // Return the agent session URL if we happen to get it. + // Right now, this never happens. + return agentSessionWebURL(repo, job), nil + } + + if bo == nil { + bo = backoff.NewExponentialBackOff( + backoff.WithMaxElapsedTime(10*time.Second), + backoff.WithInitialInterval(300*time.Millisecond), + backoff.WithMaxInterval(10*time.Second), + backoff.WithMultiplier(1.5), + ) + } + + jobWithPR, err := fetchJobWithBackoff(ctx, client, repo, job.ID, bo) + if jobWithPR != nil { + return agentSessionWebURL(repo, jobWithPR), nil + } + return "", err +} + // 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. From 4f7d577b9772cf42a488b69ef970fda31c17b01c Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 16 Sep 2025 13:18:09 +0100 Subject: [PATCH 149/208] feat(agent-task create): add `--follow` flag Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 55 ++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 51ae64e9b..7186f7d59 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -21,25 +21,38 @@ import ( "github.com/spf13/cobra" ) +const defaultLogPollInterval = 5 * time.Second + // 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) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + CapiClient func() (capi.CapiClient, error) + Config func() (gh.Config, error) + + LogRenderer func() shared.LogRenderer + Sleep func(d time.Duration) + ProblemStatement string BackOff backoff.BackOff BaseBranch string Prompter prompter.Prompter ProblemStatementFile string + Follow bool +} + +func defaultLogRenderer() shared.LogRenderer { + return shared.NewLogRenderer() } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ - IO: f.IOStreams, - CapiClient: shared.CapiClientFunc(f), - Config: f.Config, - Prompter: f.Prompter, + IO: f.IOStreams, + CapiClient: shared.CapiClientFunc(f), + Config: f.Config, + Prompter: f.Prompter, + LogRenderer: defaultLogRenderer, + Sleep: time.Sleep, } cmd := &cobra.Command{ @@ -91,6 +104,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.ProblemStatementFile, "from-file", "F", "", "Read task description from `file` (use \"-\" to read from standard input)") cmd.Flags().StringVarP(&opts.BaseBranch, "base", "b", "", "Base branch for the pull request (use default branch if not provided)") + cmd.Flags().BoolVar(&opts.Follow, "follow", false, "Follow agent session logs") return cmd } @@ -166,6 +180,9 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.IO.Out, "job %s queued. View progress: %s\n", job.ID, capi.AgentsHomeURL) } + if opts.Follow { + return followLogs(opts, client, job.SessionID) + } return nil } @@ -234,3 +251,25 @@ func fetchJobWithBackoff(ctx context.Context, client capi.CapiClient, repo ghrep } return result, nil } + +func followLogs(opts *CreateOptions, capiClient capi.CapiClient, sessionID string) error { + ctx := context.Background() + + renderer := opts.LogRenderer() + + 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) +} From f5ed563a42d0c84c68303983144b0076431146e3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 16 Sep 2025 13:18:26 +0100 Subject: [PATCH 150/208] docs(agent-task create): add example for `--follow` flag Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 7186f7d59..8b8084a1f 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -83,6 +83,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co # Create a task from an inline description $ gh agent-task create "build me a new app" + # Create a task from an inline description and follow logs + $ gh agent-task create "build me a new app" --follow + # Create a task from a file $ gh agent-task create -F task-desc.md From dab285c61a1e9c591a9f522c37039780e34b6877 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 16 Sep 2025 13:26:49 +0100 Subject: [PATCH 151/208] test(agent-task create): add test for `--follow` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create_test.go | 93 ++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index b855ce687..d1298e606 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -9,13 +9,16 @@ import ( "testing" "time" + "github.com/MakeNowJust/heredoc" "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/cmd/agent-task/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -57,6 +60,15 @@ func TestNewCmdCreate(t *testing.T) { BaseBranch: "feature", }, }, + { + name: "with --follow", + args: "'task description from args' --follow", + wantOpts: &CreateOptions{ + ProblemStatement: "task description from args", + ProblemStatementFile: "", + Follow: true, + }, + }, } for _, tt := range tests { @@ -135,14 +147,15 @@ func Test_createRun(t *testing.T) { } tests := []struct { - name string - isTTY bool - capiStubs func(*testing.T, *capi.CapiClientMock) - opts *CreateOptions // input options (IO & BackOff set later) - wantStdout string - wantStdErr string - wantErr string - wantErrIs error + name string + isTTY bool + opts *CreateOptions // input options (IO & BackOff set later) + capiStubs func(*testing.T, *capi.CapiClientMock) + logRendererStubs func(*testing.T, *shared.LogRendererMock) + wantStdout string + wantStdErr string + wantErr string + wantErrIs error }{ { name: "interactive with file prompts to edit with file contents", @@ -428,6 +441,62 @@ func Test_createRun(t *testing.T) { }, wantStdout: "job job123 queued. View progress: https://github.com/copilot/agents\n", }, + { + name: "success with follow logs and delayed PR after polling", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + ProblemStatement: "Do the thing", + Follow: true, + Sleep: func(d time.Duration) {}, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "Do the thing", problemStatement) + require.Equal(t, "", baseBranch) + return &createdJobSuccess, nil + } + m.GetJobFunc = func(ctx context.Context, owner, repo, jobID string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "job123", jobID) + return &createdJobSuccessWithPR, nil + } + + var count int + m.GetSessionLogsFunc = func(_ context.Context, id string) ([]byte, error) { + assert.Equal(t, "sess1", id) + + count++ + require.Less(t, count, 3, "too many calls to fetch logs") + if count == 1 { + return []byte(""), nil + } + return []byte(""), nil + } + }, + logRendererStubs: func(t *testing.T, m *shared.LogRendererMock) { + m.FollowFunc = func(fetcher func() ([]byte, error), w io.Writer, ios *iostreams.IOStreams) error { + raw, err := fetcher() + require.NoError(t, err) + w.Write([]byte("(rendered:) " + string(raw) + "\n")) + + raw, err = fetcher() + require.NoError(t, err) + w.Write([]byte("(rendered:) " + string(raw) + "\n")) + return nil + } + }, + wantStdout: heredoc.Doc(` + https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1 + + (rendered:) + (rendered:) + `), + }, } for _, tt := range tests { @@ -452,6 +521,14 @@ func Test_createRun(t *testing.T) { // fast backoff tt.opts.BackOff = backoff.WithMaxRetries(&backoff.ZeroBackOff{}, 3) + logRenderer := &shared.LogRendererMock{} + if tt.logRendererStubs != nil { + tt.logRendererStubs(t, logRenderer) + } + tt.opts.LogRenderer = func() shared.LogRenderer { + return logRenderer + } + err := createRun(tt.opts) if tt.wantErrIs != nil { require.ErrorIs(t, err, tt.wantErrIs) From 4d9038ad31bb86e310d3638204a875682e77f70f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:26:55 -0600 Subject: [PATCH 152/208] Add pager support to log output in printLogs Introduces use of a pager for log output in the printLogs function. If the pager fails to start, an error message is printed to stderr. This improves log readability for long outputs. --- pkg/cmd/agent-task/view/view.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 8d43b1f7e..832c20806 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -325,6 +325,12 @@ func printLogs(opts *ViewOptions, capiClient capi.CapiClient, sessionID string) renderer := opts.LogRenderer() + if err := opts.IO.StartPager(); err == nil { + defer opts.IO.StopPager() + } else { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + if opts.Follow { var called bool fetcher := func() ([]byte, error) { From 37d8e0a4380aa16971b7ac200e3129d56a754d0f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:46:16 -0600 Subject: [PATCH 153/208] Refactor generic tool call titles map to package scope Moved the genericToolCallNamesToTitles map from inside the renderGenericToolCall function to a package-level variable for improved readability and potential reuse. This change also updates the function signature order for consistency. --- pkg/cmd/agent-task/shared/log.go | 111 ++++++++++++++++--------------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 7ca3dd9e5..9a91c2a14 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -422,62 +422,63 @@ func renderToolCallTitle(w io.Writer, cs *iostreams.ColorScheme, toolName, title } } +// genericToolCallNamesToTitles maps known generic tool call identifiers to human-friendly titles. +var genericToolCallNamesToTitles = map[string]string{ + // Custom tools, the GitHub UI doesn't currently have these. + "codeql_checker": "Run CodeQL analysis", + + // Playwright tools. + "playwright-browser_navigate": "Navigate Playwright web browser to a URL", + "playwright-browser_navigate_back": "Navigate back in Playwright web browser", + "playwright-browser_navigate_forward": "Navigate forward in Playwright web browser", + "playwright-browser_click": "Click element in Playwright web browser", + "playwright-browser_take_screenshot": "Take screenshot of Playwright web browser", + "playwright-browser_type": "Type in Playwright web browser", + "playwright-browser_wait_for": "Wait for text to appear/disappear in Playwright web browser", + "playwright-browser_evaluate": "Run JavaScript in Playwright web browser", + "playwright-browser_snapshot": "Take snapshot of page in Playwright web browser", + "playwright-browser_resize": "Resize Playwright web browser window", + "playwright-browser_close": "Close Playwright web browser", + "playwright-browser_press_key": "Press key in Playwright web browser", + "playwright-browser_select_option": "Select option in Playwright web browser", + "playwright-browser_handle_dialog": "Interact with dialog in Playwright web browser", + "playwright-browser_console_messages": "Get console messages from Playwright web browser", + "playwright-browser_drag": "Drag mouse between elements in Playwright web browser", + "playwright-browser_file_upload": "Upload file in Playwright web browser", + "playwright-browser_hover": "Hover mouse over element in Playwright web browser", + "playwright-browser_network_requests": "Get network requests from Playwright web browser", + + // GitHub MCP server common tools + "github-mcp-server-get_file_contents": "Get file contents from GitHub", + "github-mcp-server-get_pull_request": "Get pull request from GitHub", + "github-mcp-server-get_issue": "Get issue from GitHub", + "github-mcp-server-get_pull_request_files": "Get pull request changed files from GitHub", + "github-mcp-server-list_pull_requests": "List pull requests on GitHub", + "github-mcp-server-list_branches": "List branches on GitHub", + "github-mcp-server-get_pull_request_diff": "Get pull request diff from GitHub", + "github-mcp-server-get_pull_request_comments": "Get pull request comments from GitHub", + "github-mcp-server-get_commit": "Get commit from GitHub", + "github-mcp-server-search_repositories": "Search repositories on GitHub", + "github-mcp-server-search_code": "Search code on GitHub", + "github-mcp-server-get_issue_comments": "Get issue comments from GitHub", + "github-mcp-server-list_issues": "List issues on GitHub", + "github-mcp-server-search_pull_requests": "Search pull requests on GitHub", + "github-mcp-server-list_commits": "List commits on GitHub", + "github-mcp-server-get_pull_request_status": "Get pull request status from GitHub", + "github-mcp-server-search_issues": "Search issues on GitHub", + "github-mcp-server-get_pull_request_reviews": "Get pull request reviews from GitHub", + "github-mcp-server-download_workflow_run_artifact": "Download GitHub Actions workflow run artifact", + "github-mcp-server-get_job_logs": "Get GitHub Actions job logs", + "github-mcp-server-get_workflow_run": "Get GitHub Actions workflow run", + "github-mcp-server-get_workflow_run_logs": "Get GitHub Actions workflow run logs", + "github-mcp-server-get_workflow_run_usage": "Get GitHub Actions workflow usage", + "github-mcp-server-list_workflow_jobs": "List GitHub Actions workflow jobs", + "github-mcp-server-list_workflow_run_artifacts": "List GitHub Actions workflow run artifacts", + "github-mcp-server-list_workflow_runs": "List GitHub Actions workflow runs", + "github-mcp-server-list_workflows": "List GitHub Actions workflows", +} + func renderGenericToolCall(w io.Writer, cs *iostreams.ColorScheme, name string) { - genericToolCallNamesToTitles := map[string]string{ - // Custom tools, the GitHub UI doesn't currently have these. - "codeql_checker": "Run CodeQL analysis", - - // Playwright tools. - "playwright-browser_navigate": "Navigate Playwright web browser to a URL", - "playwright-browser_navigate_back": "Navigate back in Playwright web browser", - "playwright-browser_navigate_forward": "Navigate forward in Playwright web browser", - "playwright-browser_click": "Click element in Playwright web browser", - "playwright-browser_take_screenshot": "Take screenshot of Playwright web browser", - "playwright-browser_type": "Type in Playwright web browser", - "playwright-browser_wait_for": "Wait for text to appear/disappear in Playwright web browser", - "playwright-browser_evaluate": "Run JavaScript in Playwright web browser", - "playwright-browser_snapshot": "Take snapshot of page in Playwright web browser", - "playwright-browser_resize": "Resize Playwright web browser window", - "playwright-browser_close": "Close Playwright web browser", - "playwright-browser_press_key": "Press key in Playwright web browser", - "playwright-browser_select_option": "Select option in Playwright web browser", - "playwright-browser_handle_dialog": "Interact with dialog in Playwright web browser", - "playwright-browser_console_messages": "Get console messages from Playwright web browser", - "playwright-browser_drag": "Drag mouse between elements in Playwright web browser", - "playwright-browser_file_upload": "Upload file in Playwright web browser", - "playwright-browser_hover": "Hover mouse over element in Playwright web browser", - "playwright-browser_network_requests": "Get network requests from Playwright web browser", - - // GitHub MCP server common tools - "github-mcp-server-get_file_contents": "Get file contents from GitHub", - "github-mcp-server-get_pull_request": "Get pull request from GitHub", - "github-mcp-server-get_issue": "Get issue from GitHub", - "github-mcp-server-get_pull_request_files": "Get pull request changed files from GitHub", - "github-mcp-server-list_pull_requests": "List pull requests on GitHub", - "github-mcp-server-list_branches": "List branches on GitHub", - "github-mcp-server-get_pull_request_diff": "Get pull request diff from GitHub", - "github-mcp-server-get_pull_request_comments": "Get pull request comments from GitHub", - "github-mcp-server-get_commit": "Get commit from GitHub", - "github-mcp-server-search_repositories": "Search repositories on GitHub", - "github-mcp-server-search_code": "Search code on GitHub", - "github-mcp-server-get_issue_comments": "Get issue comments from GitHub", - "github-mcp-server-list_issues": "List issues on GitHub", - "github-mcp-server-search_pull_requests": "Search pull requests on GitHub", - "github-mcp-server-list_commits": "List commits on GitHub", - "github-mcp-server-get_pull_request_status": "Get pull request status from GitHub", - "github-mcp-server-search_issues": "Search issues on GitHub", - "github-mcp-server-get_pull_request_reviews": "Get pull request reviews from GitHub", - "github-mcp-server-download_workflow_run_artifact": "Download GitHub Actions workflow run artifact", - "github-mcp-server-get_job_logs": "Get GitHub Actions job logs", - "github-mcp-server-get_workflow_run": "Get GitHub Actions workflow run", - "github-mcp-server-get_workflow_run_logs": "Get GitHub Actions workflow run logs", - "github-mcp-server-get_workflow_run_usage": "Get GitHub Actions workflow usage", - "github-mcp-server-list_workflow_jobs": "List GitHub Actions workflow jobs", - "github-mcp-server-list_workflow_run_artifacts": "List GitHub Actions workflow run artifacts", - "github-mcp-server-list_workflow_runs": "List GitHub Actions workflow runs", - "github-mcp-server-list_workflows": "List GitHub Actions workflows", - } - toolName, ok := genericToolCallNamesToTitles[name] if !ok { toolName = fmt.Sprintf("Call to %s", name) From d636f4c2136ed9ce06b7fdf7a4816f764cda6bee Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:47:48 -0600 Subject: [PATCH 154/208] Clarify comment on CRLF normalization in tests Updated comments in log_test.go to clarify that CRLF is normalized to LF for OS-agnostic test behavior, improving code readability. --- pkg/cmd/agent-task/shared/log_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go index f5913bf4b..586890d9f 100644 --- a/pkg/cmd/agent-task/shared/log_test.go +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -34,7 +34,7 @@ func TestFollow(t *testing.T) { raw, err := os.ReadFile(tt.log) require.NoError(t, err) - // Delete all the `/r` to make the tests OS-agnostic. + // Normalize CRLF to LF to make the tests OS-agnostic. raw = []byte(strings.ReplaceAll(string(raw), "\r\n", "\n")) lines := slices.DeleteFunc(strings.Split(string(raw), "\n"), func(line string) bool { @@ -63,7 +63,7 @@ func TestFollow(t *testing.T) { want, err := os.ReadFile(tt.want) require.NoError(t, err) - // Delete all the `/r` to make the tests OS-agnostic. + // Normalize CRLF to LF to make the tests OS-agnostic. want = []byte(strings.ReplaceAll(string(want), "\r\n", "\n")) assert.Equal(t, string(want), stdout.String()) From 97d3253aaf11ba6f1a366afc8be6772719b301cd Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:58:58 -0600 Subject: [PATCH 155/208] Prefix rendered shell commands with '$ ' in logs Shell commands in log output are now prefixed with '$ ' for improved readability and consistency with common shell output conventions. Updated related test data and test helper comments to reflect this change. --- pkg/cmd/agent-task/shared/log.go | 2 +- pkg/cmd/agent-task/shared/log_test.go | 3 ++- pkg/cmd/agent-task/shared/testdata/log-1-want.txt | 12 ++++++------ pkg/cmd/agent-task/shared/testdata/log-2-want.txt | 14 +++++++------- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 9a91c2a14..e58bed79a 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -153,7 +153,7 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I contentWithCommand := choice.Delta.Content if v.Command != "" { - contentWithCommand = fmt.Sprintf("%s\n%s", v.Command, choice.Delta.Content) + contentWithCommand = fmt.Sprintf("$ %s\n%s", v.Command, choice.Delta.Content) } if err := renderFileContentAsMarkdown("commands.sh", contentWithCommand, w, io); err != nil { return false, fmt.Errorf("failed to render bash command output: %w", err) diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go index 586890d9f..4c95a9853 100644 --- a/pkg/cmd/agent-task/shared/log_test.go +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -58,7 +58,8 @@ func TestFollow(t *testing.T) { // Handy note for updating the testdata files when they change: // ext := filepath.Ext(tt.log) // stripped := strings.TrimSuffix(tt.log, ext) - // os.WriteFile(stripped+".want"+ext, stdout.Bytes(), 0644) + // stripped = strings.TrimSuffix(stripped, "-input") + // os.WriteFile(stripped+"-want"+ext, stdout.Bytes(), 0644) want, err := os.ReadFile(tt.want) require.NoError(t, err) diff --git a/pkg/cmd/agent-task/shared/testdata/log-1-want.txt b/pkg/cmd/agent-task/shared/testdata/log-1-want.txt index e36d011a7..6ba206dda 100644 --- a/pkg/cmd/agent-task/shared/testdata/log-1-want.txt +++ b/pkg/cmd/agent-task/shared/testdata/log-1-want.txt @@ -11,7 +11,7 @@ View repository README.md Bash: Check current directory and list all files -cd /home/runner/work/foo/foo && pwd && ls -la +$ cd /home/runner/work/foo/foo && pwd && ls -la /home/runner/work/foo/foo total 20 drwxr-xr-x 4 runner docker 4096 Sep 9 10:17 . @@ -36,8 +36,8 @@ View README.md # foo Bash: Check if there are any existing scripts or build files -cd /home/runner/work/foo/foo && find . -name "*.sh" -o -name "Makefile" -o - - name "package.json" -o -name "*.yml" -o -name "*.yaml" +$ cd /home/runner/work/foo/foo && find . -name "*.sh" -o -name "Makefile" - + o -name "package.json" -o -name "*.yml" -o -name "*.yaml" ./.github/workflows/upload-artifact.yml ./.github/workflows/manual-dispatch.yml ./.github/workflows/cache.yml @@ -195,7 +195,7 @@ Create: os_info.sh Now let me make the script executable and test it: Bash: Make script executable and test it -cd /home/runner/work/foo/foo && chmod +x os_info.sh && ./os_info.sh +$ cd /home/runner/work/foo/foo && chmod +x os_info.sh && ./os_info.sh ================================================== OPERATING SYSTEM INFORMATION ================================================== @@ -270,7 +270,7 @@ diff --git a/home/runner/work/foo/foo/README.md Let me verify the changes look good: Bash: Check file permissions and final status -cd /home/runner/work/foo/foo && ls -la os_info.sh && echo "---" && git +$ cd /home/runner/work/foo/foo && ls -la os_info.sh && echo "---" && git status -rwxr-xr-x 1 runner docker 2139 Sep 9 10:18 os_info.sh --- @@ -323,7 +323,7 @@ Perfect! Let me test the script one more time to make sure everything is working correctly and commit the changes: Bash: Final test of the script -cd /home/runner/work/foo/foo && echo "Testing script execution:" && . +$ cd /home/runner/work/foo/foo && echo "Testing script execution:" && . /os_info.sh | head -10 Testing script execution: ================================================== diff --git a/pkg/cmd/agent-task/shared/testdata/log-2-want.txt b/pkg/cmd/agent-task/shared/testdata/log-2-want.txt index 066658f91..7665b4d51 100644 --- a/pkg/cmd/agent-task/shared/testdata/log-2-want.txt +++ b/pkg/cmd/agent-task/shared/testdata/log-2-want.txt @@ -6,7 +6,7 @@ I'll help you create a .yaml file with random fields in the repository. Let and then create a plan. Bash: Find documentation and config files to understand the repository -cd /home/runner/work/foo/foo && find . -type f -name "*.md" -o -name "*. +$ cd /home/runner/work/foo/foo && find . -type f -name "*.md" -o -name "*. txt" -o -name "*.json" -o -name "*.yaml" -o -name "*.yml" | head -20 ./README.md ./.github/workflows/upload-artifact.yml @@ -23,7 +23,7 @@ View repository README.md Bash: List all files in the root directory -cd /home/runner/work/foo/foo && ls -la +$ cd /home/runner/work/foo/foo && ls -la total 20 drwxr-xr-x 4 runner runner 4096 Sep 11 13:35 . drwxr-xr-x 3 runner runner 4096 Sep 11 13:34 .. @@ -37,7 +37,7 @@ View README.md # foo Bash: Check git status to understand current state -cd /home/runner/work/foo/foo && git --no-pager status +$ cd /home/runner/work/foo/foo && git --no-pager status On branch copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25 Your branch is up to date with 'origin/copilot/fix-5b1c2fcb-9248-492c-84fa- 04a3e0304d25'. @@ -197,13 +197,13 @@ Now let me verify that the YAML file is valid by testing it with a YAML parser: Bash: Validate the YAML file syntax -cd /home/runner/work/foo/foo && python3 -c "import yaml; yaml. +$ cd /home/runner/work/foo/foo && python3 -c "import yaml; yaml. safe_load(open('sample-data.yaml', 'r')); print('YAML file is valid!')" Bash: Validate YAML and show structure -cd /home/runner/work/foo/foo && python3 -c "import yaml; data = yaml. +$ cd /home/runner/work/foo/foo && python3 -c "import yaml; data = yaml. safe_load(open('sample-data.yaml', 'r')); print('YAML file is valid!'); print(f'Loaded {len(data)} top-level keys: {list(data.keys())}')" @@ -213,7 +213,7 @@ cd /home/runner/work/foo/foo && python3 -c "import yaml; data = yaml. Let me try a different approach to verify the YAML: Bash: Detailed YAML validation with content verification -cd /home/runner/work/foo/foo && python3 -c " +$ cd /home/runner/work/foo/foo && python3 -c " import yaml try: with open('sample-data.yaml', 'r') as f: @@ -237,7 +237,7 @@ cd /home/runner/work/foo/foo && python3 -c " Perfect! Let me also check the file contents and verify the current status: Bash: Check git status to see the new file -cd /home/runner/work/foo/foo && git --no-pager status +$ cd /home/runner/work/foo/foo && git --no-pager status On branch copilot/fix-5b1c2fcb-9248-492c-84fa-04a3e0304d25 Your branch is up to date with 'origin/copilot/fix-5b1c2fcb-9248-492c-84fa- 04a3e0304d25'. From 65b45adbd83a98daa57770e340b448924edeca55 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:21:01 -0600 Subject: [PATCH 156/208] Improve log rendering error handling and test coverage Refactors log rendering to print errors to stderr and continue processing on JSON parse or rendering failures, instead of returning early. Updates tests to check both stdout and stderr outputs, and adds new test cases and testdata for tolerant parsing and error scenarios. --- pkg/cmd/agent-task/shared/log.go | 36 ++++++++------ pkg/cmd/agent-task/shared/log_test.go | 48 ++++++++++++++----- .../shared/testdata/log-3-input.txt | 27 +++++++++++ .../log-3-synthetic-failures-input.txt | 27 +++++++++++ .../log-3-synthetic-failures-want-stderr.txt | 10 ++++ .../log-3-synthetic-failures-want.txt | 39 +++++++++++++++ .../agent-task/shared/testdata/log-3-want.txt | 39 +++++++++++++++ 7 files changed, 199 insertions(+), 27 deletions(-) create mode 100644 pkg/cmd/agent-task/shared/testdata/log-3-input.txt create mode 100644 pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-input.txt create mode 100644 pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-want-stderr.txt create mode 100644 pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-want.txt create mode 100644 pkg/cmd/agent-task/shared/testdata/log-3-want.txt diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index e58bed79a..a1b1740e0 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -133,15 +133,16 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I case "view": args := viewToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - return false, fmt.Errorf("failed to parse 'view' tool call arguments: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to parse 'view' tool call arguments: %v\n", err) + continue } - fmt.Fprintf(w, "View %s\n", cs.Bold(relativeFilePath(args.Path))) + renderToolCallTitle(w, cs, fmt.Sprintf("View %s", cs.Bold(relativeFilePath(args.Path))), "") content := stripDiffFormat(choice.Delta.Content) - // TODO: Strip the diff formatting from this, but for now render as it is. if err := renderFileContentAsMarkdown(args.Path, content, w, io); err != nil { - return false, fmt.Errorf("failed to render viewed file content: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to render viewed file content: %v\n\n", err) + fmt.Fprintln(io.ErrOut, content) // raw fallback } case "bash": if v := unmarshal[bashToolArgs](args); v != nil { @@ -156,7 +157,8 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I contentWithCommand = fmt.Sprintf("$ %s\n%s", v.Command, choice.Delta.Content) } if err := renderFileContentAsMarkdown("commands.sh", contentWithCommand, w, io); err != nil { - return false, fmt.Errorf("failed to render bash command output: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to render bash command output: %v\n\n", err) + fmt.Fprintln(io.ErrOut, contentWithCommand) } } // TODO: consider including more details for these bash-related tool calls. @@ -193,24 +195,26 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I case "think": args := thinkToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - return false, fmt.Errorf("failed to parse 'think' tool call arguments: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to parse 'think' tool call arguments: %v\n", err) + continue } // NOTE: omit the delta.content since it's the same as thought renderToolCallTitle(w, cs, "Thought", "") if err := renderRawMarkdown(args.Thought, w, io); err != nil { - return false, fmt.Errorf("failed to render thought: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to render thought: %v\n", err) } case "report_progress": args := reportProgressToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - return false, fmt.Errorf("failed to parse 'report_progress' tool call arguments: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to parse 'report_progress' tool call arguments: %v\n", err) + continue } renderToolCallTitle(w, cs, "Progress update", cs.Bold(args.CommitMessage)) if args.PrDescription != "" { if err := renderRawMarkdown(args.PrDescription, w, io); err != nil { - return false, fmt.Errorf("failed to render PR description: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to render PR description: %v\n", err) } } @@ -218,29 +222,33 @@ func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.I if choice.Delta.Content != "" { // Try to treat this as JSON if err := renderContentAsJSONMarkdown("", choice.Delta.Content, w, io); err != nil { - return false, fmt.Errorf("failed to render progress update content: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to render progress update content: %v\n", err) } } case "create": args := createToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - return false, fmt.Errorf("failed to parse 'create' tool call arguments: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to parse 'create' tool call arguments: %v\n", err) + continue } renderToolCallTitle(w, cs, "Create", cs.Bold(relativeFilePath(args.Path))) if err := renderFileContentAsMarkdown(args.Path, args.FileText, w, io); err != nil { - return false, fmt.Errorf("failed to render created file content: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to render created file content: %v\n\n", err) + fmt.Fprintln(io.ErrOut, args.FileText) } case "str_replace": args := strReplaceToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - return false, fmt.Errorf("failed to parse 'str_replace' tool call arguments: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to parse 'str_replace' tool call arguments: %v\n", err) + continue } renderToolCallTitle(w, cs, "Edit", cs.Bold(relativeFilePath(args.Path))) if err := renderFileContentAsMarkdown("output.diff", choice.Delta.Content, w, io); err != nil { - return false, fmt.Errorf("failed to render str_replace diff: %w", err) + fmt.Fprintf(io.ErrOut, "\nfailed to render str_replace diff: %v\n\n", err) + fmt.Fprintln(io.ErrOut, choice.Delta.Content) } default: // Unknown tool call. For example for "codeql_checker": diff --git a/pkg/cmd/agent-task/shared/log_test.go b/pkg/cmd/agent-task/shared/log_test.go index 4c95a9853..07b562dc8 100644 --- a/pkg/cmd/agent-task/shared/log_test.go +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -13,19 +13,26 @@ import ( func TestFollow(t *testing.T) { tests := []struct { - name string - log string - want string + name string + log string + wantStdoutFile string + wantStderrFile string }{ { - name: "sample log 1", - log: "testdata/log-1-input.txt", - want: "testdata/log-1-want.txt", + name: "sample log 1", + log: "testdata/log-1-input.txt", + wantStdoutFile: "testdata/log-1-want.txt", }, { - name: "sample log 2", - log: "testdata/log-2-input.txt", - want: "testdata/log-2-want.txt", + name: "sample log 2", + log: "testdata/log-2-input.txt", + wantStdoutFile: "testdata/log-2-want.txt", + }, + { + name: "sample log 3 (tolerant parse failures)", + log: "testdata/log-3-synthetic-failures-input.txt", + wantStdoutFile: "testdata/log-3-synthetic-failures-want.txt", + wantStderrFile: "testdata/log-3-synthetic-failures-want-stderr.txt", }, } @@ -50,7 +57,7 @@ func TestFollow(t *testing.T) { return []byte(strings.Join(lines[0:hits], "\n\n")), nil } - ios, _, stdout, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() err = NewLogRenderer().Follow(fetcher, stdout, ios) require.NoError(t, err) @@ -60,14 +67,29 @@ func TestFollow(t *testing.T) { // stripped := strings.TrimSuffix(tt.log, ext) // stripped = strings.TrimSuffix(stripped, "-input") // os.WriteFile(stripped+"-want"+ext, stdout.Bytes(), 0644) + // if tt.wantStderrFile != "" { + // os.WriteFile(stripped+"-want-stderr"+ext, stderr.Bytes(), 0644) + // } - want, err := os.ReadFile(tt.want) + wantStdout, err := os.ReadFile(tt.wantStdoutFile) require.NoError(t, err) // Normalize CRLF to LF to make the tests OS-agnostic. - want = []byte(strings.ReplaceAll(string(want), "\r\n", "\n")) + wantStdout = []byte(strings.ReplaceAll(string(wantStdout), "\r\n", "\n")) - assert.Equal(t, string(want), stdout.String()) + assert.Equal(t, string(wantStdout), stdout.String()) + + if tt.wantStderrFile != "" { + wantStderr, err := os.ReadFile(tt.wantStderrFile) + require.NoError(t, err) + + // Normalize CRLF to LF to make the tests OS-agnostic. + wantStderr = []byte(strings.ReplaceAll(string(wantStderr), "\r\n", "\n")) + + assert.Equal(t, string(wantStderr), stderr.String()) + } else { + require.Empty(t, stderr, "expected no stderr output") + } }) } } diff --git a/pkg/cmd/agent-task/shared/testdata/log-3-input.txt b/pkg/cmd/agent-task/shared/testdata/log-3-input.txt new file mode 100644 index 000000000..5fa8e0a63 --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/log-3-input.txt @@ -0,0 +1,27 @@ +data: {"id": "bad1", "object": "chat.completion.chunk", "choices": [ { "delta": { "tool_calls": [ { "function": { "name": "view", "arguments": "{bad json" } } ] } } ] } + +data: {"id":"v1","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line\nINSIDE A VIEW CALL","tool_calls":[{"function":{"name":"view","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/README.md\"}"},"id":"tc1","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"v1b","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line","tool_calls":[{"function":{"name":"view","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/README.md\"}"},"id":"tc1b","index":0}]}],"finish_reason":"tool_calls","index":0}]} + +data: {"id":"v1","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line\nINSIDE A VIEW CALL","tool_calls":[{"function":{"name":"view","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/README.md"},"id":"tc1","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"t1","object":"chat.completion.chunk","choices":[{"delta":{"content":"THINK","tool_calls":[{"function":{"name":"think","arguments":"{\"thought\":123"},"id":"tc2","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"t2","object":"chat.completion.chunk","choices":[{"delta":{"content":"A valid thought to render.","reasoning_text":"Interim reasoning that should show as raw markdown.","tool_calls":[{"function":{"name":"think","arguments":"{\"thought\":\"A valid thought to render.\"}"},"id":"tc3","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"rp1","object":"chat.completion.chunk","choices":[{"delta":{"content":"RP","tool_calls":[{"function":{"name":"report_progress","arguments":"{\"commitMessage\": 5"},"id":"tc4","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"rp2","object":"chat.completion.chunk","choices":[{"delta":{"content":"not-json","tool_calls":[{"function":{"name":"report_progress","arguments":"{\"commitMessage\":\"Valid commit msg\"}"},"id":"tc5","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"CREATE","tool_calls":[{"function":{"name":"create","arguments":"{\"path\":\"/abs/path/file.txt\""},"id":"tc6","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"c2","object":"chat.completion.chunk","choices":[{"delta":{"content":"CREATE2","tool_calls":[{"function":{"name":"create","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/new.txt\",\"file_text\":\"hello world\"}"},"id":"tc7","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"sr1","object":"chat.completion.chunk","choices":[{"delta":{"content":"SR","tool_calls":[{"function":{"name":"str_replace","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/file.diff"},"id":"tc8","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"sr2","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line","tool_calls":[{"function":{"name":"str_replace","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/file.diff\"}"},"id":"tc9","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"u1","object":"chat.completion.chunk","choices":[{"delta":{"content":"{\"foo\":1}","tool_calls":[{"function":{"name":"mystery_tool","arguments":"{\"bar\":2}"},"id":"tc10","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"end","object":"chat.completion.chunk","choices":[{"delta":{"content":"","tool_calls":[],"role":"assistant"},"finish_reason":"stop","index":0}]} diff --git a/pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-input.txt b/pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-input.txt new file mode 100644 index 000000000..5fa8e0a63 --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-input.txt @@ -0,0 +1,27 @@ +data: {"id": "bad1", "object": "chat.completion.chunk", "choices": [ { "delta": { "tool_calls": [ { "function": { "name": "view", "arguments": "{bad json" } } ] } } ] } + +data: {"id":"v1","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line\nINSIDE A VIEW CALL","tool_calls":[{"function":{"name":"view","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/README.md\"}"},"id":"tc1","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"v1b","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line","tool_calls":[{"function":{"name":"view","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/README.md\"}"},"id":"tc1b","index":0}]}],"finish_reason":"tool_calls","index":0}]} + +data: {"id":"v1","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line\nINSIDE A VIEW CALL","tool_calls":[{"function":{"name":"view","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/README.md"},"id":"tc1","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"t1","object":"chat.completion.chunk","choices":[{"delta":{"content":"THINK","tool_calls":[{"function":{"name":"think","arguments":"{\"thought\":123"},"id":"tc2","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"t2","object":"chat.completion.chunk","choices":[{"delta":{"content":"A valid thought to render.","reasoning_text":"Interim reasoning that should show as raw markdown.","tool_calls":[{"function":{"name":"think","arguments":"{\"thought\":\"A valid thought to render.\"}"},"id":"tc3","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"rp1","object":"chat.completion.chunk","choices":[{"delta":{"content":"RP","tool_calls":[{"function":{"name":"report_progress","arguments":"{\"commitMessage\": 5"},"id":"tc4","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"rp2","object":"chat.completion.chunk","choices":[{"delta":{"content":"not-json","tool_calls":[{"function":{"name":"report_progress","arguments":"{\"commitMessage\":\"Valid commit msg\"}"},"id":"tc5","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"CREATE","tool_calls":[{"function":{"name":"create","arguments":"{\"path\":\"/abs/path/file.txt\""},"id":"tc6","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"c2","object":"chat.completion.chunk","choices":[{"delta":{"content":"CREATE2","tool_calls":[{"function":{"name":"create","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/new.txt\",\"file_text\":\"hello world\"}"},"id":"tc7","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"sr1","object":"chat.completion.chunk","choices":[{"delta":{"content":"SR","tool_calls":[{"function":{"name":"str_replace","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/file.diff"},"id":"tc8","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"sr2","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line","tool_calls":[{"function":{"name":"str_replace","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/file.diff\"}"},"id":"tc9","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"u1","object":"chat.completion.chunk","choices":[{"delta":{"content":"{\"foo\":1}","tool_calls":[{"function":{"name":"mystery_tool","arguments":"{\"bar\":2}"},"id":"tc10","index":0}]},"finish_reason":"tool_calls","index":0}]} + +data: {"id":"end","object":"chat.completion.chunk","choices":[{"delta":{"content":"","tool_calls":[],"role":"assistant"},"finish_reason":"stop","index":0}]} diff --git a/pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-want-stderr.txt b/pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-want-stderr.txt new file mode 100644 index 000000000..199ab66f3 --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-want-stderr.txt @@ -0,0 +1,10 @@ + +failed to parse 'view' tool call arguments: unexpected end of JSON input + +failed to parse 'think' tool call arguments: unexpected end of JSON input + +failed to parse 'report_progress' tool call arguments: unexpected end of JSON input + +failed to parse 'create' tool call arguments: unexpected end of JSON input + +failed to parse 'str_replace' tool call arguments: unexpected end of JSON input diff --git a/pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-want.txt b/pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-want.txt new file mode 100644 index 000000000..52a36d427 --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/log-3-synthetic-failures-want.txt @@ -0,0 +1,39 @@ +View repo/README.md + +old line + new line + unchanged line + INSIDE A VIEW CALL + + +Interim reasoning that should show as raw markdown. + +Thought + +A valid thought to render. + +Progress update: Valid commit msg +Create: repo/new.txt +hello world + +Edit: repo/file.diff +@@ -1,2 +1,2 @@ + -old line + +new line + unchanged line + +Call to mystery_tool + +Output: + +{ + "foo": 1 + } + + +Input: + +{ + "bar": 2 + } + diff --git a/pkg/cmd/agent-task/shared/testdata/log-3-want.txt b/pkg/cmd/agent-task/shared/testdata/log-3-want.txt new file mode 100644 index 000000000..52a36d427 --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/log-3-want.txt @@ -0,0 +1,39 @@ +View repo/README.md + +old line + new line + unchanged line + INSIDE A VIEW CALL + + +Interim reasoning that should show as raw markdown. + +Thought + +A valid thought to render. + +Progress update: Valid commit msg +Create: repo/new.txt +hello world + +Edit: repo/file.diff +@@ -1,2 +1,2 @@ + -old line + +new line + unchanged line + +Call to mystery_tool + +Output: + +{ + "foo": 1 + } + + +Input: + +{ + "bar": 2 + } + From c0a2648436c8f232f0b550130bc1dff53682cc4a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:34:57 -0600 Subject: [PATCH 157/208] Use filepath.Ext to detect file extension in markdown renderer Replaces manual string splitting with filepath.Ext for determining the file extension in renderFileContentAsMarkdown. This improves accuracy, especially for files with multiple dots in their names. --- pkg/cmd/agent-task/shared/log.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index a1b1740e0..28b98eea7 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "path/filepath" "slices" "strings" @@ -366,10 +367,9 @@ func stripDiffFormat(diff string) string { // renderFileContentAsMarkdown renders the given content as markdown // based on the file extension of the path. func renderFileContentAsMarkdown(path, content string, w io.Writer, io *iostreams.IOStreams) error { - parts := strings.Split(path, ".") - lang := parts[len(parts)-1] + lang := filepath.Ext(filepath.ToSlash(path)) - if lang == "md" { + if lang == ".md" { return renderRawMarkdown(content, w, io) } From b3401ffd740d67dedd83933ae0d72b5278004f10 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:46:43 -0600 Subject: [PATCH 158/208] Refactor stripDiffFormat guard clause logic Rearranged the guard clause in stripDiffFormat to return early if no hunk header is found, improving code clarity and reducing nesting. --- pkg/cmd/agent-task/shared/log.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 28b98eea7..4959cecbd 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -342,14 +342,15 @@ func stripDiffFormat(diff string) string { } } - // If we found the hunk header end, we strip everything before it. - if hunkEndIndex != -1 { - lines = lines[hunkEndIndex+1:] - } else { - // This isn't a diff, so we defensively just return the original string. + // Guard clause: if we didn't find a hunk header, this isn't a diff, so + // we defensively just return the original string. + if hunkEndIndex == -1 { return diff } + // We found the hunk header end; strip everything before it. + lines = lines[hunkEndIndex+1:] + // Now we strip the leading + and - from lines, if they exist. // Note: most of the time, but not all the time, we get a diff without // these prefixes. From 5b9d6ae3fc2922021254c3f6e0efb49049fe5af8 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 17 Sep 2025 11:47:18 +0100 Subject: [PATCH 159/208] fix(pr/shared): add `DisableProgress` field to `FindOptions` Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/finder.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 7d66d60f3..2861627a9 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -103,6 +103,8 @@ type FindOptions struct { // States lists the possible PR states to scope the PR-for-branch lookup to. States []string + DisableProgress bool + Detector fd.Detector } @@ -197,7 +199,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err } // TODO(josebalius): Should we be guarding here? - if f.progress != nil { + if !opts.DisableProgress && f.progress != nil { f.progress.StartProgressIndicator() defer f.progress.StopProgressIndicator() } From 1f5cbc5dff886b2e9d861ad55b553a009d29a4e1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 17 Sep 2025 11:47:49 +0100 Subject: [PATCH 160/208] test(pr/shared): assert `FindOptions.DisableProgress` is respected Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/finder_test.go | 93 ++++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 470709480..3f9ba6f50 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -190,9 +190,11 @@ type args struct { baseRepoFn func() (ghrepo.Interface, error) branchFn func() (string, error) gitConfigClient stubGitConfigClient + progress *stubProgressIndicator selector string fields []string baseBranch string + disableProgress bool } func TestFind(t *testing.T) { @@ -228,12 +230,13 @@ func TestFind(t *testing.T) { } tests := []struct { - name string - args args - httpStub func(*httpmock.Registry) - wantPR int - wantRepo string - wantErr bool + name string + args args + httpStub func(*httpmock.Registry) + wantUseProgress bool + wantPR int + wantRepo string + wantErr bool }{ { name: "number argument", @@ -824,6 +827,51 @@ func TestFind(t *testing.T) { wantPR: 13, wantRepo: "https://github.com/OWNER/REPO", }, + { + name: "number argument, with non nil-progress indicator", + args: args{ + selector: "13", + fields: []string{"id", "number"}, + baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil), + branchFn: func() (string, error) { + return "blueberries", nil + }, + progress: &stubProgressIndicator{}, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{"number":13} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/ORIGINOWNER/REPO", + wantUseProgress: true, + }, + { + name: "number argument, with non-nil progress indicator and DisableProgress set", + args: args{ + selector: "13", + fields: []string{"id", "number"}, + baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil), + branchFn: func() (string, error) { + return "blueberries", nil + }, + progress: &stubProgressIndicator{}, + disableProgress: true, + }, + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{"number":13} + }}}`)) + }, + wantPR: 13, + wantRepo: "https://github.com/ORIGINOWNER/REPO", + wantUseProgress: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -847,11 +895,25 @@ func TestFind(t *testing.T) { }, nil), } + if tt.args.progress != nil { + f.progress = tt.args.progress + } + pr, repo, err := f.Find(FindOptions{ - Selector: tt.args.selector, - Fields: tt.args.fields, - BaseBranch: tt.args.baseBranch, + Selector: tt.args.selector, + Fields: tt.args.fields, + BaseBranch: tt.args.baseBranch, + DisableProgress: tt.args.disableProgress, }) + + if tt.args.progress != nil { + if tt.args.progress.startCalled != tt.wantUseProgress { + t.Errorf("progress was (not) used as expected") + } else if tt.args.progress.startCalled != tt.args.progress.stopCalled { + t.Errorf("progress was started but not stopped") + } + } + if (err != nil) != tt.wantErr { t.Errorf("Find() error = %v, wantErr %v", err, tt.wantErr) return @@ -947,3 +1009,16 @@ func (s stubGitConfigClient) PushRevision(ctx context.Context, branchName string } return s.pushRevisionFn(ctx, branchName) } + +type stubProgressIndicator struct { + startCalled bool + stopCalled bool +} + +func (s *stubProgressIndicator) StartProgressIndicator() { + s.startCalled = true +} + +func (s *stubProgressIndicator) StopProgressIndicator() { + s.stopCalled = true +} From 897c2b12b4ce421056b22733121560df46f69709 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 17 Sep 2025 11:50:08 +0100 Subject: [PATCH 161/208] fix(agent-task view): disable PR finder progress indicator Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 832c20806..3ac19e5ff 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -200,8 +200,9 @@ func viewRun(opts *ViewOptions) error { if prID == 0 { findOptions := prShared.FindOptions{ - Selector: opts.SelectorArg, - Fields: []string{"id", "url", "fullDatabaseId"}, + Selector: opts.SelectorArg, + Fields: []string{"id", "url", "fullDatabaseId"}, + DisableProgress: true, } pr, repo, err := opts.Finder.Find(findOptions) From 87b310a8812b469640c3b4a64ba72c4095cb93d8 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 17 Sep 2025 11:58:04 +0100 Subject: [PATCH 162/208] fix(agent-task view): display completed sessions as "Ready for review" Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/display.go | 2 +- pkg/cmd/agent-task/view/view_test.go | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/agent-task/shared/display.go b/pkg/cmd/agent-task/shared/display.go index e841f4c41..76559b1bf 100644 --- a/pkg/cmd/agent-task/shared/display.go +++ b/pkg/cmd/agent-task/shared/display.go @@ -32,7 +32,7 @@ func SessionStateString(state string) string { case "in_progress": return "In Progress" case "completed": - return "Completed" + return "Ready for review" case "failed": return "Failed" case "idle": diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 1d4a8b2cc..905b4be59 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -230,7 +230,7 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Completed • fix something • OWNER/REPO#101 + Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago For detailed session logs, try: @@ -267,7 +267,7 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Completed • fix something • OWNER/REPO#101 + Ready for review • fix something • OWNER/REPO#101 Started about 6 hours ago For detailed session logs, try: @@ -299,7 +299,7 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Completed + Ready for review Started on behalf of octocat about 6 hours ago For detailed session logs, try: @@ -325,7 +325,7 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Completed + Ready for review Started about 6 hours ago For detailed session logs, try: @@ -507,7 +507,7 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Completed • fix something • OWNER/REPO#101 + Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago For detailed session logs, try: @@ -587,7 +587,7 @@ func Test_viewRun(t *testing.T) { ) }, wantOut: heredoc.Doc(` - Completed • fix something • OWNER/REPO#101 + Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago For detailed session logs, try: @@ -669,7 +669,7 @@ func Test_viewRun(t *testing.T) { ) }, wantOut: heredoc.Doc(` - Completed • fix something • OWNER/REPO#101 + Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago For detailed session logs, try: @@ -890,7 +890,7 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Completed + Ready for review Started on behalf of octocat about 6 hours ago To follow session logs, try: @@ -947,7 +947,7 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Completed + Ready for review Started on behalf of octocat about 6 hours ago (rendered:) From 49ba21a366b4784132d7ee13750c925aff105c3e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 17 Sep 2025 12:20:40 +0100 Subject: [PATCH 163/208] fix(agent-task view): display session duration Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 9 ++ pkg/cmd/agent-task/view/view_test.go | 152 ++++++++++++++++----------- 2 files changed, 99 insertions(+), 62 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 3ac19e5ff..7d992ed87 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -306,6 +306,15 @@ func printSession(opts *ViewOptions, session *capi.Session) { fmt.Fprintf(opts.IO.Out, "Started %s\n", text.FuzzyAgo(time.Now(), session.CreatedAt)) } + var durationNote string + if session.CompletedAt.After(session.CreatedAt) { + durationNote = fmt.Sprintf("Duration %s", session.CompletedAt.Sub(session.CreatedAt).Round(time.Second).String()) + } + + if durationNote != "" { + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Muted(durationNote)) + } + 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) diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 905b4be59..28d63db3e 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -159,6 +159,7 @@ func TestNewCmdList(t *testing.T) { func Test_viewRun(t *testing.T) { sampleDate := time.Now().Add(-6 * time.Hour) // 6h ago + sampleCompletedAt := sampleDate.Add(5 * time.Minute) tests := []struct { name string @@ -212,9 +213,10 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -232,6 +234,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago + Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -252,9 +255,10 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -269,6 +273,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started about 6 hours ago + Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -289,9 +294,10 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, User: &api.GitHubUser{ Login: "octocat", }, @@ -301,6 +307,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review Started on behalf of octocat about 6 hours ago + Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -318,15 +325,17 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, }, nil } }, wantOut: heredoc.Doc(` Ready for review Started about 6 hours ago + Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -360,9 +369,10 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, // User data is irrelevant in this case }, nil } @@ -382,9 +392,10 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -488,9 +499,10 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -509,6 +521,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago + Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -538,10 +551,11 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -555,10 +569,11 @@ func Test_viewRun(t *testing.T) { }, }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -589,6 +604,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago + Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -620,10 +636,11 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -637,10 +654,11 @@ func Test_viewRun(t *testing.T) { }, }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -671,6 +689,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago + Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -723,9 +742,10 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -764,10 +784,11 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -779,10 +800,11 @@ func Test_viewRun(t *testing.T) { // User data is irrelevant in this case }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -823,10 +845,11 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -838,10 +861,11 @@ func Test_viewRun(t *testing.T) { // User data is irrelevant in this case }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -870,9 +894,10 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, User: &api.GitHubUser{ Login: "octocat", }, @@ -892,6 +917,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review Started on behalf of octocat about 6 hours ago + Duration 5m0s To follow session logs, try: gh agent-task view 'some-session-id' --log --follow @@ -913,9 +939,10 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, User: &api.GitHubUser{ Login: "octocat", }, @@ -949,6 +976,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review Started on behalf of octocat about 6 hours ago + Duration 5m0s (rendered:) (rendered:) From fe95cd86f15897c19858546c6b6561650cc0c311 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 17 Sep 2025 13:27:59 +0100 Subject: [PATCH 164/208] fix(agent-task/capi): add `PremiumRequests` field to `Session` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 93 +++--- pkg/cmd/agent-task/capi/sessions_test.go | 350 ++++++++++++----------- 2 files changed, 238 insertions(+), 205 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 7f16b4ae5..f4f12254d 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -27,21 +27,22 @@ var ErrSessionNotFound = errors.New("not found") // session is an in-flight agent task type session struct { - ID string `json:"id"` - Name string `json:"name"` - UserID int64 `json:"user_id"` - AgentID int64 `json:"agent_id"` - Logs string `json:"logs"` - State string `json:"state"` - OwnerID uint64 `json:"owner_id"` - RepoID uint64 `json:"repo_id"` - ResourceType string `json:"resource_type"` - ResourceID int64 `json:"resource_id"` - LastUpdatedAt time.Time `json:"last_updated_at,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - CompletedAt time.Time `json:"completed_at,omitempty"` - EventURL string `json:"event_url"` - EventType string `json:"event_type"` + ID string `json:"id"` + Name string `json:"name"` + UserID int64 `json:"user_id"` + AgentID int64 `json:"agent_id"` + Logs string `json:"logs"` + State string `json:"state"` + OwnerID uint64 `json:"owner_id"` + RepoID uint64 `json:"repo_id"` + ResourceType string `json:"resource_type"` + ResourceID int64 `json:"resource_id"` + LastUpdatedAt time.Time `json:"last_updated_at,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + CompletedAt time.Time `json:"completed_at,omitempty"` + EventURL string `json:"event_url"` + EventType string `json:"event_type"` + PremiumRequests float64 `json:"premium_requests"` } // A shim of a full pull request because looking up by node ID @@ -66,21 +67,22 @@ type sessionPullRequest struct { // Session is a hydrated in-flight agent task type Session struct { - ID string - Name string - UserID int64 - AgentID int64 - Logs string - State string - OwnerID uint64 - RepoID uint64 - ResourceType string - ResourceID int64 - LastUpdatedAt time.Time - CreatedAt time.Time - CompletedAt time.Time - EventURL string - EventType string + ID string + Name string + UserID int64 + AgentID int64 + Logs string + State string + OwnerID uint64 + RepoID uint64 + ResourceType string + ResourceID int64 + LastUpdatedAt time.Time + CreatedAt time.Time + CompletedAt time.Time + EventURL string + EventType string + PremiumRequests float64 PullRequest *api.PullRequest User *api.GitHubUser @@ -475,20 +477,21 @@ func generateUserNodeID(userID int64) string { func fromAPISession(s session) *Session { return &Session{ - ID: s.ID, - Name: s.Name, - UserID: s.UserID, - AgentID: s.AgentID, - Logs: s.Logs, - State: s.State, - OwnerID: s.OwnerID, - RepoID: s.RepoID, - ResourceType: s.ResourceType, - ResourceID: s.ResourceID, - LastUpdatedAt: s.LastUpdatedAt, - CreatedAt: s.CreatedAt, - CompletedAt: s.CompletedAt, - EventURL: s.EventURL, - EventType: s.EventType, + ID: s.ID, + Name: s.Name, + UserID: s.UserID, + AgentID: s.AgentID, + Logs: s.Logs, + State: s.State, + OwnerID: s.OwnerID, + RepoID: s.RepoID, + ResourceType: s.ResourceType, + ResourceID: s.ResourceID, + LastUpdatedAt: s.LastUpdatedAt, + CreatedAt: s.CreatedAt, + CompletedAt: s.CompletedAt, + EventURL: s.EventURL, + EventType: s.EventType, + PremiumRequests: s.PremiumRequests, } } diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index e67ed2ab1..45984dfec 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -77,7 +77,8 @@ func TestListSessionsForViewer(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -125,17 +126,18 @@ func TestListSessionsForViewer(t *testing.T) { wantOut: []*Session{ { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -186,7 +188,8 @@ func TestListSessionsForViewer(t *testing.T) { "repo_id": 1000, "resource_type": "", "resource_id": 0, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -218,17 +221,18 @@ func TestListSessionsForViewer(t *testing.T) { wantOut: []*Session{ { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "", - ResourceID: 0, - CreatedAt: sampleDate, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "", + ResourceID: 0, + CreatedAt: sampleDate, + PremiumRequests: 0.1, User: &api.GitHubUser{ Login: "octocat", Name: "Octocat", @@ -264,7 +268,8 @@ func TestListSessionsForViewer(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -295,7 +300,8 @@ func TestListSessionsForViewer(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2001, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -358,17 +364,18 @@ func TestListSessionsForViewer(t *testing.T) { }, wantOut: []*Session{ { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -391,17 +398,18 @@ func TestListSessionsForViewer(t *testing.T) { }, }, { - ID: "sess2", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2001, - CreatedAt: sampleDate, + ID: "sess2", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2001, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2001", @@ -467,7 +475,8 @@ func TestListSessionsForViewer(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -591,7 +600,8 @@ func TestListSessionsForRepo(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -638,17 +648,18 @@ func TestListSessionsForRepo(t *testing.T) { }, wantOut: []*Session{ { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -699,7 +710,8 @@ func TestListSessionsForRepo(t *testing.T) { "repo_id": 1000, "resource_type": "", "resource_id": 0, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -731,17 +743,18 @@ func TestListSessionsForRepo(t *testing.T) { wantOut: []*Session{ { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "", - ResourceID: 0, - CreatedAt: sampleDate, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "", + ResourceID: 0, + CreatedAt: sampleDate, + PremiumRequests: 0.1, User: &api.GitHubUser{ Login: "octocat", Name: "Octocat", @@ -777,7 +790,8 @@ func TestListSessionsForRepo(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -808,7 +822,8 @@ func TestListSessionsForRepo(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2001, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -871,17 +886,18 @@ func TestListSessionsForRepo(t *testing.T) { }, wantOut: []*Session{ { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -904,17 +920,18 @@ func TestListSessionsForRepo(t *testing.T) { }, }, { - ID: "sess2", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2001, - CreatedAt: sampleDate, + ID: "sess2", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2001, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2001", @@ -980,7 +997,8 @@ func TestListSessionsForRepo(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -1107,7 +1125,8 @@ func TestListSessionsByResourceID(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -1155,17 +1174,18 @@ func TestListSessionsByResourceID(t *testing.T) { wantOut: []*Session{ { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -1216,7 +1236,8 @@ func TestListSessionsByResourceID(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -1247,7 +1268,8 @@ func TestListSessionsByResourceID(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2001, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -1310,17 +1332,18 @@ func TestListSessionsByResourceID(t *testing.T) { }, wantOut: []*Session{ { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -1343,17 +1366,18 @@ func TestListSessionsByResourceID(t *testing.T) { }, }, { - ID: "sess2", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2001, - CreatedAt: sampleDate, + ID: "sess2", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2001, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2001", @@ -1419,7 +1443,8 @@ func TestListSessionsByResourceID(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -1538,7 +1563,8 @@ func TestGetSession(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 }`, sampleDateString, )), @@ -1582,17 +1608,18 @@ func TestGetSession(t *testing.T) { ) }, wantOut: &Session{ - ID: "some-uuid", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, + ID: "some-uuid", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 2000, + CreatedAt: sampleDate, + PremiumRequests: 0.1, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -1633,7 +1660,8 @@ func TestGetSession(t *testing.T) { "repo_id": 1000, "resource_type": "", "resource_id": 0, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 }`, sampleDateString, )), @@ -1661,17 +1689,18 @@ func TestGetSession(t *testing.T) { ) }, wantOut: &Session{ - ID: "some-uuid", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "", - ResourceID: 0, - CreatedAt: sampleDate, + ID: "some-uuid", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "", + ResourceID: 0, + CreatedAt: sampleDate, + PremiumRequests: 0.1, User: &api.GitHubUser{ Login: "octocat", Name: "Octocat", @@ -1696,7 +1725,8 @@ func TestGetSession(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s" + "created_at": "%[1]s", + "premium_requests": 0.1 }`, sampleDateString, )), From 99a61618df2f1f941a12e73604fd0a167d8895a3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 17 Sep 2025 13:29:38 +0100 Subject: [PATCH 165/208] fix(agent-task view): display premium requests used Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 17 ++- pkg/cmd/agent-task/view/view_test.go | 196 +++++++++++++++------------ 2 files changed, 119 insertions(+), 94 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 7d992ed87..c7474c12d 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" "github.com/MakeNowJust/heredoc" @@ -306,13 +307,19 @@ func printSession(opts *ViewOptions, session *capi.Session) { fmt.Fprintf(opts.IO.Out, "Started %s\n", text.FuzzyAgo(time.Now(), session.CreatedAt)) } - var durationNote string - if session.CompletedAt.After(session.CreatedAt) { - durationNote = fmt.Sprintf("Duration %s", session.CompletedAt.Sub(session.CreatedAt).Round(time.Second).String()) + additionalNotes := make([]string, 0, 2) + + if session.PremiumRequests > 0 { + s := strings.TrimSuffix(fmt.Sprintf("%.1f", session.PremiumRequests), ".0") + additionalNotes = append(additionalNotes, fmt.Sprintf("Used %s premium request(s)", s)) } - if durationNote != "" { - fmt.Fprintf(opts.IO.Out, "%s\n", cs.Muted(durationNote)) + if session.CompletedAt.After(session.CreatedAt) { + additionalNotes = append(additionalNotes, fmt.Sprintf("Duration %s", session.CompletedAt.Sub(session.CreatedAt).Round(time.Second).String())) + } + + if len(additionalNotes) > 0 { + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Muted(strings.Join(additionalNotes, " • "))) } if !opts.Log { diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 28d63db3e..08ffaa1ba 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -213,10 +213,11 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -234,7 +235,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago - Duration 5m0s + Used 1.5 premium request(s) • Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -255,10 +256,11 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -273,7 +275,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started about 6 hours ago - Duration 5m0s + Used 1.5 premium request(s) • Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -294,10 +296,11 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, User: &api.GitHubUser{ Login: "octocat", }, @@ -307,7 +310,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review Started on behalf of octocat about 6 hours ago - Duration 5m0s + Used 1.5 premium request(s) • Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -325,17 +328,18 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, }, nil } }, wantOut: heredoc.Doc(` Ready for review Started about 6 hours ago - Duration 5m0s + Used 1.5 premium request(s) • Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -369,10 +373,11 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, // User data is irrelevant in this case }, nil } @@ -392,10 +397,11 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -499,10 +505,11 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -521,7 +528,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago - Duration 5m0s + Used 1.5 premium request(s) • Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -551,11 +558,12 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -569,11 +577,12 @@ func Test_viewRun(t *testing.T) { }, }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -604,7 +613,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago - Duration 5m0s + Used 1.5 premium request(s) • Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -636,11 +645,12 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -654,11 +664,12 @@ func Test_viewRun(t *testing.T) { }, }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -689,7 +700,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review • fix something • OWNER/REPO#101 Started on behalf of octocat about 6 hours ago - Duration 5m0s + Used 1.5 premium request(s) • Duration 5m0s For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -742,10 +753,11 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -784,11 +796,12 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -800,11 +813,12 @@ func Test_viewRun(t *testing.T) { // User data is irrelevant in this case }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -845,11 +859,12 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -861,11 +876,12 @@ func Test_viewRun(t *testing.T) { // User data is irrelevant in this case }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-other-session-id", + Name: "session two", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, PullRequest: &api.PullRequest{ Title: "fix something", Number: 101, @@ -894,10 +910,11 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, User: &api.GitHubUser{ Login: "octocat", }, @@ -917,7 +934,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review Started on behalf of octocat about 6 hours ago - Duration 5m0s + Used 1.5 premium request(s) • Duration 5m0s To follow session logs, try: gh agent-task view 'some-session-id' --log --follow @@ -939,10 +956,11 @@ func Test_viewRun(t *testing.T) { m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { assert.Equal(t, "some-session-id", id) return &capi.Session{ - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, User: &api.GitHubUser{ Login: "octocat", }, @@ -976,7 +994,7 @@ func Test_viewRun(t *testing.T) { wantOut: heredoc.Doc(` Ready for review Started on behalf of octocat about 6 hours ago - Duration 5m0s + Used 1.5 premium request(s) • Duration 5m0s (rendered:) (rendered:) From 661817de50d1561077ea615c73f27f2620e2f7f8 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 17 Sep 2025 13:59:13 +0100 Subject: [PATCH 166/208] fix(agent-task view): omit session overview in log mode Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 6 ++---- pkg/cmd/agent-task/view/view_test.go | 11 ----------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index c7474c12d..b751201db 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -277,11 +277,11 @@ func viewRun(opts *ViewOptions) error { } } - printSession(opts, session) - if opts.Log { return printLogs(opts, capiClient, session.ID) } + + printSession(opts, session) return nil } @@ -362,7 +362,6 @@ func printLogs(opts *ViewOptions, capiClient capi.CapiClient, sessionID string) return raw, nil } - fmt.Fprintln(opts.IO.Out, "") return renderer.Follow(fetcher, opts.IO.Out, opts.IO) } @@ -371,7 +370,6 @@ func printLogs(opts *ViewOptions, capiClient capi.CapiClient, sessionID string) 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 } diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 08ffaa1ba..d39ac0a41 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -932,13 +932,6 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Ready for review - Started on behalf of octocat about 6 hours ago - Used 1.5 premium request(s) • Duration 5m0s - - To follow session logs, try: - gh agent-task view 'some-session-id' --log --follow - (rendered:) `), }, @@ -992,10 +985,6 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Ready for review - Started on behalf of octocat about 6 hours ago - Used 1.5 premium request(s) • Duration 5m0s - (rendered:) (rendered:) `), From f6b49858a26375882137b8d97c96259d8be33b75 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 17 Sep 2025 12:09:51 -0600 Subject: [PATCH 167/208] Remove obsolete log-3 test data files Deleted log-3-input.txt and log-3-want.txt from testdata as they are no longer needed for current tests. --- .../shared/testdata/log-3-input.txt | 27 ------------- .../agent-task/shared/testdata/log-3-want.txt | 39 ------------------- 2 files changed, 66 deletions(-) delete mode 100644 pkg/cmd/agent-task/shared/testdata/log-3-input.txt delete mode 100644 pkg/cmd/agent-task/shared/testdata/log-3-want.txt diff --git a/pkg/cmd/agent-task/shared/testdata/log-3-input.txt b/pkg/cmd/agent-task/shared/testdata/log-3-input.txt deleted file mode 100644 index 5fa8e0a63..000000000 --- a/pkg/cmd/agent-task/shared/testdata/log-3-input.txt +++ /dev/null @@ -1,27 +0,0 @@ -data: {"id": "bad1", "object": "chat.completion.chunk", "choices": [ { "delta": { "tool_calls": [ { "function": { "name": "view", "arguments": "{bad json" } } ] } } ] } - -data: {"id":"v1","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line\nINSIDE A VIEW CALL","tool_calls":[{"function":{"name":"view","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/README.md\"}"},"id":"tc1","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"v1b","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line","tool_calls":[{"function":{"name":"view","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/README.md\"}"},"id":"tc1b","index":0}]}],"finish_reason":"tool_calls","index":0}]} - -data: {"id":"v1","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line\nINSIDE A VIEW CALL","tool_calls":[{"function":{"name":"view","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/README.md"},"id":"tc1","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"t1","object":"chat.completion.chunk","choices":[{"delta":{"content":"THINK","tool_calls":[{"function":{"name":"think","arguments":"{\"thought\":123"},"id":"tc2","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"t2","object":"chat.completion.chunk","choices":[{"delta":{"content":"A valid thought to render.","reasoning_text":"Interim reasoning that should show as raw markdown.","tool_calls":[{"function":{"name":"think","arguments":"{\"thought\":\"A valid thought to render.\"}"},"id":"tc3","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"rp1","object":"chat.completion.chunk","choices":[{"delta":{"content":"RP","tool_calls":[{"function":{"name":"report_progress","arguments":"{\"commitMessage\": 5"},"id":"tc4","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"rp2","object":"chat.completion.chunk","choices":[{"delta":{"content":"not-json","tool_calls":[{"function":{"name":"report_progress","arguments":"{\"commitMessage\":\"Valid commit msg\"}"},"id":"tc5","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"CREATE","tool_calls":[{"function":{"name":"create","arguments":"{\"path\":\"/abs/path/file.txt\""},"id":"tc6","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"c2","object":"chat.completion.chunk","choices":[{"delta":{"content":"CREATE2","tool_calls":[{"function":{"name":"create","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/new.txt\",\"file_text\":\"hello world\"}"},"id":"tc7","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"sr1","object":"chat.completion.chunk","choices":[{"delta":{"content":"SR","tool_calls":[{"function":{"name":"str_replace","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/file.diff"},"id":"tc8","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"sr2","object":"chat.completion.chunk","choices":[{"delta":{"content":"@@ -1,2 +1,2 @@\n-old line\n+new line\nunchanged line","tool_calls":[{"function":{"name":"str_replace","arguments":"{\"path\":\"/home/runner/work/repo/owner/repo/file.diff\"}"},"id":"tc9","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"u1","object":"chat.completion.chunk","choices":[{"delta":{"content":"{\"foo\":1}","tool_calls":[{"function":{"name":"mystery_tool","arguments":"{\"bar\":2}"},"id":"tc10","index":0}]},"finish_reason":"tool_calls","index":0}]} - -data: {"id":"end","object":"chat.completion.chunk","choices":[{"delta":{"content":"","tool_calls":[],"role":"assistant"},"finish_reason":"stop","index":0}]} diff --git a/pkg/cmd/agent-task/shared/testdata/log-3-want.txt b/pkg/cmd/agent-task/shared/testdata/log-3-want.txt deleted file mode 100644 index 52a36d427..000000000 --- a/pkg/cmd/agent-task/shared/testdata/log-3-want.txt +++ /dev/null @@ -1,39 +0,0 @@ -View repo/README.md - -old line - new line - unchanged line - INSIDE A VIEW CALL - - -Interim reasoning that should show as raw markdown. - -Thought - -A valid thought to render. - -Progress update: Valid commit msg -Create: repo/new.txt -hello world - -Edit: repo/file.diff -@@ -1,2 +1,2 @@ - -old line - +new line - unchanged line - -Call to mystery_tool - -Output: - -{ - "foo": 1 - } - - -Input: - -{ - "bar": 2 - } - From 25c0d0cf90edce3266e32ec1bfeaa2614d98c2f3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 17 Sep 2025 12:16:04 -0600 Subject: [PATCH 168/208] Refactor comments in stripDiffFormat function Simplified and clarified comments in the stripDiffFormat function to improve readability and maintainability. No functional changes were made to the code. --- pkg/cmd/agent-task/shared/log.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go index 4959cecbd..c94f5e603 100644 --- a/pkg/cmd/agent-task/shared/log.go +++ b/pkg/cmd/agent-task/shared/log.go @@ -342,18 +342,15 @@ func stripDiffFormat(diff string) string { } } - // Guard clause: if we didn't find a hunk header, this isn't a diff, so - // we defensively just return the original string. + // This isn't a diff. if hunkEndIndex == -1 { return diff } - // We found the hunk header end; strip everything before it. + // Removing hunk header. lines = lines[hunkEndIndex+1:] - // Now we strip the leading + and - from lines, if they exist. - // Note: most of the time, but not all the time, we get a diff without - // these prefixes. + // Strip the leading + and - from lines, if they exist. var stripped []string for _, line := range lines { if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") { From 40a590773eb3c4a6b630ca665ab0b1392ae7eb11 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:31:24 -0600 Subject: [PATCH 169/208] Enhance agent-task command help and usage info Updated the agent-task command to provide improved usage, long description, argument annotations, and examples. This enhances the CLI user experience by clarifying how to use the command and identify agent tasks. --- pkg/cmd/agent-task/agent_task.go | 33 ++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index 4eeed3719..603d5e76b 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/MakeNowJust/heredoc" cmdCreate "github.com/cli/cli/v2/pkg/cmd/agent-task/create" cmdList "github.com/cli/cli/v2/pkg/cmd/agent-task/list" cmdView "github.com/cli/cli/v2/pkg/cmd/agent-task/view" @@ -16,9 +17,37 @@ import ( // NewCmdAgentTask creates the base `agent-task` command. func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "agent-task", + Use: "agent-task ", Aliases: []string{"agent-tasks", "agent", "agents"}, - Short: "Manage agent tasks (preview)", + Short: "Work with agent tasks (preview)", + Long: heredoc.Docf(` + Working with agent tasks in the GitHub CLI is currently preview and + is subject to change without notice. + `), + Annotations: map[string]string{ + "help:arguments": heredoc.Doc(` + A task can be identified as argument in any of the following formats: + - by pull request number, e.g. "123"; or + - by session ID, e.g. "12345abc-12345-12345-12345-12345abc"; or + - by URL, e.g. "https://github.com/OWNER/REPO/pull/123/agent-sessions/12345bc-12345-12345-12345-12345abc"; + + Identifying tasks by pull request is not recommended for non-interactive use cases as + there may be multiple tasks for a given pull request that require disambiguation. + `), + }, + Example: heredoc.Doc(` + # List your most recent agent tasks + $ gh agent-task list + + # Create a new agent task on the current repository + $ gh agent-task create "Improve the performance of the data processing pipeline" + + # View details about agent tasks associated with a pull request + $ gh agent-task view 123 + + # View details about a specific agent task + $ gh agent-task view 12345abc-12345-12345-12345-12345abc + `), PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return requireOAuthToken(f) }, From 562efc91c7866fe4e2f0cc3078b4c3cc85aedda0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:34:03 -0600 Subject: [PATCH 170/208] Update pkg/cmd/agent-task/agent_task.go --- pkg/cmd/agent-task/agent_task.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index 603d5e76b..6198d2ef8 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -21,8 +21,8 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command { Aliases: []string{"agent-tasks", "agent", "agents"}, Short: "Work with agent tasks (preview)", Long: heredoc.Docf(` - Working with agent tasks in the GitHub CLI is currently preview and - is subject to change without notice. + Working with agent tasks in the GitHub CLI is in preview and + subject to change without notice. `), Annotations: map[string]string{ "help:arguments": heredoc.Doc(` From 7d2dfd3493272c8f0c1217779879a807a470d2ff Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:37:12 -0600 Subject: [PATCH 171/208] Update pkg/cmd/agent-task/agent_task.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/cmd/agent-task/agent_task.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index 6198d2ef8..d7259eb45 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -29,7 +29,7 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command { A task can be identified as argument in any of the following formats: - by pull request number, e.g. "123"; or - by session ID, e.g. "12345abc-12345-12345-12345-12345abc"; or - - by URL, e.g. "https://github.com/OWNER/REPO/pull/123/agent-sessions/12345bc-12345-12345-12345-12345abc"; + - by URL, e.g. "https://github.com/OWNER/REPO/pull/123/agent-sessions/12345abc-12345-12345-12345-12345abc"; Identifying tasks by pull request is not recommended for non-interactive use cases as there may be multiple tasks for a given pull request that require disambiguation. From 6927d642f6b87fe24d6d06af367f421235ec18e1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 10:16:00 +0100 Subject: [PATCH 172/208] fix(agent-task/shared): make capitalised status names consistent Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/display.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/shared/display.go b/pkg/cmd/agent-task/shared/display.go index 76559b1bf..99ae91c43 100644 --- a/pkg/cmd/agent-task/shared/display.go +++ b/pkg/cmd/agent-task/shared/display.go @@ -30,7 +30,7 @@ func SessionStateString(state string) string { case "queued": return "Queued" case "in_progress": - return "In Progress" + return "In progress" case "completed": return "Ready for review" case "failed": @@ -38,9 +38,9 @@ func SessionStateString(state string) string { case "idle": return "Idle" case "waiting_for_user": - return "Waiting for User" + return "Waiting for user" case "timed_out": - return "Timed Out" + return "Timed out" case "cancelled": return "Cancelled" default: From f0a0c4b85686b7973ddb10d4fc9fa61401de56ca Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 10:18:40 +0100 Subject: [PATCH 173/208] fix(agent-task view): display zero premium requests Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 15 ++--- pkg/cmd/agent-task/view/view_test.go | 83 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index b751201db..2ff09386a 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -307,20 +307,15 @@ func printSession(opts *ViewOptions, session *capi.Session) { fmt.Fprintf(opts.IO.Out, "Started %s\n", text.FuzzyAgo(time.Now(), session.CreatedAt)) } - additionalNotes := make([]string, 0, 2) - - if session.PremiumRequests > 0 { - s := strings.TrimSuffix(fmt.Sprintf("%.1f", session.PremiumRequests), ".0") - additionalNotes = append(additionalNotes, fmt.Sprintf("Used %s premium request(s)", s)) - } + usedPremiumRequests := strings.TrimSuffix(fmt.Sprintf("%.1f", session.PremiumRequests), ".0") + usedPremiumRequestsNote := fmt.Sprintf("Used %s premium request(s)", usedPremiumRequests) + var durationNote string if session.CompletedAt.After(session.CreatedAt) { - additionalNotes = append(additionalNotes, fmt.Sprintf("Duration %s", session.CompletedAt.Sub(session.CreatedAt).Round(time.Second).String())) + durationNote = fmt.Sprintf(" • Duration %s", session.CompletedAt.Sub(session.CreatedAt).Round(time.Second).String()) } - if len(additionalNotes) > 0 { - fmt.Fprintf(opts.IO.Out, "%s\n", cs.Muted(strings.Join(additionalNotes, " • "))) - } + fmt.Fprintf(opts.IO.Out, "%s%s\n", cs.Muted(usedPremiumRequestsNote), cs.Muted(durationNote)) if !opts.Log { fmt.Fprintln(opts.IO.Out, "") diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index d39ac0a41..b8f872560 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -345,6 +345,89 @@ func Test_viewRun(t *testing.T) { gh agent-task view 'some-session-id' --log `), }, + { + name: "with session id, success, with zero premium requests (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 0, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + Ready for review • fix something • OWNER/REPO#101 + Started on behalf of octocat about 6 hours ago + Used 0 premium request(s) • Duration 5m0s + + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + name: "with session id, success, duration not available (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + State: "in_progress", + CreatedAt: sampleDate, + PremiumRequests: 1.5, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + In progress • fix something • OWNER/REPO#101 + Started on behalf of octocat about 6 hours ago + Used 1.5 premium request(s) + + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, { name: "with session id, not found, web mode (tty)", tty: true, From 6f698407fb0380871143f7799161e45e96a739c3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 10:25:49 +0100 Subject: [PATCH 174/208] docs(pr/shared): add a `TODO` on decoupling PR finder from IO Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/finder.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 2861627a9..1879fff62 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -198,6 +198,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return nil, nil, err } + // TODO: Decouple the PR finder from IO // TODO(josebalius): Should we be guarding here? if !opts.DisableProgress && f.progress != nil { f.progress.StartProgressIndicator() From a7c8fa4b849aca82d7c9ac8ffc8e6242bb398979 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 10:48:34 +0100 Subject: [PATCH 175/208] docs(agent-task): use `heredoc.Doc` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/agent_task.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/agent_task.go b/pkg/cmd/agent-task/agent_task.go index d7259eb45..8b59d59cc 100644 --- a/pkg/cmd/agent-task/agent_task.go +++ b/pkg/cmd/agent-task/agent_task.go @@ -20,7 +20,7 @@ func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command { Use: "agent-task ", Aliases: []string{"agent-tasks", "agent", "agents"}, Short: "Work with agent tasks (preview)", - Long: heredoc.Docf(` + Long: heredoc.Doc(` Working with agent tasks in the GitHub CLI is in preview and subject to change without notice. `), From 680ebade594c3dc80bbdc6872a78369b6ad842cd Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:47:50 -0600 Subject: [PATCH 176/208] Remove repo-scoped session listing and related code This commit removes support for listing agent sessions scoped to a specific repository, including the ListSessionsForRepo method and related tests. The list command now always lists the latest sessions for the viewer, simplifying the code and user experience. Test and mock code have been updated accordingly. --- pkg/cmd/agent-task/capi/client.go | 3 +- pkg/cmd/agent-task/capi/client_mock.go | 174 +++----- pkg/cmd/agent-task/capi/sessions.go | 88 +--- pkg/cmd/agent-task/capi/sessions_test.go | 524 +---------------------- pkg/cmd/agent-task/list/list.go | 31 +- pkg/cmd/agent-task/list/list_test.go | 244 +---------- 6 files changed, 102 insertions(+), 962 deletions(-) diff --git a/pkg/cmd/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go index 6d35f11c2..c25549121 100644 --- a/pkg/cmd/agent-task/capi/client.go +++ b/pkg/cmd/agent-task/capi/client.go @@ -15,8 +15,7 @@ const capiHost = "api.githubcopilot.com" // CapiClient defines the methods used by the caller. Implementations // may be replaced with test doubles in unit tests. type CapiClient interface { - ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) - ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) + ListLatestSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) CreateJob(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*Job, error) GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) GetSession(ctx context.Context, id string) (*Session, error) diff --git a/pkg/cmd/agent-task/capi/client_mock.go b/pkg/cmd/agent-task/capi/client_mock.go index fa59ffab7..325a8d513 100644 --- a/pkg/cmd/agent-task/capi/client_mock.go +++ b/pkg/cmd/agent-task/capi/client_mock.go @@ -33,15 +33,12 @@ var _ CapiClient = &CapiClientMock{} // GetSessionLogsFunc: func(ctx context.Context, id string) ([]byte, error) { // panic("mock out the GetSessionLogs method") // }, +// ListLatestSessionsForViewerFunc: func(ctx context.Context, limit int) ([]*Session, error) { +// panic("mock out the ListLatestSessionsForViewer method") +// }, // ListSessionsByResourceIDFunc: func(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) { // panic("mock out the ListSessionsByResourceID method") // }, -// ListSessionsForRepoFunc: func(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { -// panic("mock out the ListSessionsForRepo method") -// }, -// ListSessionsForViewerFunc: func(ctx context.Context, limit int) ([]*Session, error) { -// panic("mock out the ListSessionsForViewer method") -// }, // } // // // use mockedCapiClient in code that requires CapiClient @@ -64,15 +61,12 @@ type CapiClientMock struct { // GetSessionLogsFunc mocks the GetSessionLogs method. GetSessionLogsFunc func(ctx context.Context, id string) ([]byte, error) + // ListLatestSessionsForViewerFunc mocks the ListLatestSessionsForViewer method. + ListLatestSessionsForViewerFunc func(ctx context.Context, limit int) ([]*Session, error) + // ListSessionsByResourceIDFunc mocks the ListSessionsByResourceID method. ListSessionsByResourceIDFunc func(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) - // ListSessionsForRepoFunc mocks the ListSessionsForRepo method. - ListSessionsForRepoFunc func(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) - - // ListSessionsForViewerFunc mocks the ListSessionsForViewer method. - ListSessionsForViewerFunc func(ctx context.Context, limit int) ([]*Session, error) - // calls tracks calls to the methods. calls struct { // CreateJob holds details about calls to the CreateJob method. @@ -126,6 +120,13 @@ type CapiClientMock struct { // ID is the id argument value. ID string } + // ListLatestSessionsForViewer holds details about calls to the ListLatestSessionsForViewer method. + ListLatestSessionsForViewer []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Limit is the limit argument value. + Limit int + } // ListSessionsByResourceID holds details about calls to the ListSessionsByResourceID method. ListSessionsByResourceID []struct { // Ctx is the ctx argument value. @@ -137,33 +138,14 @@ type CapiClientMock struct { // Limit is the limit argument value. Limit int } - // ListSessionsForRepo holds details about calls to the ListSessionsForRepo method. - ListSessionsForRepo []struct { - // Ctx is the ctx argument value. - Ctx context.Context - // Owner is the owner argument value. - Owner string - // Repo is the repo argument value. - Repo string - // Limit is the limit argument value. - Limit int - } - // ListSessionsForViewer holds details about calls to the ListSessionsForViewer method. - ListSessionsForViewer []struct { - // Ctx is the ctx argument value. - Ctx context.Context - // Limit is the limit argument value. - Limit int - } } - lockCreateJob sync.RWMutex - lockGetJob sync.RWMutex - lockGetPullRequestDatabaseID sync.RWMutex - lockGetSession sync.RWMutex - lockGetSessionLogs sync.RWMutex - lockListSessionsByResourceID sync.RWMutex - lockListSessionsForRepo sync.RWMutex - lockListSessionsForViewer sync.RWMutex + lockCreateJob sync.RWMutex + lockGetJob sync.RWMutex + lockGetPullRequestDatabaseID sync.RWMutex + lockGetSession sync.RWMutex + lockGetSessionLogs sync.RWMutex + lockListLatestSessionsForViewer sync.RWMutex + lockListSessionsByResourceID sync.RWMutex } // CreateJob calls CreateJobFunc. @@ -378,6 +360,42 @@ func (mock *CapiClientMock) GetSessionLogsCalls() []struct { return calls } +// ListLatestSessionsForViewer calls ListLatestSessionsForViewerFunc. +func (mock *CapiClientMock) ListLatestSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { + if mock.ListLatestSessionsForViewerFunc == nil { + panic("CapiClientMock.ListLatestSessionsForViewerFunc: method is nil but CapiClient.ListLatestSessionsForViewer was just called") + } + callInfo := struct { + Ctx context.Context + Limit int + }{ + Ctx: ctx, + Limit: limit, + } + mock.lockListLatestSessionsForViewer.Lock() + mock.calls.ListLatestSessionsForViewer = append(mock.calls.ListLatestSessionsForViewer, callInfo) + mock.lockListLatestSessionsForViewer.Unlock() + return mock.ListLatestSessionsForViewerFunc(ctx, limit) +} + +// ListLatestSessionsForViewerCalls gets all the calls that were made to ListLatestSessionsForViewer. +// Check the length with: +// +// len(mockedCapiClient.ListLatestSessionsForViewerCalls()) +func (mock *CapiClientMock) ListLatestSessionsForViewerCalls() []struct { + Ctx context.Context + Limit int +} { + var calls []struct { + Ctx context.Context + Limit int + } + mock.lockListLatestSessionsForViewer.RLock() + calls = mock.calls.ListLatestSessionsForViewer + mock.lockListLatestSessionsForViewer.RUnlock() + return calls +} + // ListSessionsByResourceID calls ListSessionsByResourceIDFunc. func (mock *CapiClientMock) ListSessionsByResourceID(ctx context.Context, resourceType string, resourceID int64, limit int) ([]*Session, error) { if mock.ListSessionsByResourceIDFunc == nil { @@ -421,83 +439,3 @@ func (mock *CapiClientMock) ListSessionsByResourceIDCalls() []struct { mock.lockListSessionsByResourceID.RUnlock() return calls } - -// ListSessionsForRepo calls ListSessionsForRepoFunc. -func (mock *CapiClientMock) ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { - if mock.ListSessionsForRepoFunc == nil { - panic("CapiClientMock.ListSessionsForRepoFunc: method is nil but CapiClient.ListSessionsForRepo was just called") - } - callInfo := struct { - Ctx context.Context - Owner string - Repo string - Limit int - }{ - Ctx: ctx, - Owner: owner, - Repo: repo, - Limit: limit, - } - mock.lockListSessionsForRepo.Lock() - mock.calls.ListSessionsForRepo = append(mock.calls.ListSessionsForRepo, callInfo) - mock.lockListSessionsForRepo.Unlock() - return mock.ListSessionsForRepoFunc(ctx, owner, repo, limit) -} - -// ListSessionsForRepoCalls gets all the calls that were made to ListSessionsForRepo. -// Check the length with: -// -// len(mockedCapiClient.ListSessionsForRepoCalls()) -func (mock *CapiClientMock) ListSessionsForRepoCalls() []struct { - Ctx context.Context - Owner string - Repo string - Limit int -} { - var calls []struct { - Ctx context.Context - Owner string - Repo string - Limit int - } - mock.lockListSessionsForRepo.RLock() - calls = mock.calls.ListSessionsForRepo - mock.lockListSessionsForRepo.RUnlock() - return calls -} - -// ListSessionsForViewer calls ListSessionsForViewerFunc. -func (mock *CapiClientMock) ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { - if mock.ListSessionsForViewerFunc == nil { - panic("CapiClientMock.ListSessionsForViewerFunc: method is nil but CapiClient.ListSessionsForViewer was just called") - } - callInfo := struct { - Ctx context.Context - Limit int - }{ - Ctx: ctx, - Limit: limit, - } - mock.lockListSessionsForViewer.Lock() - mock.calls.ListSessionsForViewer = append(mock.calls.ListSessionsForViewer, callInfo) - mock.lockListSessionsForViewer.Unlock() - return mock.ListSessionsForViewerFunc(ctx, limit) -} - -// ListSessionsForViewerCalls gets all the calls that were made to ListSessionsForViewer. -// Check the length with: -// -// len(mockedCapiClient.ListSessionsForViewerCalls()) -func (mock *CapiClientMock) ListSessionsForViewerCalls() []struct { - Ctx context.Context - Limit int -} { - var calls []struct { - Ctx context.Context - Limit int - } - mock.lockListSessionsForViewer.RLock() - calls = mock.calls.ListSessionsForViewer - mock.lockListSessionsForViewer.RUnlock() - return calls -} diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index f4f12254d..f6825e674 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -88,9 +88,9 @@ type Session struct { User *api.GitHubUser } -// ListSessionsForViewer lists all agent sessions for the +// ListLatestSessionsForViewer lists all agent sessions for the // authenticated user up to limit. -func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { +func (c *CAPIClient) ListLatestSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { if limit == 0 { return nil, nil } @@ -99,7 +99,8 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S pageSize := defaultSessionsPerPage sessions := make([]session, 0, limit+pageSize) - + var seenResources []int64 + var latestSessions []session for page := 1; ; page++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { @@ -109,6 +110,7 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S q := req.URL.Query() q.Set("page_size", strconv.Itoa(pageSize)) q.Set("page_number", strconv.Itoa(page)) + q.Set("sort", "last_updated_at,desc") req.URL.RawQuery = q.Encode() res, err := c.httpClient.Do(req) @@ -127,17 +129,30 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S } sessions = append(sessions, response.Sessions...) - if len(response.Sessions) < pageSize || len(sessions) >= limit { + + // De-duplicate sessions by resource ID. + // Because the API returns newest first, + // we can safely skip any additional sessions + // for a resource we have already seen. + for _, s := range sessions { + if slices.Contains(seenResources, s.ResourceID) { + continue + } + seenResources = append(seenResources, s.ResourceID) + latestSessions = append(latestSessions, s) + } + + if len(response.Sessions) < pageSize || len(latestSessions) >= limit { break } } // Drop any above the limit - if len(sessions) > limit { - sessions = sessions[:limit] + if len(latestSessions) > limit { + latestSessions = latestSessions[:limit] } - result, err := c.hydrateSessionPullRequestsAndUsers(sessions) + result, err := c.hydrateSessionPullRequestsAndUsers(latestSessions) if err != nil { return nil, fmt.Errorf("failed to fetch session resources: %w", err) } @@ -145,65 +160,6 @@ func (c *CAPIClient) ListSessionsForViewer(ctx context.Context, limit int) ([]*S return result, nil } -// ListSessionsForRepo lists agent sessions for a specific repository identified by owner/name up to limit. -func (c *CAPIClient) ListSessionsForRepo(ctx context.Context, owner string, repo string, limit int) ([]*Session, error) { - if owner == "" || repo == "" { - return nil, fmt.Errorf("owner and repo are required") - } - - if limit == 0 { - return nil, nil - } - - url := fmt.Sprintf("%s/agents/sessions/nwo/%s/%s", baseCAPIURL, url.PathEscape(owner), url.PathEscape(repo)) - pageSize := defaultSessionsPerPage - - sessions := make([]session, 0, limit+pageSize) - - for page := 1; ; page++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) - if err != nil { - return nil, err - } - - q := req.URL.Query() - q.Set("page_size", strconv.Itoa(pageSize)) - q.Set("page_number", strconv.Itoa(page)) - req.URL.RawQuery = q.Encode() - - 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 list sessions: %s", res.Status) - } - var response struct { - Sessions []session `json:"sessions"` - } - if err := json.NewDecoder(res.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("failed to decode sessions response: %w", err) - } - - sessions = append(sessions, response.Sessions...) - if len(response.Sessions) < pageSize || len(sessions) >= limit { - break - } - } - - // Drop any above the limit - if len(sessions) > limit { - sessions = sessions[:limit] - } - - result, err := c.hydrateSessionPullRequestsAndUsers(sessions) - if err != nil { - return nil, fmt.Errorf("failed to fetch session resources: %w", err) - } - return result, nil -} - // GetSession retrieves a specific agent session by ID. func (c *CAPIClient) GetSession(ctx context.Context, id string) (*Session, error) { if id == "" { diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 45984dfec..1c49444db 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -514,529 +514,7 @@ func TestListSessionsForViewer(t *testing.T) { }() } - sessions, err := capiClient.ListSessionsForViewer(context.Background(), tt.limit) - - if tt.wantErr != "" { - require.ErrorContains(t, err, tt.wantErr) - require.Nil(t, sessions) - return - } - - require.NoError(t, err) - require.Equal(t, tt.wantOut, sessions) - }) - } -} - -func TestListSessionForRepoRequiresRepo(t *testing.T) { - client := &CAPIClient{} - - _, err := client.ListSessionsForRepo(context.Background(), "", "only-repo", 0) - assert.EqualError(t, err, "owner and repo are required") - _, err = client.ListSessionsForRepo(context.Background(), "only-owner", "", 0) - assert.EqualError(t, err, "owner and repo are required") - _, err = client.ListSessionsForRepo(context.Background(), "", "", 0) - assert.EqualError(t, err, "owner and repo are required") -} - -func TestListSessionsForRepo(t *testing.T) { - sampleDateString := "2025-08-29T00:00:00Z" - sampleDate, err := time.Parse(time.RFC3339, sampleDateString) - require.NoError(t, err) - - tests := []struct { - name string - perPage int - limit int - httpStubs func(*testing.T, *httpmock.Registry) - wantErr string - wantOut []*Session - }{ - { - name: "zero limit", - limit: 0, - wantOut: nil, - }, - { - name: "no sessions", - limit: 10, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ - "page_number": {"1"}, - "page_size": {"50"}, - }), - "api.githubcopilot.com", - ), - httpmock.StringResponse(`{"sessions":[]}`), - ) - }, - wantOut: nil, - }, - { - name: "single session", - limit: 10, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ - "page_number": {"1"}, - "page_size": {"50"}, - }), - "api.githubcopilot.com", - ), - httpmock.StringResponse(heredoc.Docf(` - { - "sessions": [ - { - "id": "sess1", - "name": "Build artifacts", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2000, - "created_at": "%[1]s", - "premium_requests": 0.1 - } - ] - }`, - sampleDateString, - )), - ) - // GraphQL hydration - reg.Register( - httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), - httpmock.GraphQLQuery(heredoc.Docf(` - { - "data": { - "nodes": [ - { - "__typename": "PullRequest", - "id": "PR_node", - "fullDatabaseId": "2000", - "number": 42, - "title": "Improve docs", - "state": "OPEN", - "isDraft": true, - "url": "https://github.com/OWNER/REPO/pull/42", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "__typename": "User", - "login": "octocat", - "name": "Octocat", - "databaseId": 1 - } - ] - } - }`, - sampleDateString, - ), func(q string, vars map[string]interface{}) { - assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) - }), - ) - }, - wantOut: []*Session{ - { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, - PremiumRequests: 0.1, - PullRequest: &api.PullRequest{ - ID: "PR_node", - FullDatabaseID: "2000", - Number: 42, - Title: "Improve docs", - State: "OPEN", - IsDraft: true, - URL: "https://github.com/OWNER/REPO/pull/42", - Body: "", - CreatedAt: sampleDate, - UpdatedAt: sampleDate, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", - Name: "Octocat", - DatabaseID: 1, - }, - }, - }, - }, - { - // This happens at the early moments of a session lifecycle, before a PR is created and associated with it. - name: "single session, no pull request resource", - limit: 10, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ - "page_number": {"1"}, - "page_size": {"50"}, - }), - "api.githubcopilot.com", - ), - httpmock.StringResponse(heredoc.Docf(` - { - "sessions": [ - { - "id": "sess1", - "name": "Build artifacts", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "", - "resource_id": 0, - "created_at": "%[1]s", - "premium_requests": 0.1 - } - ] - }`, - sampleDateString, - )), - ) - // GraphQL hydration - reg.Register( - httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), - httpmock.GraphQLQuery(heredoc.Docf(` - { - "data": { - "nodes": [ - { - "__typename": "User", - "login": "octocat", - "name": "Octocat", - "databaseId": 1 - } - ] - } - }`, - sampleDateString, - ), func(q string, vars map[string]interface{}) { - assert.Equal(t, []interface{}{"U_kgAB"}, vars["ids"]) - }), - ) - }, - wantOut: []*Session{ - { - - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "", - ResourceID: 0, - CreatedAt: sampleDate, - PremiumRequests: 0.1, - User: &api.GitHubUser{ - Login: "octocat", - Name: "Octocat", - DatabaseID: 1, - }, - }, - }, - }, - { - name: "multiple sessions, paginated", - perPage: 1, // to enforce pagination - limit: 2, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ - "page_number": {"1"}, - "page_size": {"1"}, - }), - "api.githubcopilot.com", - ), - httpmock.StringResponse(heredoc.Docf(` - { - "sessions": [ - { - "id": "sess1", - "name": "Build artifacts", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2000, - "created_at": "%[1]s", - "premium_requests": 0.1 - } - ] - }`, - sampleDateString, - )), - ) - - // Second page - reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ - "page_number": {"2"}, - "page_size": {"1"}, - }), - "api.githubcopilot.com", - ), - httpmock.StringResponse(heredoc.Docf(` - { - "sessions": [ - { - "id": "sess2", - "name": "Build artifacts", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2001, - "created_at": "%[1]s", - "premium_requests": 0.1 - } - ] - }`, - sampleDateString, - )), - ) - // GraphQL hydration - reg.Register( - httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), - httpmock.GraphQLQuery(heredoc.Docf(` - { - "data": { - "nodes": [ - { - "__typename": "PullRequest", - "id": "PR_node", - "fullDatabaseId": "2000", - "number": 42, - "title": "Improve docs", - "state": "OPEN", - "isDraft": true, - "url": "https://github.com/OWNER/REPO/pull/42", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "__typename": "PullRequest", - "id": "PR_node", - "fullDatabaseId": "2001", - "number": 43, - "title": "Improve docs", - "state": "OPEN", - "isDraft": true, - "url": "https://github.com/OWNER/REPO/pull/43", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "__typename": "User", - "login": "octocat", - "name": "Octocat", - "databaseId": 1 - } - ] - } - }`, - sampleDateString, - ), func(q string, vars map[string]interface{}) { - assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "PR_kwDNA-jNB9E", "U_kgAB"}, vars["ids"]) - }), - ) - }, - wantOut: []*Session{ - { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, - PremiumRequests: 0.1, - PullRequest: &api.PullRequest{ - ID: "PR_node", - FullDatabaseID: "2000", - Number: 42, - Title: "Improve docs", - State: "OPEN", - IsDraft: true, - URL: "https://github.com/OWNER/REPO/pull/42", - Body: "", - CreatedAt: sampleDate, - UpdatedAt: sampleDate, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", - Name: "Octocat", - DatabaseID: 1, - }, - }, - { - ID: "sess2", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2001, - CreatedAt: sampleDate, - PremiumRequests: 0.1, - PullRequest: &api.PullRequest{ - ID: "PR_node", - FullDatabaseID: "2001", - Number: 43, - Title: "Improve docs", - State: "OPEN", - IsDraft: true, - URL: "https://github.com/OWNER/REPO/pull/43", - Body: "", - CreatedAt: sampleDate, - UpdatedAt: sampleDate, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", - Name: "Octocat", - DatabaseID: 1, - }, - }, - }, - }, - { - name: "API error", - limit: 10, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ - "page_number": {"1"}, - "page_size": {"50"}, - }), - "api.githubcopilot.com", - ), - httpmock.StatusStringResponse(500, "{}"), - ) - }, - wantErr: "failed to list sessions:", - }, { - name: "API error at hydration", - limit: 10, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/nwo/OWNER/REPO", url.Values{ - "page_number": {"1"}, - "page_size": {"50"}, - }), - "api.githubcopilot.com", - ), - httpmock.StringResponse(heredoc.Docf(` - { - "sessions": [ - { - "id": "sess1", - "name": "Build artifacts", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2000, - "created_at": "%[1]s", - "premium_requests": 0.1 - } - ] - }`, - sampleDateString, - )), - ) - // GraphQL hydration - reg.Register( - httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), - httpmock.StatusStringResponse(500, `{}`), - ) - }, - wantErr: `failed to fetch session resources: non-200 OK status code:`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - reg := &httpmock.Registry{} - if tt.httpStubs != nil { - tt.httpStubs(t, reg) - } - defer reg.Verify(t) - - httpClient := &http.Client{Transport: reg} - - cfg := config.NewBlankConfig() - capiClient := NewCAPIClient(httpClient, cfg.Authentication()) - - if tt.perPage != 0 { - last := defaultSessionsPerPage - defaultSessionsPerPage = tt.perPage - defer func() { - defaultSessionsPerPage = last - }() - } - - sessions, err := capiClient.ListSessionsForRepo(context.Background(), "OWNER", "REPO", tt.limit) + sessions, err := capiClient.ListLatestSessionsForViewer(context.Background(), tt.limit) if tt.wantErr != "" { require.ErrorContains(t, err, tt.wantErr) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 5886955e6..0eb7fd34a 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -56,8 +56,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman }, } - cmdutil.EnableRepoOverride(cmd, f) - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, fmt.Sprintf("Maximum number of agent tasks to fetch (default %d)", defaultLimit)) cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent tasks in the browser") @@ -66,10 +64,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman func listRun(opts *ListOptions) error { if opts.Web { - // Currently the web GUI does not have a page that supports filtering - // based on repo, so we just open the agents dashboard with no args. - // If that page is ever added in the future, we should route to that - // page instead of the global one when --repo is set. webURL := capi.AgentsHomeURL if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) @@ -91,24 +85,11 @@ func listRun(opts *ListOptions) error { var sessions []*capi.Session ctx := context.Background() - var repo ghrepo.Interface - if opts.BaseRepo != nil { - // We swallow this error because when CWD is not a repo and - // the --repo flag is not set, we use the global/user session listing. - repo, _ = opts.BaseRepo() + sessions, err = capiClient.ListLatestSessionsForViewer(ctx, opts.Limit) + if err != nil { + return err } - if repo != nil && repo.RepoOwner() != "" && repo.RepoName() != "" { - sessions, err = capiClient.ListSessionsForRepo(ctx, repo.RepoOwner(), repo.RepoName(), opts.Limit) - if err != nil { - return err - } - } else { - sessions, err = capiClient.ListSessionsForViewer(ctx, opts.Limit) - if err != nil { - return err - } - } opts.IO.StopProgressIndicator() if len(sessions) == 0 { @@ -121,6 +102,12 @@ func listRun(opts *ListOptions) error { fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) } + if opts.IO.IsStdoutTTY() { + count := len(sessions) + header := fmt.Sprintf("Showing %s", text.Pluralize(count, "session")) + fmt.Fprintf(opts.IO.Out, "%s\n\n", header) + } + cs := opts.IO.ColorScheme() tp := tableprinter.New(opts.IO, tableprinter.WithHeader("Session Name", "Pull Request", "Repo", "Session State", "Created")) for _, s := range sessions { diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index 9b981f28a..b9a750a6d 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -3,7 +3,6 @@ package list import ( "bytes" "context" - "errors" "io" "testing" "time" @@ -11,7 +10,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" - "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" @@ -22,11 +20,10 @@ import ( func TestNewCmdList(t *testing.T) { tests := []struct { - name string - args string - wantOpts ListOptions - wantBaseRepo ghrepo.Interface - wantErr string + name string + args string + wantOpts ListOptions + wantErr string }{ { name: "no arguments", @@ -34,14 +31,6 @@ func TestNewCmdList(t *testing.T) { Limit: defaultLimit, }, }, - { - name: "base repo specified", - args: "--repo OWNER/REPO", - wantOpts: ListOptions{ - Limit: defaultLimit, - }, - wantBaseRepo: ghrepo.New("OWNER", "REPO"), - }, { name: "custom limit", args: "--limit 15", @@ -96,12 +85,6 @@ func TestNewCmdList(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) assert.Equal(t, tt.wantOpts.Web, gotOpts.Web) - - if tt.wantBaseRepo != nil { - baseRepo, err := gotOpts.BaseRepo() - require.NoError(t, err) - assert.True(t, ghrepo.IsSame(tt.wantBaseRepo, baseRepo)) - } }) } } @@ -114,8 +97,6 @@ func Test_listRun(t *testing.T) { name string tty bool capiStubs func(*testing.T, *capi.CapiClientMock) - baseRepo ghrepo.Interface - baseRepoErr error limit int web bool wantOut string @@ -127,7 +108,7 @@ func Test_listRun(t *testing.T) { name: "viewer-scoped no sessions", tty: true, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { return nil, nil } }, @@ -138,7 +119,7 @@ func Test_listRun(t *testing.T) { tty: true, limit: 999, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { assert.Equal(t, 999, limit) return nil, nil } @@ -149,7 +130,7 @@ func Test_listRun(t *testing.T) { name: "viewer-scoped single session (tty)", tty: true, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { return []*capi.Session{ { ID: "id1", @@ -168,6 +149,8 @@ func Test_listRun(t *testing.T) { } }, wantOut: heredoc.Doc(` + Showing 1 session + SESSION NAME PULL REQUEST REPO SESSION STATE CREATED s1 #101 OWNER/REPO completed about 6 hours ago `), @@ -176,7 +159,7 @@ func Test_listRun(t *testing.T) { name: "viewer-scoped single session (nontty)", tty: false, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { return []*capi.Session{ { ID: "id1", @@ -200,7 +183,7 @@ func Test_listRun(t *testing.T) { name: "viewer-scoped many sessions (tty)", tty: true, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { return []*capi.Session{ { ID: "id1", @@ -284,6 +267,8 @@ func Test_listRun(t *testing.T) { } }, wantOut: heredoc.Doc(` + Showing 6 sessions + SESSION NAME PULL REQUEST REPO SESSION STATE CREATED s1 #101 OWNER/REPO completed about 6 hours ago s2 #102 OWNER/REPO failed about 6 hours ago @@ -293,195 +278,6 @@ func Test_listRun(t *testing.T) { s6 #106 OWNER/REPO mystery about 6 hours ago `), }, - { - name: "repo-scoped no sessions", - tty: true, - baseRepo: ghrepo.New("OWNER", "REPO"), - capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { - return nil, nil - } - }, - wantErr: cmdutil.NewNoResultsError("no agent tasks found"), - }, - { - name: "repo-scoped respects --limit/--repo", - tty: true, - limit: 999, - baseRepo: ghrepo.New("OWNER", "REPO"), - capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { - assert.Equal(t, 999, limit) - assert.Equal(t, "OWNER", owner) - assert.Equal(t, "REPO", repo) - return nil, nil - } - }, - wantErr: cmdutil.NewNoResultsError("no agent tasks found"), // not important - }, - { - name: "repo-scoped single session (tty)", - tty: true, - baseRepo: ghrepo.New("OWNER", "REPO"), - capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { - return []*capi.Session{ - { - ID: "id1", - Name: "s1", - State: "completed", - CreatedAt: sampleDate, - ResourceType: "pull", - PullRequest: &api.PullRequest{ - Number: 101, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - }, - }, nil - } - }, - wantOut: heredoc.Doc(` - SESSION NAME PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago - `), - }, - { - name: "repo-scoped single session (nontty)", - tty: false, - baseRepo: ghrepo.New("OWNER", "REPO"), - capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { - return []*capi.Session{ - { - ID: "id1", - Name: "s1", - State: "completed", - ResourceType: "pull", - CreatedAt: sampleDate, - PullRequest: &api.PullRequest{ - Number: 101, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - }, - }, nil - } - }, - wantOut: "s1\t#101\tOWNER/REPO\tcompleted\t" + sampleDateString + "\n", // header omitted for non-tty - }, - { - name: "repo-scoped many sessions (tty)", - tty: true, - baseRepo: ghrepo.New("OWNER", "REPO"), - capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - m.ListSessionsForRepoFunc = func(ctx context.Context, owner, repo string, limit int) ([]*capi.Session, error) { - return []*capi.Session{ - { - ID: "id1", - Name: "s1", - State: "completed", - CreatedAt: sampleDate, - ResourceType: "pull", - PullRequest: &api.PullRequest{ - Number: 101, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - }, - { - ID: "id2", - Name: "s2", - State: "failed", - CreatedAt: sampleDate, - ResourceType: "pull", - PullRequest: &api.PullRequest{ - Number: 102, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - }, - { - ID: "id3", - Name: "s3", - State: "in_progress", - CreatedAt: sampleDate, - ResourceType: "pull", - PullRequest: &api.PullRequest{ - Number: 103, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - }, - { - ID: "id4", - Name: "s4", - State: "queued", - CreatedAt: sampleDate, - ResourceType: "pull", - PullRequest: &api.PullRequest{ - Number: 104, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - }, - { - ID: "id5", - Name: "s5", - State: "canceled", - CreatedAt: sampleDate, - ResourceType: "pull", - PullRequest: &api.PullRequest{ - Number: 105, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - }, - { - ID: "id6", - Name: "s6", - State: "mystery", - CreatedAt: sampleDate, - ResourceType: "pull", - PullRequest: &api.PullRequest{ - Number: 106, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - }, - }, nil - } - }, - wantOut: heredoc.Doc(` - SESSION NAME PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago - s2 #102 OWNER/REPO failed about 6 hours ago - s3 #103 OWNER/REPO in_progress about 6 hours ago - s4 #104 OWNER/REPO queued about 6 hours ago - s5 #105 OWNER/REPO canceled about 6 hours ago - s6 #106 OWNER/REPO mystery about 6 hours ago - `), - }, - { - name: "repo resolution error does not surface", - tty: true, - baseRepoErr: errors.New("ambiguous repo"), - capiStubs: func(t *testing.T, m *capi.CapiClientMock) { - // We expect a viewer-scoped fetch request: - m.ListSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { - return nil, nil - } - }, - wantErr: cmdutil.NewNoResultsError("no agent tasks found"), - }, { name: "web mode", tty: true, @@ -490,15 +286,6 @@ func Test_listRun(t *testing.T) { wantStderr: "Opening https://github.com/copilot/agents in your browser.\n", wantBrowserURL: "https://github.com/copilot/agents", }, - { - name: "web mode with repo still uses global URL, even when --repo is set", - tty: true, - web: true, - baseRepo: ghrepo.New("OWNER", "REPO"), - wantOut: "", - wantStderr: "Opening https://github.com/copilot/agents in your browser.\n", - wantBrowserURL: "https://github.com/copilot/agents", - }, } for _, tt := range tests { @@ -528,11 +315,6 @@ func Test_listRun(t *testing.T) { return capiClientMock, nil }, } - if tt.baseRepo != nil || tt.baseRepoErr != nil { - baseRepo := tt.baseRepo - baseRepoErr := tt.baseRepoErr - opts.BaseRepo = func() (ghrepo.Interface, error) { return baseRepo, baseRepoErr } - } err := listRun(opts) if tt.wantErr != nil { From 08bc1a8d29088418407427ff9802fb39327af861 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:48:09 -0600 Subject: [PATCH 177/208] Fix session deduplication and add test for paging Refactor ListLatestSessionsForViewer to use a map for tracking seen resource IDs, ensuring only the newest session per resource is kept. Add a test case to verify correct deduplication across paginated API responses. --- pkg/cmd/agent-task/capi/sessions.go | 22 +- pkg/cmd/agent-task/capi/sessions_test.go | 252 ++++++++++++++++++++++- 2 files changed, 264 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index f6825e674..c34732d9b 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -99,8 +99,8 @@ func (c *CAPIClient) ListLatestSessionsForViewer(ctx context.Context, limit int) pageSize := defaultSessionsPerPage sessions := make([]session, 0, limit+pageSize) - var seenResources []int64 - var latestSessions []session + seenResources := make(map[int64]struct{}) + latestSessions := make([]session, 0, limit) for page := 1; ; page++ { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { @@ -128,18 +128,22 @@ func (c *CAPIClient) ListLatestSessionsForViewer(ctx context.Context, limit int) return nil, fmt.Errorf("failed to decode sessions response: %w", err) } - sessions = append(sessions, response.Sessions...) + // Process only the newly fetched page worth of sessions. + pageSessions := response.Sessions + sessions = append(sessions, pageSessions...) // De-duplicate sessions by resource ID. - // Because the API returns newest first, - // we can safely skip any additional sessions - // for a resource we have already seen. - for _, s := range sessions { - if slices.Contains(seenResources, s.ResourceID) { + // Because the API returns newest first, once we've seen + // a resource ID we can ignore any older sessions for it. + for _, s := range pageSessions { + if _, exists := seenResources[s.ResourceID]; exists { continue } - seenResources = append(seenResources, s.ResourceID) + seenResources[s.ResourceID] = struct{}{} latestSessions = append(latestSessions, s) + if len(latestSessions) >= limit { + break + } } if len(response.Sessions) < pageSize || len(latestSessions) >= limit { diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 1c49444db..26a079501 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -16,7 +16,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestListSessionsForViewer(t *testing.T) { +func TestListLatestSessionsForViewer(t *testing.T) { sampleDateString := "2025-08-29T00:00:00Z" sampleDate, err := time.Parse(time.RFC3339, sampleDateString) require.NoError(t, err) @@ -433,6 +433,256 @@ func TestListSessionsForViewer(t *testing.T) { }, }, }, + { + name: "multiple pages with duplicates per PR only newest kept", + perPage: 2, + limit: 3, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + // Page 1 returns newest sessions (ordered newest first overall) + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"2"}, + "sort": {"last_updated_at,desc"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sessA-new", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3000, + "created_at": "%[1]s" + }, + { + "id": "sessB-new", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3001, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + + // Page 2 returns older duplicate sessions for 3000 and 3001, plus another new PR 3002 + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"2"}, + "page_size": {"2"}, + "sort": {"last_updated_at,desc"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sessA-old", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3000, + "created_at": "%[1]s" + }, + { + "id": "sessC-new", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3002, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + + // GraphQL hydration for PRs 3000, 3001, 3002 and user 1 + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node3000", + "fullDatabaseId": "3000", + "number": 100, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/100", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": {"nameWithOwner": "OWNER/REPO"} + }, + { + "__typename": "PullRequest", + "id": "PR_node3001", + "fullDatabaseId": "3001", + "number": 101, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/101", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": {"nameWithOwner": "OWNER/REPO"} + }, + { + "__typename": "PullRequest", + "id": "PR_node3002", + "fullDatabaseId": "3002", + "number": 102, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/102", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": {"nameWithOwner": "OWNER/REPO"} + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + // Expected encoded node IDs for resource IDs 3000,3001,3002 + assert.Equal(t, []interface{}{"PR_kwDNA-jNC7g", "PR_kwDNA-jNC7k", "PR_kwDNA-jNC7o", "U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + ID: "sessA-new", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 3000, + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + ID: "PR_node3000", + FullDatabaseID: "3000", + Number: 100, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/100", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{Login: "octocat", Name: "Octocat", DatabaseID: 1}, + }, + { + ID: "sessB-new", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 3001, + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + ID: "PR_node3001", + FullDatabaseID: "3001", + Number: 101, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/101", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{Login: "octocat", Name: "Octocat", DatabaseID: 1}, + }, + { + ID: "sessC-new", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 3002, + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + ID: "PR_node3002", + FullDatabaseID: "3002", + Number: 102, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/102", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{Login: "octocat", Name: "Octocat", DatabaseID: 1}, + }, + }, + }, { name: "API error", limit: 10, From 6ec4324d6d1dd93545bef697b1aac0efef9e903b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 11:40:51 +0100 Subject: [PATCH 178/208] docs(agent-task/capi): fix test case explanation Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 26a079501..4eb0bc199 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -483,7 +483,7 @@ func TestListLatestSessionsForViewer(t *testing.T) { )), ) - // Page 2 returns older duplicate sessions for 3000 and 3001, plus another new PR 3002 + // Page 2 returns older duplicate sessions for 3000, plus another new PR 3002 reg.Register( httpmock.WithHost( httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ From 5f73d3b43848e20e3f6b1f065cceada5bf1eb21b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 11:42:02 +0100 Subject: [PATCH 179/208] refactor(agent-task list): remove unused `BaseRepo` from `ListOptions` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/list/list.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index 0eb7fd34a..f1fca365d 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -6,7 +6,6 @@ import ( "time" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" @@ -24,7 +23,6 @@ type ListOptions struct { IO *iostreams.IOStreams Limit int CapiClient func() (capi.CapiClient, error) - BaseRepo func() (ghrepo.Interface, error) Web bool Browser browser.Browser } @@ -43,9 +41,6 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Short: "List agent tasks", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - // Support -R/--repo override - opts.BaseRepo = f.BaseRepo - if opts.Limit < 1 { return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) } From c68d1a31c05febd8ff803e3a0961e307a3c148e0 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 11:53:44 +0100 Subject: [PATCH 180/208] fix(agent-task/capi): keep sessions with no resource attached Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index c34732d9b..b379f6543 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -139,7 +139,13 @@ func (c *CAPIClient) ListLatestSessionsForViewer(ctx context.Context, limit int) if _, exists := seenResources[s.ResourceID]; exists { continue } - seenResources[s.ResourceID] = struct{}{} + + // A zero resource ID is a temporary situation before a PR/resource + // is associated with the session. We should not mark such case as seen. + if s.ResourceID != 0 { + seenResources[s.ResourceID] = struct{}{} + } + latestSessions = append(latestSessions, s) if len(latestSessions) >= limit { break From 6235c33ff000225b12de8d7c7e29fcc18b527ea4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 11:54:20 +0100 Subject: [PATCH 181/208] test(agent-task/capi): assert session with zero resource ID are kept Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions_test.go | 194 ++++++++++++++++++++++- 1 file changed, 193 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 4eb0bc199..b91a8b401 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -588,7 +588,7 @@ func TestListLatestSessionsForViewer(t *testing.T) { }`, sampleDateString, ), func(q string, vars map[string]interface{}) { - // Expected encoded node IDs for resource IDs 3000,3001,3002 + // Expected encoded node IDs for resource IDs 3000,3001,3002 and user octocat assert.Equal(t, []interface{}{"PR_kwDNA-jNC7g", "PR_kwDNA-jNC7k", "PR_kwDNA-jNC7o", "U_kgAB"}, vars["ids"]) }), ) @@ -683,6 +683,198 @@ func TestListLatestSessionsForViewer(t *testing.T) { }, }, }, + { + name: "multiple pages with zero resource IDs all kept", + perPage: 2, + limit: 3, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + // Page 1 returns newest sessions, one with a zero resource ID + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"2"}, + "sort": {"last_updated_at,desc"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sessA-new", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3000, + "created_at": "%[1]s" + }, + { + "id": "sessB-new", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "queued", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "", + "resource_id": 0, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + + // Page 2 returns older duplicate sessions for 3000, plus another new session with zero resource ID + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"2"}, + "page_size": {"2"}, + "sort": {"last_updated_at,desc"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sessA-old", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3000, + "created_at": "%[1]s" + }, + { + "id": "sessC-new", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "queued", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "", + "resource_id": 0, + "created_at": "%[1]s" + } + ] + }`, + sampleDateString, + )), + ) + + // GraphQL hydration for PRs 3000 and user 1 + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node3000", + "fullDatabaseId": "3000", + "number": 100, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/100", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": {"nameWithOwner": "OWNER/REPO"} + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + // Expected encoded node IDs for resource IDs 3000 and user octocat + assert.Equal(t, []interface{}{"PR_kwDNA-jNC7g", "U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + ID: "sessA-new", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "completed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 3000, + CreatedAt: sampleDate, + PullRequest: &api.PullRequest{ + ID: "PR_node3000", + FullDatabaseID: "3000", + Number: 100, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/100", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{Login: "octocat", Name: "Octocat", DatabaseID: 1}, + }, + { + ID: "sessB-new", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "queued", + OwnerID: 10, + RepoID: 1000, + ResourceType: "", + ResourceID: 0, + CreatedAt: sampleDate, + User: &api.GitHubUser{Login: "octocat", Name: "Octocat", DatabaseID: 1}, + }, + { + ID: "sessC-new", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "queued", + OwnerID: 10, + RepoID: 1000, + ResourceType: "", + ResourceID: 0, + CreatedAt: sampleDate, + User: &api.GitHubUser{Login: "octocat", Name: "Octocat", DatabaseID: 1}, + }, + }, + }, { name: "API error", limit: 10, From 169f45daf9940b0c28c732dd845cbfd372eeef1b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 11:57:28 +0100 Subject: [PATCH 182/208] chore(agent-task/shared): regenerate `LogRenderer` mock Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/log_mock.go | 72 +++++++++++++-------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/pkg/cmd/agent-task/shared/log_mock.go b/pkg/cmd/agent-task/shared/log_mock.go index e4096e273..f12cf6bf3 100644 --- a/pkg/cmd/agent-task/shared/log_mock.go +++ b/pkg/cmd/agent-task/shared/log_mock.go @@ -19,10 +19,10 @@ var _ LogRenderer = &LogRendererMock{} // // // make and configure a mocked LogRenderer // mockedLogRenderer := &LogRendererMock{ -// FollowFunc: func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.IOStreams) error { +// FollowFunc: func(fetcher func() ([]byte, error), w io.Writer, ioMoqParam *iostreams.IOStreams) error { // panic("mock out the Follow method") // }, -// RenderFunc: func(logs []byte, w io.Writer, cs *iostreams.IOStreams) (bool, error) { +// RenderFunc: func(logs []byte, w io.Writer, ioMoqParam *iostreams.IOStreams) (bool, error) { // panic("mock out the Render method") // }, // } @@ -33,10 +33,10 @@ var _ LogRenderer = &LogRendererMock{} // } type LogRendererMock struct { // FollowFunc mocks the Follow method. - FollowFunc func(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.IOStreams) error + FollowFunc func(fetcher func() ([]byte, error), w io.Writer, ioMoqParam *iostreams.IOStreams) error // RenderFunc mocks the Render method. - RenderFunc func(logs []byte, w io.Writer, cs *iostreams.IOStreams) (bool, error) + RenderFunc func(logs []byte, w io.Writer, ioMoqParam *iostreams.IOStreams) (bool, error) // calls tracks calls to the methods. calls struct { @@ -46,8 +46,8 @@ type LogRendererMock struct { Fetcher func() ([]byte, error) // W is the w argument value. W io.Writer - // Cs is the cs argument value. - Cs *iostreams.IOStreams + // IoMoqParam is the ioMoqParam argument value. + IoMoqParam *iostreams.IOStreams } // Render holds details about calls to the Render method. Render []struct { @@ -55,8 +55,8 @@ type LogRendererMock struct { Logs []byte // W is the w argument value. W io.Writer - // Cs is the cs argument value. - Cs *iostreams.IOStreams + // IoMoqParam is the ioMoqParam argument value. + IoMoqParam *iostreams.IOStreams } } lockFollow sync.RWMutex @@ -64,23 +64,23 @@ type LogRendererMock struct { } // Follow calls FollowFunc. -func (mock *LogRendererMock) Follow(fetcher func() ([]byte, error), w io.Writer, cs *iostreams.IOStreams) error { +func (mock *LogRendererMock) Follow(fetcher func() ([]byte, error), w io.Writer, ioMoqParam *iostreams.IOStreams) error { if mock.FollowFunc == nil { panic("LogRendererMock.FollowFunc: method is nil but LogRenderer.Follow was just called") } callInfo := struct { - Fetcher func() ([]byte, error) - W io.Writer - Cs *iostreams.IOStreams + Fetcher func() ([]byte, error) + W io.Writer + IoMoqParam *iostreams.IOStreams }{ - Fetcher: fetcher, - W: w, - Cs: cs, + Fetcher: fetcher, + W: w, + IoMoqParam: ioMoqParam, } mock.lockFollow.Lock() mock.calls.Follow = append(mock.calls.Follow, callInfo) mock.lockFollow.Unlock() - return mock.FollowFunc(fetcher, w, cs) + return mock.FollowFunc(fetcher, w, ioMoqParam) } // FollowCalls gets all the calls that were made to Follow. @@ -88,14 +88,14 @@ func (mock *LogRendererMock) Follow(fetcher func() ([]byte, error), w io.Writer, // // len(mockedLogRenderer.FollowCalls()) func (mock *LogRendererMock) FollowCalls() []struct { - Fetcher func() ([]byte, error) - W io.Writer - Cs *iostreams.IOStreams + Fetcher func() ([]byte, error) + W io.Writer + IoMoqParam *iostreams.IOStreams } { var calls []struct { - Fetcher func() ([]byte, error) - W io.Writer - Cs *iostreams.IOStreams + Fetcher func() ([]byte, error) + W io.Writer + IoMoqParam *iostreams.IOStreams } mock.lockFollow.RLock() calls = mock.calls.Follow @@ -104,23 +104,23 @@ func (mock *LogRendererMock) FollowCalls() []struct { } // Render calls RenderFunc. -func (mock *LogRendererMock) Render(logs []byte, w io.Writer, cs *iostreams.IOStreams) (bool, error) { +func (mock *LogRendererMock) Render(logs []byte, w io.Writer, ioMoqParam *iostreams.IOStreams) (bool, error) { if mock.RenderFunc == nil { panic("LogRendererMock.RenderFunc: method is nil but LogRenderer.Render was just called") } callInfo := struct { - Logs []byte - W io.Writer - Cs *iostreams.IOStreams + Logs []byte + W io.Writer + IoMoqParam *iostreams.IOStreams }{ - Logs: logs, - W: w, - Cs: cs, + Logs: logs, + W: w, + IoMoqParam: ioMoqParam, } mock.lockRender.Lock() mock.calls.Render = append(mock.calls.Render, callInfo) mock.lockRender.Unlock() - return mock.RenderFunc(logs, w, cs) + return mock.RenderFunc(logs, w, ioMoqParam) } // RenderCalls gets all the calls that were made to Render. @@ -128,14 +128,14 @@ func (mock *LogRendererMock) Render(logs []byte, w io.Writer, cs *iostreams.IOSt // // len(mockedLogRenderer.RenderCalls()) func (mock *LogRendererMock) RenderCalls() []struct { - Logs []byte - W io.Writer - Cs *iostreams.IOStreams + Logs []byte + W io.Writer + IoMoqParam *iostreams.IOStreams } { var calls []struct { - Logs []byte - W io.Writer - Cs *iostreams.IOStreams + Logs []byte + W io.Writer + IoMoqParam *iostreams.IOStreams } mock.lockRender.RLock() calls = mock.calls.Render From 7d163eaf27d019a573d227c1ab5756497f6740f3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 12:20:34 +0100 Subject: [PATCH 183/208] docs(agent-task list): add "(preview)" note to cmd docs Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/list/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index f1fca365d..c2897acc1 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -38,7 +38,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd := &cobra.Command{ Use: "list", - Short: "List agent tasks", + Short: "List agent tasks (preview)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if opts.Limit < 1 { From 764376836ca08b6202dba3bdf4819a176148b7f1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 12:33:47 +0100 Subject: [PATCH 184/208] fix(agent-task/shared): fix session state value Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/shared/display.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/shared/display.go b/pkg/cmd/agent-task/shared/display.go index 99ae91c43..3859a0e75 100644 --- a/pkg/cmd/agent-task/shared/display.go +++ b/pkg/cmd/agent-task/shared/display.go @@ -11,7 +11,7 @@ func ColorFuncForSessionState(s capi.Session, cs *iostreams.ColorScheme) func(st switch s.State { case "completed": stateColor = cs.Green - case "canceled": + case "cancelled": stateColor = cs.Muted case "in_progress", "queued": stateColor = cs.Yellow From 4a0c32ef9f8fd6ac173d653a62fb25d77e846134 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 12:34:27 +0100 Subject: [PATCH 185/208] fix(agent-task list): show capitalised session state Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/list/list.go | 4 ++-- pkg/cmd/agent-task/list/list_test.go | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go index c2897acc1..4370a0170 100644 --- a/pkg/cmd/agent-task/list/list.go +++ b/pkg/cmd/agent-task/list/list.go @@ -127,9 +127,9 @@ func listRun(opts *ListOptions) error { // State if tp.IsTTY() { - tp.AddField(s.State, tableprinter.WithColor(shared.ColorFuncForSessionState(*s, cs))) + tp.AddField(shared.SessionStateString(s.State), tableprinter.WithColor(shared.ColorFuncForSessionState(*s, cs))) } else { - tp.AddField(s.State) + tp.AddField(shared.SessionStateString(s.State)) } // Created diff --git a/pkg/cmd/agent-task/list/list_test.go b/pkg/cmd/agent-task/list/list_test.go index b9a750a6d..747650283 100644 --- a/pkg/cmd/agent-task/list/list_test.go +++ b/pkg/cmd/agent-task/list/list_test.go @@ -151,8 +151,8 @@ func Test_listRun(t *testing.T) { wantOut: heredoc.Doc(` Showing 1 session - SESSION NAME PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago + SESSION NAME PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO Ready for review about 6 hours ago `), }, { @@ -177,7 +177,7 @@ func Test_listRun(t *testing.T) { }, nil } }, - wantOut: "s1\t#101\tOWNER/REPO\tcompleted\t" + sampleDateString + "\n", // header omitted for non-tty + wantOut: "s1\t#101\tOWNER/REPO\tReady for review\t" + sampleDateString + "\n", // header omitted for non-tty }, { name: "viewer-scoped many sessions (tty)", @@ -240,7 +240,7 @@ func Test_listRun(t *testing.T) { { ID: "id5", Name: "s5", - State: "canceled", + State: "cancelled", CreatedAt: sampleDate, ResourceType: "pull", PullRequest: &api.PullRequest{ @@ -269,13 +269,13 @@ func Test_listRun(t *testing.T) { wantOut: heredoc.Doc(` Showing 6 sessions - SESSION NAME PULL REQUEST REPO SESSION STATE CREATED - s1 #101 OWNER/REPO completed about 6 hours ago - s2 #102 OWNER/REPO failed about 6 hours ago - s3 #103 OWNER/REPO in_progress about 6 hours ago - s4 #104 OWNER/REPO queued about 6 hours ago - s5 #105 OWNER/REPO canceled about 6 hours ago - s6 #106 OWNER/REPO mystery about 6 hours ago + SESSION NAME PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO Ready for review about 6 hours ago + s2 #102 OWNER/REPO Failed about 6 hours ago + s3 #103 OWNER/REPO In progress about 6 hours ago + s4 #104 OWNER/REPO Queued about 6 hours ago + s5 #105 OWNER/REPO Cancelled about 6 hours ago + s6 #106 OWNER/REPO mystery about 6 hours ago `), }, { From cb9808fa6e6657f27d0cf5ac2b0b33ef6abe1d10 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 13:51:13 +0100 Subject: [PATCH 186/208] fix(agent-task create): avoid prompting when problem statement is provided Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 32 ++--- pkg/cmd/agent-task/create/create_test.go | 150 ++++++++++------------- 2 files changed, 79 insertions(+), 103 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 8b8084a1f..b602d0a70 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -121,36 +121,30 @@ func createRun(opts *CreateOptions) error { } if opts.ProblemStatement == "" { - // Load initial problem statement from file, if provided if opts.ProblemStatementFile != "" { fileContent, err := cmdutil.ReadFile(opts.ProblemStatementFile, opts.IO.In) if err != nil { - return cmdutil.FlagErrorf("could not read task description file: %v", err) + return fmt.Errorf("could not read task description file: %w", err) } - opts.ProblemStatement = strings.TrimSpace(string(fileContent)) - } - if opts.IO.CanPrompt() { + trimmed := strings.TrimSpace(string(fileContent)) + if trimmed == "" { + return errors.New("task description file cannot be empty") + } + + opts.ProblemStatement = trimmed + } else { desc, err := opts.Prompter.MarkdownEditor("Enter the task description", opts.ProblemStatement, false) if err != nil { return err } - opts.ProblemStatement = strings.TrimSpace(desc) - } - } - if opts.ProblemStatement == "" { - fmt.Fprintf(opts.IO.ErrOut, "a task description is required.\n") - return cmdutil.SilentError - } + trimmed := strings.TrimSpace(string(desc)) + if trimmed == "" { + return errors.New("a task description is required") + } - if opts.IO.CanPrompt() { - confirm, err := opts.Prompter.Confirm("Submit agent task", true) - if err != nil { - return err - } - if !confirm { - return cmdutil.SilentError + opts.ProblemStatement = trimmed } } diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index d1298e606..69bfef2ab 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -158,101 +158,41 @@ func Test_createRun(t *testing.T) { wantErrIs error }{ { - name: "interactive with file prompts to edit with file contents", - opts: &CreateOptions{ - BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - ProblemStatement: "", - ProblemStatementFile: taskDescFile, - Prompter: &prompter.PrompterMock{ - MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { - require.Equal(t, "Enter the task description", prompt) - require.Equal(t, "task description from file", defaultValue) - return "edited task description", nil - }, - ConfirmFunc: func(message string, defaultValue bool) (bool, error) { - require.Equal(t, "Submit agent task", message) - return true, nil - }, - }, - }, + name: "interactive, problem statement from arg", isTTY: true, + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + ProblemStatement: "task description from arg", + }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { require.Equal(t, "OWNER", owner) require.Equal(t, "REPO", repo) - require.Equal(t, "edited task description", problemStatement) + require.Equal(t, "task description from arg", problemStatement) return &createdJobSuccessWithPR, nil } }, wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "interactively rejecting confirmation prompt aborts task creation", + name: "non-interactive, problem statement from arg", opts: &CreateOptions{ BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - ProblemStatement: "", - Prompter: &prompter.PrompterMock{ - MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { - require.Equal(t, "Enter the task description", prompt) - return "From editor", nil - }, - ConfirmFunc: func(message string, defaultValue bool) (bool, error) { - require.Equal(t, "Submit agent task", message) - return false, nil - }, - }, - }, - isTTY: true, - wantErr: "SilentError", - wantErrIs: cmdutil.SilentError, - wantStdErr: "", - }, - { - name: "interactively entering task description with editor, no file", - isTTY: true, - opts: &CreateOptions{ - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - ProblemStatement: "", - Prompter: &prompter.PrompterMock{ - MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { - require.Equal(t, "Enter the task description", prompt) - return "From editor", nil - }, - ConfirmFunc: func(message string, defaultValue bool) (bool, error) { - require.Equal(t, "Submit agent task", message) - return true, nil - }, - }, + ProblemStatement: "task description from arg", }, capiStubs: func(t *testing.T, m *capi.CapiClientMock) { m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { - require.Equal(t, "From editor", problemStatement) + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "task description from arg", problemStatement) return &createdJobSuccessWithPR, nil } }, wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, { - name: "empty task description from interactive prompt returns error", + name: "interactive, problem statement from file", isTTY: true, - opts: &CreateOptions{ - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Prompter: &prompter.PrompterMock{ - MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { - return " ", nil - }, - }, - }, - wantErr: "SilentError", - wantErrIs: cmdutil.SilentError, - wantStdErr: "a task description is required.\n", - }, - { - name: "problem statement loaded from file non-interactively doesn't prompt or return error", opts: &CreateOptions{ BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, ProblemStatement: "", @@ -268,6 +208,60 @@ func Test_createRun(t *testing.T) { }, wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", }, + { + name: "non-interactive, problem statement loaded from file", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, + ProblemStatement: "", + ProblemStatementFile: taskDescFile, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "task description from file", problemStatement) + return &createdJobSuccessWithPR, nil + } + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + name: "interactive, problem statement from prompt/editor", + isTTY: true, + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Prompter: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + require.Equal(t, "Enter the task description", prompt) + return "From editor", nil + }, + }, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "From editor", problemStatement) + return &createdJobSuccessWithPR, nil + } + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + name: "interactive, empty task description from editor returns error", + isTTY: true, + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Prompter: &prompter.PrompterMock{ + MarkdownEditorFunc: func(prompt, defaultValue string, blankAllowed bool) (string, error) { + return " ", nil + }, + }, + }, + wantErr: "a task description is required", + }, { name: "missing repo returns error", opts: &CreateOptions{ @@ -276,18 +270,6 @@ func Test_createRun(t *testing.T) { }}, wantErr: "a repository is required; re-run in a repository or supply one with --repo owner/name", }, - { - name: "non-interactive empty description returns error", - opts: &CreateOptions{ - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - ProblemStatement: "", - }, - wantErr: "SilentError", - wantErrIs: cmdutil.SilentError, - wantStdErr: "a task description is required.\n", - }, { name: "problem statement loaded from arg non-interactively doesn't prompt or return error", opts: &CreateOptions{ From c7a811e56769a95d67bc9c3a5a4321d827356a08 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 13:55:37 +0100 Subject: [PATCH 187/208] fix(agent-task create): use pager when following logs Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 15 ++++++++++----- pkg/cmd/agent-task/create/create_test.go | 2 -- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index b602d0a70..a460a12e8 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -162,6 +162,10 @@ func createRun(opts *CreateOptions) error { return err } + if opts.Follow { + return followLogs(opts, client, job.SessionID) + } + sessionURL, err := fetchJobSessionURL(ctx, client, repo, job, opts.BackOff) opts.IO.StopProgressIndicator() @@ -177,9 +181,6 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.IO.Out, "job %s queued. View progress: %s\n", job.ID, capi.AgentsHomeURL) } - if opts.Follow { - return followLogs(opts, client, job.SessionID) - } return nil } @@ -250,8 +251,13 @@ func fetchJobWithBackoff(ctx context.Context, client capi.CapiClient, repo ghrep } func followLogs(opts *CreateOptions, capiClient capi.CapiClient, sessionID string) error { - ctx := context.Background() + if err := opts.IO.StartPager(); err == nil { + defer opts.IO.StopPager() + } else { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + ctx := context.Background() renderer := opts.LogRenderer() var called bool @@ -267,6 +273,5 @@ func followLogs(opts *CreateOptions, capiClient capi.CapiClient, sessionID strin return raw, nil } - fmt.Fprintln(opts.IO.Out, "") return renderer.Follow(fetcher, opts.IO.Out, opts.IO) } diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index 69bfef2ab..c96a2ef24 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -473,8 +473,6 @@ func Test_createRun(t *testing.T) { } }, wantStdout: heredoc.Doc(` - https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1 - (rendered:) (rendered:) `), From 863329b4c1b69046b02ceadf3438bbbe38363bd1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 14:14:59 +0100 Subject: [PATCH 188/208] fix(agent-task create): block empty problem statement arg Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 3 +++ pkg/cmd/agent-task/create/create_test.go | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index a460a12e8..9c9b8ed68 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -70,6 +70,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co // Populate ProblemStatement from arg if len(args) > 0 { opts.ProblemStatement = args[0] + if strings.TrimSpace(opts.ProblemStatement) == "" { + return cmdutil.FlagErrorf("task description cannot be empty") + } } else if opts.ProblemStatementFile == "" && !opts.IO.CanPrompt() { return cmdutil.FlagErrorf("a task description or -F is required when running non-interactively") } diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index c96a2ef24..ef7f529ac 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -46,6 +46,21 @@ func TestNewCmdCreate(t *testing.T) { ProblemStatementFile: "", }, }, + { + name: "empty arg", + args: "''", + wantErr: "task description cannot be empty", + }, + { + name: "whitespace arg", + args: "' '", + wantErr: "task description cannot be empty", + }, + { + name: "whitespace and newline arg", + args: "'\n'", + wantErr: "task description cannot be empty", + }, { name: "mutually exclusive arg and file", args: "'some task inline' -F foo.md", @@ -96,7 +111,7 @@ func TestNewCmdCreate(t *testing.T) { _, err = cmd.ExecuteC() if tt.wantErr != "" { - require.Error(t, err, tt.wantErr) + require.EqualError(t, err, tt.wantErr) } else { require.NoError(t, err) } From 3ed9034ff82f5e5feb23c2ee41509735091aba5e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 15:04:06 +0100 Subject: [PATCH 189/208] fix(agent-task/capi): add `Error` field to `Session` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 19 +++- pkg/cmd/agent-task/capi/sessions_test.go | 112 +++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index b379f6543..f0af72985 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -43,6 +43,10 @@ type session struct { EventURL string `json:"event_url"` EventType string `json:"event_type"` PremiumRequests float64 `json:"premium_requests"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` } // A shim of a full pull request because looking up by node ID @@ -83,11 +87,17 @@ type Session struct { EventURL string EventType string PremiumRequests float64 + Error *SessionError PullRequest *api.PullRequest User *api.GitHubUser } +type SessionError struct { + Code string + Message string +} + // ListLatestSessionsForViewer lists all agent sessions for the // authenticated user up to limit. func (c *CAPIClient) ListLatestSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { @@ -442,7 +452,7 @@ func generateUserNodeID(userID int64) string { } func fromAPISession(s session) *Session { - return &Session{ + result := Session{ ID: s.ID, Name: s.Name, UserID: s.UserID, @@ -460,4 +470,11 @@ func fromAPISession(s session) *Session { EventType: s.EventType, PremiumRequests: s.PremiumRequests, } + if s.Error != nil { + result.Error = &SessionError{ + Code: s.Error.Code, + Message: s.Error.Message, + } + } + return &result } diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index b91a8b401..8040cb413 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -875,6 +875,118 @@ func TestListLatestSessionsForViewer(t *testing.T) { }, }, }, + { + name: "session error is included", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + "sort": {"last_updated_at,desc"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sessA", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "failed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3000, + "created_at": "%[1]s", + "error": { + "code": "some-error-code", + "message": "some-error-message" + } + } + ] + }`, + sampleDateString, + )), + ) + + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node3000", + "fullDatabaseId": "3000", + "number": 100, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/100", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": {"nameWithOwner": "OWNER/REPO"} + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + // Expected encoded node IDs for resource IDs 3000 and user octocat + assert.Equal(t, []interface{}{"PR_kwDNA-jNC7g", "U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + ID: "sessA", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "failed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 3000, + CreatedAt: sampleDate, + Error: &SessionError{ + Code: "some-error-code", + Message: "some-error-message", + }, + PullRequest: &api.PullRequest{ + ID: "PR_node3000", + FullDatabaseID: "3000", + Number: 100, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/100", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{Login: "octocat", Name: "Octocat", DatabaseID: 1}, + }, + }, + }, { name: "API error", limit: 10, From b6816ad89ab3bbede093dd79dcd915e6b5672aed Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 15:05:28 +0100 Subject: [PATCH 190/208] refactor(agent-task/capi): remove unused slice Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index f0af72985..db85825eb 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -108,7 +108,6 @@ func (c *CAPIClient) ListLatestSessionsForViewer(ctx context.Context, limit int) url := baseCAPIURL + "/agents/sessions" pageSize := defaultSessionsPerPage - sessions := make([]session, 0, limit+pageSize) seenResources := make(map[int64]struct{}) latestSessions := make([]session, 0, limit) for page := 1; ; page++ { @@ -140,7 +139,6 @@ func (c *CAPIClient) ListLatestSessionsForViewer(ctx context.Context, limit int) // Process only the newly fetched page worth of sessions. pageSessions := response.Sessions - sessions = append(sessions, pageSessions...) // De-duplicate sessions by resource ID. // Because the API returns newest first, once we've seen From e5739cc54518aaa94dd7e101f3ac158305fd52f5 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 16:11:17 +0100 Subject: [PATCH 191/208] fix(agent-task/capi): add `WorkflowRunID` field to `Session` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 3 + pkg/cmd/agent-task/capi/sessions_test.go | 106 +++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index db85825eb..a3989a862 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -43,6 +43,7 @@ type session struct { EventURL string `json:"event_url"` EventType string `json:"event_type"` PremiumRequests float64 `json:"premium_requests"` + WorkflowRunID uint64 `json:"workflow_run_id,omitempty"` Error *struct { Code string `json:"code"` Message string `json:"message"` @@ -87,6 +88,7 @@ type Session struct { EventURL string EventType string PremiumRequests float64 + WorkflowRunID uint64 Error *SessionError PullRequest *api.PullRequest @@ -467,6 +469,7 @@ func fromAPISession(s session) *Session { EventURL: s.EventURL, EventType: s.EventType, PremiumRequests: s.PremiumRequests, + WorkflowRunID: s.WorkflowRunID, } if s.Error != nil { result.Error = &SessionError{ diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 8040cb413..daccbeaed 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -987,6 +987,112 @@ func TestListLatestSessionsForViewer(t *testing.T) { }, }, }, + { + name: "workflow run id is included", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/sessions", url.Values{ + "page_number": {"1"}, + "page_size": {"50"}, + "sort": {"last_updated_at,desc"}, + }), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "sessions": [ + { + "id": "sessA", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "failed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 3000, + "created_at": "%[1]s", + "workflow_run_id": 9999 + } + ] + }`, + sampleDateString, + )), + ) + + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node3000", + "fullDatabaseId": "3000", + "number": 100, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/100", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": {"nameWithOwner": "OWNER/REPO"} + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + // Expected encoded node IDs for resource IDs 3000 and user octocat + assert.Equal(t, []interface{}{"PR_kwDNA-jNC7g", "U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + ID: "sessA", + Name: "Build artifacts", + UserID: 1, + AgentID: 2, + Logs: "", + State: "failed", + OwnerID: 10, + RepoID: 1000, + ResourceType: "pull", + ResourceID: 3000, + CreatedAt: sampleDate, + WorkflowRunID: 9999, + PullRequest: &api.PullRequest{ + ID: "PR_node3000", + FullDatabaseID: "3000", + Number: 100, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/100", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{Login: "octocat", Name: "Octocat", DatabaseID: 1}, + }, + }, + }, { name: "API error", limit: 10, From beba8f6387898cc7d9dab15683a830b2d3508ad8 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 16:12:11 +0100 Subject: [PATCH 192/208] fix(agent-task view): display session error Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 20 ++++++ pkg/cmd/agent-task/view/view_test.go | 95 ++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 2ff09386a..279b980d2 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -317,6 +317,26 @@ func printSession(opts *ViewOptions, session *capi.Session) { fmt.Fprintf(opts.IO.Out, "%s%s\n", cs.Muted(usedPremiumRequestsNote), cs.Muted(durationNote)) + if session.Error != nil { + var workflowRunURL string + if session.WorkflowRunID != 0 && session.PullRequest != nil { + if u, err := url.Parse(session.PullRequest.URL); err == nil { + workflowRunURL = fmt.Sprintf("%s://%s/%s/actions/runs/%d", u.Scheme, u.Host, session.PullRequest.Repository.NameWithOwner, session.WorkflowRunID) + } + } + + message := session.Error.Message + if message == "" { + message = "An error occurred" + } + fmt.Fprintln(opts.IO.Out, "") + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.FailureIconWithColor(cs.Red), message) + + if workflowRunURL != "" { + fmt.Fprintf(opts.IO.Out, "See the detailed logs in GitHub Actions:\n%s\n", workflowRunURL) + } + } + 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) diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index b8f872560..689fd5f35 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -428,6 +428,101 @@ func Test_viewRun(t *testing.T) { https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id `), }, + { + name: "with session id, success, session has error (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + State: "failed", + CreatedAt: sampleDate, + PremiumRequests: 1.5, + Error: &capi.SessionError{ + Message: "blah blah", + }, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + Failed • fix something • OWNER/REPO#101 + Started on behalf of octocat about 6 hours ago + Used 1.5 premium request(s) + + X blah blah + + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + name: "with session id, success, session has error with workflow id (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "some-session-id", + SessionID: "some-session-id", + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + State: "failed", + CreatedAt: sampleDate, + PremiumRequests: 1.5, + WorkflowRunID: 9999, + Error: &capi.SessionError{ + Message: "blah blah", + }, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + Failed • fix something • OWNER/REPO#101 + Started on behalf of octocat about 6 hours ago + Used 1.5 premium request(s) + + X blah blah + See the detailed logs in GitHub Actions: + https://github.com/OWNER/REPO/actions/runs/9999 + + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, { name: "with session id, not found, web mode (tty)", tty: true, From 123c9eaba833934311c55da323d42ee7bad23c7b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 18 Sep 2025 16:14:31 +0100 Subject: [PATCH 193/208] fix(agent-task view): improve session error Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 4 +++- pkg/cmd/agent-task/view/view_test.go | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 279b980d2..b002c2a3d 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -333,7 +333,9 @@ func printSession(opts *ViewOptions, session *capi.Session) { fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.FailureIconWithColor(cs.Red), message) if workflowRunURL != "" { - fmt.Fprintf(opts.IO.Out, "See the detailed logs in GitHub Actions:\n%s\n", workflowRunURL) + // We don't need to prefix the link with any text (e.g. "checkout the logs here") + // because the error message already contains all the information. + fmt.Fprintf(opts.IO.Out, "%s\n", workflowRunURL) } } diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 689fd5f35..69802effe 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -513,7 +513,6 @@ func Test_viewRun(t *testing.T) { Used 1.5 premium request(s) X blah blah - See the detailed logs in GitHub Actions: https://github.com/OWNER/REPO/actions/runs/9999 For detailed session logs, try: From 185e36c60f6c512a9880cc3f4a1a44b84efacd47 Mon Sep 17 00:00:00 2001 From: Ariel Deitcher Date: Thu, 18 Sep 2025 15:40:06 -0700 Subject: [PATCH 194/208] update agents resource route --- pkg/cmd/agent-task/capi/sessions.go | 129 +++++---- pkg/cmd/agent-task/capi/sessions_test.go | 354 ++++++++++------------- pkg/cmd/agent-task/view/view.go | 4 - 3 files changed, 222 insertions(+), 265 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index a3989a862..6f64d5a1e 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -27,24 +27,25 @@ var ErrSessionNotFound = errors.New("not found") // session is an in-flight agent task type session struct { - ID string `json:"id"` - Name string `json:"name"` - UserID int64 `json:"user_id"` - AgentID int64 `json:"agent_id"` - Logs string `json:"logs"` - State string `json:"state"` - OwnerID uint64 `json:"owner_id"` - RepoID uint64 `json:"repo_id"` - ResourceType string `json:"resource_type"` - ResourceID int64 `json:"resource_id"` - LastUpdatedAt time.Time `json:"last_updated_at,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - CompletedAt time.Time `json:"completed_at,omitempty"` - EventURL string `json:"event_url"` - EventType string `json:"event_type"` - PremiumRequests float64 `json:"premium_requests"` - WorkflowRunID uint64 `json:"workflow_run_id,omitempty"` - Error *struct { + ID string `json:"id"` + Name string `json:"name"` + UserID int64 `json:"user_id"` + AgentID int64 `json:"agent_id"` + Logs string `json:"logs"` + State string `json:"state"` + OwnerID uint64 `json:"owner_id"` + RepoID uint64 `json:"repo_id"` + ResourceType string `json:"resource_type"` + ResourceID int64 `json:"resource_id"` + ResourceGlobalID string `json:"resource_global_id"` + LastUpdatedAt time.Time `json:"last_updated_at,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + CompletedAt time.Time `json:"completed_at,omitempty"` + EventURL string `json:"event_url"` + EventType string `json:"event_type"` + PremiumRequests float64 `json:"premium_requests"` + WorkflowRunID uint64 `json:"workflow_run_id,omitempty"` + Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` @@ -100,6 +101,26 @@ type SessionError struct { Message string } +type Resource struct { + ID string `json:"id"` + UserID uint64 `json:"user_id"` + ResourceType string `json:"resource_type"` + ResourceID int64 `json:"resource_id"` + ResourceGlobalID string `json:"resource_global_id"` + SessionCount int `json:"session_count"` + SessionLastUpdatedAt int64 `json:"last_updated_at"` + SessionState string `json:"state,omitempty"` + ResourceState string `json:"resource_state"` + Sessions []resourceSession `json:"sessions"` +} + +type resourceSession struct { + SessionID string `json:"id"` + Name string `json:"name"` + SessionState string `json:"state,omitempty"` + SessionLastUpdatedAt int64 `json:"last_updated_at"` +} + // ListLatestSessionsForViewer lists all agent sessions for the // authenticated user up to limit. func (c *CAPIClient) ListLatestSessionsForViewer(ctx context.Context, limit int) ([]*Session, error) { @@ -258,46 +279,39 @@ func (c *CAPIClient) ListSessionsByResourceID(ctx context.Context, resourceType return nil, nil } - url := fmt.Sprintf("%s/agents/sessions/resource/%s/%d", baseCAPIURL, url.PathEscape(resourceType), resourceID) - pageSize := defaultSessionsPerPage + url := fmt.Sprintf("%s/agents/resource/%s/%d", baseCAPIURL, url.PathEscape(resourceType), resourceID) - sessions := make([]session, 0, limit+pageSize) - - for page := 1; ; page++ { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) - if err != nil { - return nil, err - } - - q := req.URL.Query() - q.Set("page_size", strconv.Itoa(pageSize)) - q.Set("page_number", strconv.Itoa(page)) - req.URL.RawQuery = q.Encode() - - 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 list sessions: %s", res.Status) - } - var response struct { - Sessions []session `json:"sessions"` - } - if err := json.NewDecoder(res.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("failed to decode sessions response: %w", err) - } - - sessions = append(sessions, response.Sessions...) - if len(response.Sessions) < pageSize || len(sessions) >= limit { - break - } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, err } - // Drop any above the limit - if len(sessions) > limit { - sessions = sessions[:limit] + 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 list sessions: %s", res.Status) + } + + var response Resource + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode sessions response: %w", err) + } + + sessions := make([]session, 0, len(response.Sessions)) + for _, s := range response.Sessions { + sessions = append(sessions, session{ + ID: s.SessionID, + Name: s.Name, + UserID: int64(response.UserID), + ResourceType: response.ResourceType, + ResourceID: response.ResourceID, + ResourceGlobalID: response.ResourceGlobalID, + LastUpdatedAt: time.Unix(s.SessionLastUpdatedAt, 0), + State: s.SessionState, + }) } result, err := c.hydrateSessionPullRequestsAndUsers(sessions) @@ -317,7 +331,10 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* userNodeIds := make([]string, 0, len(sessions)) for _, session := range sessions { if session.ResourceType == "pull" { - prNodeID := generatePullRequestNodeID(int64(session.RepoID), session.ResourceID) + prNodeID := session.ResourceGlobalID + if session.ResourceGlobalID == "" { + prNodeID = generatePullRequestNodeID(int64(session.RepoID), session.ResourceID) + } if !slices.Contains(prNodeIds, prNodeID) { prNodeIds = append(prNodeIds, prNodeID) } diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index daccbeaed..8c13610a2 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -1200,7 +1200,7 @@ func TestListSessionsByResourceIDRequiresResource(t *testing.T) { } func TestListSessionsByResourceID(t *testing.T) { - sampleDateString := "2025-08-29T00:00:00Z" + sampleDateString := "2025-08-29T07:00:00Z" sampleDate, err := time.Parse(time.RFC3339, sampleDateString) require.NoError(t, err) @@ -1226,16 +1226,14 @@ func TestListSessionsByResourceID(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ - "page_number": {"1"}, - "page_size": {"50"}, - }), + httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), "api.githubcopilot.com", ), - httpmock.StringResponse(`{"sessions":[]}`), + + httpmock.StatusStringResponse(404, "{}"), ) }, - wantOut: nil, + wantErr: "failed to list sessions", }, { name: "single session", @@ -1243,14 +1241,123 @@ func TestListSessionsByResourceID(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ - "page_number": {"1"}, - "page_size": {"50"}, - }), + httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), "api.githubcopilot.com", ), httpmock.StringResponse(heredoc.Docf(` { + "id": "resource:pull:2000", + "user_id": 1, + "resource_global_id": "PR_kwDNA-jNB9A", + "resource_type": "pull", + "resource_id": 2000, + "session_count": 1, + "last_updated_at": 1756450800, + "state": "completed", + "resource_state": "draft", + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "state": "completed", + "last_updated_at": 1756450800 + } + ] + }`, + sampleDateString, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + }, + { + "__typename": "User", + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + ] + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) + }), + ) + }, + wantOut: []*Session{ + { + ID: "sess1", + LastUpdatedAt: sampleDate, + Name: "Build artifacts", + UserID: 1, + State: "completed", + ResourceType: "pull", + ResourceID: 2000, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2000", + Number: 42, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/42", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, + }, + }, + }, + { + name: "multiple sessions", + perPage: 1, // to enforce pagination + limit: 2, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost( + httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), + "api.githubcopilot.com", + ), + httpmock.StringResponse(heredoc.Docf(` + { + "id": "resource:pull:2000", + "user_id": 1, + "resource_global_id": "PR_kwDNA-jNB9A", + "resource_type": "pull", + "resource_id": 2000, + "session_count": 1, + "last_updated_at": 1756450800, + "state": "completed", + "resource_state": "draft", "sessions": [ { "id": "sess1", @@ -1265,6 +1372,20 @@ func TestListSessionsByResourceID(t *testing.T) { "resource_id": 2000, "created_at": "%[1]s", "premium_requests": 0.1 + }, + { + "id": "sess2", + "name": "Build artifacts", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2001, + "created_at": "%[1]s", + "premium_requests": 0.1 } ] }`, @@ -1311,177 +1432,12 @@ func TestListSessionsByResourceID(t *testing.T) { }, wantOut: []*Session{ { - - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, - PremiumRequests: 0.1, - PullRequest: &api.PullRequest{ - ID: "PR_node", - FullDatabaseID: "2000", - Number: 42, - Title: "Improve docs", - State: "OPEN", - IsDraft: true, - URL: "https://github.com/OWNER/REPO/pull/42", - Body: "", - CreatedAt: sampleDate, - UpdatedAt: sampleDate, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", - Name: "Octocat", - DatabaseID: 1, - }, - }, - }, - }, - { - name: "multiple sessions, paginated", - perPage: 1, // to enforce pagination - limit: 2, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ - "page_number": {"1"}, - "page_size": {"1"}, - }), - "api.githubcopilot.com", - ), - httpmock.StringResponse(heredoc.Docf(` - { - "sessions": [ - { - "id": "sess1", - "name": "Build artifacts", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2000, - "created_at": "%[1]s", - "premium_requests": 0.1 - } - ] - }`, - sampleDateString, - )), - ) - - // Second page - reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ - "page_number": {"2"}, - "page_size": {"1"}, - }), - "api.githubcopilot.com", - ), - httpmock.StringResponse(heredoc.Docf(` - { - "sessions": [ - { - "id": "sess2", - "name": "Build artifacts", - "user_id": 1, - "agent_id": 2, - "logs": "", - "state": "completed", - "owner_id": 10, - "repo_id": 1000, - "resource_type": "pull", - "resource_id": 2001, - "created_at": "%[1]s", - "premium_requests": 0.1 - } - ] - }`, - sampleDateString, - )), - ) - // GraphQL hydration - reg.Register( - httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), - httpmock.GraphQLQuery(heredoc.Docf(` - { - "data": { - "nodes": [ - { - "__typename": "PullRequest", - "id": "PR_node", - "fullDatabaseId": "2000", - "number": 42, - "title": "Improve docs", - "state": "OPEN", - "isDraft": true, - "url": "https://github.com/OWNER/REPO/pull/42", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "__typename": "PullRequest", - "id": "PR_node", - "fullDatabaseId": "2001", - "number": 43, - "title": "Improve docs", - "state": "OPEN", - "isDraft": true, - "url": "https://github.com/OWNER/REPO/pull/43", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - }, - { - "__typename": "User", - "login": "octocat", - "name": "Octocat", - "databaseId": 1 - } - ] - } - }`, - sampleDateString, - ), func(q string, vars map[string]interface{}) { - assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "PR_kwDNA-jNB9E", "U_kgAB"}, vars["ids"]) - }), - ) - }, - wantOut: []*Session{ - { - ID: "sess1", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2000, - CreatedAt: sampleDate, - PremiumRequests: 0.1, + ID: "sess1", + Name: "Build artifacts", + UserID: 1, + State: "completed", + ResourceType: "pull", + ResourceID: 2000, PullRequest: &api.PullRequest{ ID: "PR_node", FullDatabaseID: "2000", @@ -1504,26 +1460,20 @@ func TestListSessionsByResourceID(t *testing.T) { }, }, { - ID: "sess2", - Name: "Build artifacts", - UserID: 1, - AgentID: 2, - Logs: "", - State: "completed", - OwnerID: 10, - RepoID: 1000, - ResourceType: "pull", - ResourceID: 2001, - CreatedAt: sampleDate, - PremiumRequests: 0.1, + ID: "sess2", + Name: "Build artifacts", + UserID: 1, + State: "completed", + ResourceType: "pull", + ResourceID: 2000, PullRequest: &api.PullRequest{ ID: "PR_node", - FullDatabaseID: "2001", - Number: 43, + FullDatabaseID: "2000", + Number: 42, Title: "Improve docs", State: "OPEN", IsDraft: true, - URL: "https://github.com/OWNER/REPO/pull/43", + URL: "https://github.com/OWNER/REPO/pull/42", Body: "", CreatedAt: sampleDate, UpdatedAt: sampleDate, @@ -1545,10 +1495,7 @@ func TestListSessionsByResourceID(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ - "page_number": {"1"}, - "page_size": {"50"}, - }), + httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), "api.githubcopilot.com", ), httpmock.StatusStringResponse(500, "{}"), @@ -1561,10 +1508,7 @@ func TestListSessionsByResourceID(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/sessions/resource/pull/999", url.Values{ - "page_number": {"1"}, - "page_size": {"50"}, - }), + httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), "api.githubcopilot.com", ), httpmock.StringResponse(heredoc.Docf(` diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index b002c2a3d..60fa892bf 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -224,10 +224,6 @@ func viewRun(opts *ViewOptions) error { 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) From 546ab1cf4b3fd19c933ab3a3a94edd81b801bda0 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 14:27:08 +0100 Subject: [PATCH 195/208] refactor(agent-task/capi): keep `resource` private Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 6f64d5a1e..b789d1f8b 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -101,7 +101,7 @@ type SessionError struct { Message string } -type Resource struct { +type resource struct { ID string `json:"id"` UserID uint64 `json:"user_id"` ResourceType string `json:"resource_type"` @@ -295,7 +295,7 @@ func (c *CAPIClient) ListSessionsByResourceID(ctx context.Context, resourceType return nil, fmt.Errorf("failed to list sessions: %s", res.Status) } - var response Resource + var response resource if err := json.NewDecoder(res.Body).Decode(&response); err != nil { return nil, fmt.Errorf("failed to decode sessions response: %w", err) } From 9148f41cc6d0022e02d345caa5610e5d16509c13 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 14:28:46 +0100 Subject: [PATCH 196/208] fix(agent-task/capi): handle unpopulated resource session `LastUpdatedAt` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index b789d1f8b..96f887cdf 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -302,16 +302,19 @@ func (c *CAPIClient) ListSessionsByResourceID(ctx context.Context, resourceType sessions := make([]session, 0, len(response.Sessions)) for _, s := range response.Sessions { - sessions = append(sessions, session{ + session := session{ ID: s.SessionID, Name: s.Name, UserID: int64(response.UserID), ResourceType: response.ResourceType, ResourceID: response.ResourceID, ResourceGlobalID: response.ResourceGlobalID, - LastUpdatedAt: time.Unix(s.SessionLastUpdatedAt, 0), State: s.SessionState, - }) + } + if s.SessionLastUpdatedAt != 0 { + session.LastUpdatedAt = time.Unix(s.SessionLastUpdatedAt, 0).UTC() + } + sessions = append(sessions, session) } result, err := c.hydrateSessionPullRequestsAndUsers(sessions) From a108b4ee95038ae3e82fb9ea33ac9a702c7738f7 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 14:29:44 +0100 Subject: [PATCH 197/208] fix(agent-task/capi): fetch viewer when resource session user id is zero Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 96f887cdf..e8fac4784 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -330,6 +330,10 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* return nil, nil } + // When the session is fetched via the resources endpoint, the session user + // ID can be zero, which means it's the viewer user. + var includeViewer bool + prNodeIds := make([]string, 0, len(sessions)) userNodeIds := make([]string, 0, len(sessions)) for _, session := range sessions { @@ -343,9 +347,13 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* } } - userNodeId := generateUserNodeID(session.UserID) - if !slices.Contains(userNodeIds, userNodeId) { - userNodeIds = append(userNodeIds, userNodeId) + if session.UserID != 0 { + userNodeId := generateUserNodeID(session.UserID) + if !slices.Contains(userNodeIds, userNodeId) { + userNodeIds = append(userNodeIds, userNodeId) + } + } else { + includeViewer = true } } apiClient := api.NewClientFromHTTP(c.httpClient) @@ -356,6 +364,7 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* PullRequest sessionPullRequest `graphql:"... on PullRequest"` User api.GitHubUser `graphql:"... on User"` } `graphql:"nodes(ids: $ids)"` + Viewer api.GitHubUser `graphql:"viewer @include(if: $includeViewer)"` } ids := make([]string, 0, len(prNodeIds)+len(userNodeIds)) @@ -365,7 +374,8 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* // TODO handle pagination host, _ := c.authCfg.DefaultHost() err := apiClient.Query(host, "FetchPRsAndUsersForAgentTaskSessions", &resp, map[string]any{ - "ids": ids, + "ids": ids, + "includeViewer": includeViewer, }) if err != nil { @@ -401,7 +411,12 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* for _, s := range sessions { newSession := fromAPISession(s) newSession.PullRequest = prMap[strconv.FormatInt(s.ResourceID, 10)] - newSession.User = userMap[s.UserID] + if s.UserID != 0 { + newSession.User = userMap[s.UserID] + } else { + newSession.UserID = resp.Viewer.DatabaseID + newSession.User = &resp.Viewer + } newSessions = append(newSessions, newSession) } From 2fe60d9105c95dd8d96c556dc613517285dd2008 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 14:30:29 +0100 Subject: [PATCH 198/208] docs(agent-task/capi): add TODO note for dropping local ID generation Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index e8fac4784..8835dcdaa 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -339,6 +339,8 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* for _, session := range sessions { if session.ResourceType == "pull" { prNodeID := session.ResourceGlobalID + // TODO: probably this can be dropped since the API should always + // keep returning the resource global ID. if session.ResourceGlobalID == "" { prNodeID = generatePullRequestNodeID(int64(session.RepoID), session.ResourceID) } From eba31343a858e2f3c7fb898b55d745a91f076f58 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 14:31:03 +0100 Subject: [PATCH 199/208] test(agent-tassk/capi): update `ListSessionsByResourceID` tests Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions_test.go | 149 ++++++++++++++++++----- 1 file changed, 121 insertions(+), 28 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 8c13610a2..272b32cbe 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -1203,6 +1203,7 @@ func TestListSessionsByResourceID(t *testing.T) { sampleDateString := "2025-08-29T07:00:00Z" sampleDate, err := time.Parse(time.RFC3339, sampleDateString) require.NoError(t, err) + sampleDateTimestamp := sampleDate.Unix() resourceID := int64(999) resourceType := "pull" @@ -1221,14 +1222,14 @@ func TestListSessionsByResourceID(t *testing.T) { wantOut: nil, }, { - name: "no sessions", + // If the given pull request does not exist or the pull request has no sessions, + // the API endpoint returns 404 with different messages. We should treat them + // the same though. + name: "no sessions or no pull request", limit: 10, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), - "api.githubcopilot.com", - ), + httpmock.WithHost(httpmock.REST("GET", "agents/resource/pull/999"), "api.githubcopilot.com"), httpmock.StatusStringResponse(404, "{}"), ) @@ -1240,10 +1241,7 @@ func TestListSessionsByResourceID(t *testing.T) { limit: 10, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), - "api.githubcopilot.com", - ), + httpmock.WithHost(httpmock.REST("GET", "agents/resource/pull/999"), "api.githubcopilot.com"), httpmock.StringResponse(heredoc.Docf(` { "id": "resource:pull:2000", @@ -1252,7 +1250,7 @@ func TestListSessionsByResourceID(t *testing.T) { "resource_type": "pull", "resource_id": 2000, "session_count": 1, - "last_updated_at": 1756450800, + "last_updated_at": %[1]d, "state": "completed", "resource_state": "draft", "sessions": [ @@ -1260,11 +1258,11 @@ func TestListSessionsByResourceID(t *testing.T) { "id": "sess1", "name": "Build artifacts", "state": "completed", - "last_updated_at": 1756450800 + "last_updated_at": %[1]d } ] }`, - sampleDateString, + sampleDateTimestamp, )), ) // GraphQL hydration @@ -1302,12 +1300,115 @@ func TestListSessionsByResourceID(t *testing.T) { sampleDateString, ), func(q string, vars map[string]interface{}) { assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) + assert.Equal(t, false, vars["includeViewer"]) }), ) }, wantOut: []*Session{ { ID: "sess1", + CreatedAt: time.Time{}, + LastUpdatedAt: sampleDate, + Name: "Build artifacts", + UserID: 1, + State: "completed", + ResourceType: "pull", + ResourceID: 2000, + PullRequest: &api.PullRequest{ + ID: "PR_node", + FullDatabaseID: "2000", + Number: 42, + Title: "Improve docs", + State: "OPEN", + IsDraft: true, + URL: "https://github.com/OWNER/REPO/pull/42", + Body: "", + CreatedAt: sampleDate, + UpdatedAt: sampleDate, + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", + }, + }, + User: &api.GitHubUser{ + Login: "octocat", + Name: "Octocat", + DatabaseID: 1, + }, + }, + }, + }, + { + name: "single session with zero user ID", + limit: 10, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("GET", "agents/resource/pull/999"), "api.githubcopilot.com"), + httpmock.StringResponse(heredoc.Docf(` + { + "id": "resource:pull:2000", + "user_id": 0, + "resource_global_id": "PR_kwDNA-jNB9A", + "resource_type": "pull", + "resource_id": 2000, + "session_count": 1, + "last_updated_at": %[1]d, + "state": "completed", + "resource_state": "draft", + "sessions": [ + { + "id": "sess1", + "name": "Build artifacts", + "state": "completed", + "last_updated_at": %[1]d + } + ] + }`, + sampleDateTimestamp, + )), + ) + // GraphQL hydration + reg.Register( + httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), + httpmock.GraphQLQuery(heredoc.Docf(` + { + "data": { + "nodes": [ + { + "__typename": "PullRequest", + "id": "PR_node", + "fullDatabaseId": "2000", + "number": 42, + "title": "Improve docs", + "state": "OPEN", + "isDraft": true, + "url": "https://github.com/OWNER/REPO/pull/42", + "body": "", + "createdAt": "%[1]s", + "updatedAt": "%[1]s", + "repository": { + "nameWithOwner": "OWNER/REPO" + } + } + ], + "viewer": { + "login": "octocat", + "name": "Octocat", + "databaseId": 1 + } + } + }`, + sampleDateString, + ), func(q string, vars map[string]interface{}) { + // Should not fetch user by ID since it's zero + assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A"}, vars["ids"]) + assert.Equal(t, true, vars["includeViewer"]) + }), + ) + }, + wantOut: []*Session{ + { + ID: "sess1", + CreatedAt: time.Time{}, LastUpdatedAt: sampleDate, Name: "Build artifacts", UserID: 1, @@ -1343,10 +1444,7 @@ func TestListSessionsByResourceID(t *testing.T) { limit: 2, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), - "api.githubcopilot.com", - ), + httpmock.WithHost(httpmock.REST("GET", "agents/resource/pull/999"), "api.githubcopilot.com"), httpmock.StringResponse(heredoc.Docf(` { "id": "resource:pull:2000", @@ -1355,7 +1453,7 @@ func TestListSessionsByResourceID(t *testing.T) { "resource_type": "pull", "resource_id": 2000, "session_count": 1, - "last_updated_at": 1756450800, + "last_updated_at": %[1]d, "state": "completed", "resource_state": "draft", "sessions": [ @@ -1370,7 +1468,7 @@ func TestListSessionsByResourceID(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2000, - "created_at": "%[1]s", + "created_at": %[1]d, "premium_requests": 0.1 }, { @@ -1384,12 +1482,12 @@ func TestListSessionsByResourceID(t *testing.T) { "repo_id": 1000, "resource_type": "pull", "resource_id": 2001, - "created_at": "%[1]s", + "created_at": %[1]d, "premium_requests": 0.1 } ] }`, - sampleDateString, + sampleDateTimestamp, )), ) // GraphQL hydration @@ -1427,6 +1525,7 @@ func TestListSessionsByResourceID(t *testing.T) { sampleDateString, ), func(q string, vars map[string]interface{}) { assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) + assert.Equal(t, false, vars["includeViewer"]) }), ) }, @@ -1494,10 +1593,7 @@ func TestListSessionsByResourceID(t *testing.T) { limit: 10, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), - "api.githubcopilot.com", - ), + httpmock.WithHost(httpmock.REST("GET", "agents/resource/pull/999"), "api.githubcopilot.com"), httpmock.StatusStringResponse(500, "{}"), ) }, @@ -1507,10 +1603,7 @@ func TestListSessionsByResourceID(t *testing.T) { limit: 10, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( - httpmock.WithHost( - httpmock.QueryMatcher("GET", "agents/resource/pull/999", url.Values{}), - "api.githubcopilot.com", - ), + httpmock.WithHost(httpmock.REST("GET", "agents/resource/pull/999"), "api.githubcopilot.com"), httpmock.StringResponse(heredoc.Docf(` { "sessions": [ From 597cdaf08161ec1e99c076ea2d065821475a3cf3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 15:37:46 +0100 Subject: [PATCH 200/208] fix(agent-task/capi): remove fallback to viewer when user id is zero Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/sessions.go | 25 ++---- pkg/cmd/agent-task/capi/sessions_test.go | 103 ----------------------- 2 files changed, 5 insertions(+), 123 deletions(-) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 8835dcdaa..4d109ea26 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -330,10 +330,6 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* return nil, nil } - // When the session is fetched via the resources endpoint, the session user - // ID can be zero, which means it's the viewer user. - var includeViewer bool - prNodeIds := make([]string, 0, len(sessions)) userNodeIds := make([]string, 0, len(sessions)) for _, session := range sessions { @@ -349,13 +345,9 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* } } - if session.UserID != 0 { - userNodeId := generateUserNodeID(session.UserID) - if !slices.Contains(userNodeIds, userNodeId) { - userNodeIds = append(userNodeIds, userNodeId) - } - } else { - includeViewer = true + userNodeId := generateUserNodeID(session.UserID) + if !slices.Contains(userNodeIds, userNodeId) { + userNodeIds = append(userNodeIds, userNodeId) } } apiClient := api.NewClientFromHTTP(c.httpClient) @@ -366,7 +358,6 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* PullRequest sessionPullRequest `graphql:"... on PullRequest"` User api.GitHubUser `graphql:"... on User"` } `graphql:"nodes(ids: $ids)"` - Viewer api.GitHubUser `graphql:"viewer @include(if: $includeViewer)"` } ids := make([]string, 0, len(prNodeIds)+len(userNodeIds)) @@ -376,8 +367,7 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* // TODO handle pagination host, _ := c.authCfg.DefaultHost() err := apiClient.Query(host, "FetchPRsAndUsersForAgentTaskSessions", &resp, map[string]any{ - "ids": ids, - "includeViewer": includeViewer, + "ids": ids, }) if err != nil { @@ -413,12 +403,7 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* for _, s := range sessions { newSession := fromAPISession(s) newSession.PullRequest = prMap[strconv.FormatInt(s.ResourceID, 10)] - if s.UserID != 0 { - newSession.User = userMap[s.UserID] - } else { - newSession.UserID = resp.Viewer.DatabaseID - newSession.User = &resp.Viewer - } + newSession.User = userMap[s.UserID] newSessions = append(newSessions, newSession) } diff --git a/pkg/cmd/agent-task/capi/sessions_test.go b/pkg/cmd/agent-task/capi/sessions_test.go index 272b32cbe..848fd716a 100644 --- a/pkg/cmd/agent-task/capi/sessions_test.go +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -1300,108 +1300,6 @@ func TestListSessionsByResourceID(t *testing.T) { sampleDateString, ), func(q string, vars map[string]interface{}) { assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) - assert.Equal(t, false, vars["includeViewer"]) - }), - ) - }, - wantOut: []*Session{ - { - ID: "sess1", - CreatedAt: time.Time{}, - LastUpdatedAt: sampleDate, - Name: "Build artifacts", - UserID: 1, - State: "completed", - ResourceType: "pull", - ResourceID: 2000, - PullRequest: &api.PullRequest{ - ID: "PR_node", - FullDatabaseID: "2000", - Number: 42, - Title: "Improve docs", - State: "OPEN", - IsDraft: true, - URL: "https://github.com/OWNER/REPO/pull/42", - Body: "", - CreatedAt: sampleDate, - UpdatedAt: sampleDate, - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", - Name: "Octocat", - DatabaseID: 1, - }, - }, - }, - }, - { - name: "single session with zero user ID", - limit: 10, - httpStubs: func(t *testing.T, reg *httpmock.Registry) { - reg.Register( - httpmock.WithHost(httpmock.REST("GET", "agents/resource/pull/999"), "api.githubcopilot.com"), - httpmock.StringResponse(heredoc.Docf(` - { - "id": "resource:pull:2000", - "user_id": 0, - "resource_global_id": "PR_kwDNA-jNB9A", - "resource_type": "pull", - "resource_id": 2000, - "session_count": 1, - "last_updated_at": %[1]d, - "state": "completed", - "resource_state": "draft", - "sessions": [ - { - "id": "sess1", - "name": "Build artifacts", - "state": "completed", - "last_updated_at": %[1]d - } - ] - }`, - sampleDateTimestamp, - )), - ) - // GraphQL hydration - reg.Register( - httpmock.GraphQL(`query FetchPRsAndUsersForAgentTaskSessions\b`), - httpmock.GraphQLQuery(heredoc.Docf(` - { - "data": { - "nodes": [ - { - "__typename": "PullRequest", - "id": "PR_node", - "fullDatabaseId": "2000", - "number": 42, - "title": "Improve docs", - "state": "OPEN", - "isDraft": true, - "url": "https://github.com/OWNER/REPO/pull/42", - "body": "", - "createdAt": "%[1]s", - "updatedAt": "%[1]s", - "repository": { - "nameWithOwner": "OWNER/REPO" - } - } - ], - "viewer": { - "login": "octocat", - "name": "Octocat", - "databaseId": 1 - } - } - }`, - sampleDateString, - ), func(q string, vars map[string]interface{}) { - // Should not fetch user by ID since it's zero - assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A"}, vars["ids"]) - assert.Equal(t, true, vars["includeViewer"]) }), ) }, @@ -1525,7 +1423,6 @@ func TestListSessionsByResourceID(t *testing.T) { sampleDateString, ), func(q string, vars map[string]interface{}) { assert.Equal(t, []interface{}{"PR_kwDNA-jNB9A", "U_kgAB"}, vars["ids"]) - assert.Equal(t, false, vars["includeViewer"]) }), ) }, From 3091c21320f6c238327203c77b087d82f56977fb Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 16:45:42 +0100 Subject: [PATCH 201/208] fix(agent-task view): re-fetch session to get all pieces of data Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 20 ++- pkg/cmd/agent-task/view/view_test.go | 185 ++++++++++++++------------- 2 files changed, 114 insertions(+), 91 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 60fa892bf..2f113e662 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -251,16 +251,16 @@ func viewRun(opts *ViewOptions) error { return opts.Browser.Browse(webURL) } - session = sessions[0] + selectedSession := 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", + "%s %s • updated %s", shared.SessionSymbol(cs, session.State), session.Name, - text.FuzzyAgo(now, session.CreatedAt), + text.FuzzyAgo(now, session.LastUpdatedAt), )) } @@ -269,8 +269,20 @@ func viewRun(opts *ViewOptions) error { return err } - session = sessions[selected] + selectedSession = sessions[selected] } + + opts.IO.StartProgressIndicatorWithLabel("Fetching agent session...") + defer opts.IO.StopProgressIndicator() + + // Sessions returned by ListSessionsByResourceID do not have all fields populated. + // So, we need to fetch the individual session to get all the details. + session, err = capiClient.GetSession(ctx, selectedSession.ID) + if err != nil { + return err + } + + opts.IO.StopProgressIndicator() } if opts.Log { diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 69802effe..d6136e9e9 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -682,23 +682,34 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, - PremiumRequests: 1.5, - PullRequest: &api.PullRequest{ - Title: "fix something", - Number: 101, - URL: "https://github.com/OWNER/REPO/pull/101", - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", + ID: "some-session-id", + Name: "session one", + State: "completed", + LastUpdatedAt: sampleCompletedAt, + // Rest of the fields are not not meant to be used or relied upon + }, + }, nil + } + + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", }, }, + User: &api.GitHubUser{ + Login: "octocat", + }, }, nil } }, @@ -735,43 +746,43 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, - PremiumRequests: 1.5, - PullRequest: &api.PullRequest{ - Title: "fix something", - Number: 101, - URL: "https://github.com/OWNER/REPO/pull/101", - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", - }, + ID: "some-session-id", + Name: "session one", + State: "completed", + LastUpdatedAt: sampleCompletedAt, + // Rest of the fields are not not meant to be used or relied upon }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, - PremiumRequests: 1.5, - PullRequest: &api.PullRequest{ - Title: "fix something", - Number: 101, - URL: "https://github.com/OWNER/REPO/pull/101", - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", + ID: "some-other-session-id", + Name: "session two", + State: "completed", + LastUpdatedAt: sampleCompletedAt, + // Rest of the fields are not not meant to be used or relied upon + }, + }, nil + } + + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + LastUpdatedAt: sampleCompletedAt, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", }, }, + User: &api.GitHubUser{ + Login: "octocat", + }, }, nil } }, @@ -779,11 +790,11 @@ func Test_viewRun(t *testing.T) { pm.RegisterSelect( "Select a session", []string{ - "✓ session one • about 6 hours ago", - "✓ session two • about 6 hours ago", + "✓ session one • updated about 5 hours ago", + "✓ session two • updated about 5 hours ago", }, func(_, _ string, opts []string) (int, error) { - return prompter.IndexFor(opts, "✓ session one • about 6 hours ago") + return prompter.IndexFor(opts, "✓ session one • updated about 5 hours ago") }, ) }, @@ -822,43 +833,43 @@ func Test_viewRun(t *testing.T) { assert.Equal(t, defaultLimit, limit) return []*capi.Session{ { - ID: "some-session-id", - Name: "session one", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, - PremiumRequests: 1.5, - PullRequest: &api.PullRequest{ - Title: "fix something", - Number: 101, - URL: "https://github.com/OWNER/REPO/pull/101", - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", - }, + ID: "some-session-id", + Name: "session one", + State: "completed", + LastUpdatedAt: sampleCompletedAt, + // Rest of the fields are not not meant to be used or relied upon }, { - ID: "some-other-session-id", - Name: "session two", - State: "completed", - CreatedAt: sampleDate, - CompletedAt: sampleCompletedAt, - PremiumRequests: 1.5, - PullRequest: &api.PullRequest{ - Title: "fix something", - Number: 101, - URL: "https://github.com/OWNER/REPO/pull/101", - Repository: &api.PRRepository{ - NameWithOwner: "OWNER/REPO", - }, - }, - User: &api.GitHubUser{ - Login: "octocat", + ID: "some-other-session-id", + Name: "session two", + State: "completed", + LastUpdatedAt: sampleCompletedAt, + // Rest of the fields are not not meant to be used or relied upon + }, + }, nil + } + + m.GetSessionFunc = func(_ context.Context, id string) (*capi.Session, error) { + assert.Equal(t, "some-session-id", id) + return &capi.Session{ + ID: "some-session-id", + Name: "session one", + State: "completed", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + LastUpdatedAt: sampleCompletedAt, + PremiumRequests: 1.5, + PullRequest: &api.PullRequest{ + Title: "fix something", + Number: 101, + URL: "https://github.com/OWNER/REPO/pull/101", + Repository: &api.PRRepository{ + NameWithOwner: "OWNER/REPO", }, }, + User: &api.GitHubUser{ + Login: "octocat", + }, }, nil } }, @@ -866,11 +877,11 @@ func Test_viewRun(t *testing.T) { pm.RegisterSelect( "Select a session", []string{ - "✓ session one • about 6 hours ago", - "✓ session two • about 6 hours ago", + "✓ session one • updated about 5 hours ago", + "✓ session two • updated about 5 hours ago", }, func(_, _ string, opts []string) (int, error) { - return prompter.IndexFor(opts, "✓ session one • about 6 hours ago") + return prompter.IndexFor(opts, "✓ session one • updated about 5 hours ago") }, ) }, From 3e43a98814674a063fed08b10590a1ee14ad2338 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 16:47:35 +0100 Subject: [PATCH 202/208] refactor(agent-task view): merge empty println calls with next Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 2f113e662..91ef5c4d8 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -337,8 +337,7 @@ func printSession(opts *ViewOptions, session *capi.Session) { if message == "" { message = "An error occurred" } - fmt.Fprintln(opts.IO.Out, "") - fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.FailureIconWithColor(cs.Red), message) + fmt.Fprintf(opts.IO.Out, "\n%s %s\n", cs.FailureIconWithColor(cs.Red), message) if workflowRunURL != "" { // We don't need to prefix the link with any text (e.g. "checkout the logs here") @@ -348,16 +347,13 @@ func printSession(opts *ViewOptions, session *capi.Session) { } 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) + fmt.Fprintf(opts.IO.Out, "\nFor 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) + fmt.Fprintf(opts.IO.Out, "\nTo 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("\nView this session on GitHub:")) fmt.Fprintln(opts.IO.Out, cs.Muted(fmt.Sprintf("%s/agent-sessions/%s", session.PullRequest.URL, url.PathEscape(session.ID)))) } } From ecfbb67e9950a387c4e5878ce263512b3fc157da Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 17:37:07 +0100 Subject: [PATCH 203/208] fix(agent-task view): improve session overview Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view.go | 28 ++++++++-------- pkg/cmd/agent-task/view/view_test.go | 49 +++++++++++++++++++++------- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/pkg/cmd/agent-task/view/view.go b/pkg/cmd/agent-task/view/view.go index 91ef5c4d8..38c1e0e12 100644 --- a/pkg/cmd/agent-task/view/view.go +++ b/pkg/cmd/agent-task/view/view.go @@ -296,17 +296,10 @@ func viewRun(opts *ViewOptions) error { 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))) - } + fmt.Fprintf(opts.IO.Out, "%s • %s\n", + shared.ColorFuncForSessionState(*session, cs)(shared.SessionStateString(session.State)), + cs.Bold(session.Name), + ) 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)) @@ -325,6 +318,15 @@ func printSession(opts *ViewOptions, session *capi.Session) { fmt.Fprintf(opts.IO.Out, "%s%s\n", cs.Muted(usedPremiumRequestsNote), cs.Muted(durationNote)) + // Note that when the session is just created, a PR is not yet available for it. + if session.PullRequest != nil { + fmt.Fprintf(opts.IO.Out, "\n%s%s • %s\n", + session.PullRequest.Repository.NameWithOwner, + cs.ColorFromString(prShared.ColorForPRState(*session.PullRequest))(fmt.Sprintf("#%d", session.PullRequest.Number)), + cs.Bold(session.PullRequest.Title), + ) + } + if session.Error != nil { var workflowRunURL string if session.WorkflowRunID != 0 && session.PullRequest != nil { @@ -347,9 +349,9 @@ func printSession(opts *ViewOptions, session *capi.Session) { } if !opts.Log { - fmt.Fprintf(opts.IO.Out, "\nFor detailed session logs, try:\ngh agent-task view '%s' --log\n", session.ID) + fmt.Fprint(opts.IO.Out, cs.Mutedf("\nFor detailed session logs, try:\ngh agent-task view '%s' --log\n", session.ID)) } else if !opts.Follow { - fmt.Fprintf(opts.IO.Out, "\nTo follow session logs, try:\ngh agent-task view '%s' --log --follow\n", session.ID) + fmt.Fprint(opts.IO.Out, cs.Mutedf("\nTo follow session logs, try:\ngh agent-task view '%s' --log --follow\n", session.ID)) } if session.PullRequest != nil { diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index d6136e9e9..74ac63d4b 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -215,6 +215,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, @@ -233,10 +234,12 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Ready for review • fix something • OWNER/REPO#101 + Ready for review • session one Started on behalf of octocat about 6 hours ago Used 1.5 premium request(s) • Duration 5m0s + OWNER/REPO#101 • fix something + For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -258,6 +261,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, @@ -273,10 +277,12 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Ready for review • fix something • OWNER/REPO#101 + Ready for review • session one Started about 6 hours ago Used 1.5 premium request(s) • Duration 5m0s + OWNER/REPO#101 • fix something + For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -298,6 +304,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, @@ -308,7 +315,7 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Ready for review + Ready for review • session one Started on behalf of octocat about 6 hours ago Used 1.5 premium request(s) • Duration 5m0s @@ -330,6 +337,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, @@ -337,7 +345,7 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Ready for review + Ready for review • session one Started about 6 hours ago Used 1.5 premium request(s) • Duration 5m0s @@ -358,6 +366,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 0, @@ -376,10 +385,12 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Ready for review • fix something • OWNER/REPO#101 + Ready for review • session one Started on behalf of octocat about 6 hours ago Used 0 premium request(s) • Duration 5m0s + OWNER/REPO#101 • fix something + For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -400,6 +411,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "in_progress", + Name: "session one", CreatedAt: sampleDate, PremiumRequests: 1.5, PullRequest: &api.PullRequest{ @@ -417,10 +429,12 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - In progress • fix something • OWNER/REPO#101 + In progress • session one Started on behalf of octocat about 6 hours ago Used 1.5 premium request(s) + OWNER/REPO#101 • fix something + For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -441,6 +455,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "failed", + Name: "session one", CreatedAt: sampleDate, PremiumRequests: 1.5, Error: &capi.SessionError{ @@ -461,10 +476,12 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Failed • fix something • OWNER/REPO#101 + Failed • session one Started on behalf of octocat about 6 hours ago Used 1.5 premium request(s) + OWNER/REPO#101 • fix something + X blah blah For detailed session logs, try: @@ -487,6 +504,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "failed", + Name: "session one", CreatedAt: sampleDate, PremiumRequests: 1.5, WorkflowRunID: 9999, @@ -508,10 +526,12 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Failed • fix something • OWNER/REPO#101 + Failed • session one Started on behalf of octocat about 6 hours ago Used 1.5 premium request(s) + OWNER/REPO#101 • fix something + X blah blah https://github.com/OWNER/REPO/actions/runs/9999 @@ -696,6 +716,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, @@ -714,10 +735,12 @@ func Test_viewRun(t *testing.T) { } }, wantOut: heredoc.Doc(` - Ready for review • fix something • OWNER/REPO#101 + Ready for review • session one Started on behalf of octocat about 6 hours ago Used 1.5 premium request(s) • Duration 5m0s + OWNER/REPO#101 • fix something + For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -799,10 +822,12 @@ func Test_viewRun(t *testing.T) { ) }, wantOut: heredoc.Doc(` - Ready for review • fix something • OWNER/REPO#101 + Ready for review • session one Started on behalf of octocat about 6 hours ago Used 1.5 premium request(s) • Duration 5m0s + OWNER/REPO#101 • fix something + For detailed session logs, try: gh agent-task view 'some-session-id' --log @@ -886,10 +911,12 @@ func Test_viewRun(t *testing.T) { ) }, wantOut: heredoc.Doc(` - Ready for review • fix something • OWNER/REPO#101 + Ready for review • session one Started on behalf of octocat about 6 hours ago Used 1.5 premium request(s) • Duration 5m0s + OWNER/REPO#101 • fix something + For detailed session logs, try: gh agent-task view 'some-session-id' --log From eb3c842818163b3dd50dd4b0beb680cf9de911c4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 19 Sep 2025 17:39:06 +0100 Subject: [PATCH 204/208] test(agent-task view): populate `Session.Name` in all test cases Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/view/view_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/agent-task/view/view_test.go b/pkg/cmd/agent-task/view/view_test.go index 74ac63d4b..68cc377e3 100644 --- a/pkg/cmd/agent-task/view/view_test.go +++ b/pkg/cmd/agent-task/view/view_test.go @@ -572,6 +572,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, @@ -596,6 +597,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, @@ -970,6 +972,7 @@ func Test_viewRun(t *testing.T) { { ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, @@ -1127,6 +1130,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, @@ -1166,6 +1170,7 @@ func Test_viewRun(t *testing.T) { return &capi.Session{ ID: "some-session-id", State: "completed", + Name: "session one", CreatedAt: sampleDate, CompletedAt: sampleCompletedAt, PremiumRequests: 1.5, From 6fc5742a68d36643238dd03c459c8bd65b122feb Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 22 Sep 2025 12:59:51 +0100 Subject: [PATCH 205/208] fix(agent-task/capi): return proper error message when resp is not a JSON Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/capi/job.go | 5 +++++ pkg/cmd/agent-task/capi/job_test.go | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index c45d972de..5a56323cb 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -90,6 +90,11 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen var j Job if err := json.NewDecoder(res.Body).Decode(&j); err != nil { + if res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusOK { // accept 201 or 200 + // This happens when there's an error like unauthorized (401). + statusText := fmt.Sprintf("%d %s", res.StatusCode, http.StatusText(res.StatusCode)) + return nil, fmt.Errorf("failed to create job: %s", statusText) + } return nil, fmt.Errorf("failed to decode create job response: %w", err) } diff --git a/pkg/cmd/agent-task/capi/job_test.go b/pkg/cmd/agent-task/capi/job_test.go index 987b43b9f..573ce3039 100644 --- a/pkg/cmd/agent-task/capi/job_test.go +++ b/pkg/cmd/agent-task/capi/job_test.go @@ -330,7 +330,17 @@ func TestCreateJob(t *testing.T) { wantErr: "failed to create job: 500 Internal Server Error", }, { - name: "invalid JSON response", + name: "invalid JSON response, non-HTTP 200", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), + httpmock.StatusStringResponse(401, `Unauthorized`), + ) + }, + wantErr: "failed to create job: 401 Unauthorized", + }, + { + name: "invalid JSON response, HTTP 200", httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.WithHost(httpmock.REST("POST", "agents/swe/v1/jobs/OWNER/REPO"), "api.githubcopilot.com"), From 8818c0d47c298852940b980ed9456fa39d885017 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 23 Sep 2025 16:15:14 +0100 Subject: [PATCH 206/208] chore: update third-party licenses Signed-off-by: Babak K. Shandiz --- third-party-licenses.darwin.md | 4 ++- third-party-licenses.linux.md | 4 ++- third-party-licenses.windows.md | 4 ++- .../github.com/vmihailenco/msgpack/v5/LICENSE | 25 +++++++++++++++++++ .../vmihailenco/tagparser/v2/LICENSE | 25 +++++++++++++++++++ 5 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 third-party/github.com/vmihailenco/msgpack/v5/LICENSE create mode 100644 third-party/github.com/vmihailenco/tagparser/v2/LICENSE diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 0f84c9ff3..6da6e79a4 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -95,7 +95,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/go-openapi/strfmt](https://pkg.go.dev/github.com/go-openapi/strfmt) ([Apache-2.0](https://github.com/go-openapi/strfmt/blob/v0.23.0/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.23.1/LICENSE)) - [github.com/go-openapi/validate](https://pkg.go.dev/github.com/go-openapi/validate) ([Apache-2.0](https://github.com/go-openapi/validate/blob/v0.24.0/LICENSE)) -- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE)) +- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/2c02b8208cf8/LICENSE)) - [github.com/golang/snappy](https://pkg.go.dev/github.com/golang/snappy) ([BSD-3-Clause](https://github.com/golang/snappy/blob/v1.0.0/LICENSE)) - [github.com/google/certificate-transparency-go](https://pkg.go.dev/github.com/google/certificate-transparency-go) ([Apache-2.0](https://github.com/google/certificate-transparency-go/blob/v1.3.2/LICENSE)) @@ -189,6 +189,8 @@ Some packages may only be included on certain architectures or operating systems - [github.com/transparency-dev/merkle](https://pkg.go.dev/github.com/transparency-dev/merkle) ([Apache-2.0](https://github.com/transparency-dev/merkle/blob/v0.0.2/LICENSE)) - [github.com/transparency-dev/tessera](https://pkg.go.dev/github.com/transparency-dev/tessera) ([Apache-2.0](https://github.com/transparency-dev/tessera/blob/8ee4e93b2823/LICENSE)) - [github.com/vbatts/tar-split/archive/tar](https://pkg.go.dev/github.com/vbatts/tar-split/archive/tar) ([BSD-3-Clause](https://github.com/vbatts/tar-split/blob/v0.12.1/LICENSE)) +- [github.com/vmihailenco/msgpack/v5](https://pkg.go.dev/github.com/vmihailenco/msgpack/v5) ([BSD-2-Clause](https://github.com/vmihailenco/msgpack/blob/v5.4.1/LICENSE)) +- [github.com/vmihailenco/tagparser/v2](https://pkg.go.dev/github.com/vmihailenco/tagparser/v2) ([BSD-2-Clause](https://github.com/vmihailenco/tagparser/blob/v2.0.0/LICENSE)) - [github.com/xo/terminfo](https://pkg.go.dev/github.com/xo/terminfo) ([MIT](https://github.com/xo/terminfo/blob/abceb7e1c41e/LICENSE)) - [github.com/yuin/goldmark](https://pkg.go.dev/github.com/yuin/goldmark) ([MIT](https://github.com/yuin/goldmark/blob/v1.7.13/LICENSE)) - [github.com/yuin/goldmark-emoji](https://pkg.go.dev/github.com/yuin/goldmark-emoji) ([MIT](https://github.com/yuin/goldmark-emoji/blob/v1.0.6/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 3281ddfe1..3fbedcd48 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -94,7 +94,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/go-openapi/strfmt](https://pkg.go.dev/github.com/go-openapi/strfmt) ([Apache-2.0](https://github.com/go-openapi/strfmt/blob/v0.23.0/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.23.1/LICENSE)) - [github.com/go-openapi/validate](https://pkg.go.dev/github.com/go-openapi/validate) ([Apache-2.0](https://github.com/go-openapi/validate/blob/v0.24.0/LICENSE)) -- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE)) +- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - [github.com/godbus/dbus/v5](https://pkg.go.dev/github.com/godbus/dbus/v5) ([BSD-2-Clause](https://github.com/godbus/dbus/blob/v5.1.0/LICENSE)) - [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/2c02b8208cf8/LICENSE)) - [github.com/golang/snappy](https://pkg.go.dev/github.com/golang/snappy) ([BSD-3-Clause](https://github.com/golang/snappy/blob/v1.0.0/LICENSE)) @@ -189,6 +189,8 @@ Some packages may only be included on certain architectures or operating systems - [github.com/transparency-dev/merkle](https://pkg.go.dev/github.com/transparency-dev/merkle) ([Apache-2.0](https://github.com/transparency-dev/merkle/blob/v0.0.2/LICENSE)) - [github.com/transparency-dev/tessera](https://pkg.go.dev/github.com/transparency-dev/tessera) ([Apache-2.0](https://github.com/transparency-dev/tessera/blob/8ee4e93b2823/LICENSE)) - [github.com/vbatts/tar-split/archive/tar](https://pkg.go.dev/github.com/vbatts/tar-split/archive/tar) ([BSD-3-Clause](https://github.com/vbatts/tar-split/blob/v0.12.1/LICENSE)) +- [github.com/vmihailenco/msgpack/v5](https://pkg.go.dev/github.com/vmihailenco/msgpack/v5) ([BSD-2-Clause](https://github.com/vmihailenco/msgpack/blob/v5.4.1/LICENSE)) +- [github.com/vmihailenco/tagparser/v2](https://pkg.go.dev/github.com/vmihailenco/tagparser/v2) ([BSD-2-Clause](https://github.com/vmihailenco/tagparser/blob/v2.0.0/LICENSE)) - [github.com/xo/terminfo](https://pkg.go.dev/github.com/xo/terminfo) ([MIT](https://github.com/xo/terminfo/blob/abceb7e1c41e/LICENSE)) - [github.com/yuin/goldmark](https://pkg.go.dev/github.com/yuin/goldmark) ([MIT](https://github.com/yuin/goldmark/blob/v1.7.13/LICENSE)) - [github.com/yuin/goldmark-emoji](https://pkg.go.dev/github.com/yuin/goldmark-emoji) ([MIT](https://github.com/yuin/goldmark-emoji/blob/v1.0.6/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 52e6d759b..9d68c301d 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -96,7 +96,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/go-openapi/strfmt](https://pkg.go.dev/github.com/go-openapi/strfmt) ([Apache-2.0](https://github.com/go-openapi/strfmt/blob/v0.23.0/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.23.1/LICENSE)) - [github.com/go-openapi/validate](https://pkg.go.dev/github.com/go-openapi/validate) ([Apache-2.0](https://github.com/go-openapi/validate/blob/v0.24.0/LICENSE)) -- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE)) +- [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - [github.com/golang/groupcache/lru](https://pkg.go.dev/github.com/golang/groupcache/lru) ([Apache-2.0](https://github.com/golang/groupcache/blob/2c02b8208cf8/LICENSE)) - [github.com/golang/snappy](https://pkg.go.dev/github.com/golang/snappy) ([BSD-3-Clause](https://github.com/golang/snappy/blob/v1.0.0/LICENSE)) - [github.com/google/certificate-transparency-go](https://pkg.go.dev/github.com/google/certificate-transparency-go) ([Apache-2.0](https://github.com/google/certificate-transparency-go/blob/v1.3.2/LICENSE)) @@ -192,6 +192,8 @@ Some packages may only be included on certain architectures or operating systems - [github.com/transparency-dev/merkle](https://pkg.go.dev/github.com/transparency-dev/merkle) ([Apache-2.0](https://github.com/transparency-dev/merkle/blob/v0.0.2/LICENSE)) - [github.com/transparency-dev/tessera](https://pkg.go.dev/github.com/transparency-dev/tessera) ([Apache-2.0](https://github.com/transparency-dev/tessera/blob/8ee4e93b2823/LICENSE)) - [github.com/vbatts/tar-split/archive/tar](https://pkg.go.dev/github.com/vbatts/tar-split/archive/tar) ([BSD-3-Clause](https://github.com/vbatts/tar-split/blob/v0.12.1/LICENSE)) +- [github.com/vmihailenco/msgpack/v5](https://pkg.go.dev/github.com/vmihailenco/msgpack/v5) ([BSD-2-Clause](https://github.com/vmihailenco/msgpack/blob/v5.4.1/LICENSE)) +- [github.com/vmihailenco/tagparser/v2](https://pkg.go.dev/github.com/vmihailenco/tagparser/v2) ([BSD-2-Clause](https://github.com/vmihailenco/tagparser/blob/v2.0.0/LICENSE)) - [github.com/xo/terminfo](https://pkg.go.dev/github.com/xo/terminfo) ([MIT](https://github.com/xo/terminfo/blob/abceb7e1c41e/LICENSE)) - [github.com/yuin/goldmark](https://pkg.go.dev/github.com/yuin/goldmark) ([MIT](https://github.com/yuin/goldmark/blob/v1.7.13/LICENSE)) - [github.com/yuin/goldmark-emoji](https://pkg.go.dev/github.com/yuin/goldmark-emoji) ([MIT](https://github.com/yuin/goldmark-emoji/blob/v1.0.6/LICENSE)) diff --git a/third-party/github.com/vmihailenco/msgpack/v5/LICENSE b/third-party/github.com/vmihailenco/msgpack/v5/LICENSE new file mode 100644 index 000000000..b749d0707 --- /dev/null +++ b/third-party/github.com/vmihailenco/msgpack/v5/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2013 The github.com/vmihailenco/msgpack Authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/vmihailenco/tagparser/v2/LICENSE b/third-party/github.com/vmihailenco/tagparser/v2/LICENSE new file mode 100644 index 000000000..3fc93fdff --- /dev/null +++ b/third-party/github.com/vmihailenco/tagparser/v2/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2019 The github.com/vmihailenco/tagparser Authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From e673dfe61cd9eb40a04299751ef0f9239c277aab Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 23 Sep 2025 09:28:08 -0600 Subject: [PATCH 207/208] Add TODO for better integer handling in GetPullRequestDatabaseID --- pkg/cmd/agent-task/capi/sessions.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 4d109ea26..5b3342b56 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -412,6 +412,13 @@ func (c *CAPIClient) hydrateSessionPullRequestsAndUsers(sessions []session) ([]* // GetPullRequestDatabaseID retrieves the database ID and URL of a pull request given its number in a repository. func (c *CAPIClient) GetPullRequestDatabaseID(ctx context.Context, hostname string, owner string, repo string, number int) (int64, string, error) { + // TODO: better int handling so we don't need to do bounds checks + // to both ensure a panic is impossible and that we do not trigger + // CodeQL alerts. + if number <= 0 || number > math.MaxInt32 { + return 0, "", fmt.Errorf("pull request number %d out of bounds", number) + } + var resp struct { Repository struct { PullRequest struct { From 8c75079bf21dcec266715863a7864206efeff2b5 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 23 Sep 2025 09:33:26 -0600 Subject: [PATCH 208/208] Add math package import to sessions.go Imported the math package in sessions.go --- pkg/cmd/agent-task/capi/sessions.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go index 5b3342b56..c147afa9c 100644 --- a/pkg/cmd/agent-task/capi/sessions.go +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "net/url" "slices"