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/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/go.mod b/go.mod index 399f195ad..518cae19c 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/spf13/pflag v1.0.7 github.com/stretchr/testify v1.11.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 7cff53d87..7ad0f6745 100644 --- a/go.sum +++ b/go.sum @@ -1417,6 +1417,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 new file mode 100644 index 000000000..8b59d59cc --- /dev/null +++ b/pkg/cmd/agent-task/agent_task.go @@ -0,0 +1,100 @@ +package agent + +import ( + "errors" + "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" + "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: "Work with agent tasks (preview)", + Long: heredoc.Doc(` + 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(` + 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/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. + `), + }, + 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) + }, + // 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 cmd.Help() + }, + } + + // register subcommands + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, 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..dd4fe21b0 --- /dev/null +++ b/pkg/cmd/agent-task/agent_task_test.go @@ -0,0 +1,149 @@ +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 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 + }, + 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, + }, + } + + 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 } + } + + cmd := NewCmdAgentTask(f) + err := cmd.Execute() + + 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()) + } + }) + } +} + +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/agent-task/capi/client.go b/pkg/cmd/agent-task/capi/client.go new file mode 100644 index 000000000..c25549121 --- /dev/null +++ b/pkg/cmd/agent-task/capi/client.go @@ -0,0 +1,72 @@ +package capi + +import ( + "context" + "net/http" + + "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" + +// CapiClient defines the methods used by the caller. Implementations +// may be replaced with test doubles in unit tests. +type CapiClient interface { + 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) + 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) +} + +// 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/client_mock.go b/pkg/cmd/agent-task/capi/client_mock.go new file mode 100644 index 000000000..325a8d513 --- /dev/null +++ b/pkg/cmd/agent-task/capi/client_mock.go @@ -0,0 +1,441 @@ +// 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") +// }, +// 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) { +// panic("mock out the GetSession method") +// }, +// 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") +// }, +// } +// +// // 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) + + // GetPullRequestDatabaseIDFunc mocks the GetPullRequestDatabaseID method. + 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) + + // 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) + + // 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 + } + // 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. + Ctx context.Context + // 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 + } + // 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. + 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 + } + } + lockCreateJob sync.RWMutex + lockGetJob sync.RWMutex + lockGetPullRequestDatabaseID sync.RWMutex + lockGetSession sync.RWMutex + lockGetSessionLogs sync.RWMutex + lockListLatestSessionsForViewer sync.RWMutex + lockListSessionsByResourceID 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 +} + +// GetPullRequestDatabaseID calls GetPullRequestDatabaseIDFunc. +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") + } + 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 { + 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 +} + +// 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 +} + +// 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 { + 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 +} diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go new file mode 100644 index 000000000..5a56323cb --- /dev/null +++ b/pkg/cmd/agent-task/capi/job.go @@ -0,0 +1,137 @@ +package capi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "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,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,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + 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"` + BaseRef string `json:"base_ref,omitempty"` +} + +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, baseBranch 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, 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)) + 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() + + 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) + } + + if res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusOK { // accept 201 or 200 + 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 +} + +// 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, 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 + } + res, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + // 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 { + return nil, fmt.Errorf("failed to decode get job response: %w", err) + } + return &j, nil +} 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..573ce3039 --- /dev/null +++ b/pkg/cmd/agent-task/capi/job_test.go @@ -0,0 +1,379 @@ +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, 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"), + 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) + }) + } +} diff --git a/pkg/cmd/agent-task/capi/sessions.go b/pkg/cmd/agent-task/capi/sessions.go new file mode 100644 index 000000000..c147afa9c --- /dev/null +++ b/pkg/cmd/agent-task/capi/sessions.go @@ -0,0 +1,511 @@ +package capi + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/url" + "slices" + "strconv" + "time" + + "github.com/cli/cli/v2/api" + "github.com/shurcooL/githubv4" + "github.com/vmihailenco/msgpack/v5" +) + +const AgentsHomeURL = "https://github.com/copilot/agents" + +var defaultSessionsPerPage = 50 + +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"` + 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"` +} + +// 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 + IsDraft bool + + CreatedAt time.Time + UpdatedAt time.Time + ClosedAt *time.Time + MergedAt *time.Time + + Repository *api.PRRepository +} + +// 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 + PremiumRequests float64 + WorkflowRunID uint64 + Error *SessionError + + PullRequest *api.PullRequest + User *api.GitHubUser +} + +type SessionError struct { + Code string + 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) { + if limit == 0 { + return nil, nil + } + + url := baseCAPIURL + "/agents/sessions" + pageSize := defaultSessionsPerPage + + 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 { + return nil, err + } + + 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) + 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) + } + + // Process only the newly fetched page worth of sessions. + pageSessions := response.Sessions + + // De-duplicate sessions by resource ID. + // 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 + } + + // 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 + } + } + + if len(response.Sessions) < pageSize || len(latestSessions) >= limit { + break + } + } + + // Drop any above the limit + if len(latestSessions) > limit { + latestSessions = latestSessions[:limit] + } + + result, err := c.hydrateSessionPullRequestsAndUsers(latestSessions) + 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 == "" { + 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 +} + +// 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") + } + + 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 { + return nil, fmt.Errorf("missing resource type/ID") + } + + if limit == 0 { + return nil, nil + } + + url := fmt.Sprintf("%s/agents/resource/%s/%d", baseCAPIURL, url.PathEscape(resourceType), resourceID) + + 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 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 { + session := session{ + ID: s.SessionID, + Name: s.Name, + UserID: int64(response.UserID), + ResourceType: response.ResourceType, + ResourceID: response.ResourceID, + ResourceGlobalID: response.ResourceGlobalID, + State: s.SessionState, + } + if s.SessionLastUpdatedAt != 0 { + session.LastUpdatedAt = time.Unix(s.SessionLastUpdatedAt, 0).UTC() + } + sessions = append(sessions, session) + } + + 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 { + return nil, nil + } + + prNodeIds := make([]string, 0, len(sessions)) + userNodeIds := make([]string, 0, len(sessions)) + 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) + } + if !slices.Contains(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, "FetchPRsAndUsersForAgentTaskSessions", &resp, map[string]any{ + "ids": ids, + }) + + if err != nil { + return nil, err + } + + prMap := make(map[string]*api.PullRequest, len(prNodeIds)) + userMap := make(map[int64]*api.GitHubUser, len(userNodeIds)) + for _, node := range resp.Nodes { + 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, + IsDraft: node.PullRequest.IsDraft, + 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 _, s := range sessions { + newSession := fromAPISession(s) + newSession.PullRequest = prMap[strconv.FormatInt(s.ResourceID, 10)] + newSession.User = userMap[s.UserID] + newSessions = append(newSessions, newSession) + } + + return newSessions, nil +} + +// 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 { + FullDatabaseID string `graphql:"fullDatabaseId"` + URL string `graphql:"url"` + } `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, resp.Repository.PullRequest.URL, 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) + + if err := encoder.Encode(parts); err != nil { + panic(err) + } + + encoded := base64.RawURLEncoding.EncodeToString(buf.Bytes()) + + 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 { + result := 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, + PremiumRequests: s.PremiumRequests, + WorkflowRunID: s.WorkflowRunID, + } + 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 new file mode 100644 index 000000000..848fd716a --- /dev/null +++ b/pkg/cmd/agent-task/capi/sessions_test.go @@ -0,0 +1,1919 @@ +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 TestListLatestSessionsForViewer(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", + "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", 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", 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", 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: "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, 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 and user octocat + 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: "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: "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: "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, + 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", + "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.ListLatestSessionsForViewer(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 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(), "", 0, 0) + assert.EqualError(t, err, "missing resource type/ID") +} + +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" + + 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, + }, + { + // 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.REST("GET", "agents/resource/pull/999"), "api.githubcopilot.com"), + + httpmock.StatusStringResponse(404, "{}"), + ) + }, + wantErr: "failed to list sessions", + }, + { + name: "single session", + 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": 1, + "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" + } + }, + { + "__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", + 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: "multiple sessions", + perPage: 1, // to enforce pagination + limit: 2, + 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": 1, + "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", + "user_id": 1, + "agent_id": 2, + "logs": "", + "state": "completed", + "owner_id": 10, + "repo_id": 1000, + "resource_type": "pull", + "resource_id": 2000, + "created_at": %[1]d, + "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]d, + "premium_requests": 0.1 + } + ] + }`, + 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" + } + }, + { + "__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, + 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, + }, + }, + { + ID: "sess2", + 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: "API error", + 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.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.REST("GET", "agents/resource/pull/999"), "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.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{} + + _, 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", + "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: "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", + 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: "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", + "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: "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", + 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", + "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()) + + 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) + }) + } +} +func TestGetPullRequestDatabaseID(t *testing.T) { + tests := []struct { + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantDatabaseID int64 + wantURL string + }{ + { + 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", "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", "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"]) + }), + ) + }, + wantDatabaseID: 999, + wantURL: "some-url", + }, + } + + 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, url, 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.wantDatabaseID, databaseID) + require.Equal(t, tt.wantURL, url) + }) + } +} diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go new file mode 100644 index 000000000..9c9b8ed68 --- /dev/null +++ b/pkg/cmd/agent-task/create/create.go @@ -0,0 +1,280 @@ +package create + +import ( + "context" + "errors" + "fmt" + "net/url" + "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/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/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) + + 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, + LogRenderer: defaultLogRenderer, + Sleep: time.Sleep, + } + + cmd := &cobra.Command{ + Use: "create [] [flags]", + 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, opts.ProblemStatementFile != ""); err != nil { + return err + } + + // 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") + } + + if runF != nil { + return runF(opts) + } + 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 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 + + # 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 + $ 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 + `), + } + + cmdutil.EnableRepoOverride(cmd, f) + + 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 +} + +func createRun(opts *CreateOptions) error { + repo, err := opts.BaseRepo() + 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") + } + + if opts.ProblemStatement == "" { + if opts.ProblemStatementFile != "" { + fileContent, err := cmdutil.ReadFile(opts.ProblemStatementFile, opts.IO.In) + if err != nil { + return fmt.Errorf("could not read task description file: %w", err) + } + + 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 + } + + trimmed := strings.TrimSpace(string(desc)) + if trimmed == "" { + return errors.New("a task description is required") + } + + opts.ProblemStatement = trimmed + } + } + + 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, opts.BaseBranch) + if err != nil { + return err + } + + if opts.Follow { + return followLogs(opts, client, job.SessionID) + } + + sessionURL, err := fetchJobSessionURL(ctx, client, repo, job, opts.BackOff) + opts.IO.StopProgressIndicator() + + 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 +} + +func agentSessionWebURL(repo ghrepo.Interface, j *capi.Job) string { + if j.PullRequest == nil { + return "" + } + if j.SessionID == "" { + return fmt.Sprintf("https://github.com/%s/%s/pull/%d", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), j.PullRequest.Number) + } + return fmt.Sprintf("https://github.com/%s/%s/pull/%d/agent-sessions/%s", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), j.PullRequest.Number, url.PathEscape(j.SessionID)) +} + +// 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. +func fetchJobWithBackoff(ctx context.Context, client capi.CapiClient, repo ghrepo.Interface, jobID string, bo backoff.BackOff) (*capi.Job, error) { + // sentinel error to signal timeout + var errPRNotReady = errors.New("job not ready") + + var result *capi.Job + retryErr := backoff.Retry(func() error { + j, err := client.GetJob(ctx, repo.RepoOwner(), repo.RepoName(), jobID) + if err != nil { + // Do not retry on GetJob errors; surface immediately. + return backoff.Permanent(err) + } + if j.PullRequest != nil && j.PullRequest.Number > 0 { + result = j + return nil + } + return errPRNotReady + }, backoff.WithContext(bo, ctx)) + + if retryErr != nil { + if errors.Is(retryErr, errPRNotReady) { + // Timed out + return nil, nil + } + return nil, retryErr + } + return result, nil +} + +func followLogs(opts *CreateOptions, capiClient capi.CapiClient, sessionID string) error { + 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 + 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 + } + + 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 new file mode 100644 index 000000000..ef7f529ac --- /dev/null +++ b/pkg/cmd/agent-task/create/create_test.go @@ -0,0 +1,542 @@ +package create + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "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" +) + +func TestNewCmdCreate(t *testing.T) { + tests := []struct { + name string + args string + 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", + 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", + wantErr: "only one of -F or arg can be provided", + }, + { + name: "base branch sets baseBranch field", + args: "'task description' -b feature", + wantOpts: &CreateOptions{ + ProblemStatement: "task description", + ProblemStatementFile: "", + 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 { + t.Run(tt.name, func(t *testing.T) { + ios, stdin, _, _ := iostreams.Test() + 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 { + gotOpts = o + return nil + }) + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + cmd.SetIn(stdin) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + + 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) + } + }) + } +} + +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) + + 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 + 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, 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, "task description from arg", problemStatement) + return &createdJobSuccessWithPR, nil + } + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + name: "non-interactive, problem statement from arg", + 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, "task description from arg", problemStatement) + return &createdJobSuccessWithPR, nil + } + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + name: "interactive, problem statement from file", + isTTY: true, + 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: "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{ + 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: "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{ + 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) + 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: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + 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 API error") + } + }, + wantErr: "some API error", + }, + { + 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) + 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", + 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 &createdJobSuccessWithPR, nil + } + }, + wantStdout: "https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1\n", + }, + { + 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) + 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 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) + 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", + }, + { + 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(` + (rendered:) + (rendered:) + `), + }, + } + + 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() + if tt.isTTY { + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + ios.SetStdoutTTY(true) + } + + tt.opts.IO = ios + tt.opts.CapiClient = func() (capi.CapiClient, error) { + return capiClientMock, nil + } + + // 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) + } + if tt.wantErr != "" { + require.Error(t, err) + require.Equal(t, tt.wantErr, err.Error()) + } else if tt.wantErrIs == nil { + require.NoError(t, err) + } + + require.Equal(t, tt.wantStdout, stdout.String()) + require.Equal(t, tt.wantStdErr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/agent-task/list/list.go b/pkg/cmd/agent-task/list/list.go new file mode 100644 index 000000000..4370a0170 --- /dev/null +++ b/pkg/cmd/agent-task/list/list.go @@ -0,0 +1,150 @@ +package list + +import ( + "context" + "fmt" + "time" + + "github.com/cli/cli/v2/internal/browser" + "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" + "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 + Limit int + CapiClient func() (capi.CapiClient, 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, + CapiClient: shared.CapiClientFunc(f), + Limit: defaultLimit, + Browser: f.Browser, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List agent tasks (preview)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit) + } + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + 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") + + return cmd +} + +func listRun(opts *ListOptions) error { + if opts.Web { + 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) + } + + if opts.Limit <= 0 { + opts.Limit = defaultLimit + } + + capiClient, err := opts.CapiClient() + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching agent tasks...") + defer opts.IO.StopProgressIndicator() + var sessions []*capi.Session + ctx := context.Background() + + sessions, err = capiClient.ListLatestSessionsForViewer(ctx, opts.Limit) + if err != nil { + return err + } + + opts.IO.StopProgressIndicator() + + if len(sessions) == 0 { + 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) + } + + 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 { + 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 + + // Name + tp.AddField(s.Name) + if tp.IsTTY() { + tp.AddField(pr, tableprinter.WithColor(cs.ColorFromString(prShared.ColorForPRState(*s.PullRequest)))) + } else { + tp.AddField(pr) + } + + // Repo + tp.AddField(repo, tableprinter.WithColor(cs.Muted)) + + // State + if tp.IsTTY() { + tp.AddField(shared.SessionStateString(s.State), tableprinter.WithColor(shared.ColorFuncForSessionState(*s, cs))) + } else { + tp.AddField(shared.SessionStateString(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..747650283 --- /dev/null +++ b/pkg/cmd/agent-task/list/list_test.go @@ -0,0 +1,334 @@ +package list + +import ( + "bytes" + "context" + "io" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/browser" + "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 ListOptions + wantErr string + }{ + { + name: "no arguments", + wantOpts: ListOptions{ + Limit: defaultLimit, + }, + }, + { + name: "custom limit", + args: "--limit 15", + wantOpts: ListOptions{ + Limit: 15, + }, + }, + { + name: "invalid limit", + args: "--limit 0", + wantErr: "invalid limit: 0", + }, + { + name: "negative limit", + args: "--limit -5", + wantErr: "invalid limit: -5", + }, + { + name: "web flag", + args: "--web", + wantOpts: ListOptions{ + Limit: defaultLimit, + Web: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) 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.Limit, gotOpts.Limit) + assert.Equal(t, tt.wantOpts.Web, gotOpts.Web) + }) + } +} + +func Test_listRun(t *testing.T) { + sampleDate := time.Now().Add(-6 * time.Hour) // 6h ago + sampleDateString := sampleDate.Format(time.RFC3339) + + tests := []struct { + name string + tty bool + capiStubs func(*testing.T, *capi.CapiClientMock) + limit int + web bool + wantOut string + wantErr error + wantStderr string + wantBrowserURL string + }{ + { + name: "viewer-scoped no sessions", + tty: true, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, limit int) ([]*capi.Session, error) { + return nil, nil + } + }, + wantErr: cmdutil.NewNoResultsError("no agent tasks found"), + }, + { + name: "viewer-scoped respects --limit", + tty: true, + limit: 999, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListLatestSessionsForViewerFunc = 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.ListLatestSessionsForViewerFunc = func(ctx context.Context, 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(` + Showing 1 session + + SESSION NAME PULL REQUEST REPO SESSION STATE CREATED + s1 #101 OWNER/REPO Ready for review about 6 hours ago + `), + }, + { + name: "viewer-scoped single session (nontty)", + tty: false, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.ListLatestSessionsForViewerFunc = func(ctx context.Context, 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\tReady for review\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.ListLatestSessionsForViewerFunc = func(ctx context.Context, 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: "cancelled", + 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(` + Showing 6 sessions + + 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 + `), + }, + { + 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 { + 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) + + var br *browser.Stub + if tt.web { + br = &browser.Stub{} + } + + opts := &ListOptions{ + IO: ios, + Limit: tt.limit, + Web: tt.web, + Browser: br, + CapiClient: func() (capi.CapiClient, error) { + if tt.web { + require.FailNow(t, "CapiClient was called with --web") + } + return capiClientMock, nil + }, + } + + err := listRun(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()) + if tt.web { + br.Verify(t, tt.wantBrowserURL) + } + }) + } +} diff --git a/pkg/cmd/agent-task/shared/capi.go b/pkg/cmd/agent-task/shared/capi.go new file mode 100644 index 000000000..c61aa13a7 --- /dev/null +++ b/pkg/cmd/agent-task/shared/capi.go @@ -0,0 +1,54 @@ +package shared + +import ( + "errors" + "fmt" + "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" +) + +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 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) { + 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 + } +} + +func IsSessionID(s string) bool { + return sessionIDRegexp.MatchString(s) +} + +// 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 ParseSessionIDFromURL(u string) (string, error) { + _, _, rest, err := prShared.ParseURL(u) + if err != nil { + return "", err + } + + match := agentSessionURLRegexp.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 new file mode 100644 index 000000000..205d881c8 --- /dev/null +++ b/pkg/cmd/agent-task/shared/capi_test.go @@ -0,0 +1,76 @@ +package shared + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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")) +} + +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 := ParseSessionIDFromURL(tt.url) + + if tt.wantErr { + require.Error(t, err) + assert.Zero(t, sessionID) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantSessionID, sessionID) + }) + } +} diff --git a/pkg/cmd/agent-task/shared/display.go b/pkg/cmd/agent-task/shared/display.go new file mode 100644 index 000000000..3859a0e75 --- /dev/null +++ b/pkg/cmd/agent-task/shared/display.go @@ -0,0 +1,63 @@ +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 "cancelled": + stateColor = cs.Muted + case "in_progress", "queued": + stateColor = cs.Yellow + case "failed": + stateColor = cs.Red + default: + stateColor = cs.Muted + } + + return stateColor +} + +// SessionStateString returns the humane/capitalised form of the given session state. +func SessionStateString(state string) string { + switch state { + case "queued": + return "Queued" + case "in_progress": + return "In progress" + case "completed": + return "Ready for review" + 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 + } +} + +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 "-" + } +} diff --git a/pkg/cmd/agent-task/shared/log.go b/pkg/cmd/agent-task/shared/log.go new file mode 100644 index 000000000..c94f5e603 --- /dev/null +++ b/pkg/cmd/agent-task/shared/log.go @@ -0,0 +1,577 @@ +package shared + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "slices" + "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, io *iostreams.IOStreams) error + Render(logs []byte, w io.Writer, io *iostreams.IOStreams) (stop bool, err error) +} + +type logRenderer struct{} + +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 { + 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, io); err != nil { + return err + } else if stop { + return nil + } + + last = logs + } +} + +// 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 == "" + }) + + for _, line := range lines { + raw, found := strings.CutPrefix(line, "data: ") + if !found { + return false, errors.New("unexpected log format") + } + + // 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 || entry.Object != "chat.completion.chunk" { + continue + } + + 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 + } + } + + return false, nil +} + +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" { + stop = true + } + + if len(choice.Delta.ToolCalls) == 0 { + if choice.Delta.Content != "" && choice.Delta.Role == "assistant" { + // Copilot message and we should display. + renderRawMarkdown(choice.Delta.Content, w, io) + } + 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 "thought" message, + // without the heading. + renderRawMarkdown(choice.Delta.ReasoningText, w, io) + } + + for _, tc := range choice.Delta.ToolCalls { + name := tc.Function.Name + if name == "" { + continue + } + + args := tc.Function.Arguments + + switch name { + case "run_setup": + if v := unmarshal[runSetupToolArgs](args); v != nil { + renderToolCallTitle(w, cs, v.Name, "") + continue + } + case "view": + args := viewToolArgs{} + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + fmt.Fprintf(io.ErrOut, "\nfailed to parse 'view' tool call arguments: %v\n", err) + continue + } + renderToolCallTitle(w, cs, fmt.Sprintf("View %s", cs.Bold(relativeFilePath(args.Path))), "") + + content := stripDiffFormat(choice.Delta.Content) + + if err := renderFileContentAsMarkdown(args.Path, content, w, io); err != nil { + 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 { + if v.Description != "" { + renderToolCallTitle(w, cs, "Bash", v.Description) + } else { + renderToolCallTitle(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 { + 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. + 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 { + 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 { + 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 { + 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 { + fmt.Fprintf(io.ErrOut, "\nfailed to render PR description: %v\n", err) + } + } + + // 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 { + 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 { + 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 { + 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 { + 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 { + 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": + // 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. + _ = 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 +} + +// 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 { + marshaled, err := json.MarshalIndent(contentAsJSON, "", " ") + 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 { + return fmt.Errorf("failed to render JSON: %w", err) + } + } + 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. + formatFunc := func(s string) string { + return fmt.Sprintf("\n%s\n\n", s) + } + + return renderMarkdownWithFormat(md, w, io, formatFunc) +} + +// 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 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()), + ) + + if err != nil { + return fmt.Errorf("failed to render markdown: %w", err) + } + + rendered = strings.TrimSpace(rendered) + if formatFunc != nil { + rendered = formatFunc(rendered) + } + + fmt.Fprint(w, rendered) + + 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") + + // Find where the hunk header ends. + hunkEndIndex := -1 + for i, line := range lines { + if strings.HasPrefix(line, "@@") { + hunkEndIndex = i + break + } + } + + // This isn't a diff. + if hunkEndIndex == -1 { + return diff + } + + // Removing hunk header. + lines = lines[hunkEndIndex+1:] + + // Strip the leading + and - from lines, if they exist. + 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 { + lang := filepath.Ext(filepath.ToSlash(path)) + + if lang == ".md" { + return renderRawMarkdown(content, w, io) + } + + 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. + formatFunc := func(s string) string { + return fmt.Sprintf("%s\n\n", s) + } + + 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/") + + 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 { + return nil + } + return &t +} + +// 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 title != "" { + fmt.Fprintf(w, "%s: %s\n", toolName, title) + } else { + fmt.Fprintf(w, "%s\n", toolName) + } +} + +// 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) { + toolName, ok := genericToolCallNamesToTitles[name] + if !ok { + toolName = fmt.Sprintf("Call to %s", name) + } + + renderToolCallTitle(w, cs, toolName, "") +} + +type chatCompletionChunkEntry struct { + ID string `json:"id"` + Created int64 `json:"created"` + Model string `json:"model"` + Object string `json:"object"` + Choices []struct { + Delta 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"` + } `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 runSetupToolArgs struct { + Name string `json:"name"` +} + +type bashToolArgs struct { + Command string `json:"command"` + Description string `json:"description"` +} + +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 { + SessionID string `json:"sessionId"` + Thought string `json:"thought"` +} + +type reportProgressToolArgs struct { + CommitMessage string `json:"commitMessage"` + PrDescription string `json:"prDescription"` +} + +type createToolArgs struct { + FileText string `json:"file_text"` + Path string `json:"path"` +} + +type strReplaceToolArgs 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..f12cf6bf3 --- /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, ioMoqParam *iostreams.IOStreams) error { +// panic("mock out the Follow method") +// }, +// RenderFunc: func(logs []byte, w io.Writer, ioMoqParam *iostreams.IOStreams) (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, ioMoqParam *iostreams.IOStreams) error + + // RenderFunc mocks the Render method. + RenderFunc func(logs []byte, w io.Writer, ioMoqParam *iostreams.IOStreams) (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 + // IoMoqParam is the ioMoqParam argument value. + IoMoqParam *iostreams.IOStreams + } + // 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 + // IoMoqParam is the ioMoqParam argument value. + IoMoqParam *iostreams.IOStreams + } + } + lockFollow sync.RWMutex + lockRender sync.RWMutex +} + +// Follow calls FollowFunc. +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 + IoMoqParam *iostreams.IOStreams + }{ + 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, ioMoqParam) +} + +// 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 + IoMoqParam *iostreams.IOStreams +} { + var calls []struct { + Fetcher func() ([]byte, error) + W io.Writer + IoMoqParam *iostreams.IOStreams + } + mock.lockFollow.RLock() + calls = mock.calls.Follow + mock.lockFollow.RUnlock() + return calls +} + +// Render calls RenderFunc. +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 + IoMoqParam *iostreams.IOStreams + }{ + 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, ioMoqParam) +} + +// 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 + IoMoqParam *iostreams.IOStreams +} { + var calls []struct { + Logs []byte + W io.Writer + IoMoqParam *iostreams.IOStreams + } + 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..07b562dc8 --- /dev/null +++ b/pkg/cmd/agent-task/shared/log_test.go @@ -0,0 +1,95 @@ +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 + wantStdoutFile string + wantStderrFile string + }{ + { + 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", + 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", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, err := os.ReadFile(tt.log) + require.NoError(t, err) + + // 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 { + 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, stderr := iostreams.Test() + + 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) + // 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) + // } + + wantStdout, err := os.ReadFile(tt.wantStdoutFile) + require.NoError(t, err) + + // Normalize CRLF to LF to make the tests OS-agnostic. + wantStdout = []byte(strings.ReplaceAll(string(wantStdout), "\r\n", "\n")) + + 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-1-input.txt b/pkg/cmd/agent-task/shared/testdata/log-1-input.txt new file mode 100644 index 000000000..19fdb5dfd --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/log-1-input.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/log-1-want.txt b/pkg/cmd/agent-task/shared/testdata/log-1-want.txt new file mode 100644 index 000000000..6ba206dda --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/log-1-want.txt @@ -0,0 +1,425 @@ +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 repository +.git + .github + README.md + +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 + + + +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 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" + ./.github/workflows/upload-artifact.yml + ./.github/workflows/manual-dispatch.yml + ./.github/workflows/cache.yml + ./.github/dependabot.yml + + +View .github/workflows +cache.yml + manual-dispatch.yml + upload-artifact.yml + +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 + +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 + + +Now let me create the shell script with comprehensive OS information: + +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 +$ 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 + + ================================================== + + + +Great! The script works perfectly. Now let me update the README to document + the new script: + +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) + + + + +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 + 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") + + +View README.md + +# 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! 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:" && . + /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 + + + +Excellent! Now let me commit all the changes: + +Progress update: Add os_info.sh shell script to display OS information + +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 + + +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: + +Run CodeQL analysis + +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. + diff --git a/pkg/cmd/agent-task/shared/testdata/log-2-input.txt b/pkg/cmd/agent-task/shared/testdata/log-2-input.txt new file mode 100644 index 000000000..4a77bbbac --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/log-2-input.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/log-2-want.txt b/pkg/cmd/agent-task/shared/testdata/log-2-want.txt new file mode 100644 index 000000000..7665b4d51 --- /dev/null +++ b/pkg/cmd/agent-task/shared/testdata/log-2-want.txt @@ -0,0 +1,371 @@ +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 +$ 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 + + +View repository +.git + .github + README.md + +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 README.md + +# foo + +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 + + +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 + +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. + + +Now I'll create a YAML file with random fields that demonstrates various + YAML data types and structures. + +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 + + +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. + 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. + safe_load(open('sample-data.yaml', 'r')); print('YAML file is valid!'); + print(f'Loaded {len(data)} top-level keys: {list(data.keys())}')" + + + + +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 + + + +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 + 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 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 + +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. + + +Perfect! I have successfully completed the task. Let me also run a security + check to ensure there are no vulnerabilities in the changes: + +Run CodeQL analysis + +Input: + +{} + + +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. + 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/view/view.go b/pkg/cmd/agent-task/view/view.go new file mode 100644 index 000000000..38c1e0e12 --- /dev/null +++ b/pkg/cmd/agent-task/view/view.go @@ -0,0 +1,398 @@ +package view + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/cmd/agent-task/shared" + prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const ( + defaultLimit = 40 + defaultLogPollInterval = 5 * time.Second +) + +type ViewOptions struct { + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + CapiClient func() (capi.CapiClient, error) + HttpClient func() (*http.Client, error) + Finder prShared.PRFinder + Prompter prompter.Prompter + Browser browser.Browser + + LogRenderer func() shared.LogRenderer + Sleep func(d time.Duration) + + SelectorArg string + PRNumber int + SessionID string + Web bool + Log bool + Follow bool +} + +func defaultLogRenderer() shared.LogRenderer { + return shared.NewLogRenderer() +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + CapiClient: shared.CapiClientFunc(f), + Prompter: f.Prompter, + Browser: f.Browser, + LogRenderer: defaultLogRenderer, + Sleep: time.Sleep, + } + + cmd := &cobra.Command{ + Use: "view [ | | | ]", + Short: "View an agent task session (preview)", + Long: heredoc.Doc(` + View an agent task session. + `), + Example: heredoc.Doc(` + # View an agent task by session ID + $ gh agent-task view e2fa49d2-f164-4a56-ab99-498090b8fcdf + + # View an agent task by pull request number in current repo + $ gh agent-task view 12345 + + # View an agent task by pull request number + $ gh agent-task view --repo OWNER/REPO 12345 + + # View an agent task by pull request reference + $ gh agent-task view OWNER/REPO#12345 + + # View a pull request agents tasks in the browser + $ gh agent-task view 12345 --web + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // Support -R/--repo override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + if shared.IsSessionID(opts.SelectorArg) { + opts.SessionID = opts.SelectorArg + } else if sessionID, err := shared.ParseSessionIDFromURL(opts.SelectorArg); err == nil { + opts.SessionID = sessionID + } + } + + if opts.SessionID == "" && !opts.IO.CanPrompt() { + return fmt.Errorf("session ID is required when not running interactively") + } + + if opts.Follow && !opts.Log { + return cmdutil.FlagErrorf("--log is required when providing --follow") + } + + if opts.Finder == nil { + opts.Finder = prShared.NewFinder(f) + } + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open agent task in the browser") + cmd.Flags().BoolVar(&opts.Log, "log", false, "Show agent session logs") + cmd.Flags().BoolVar(&opts.Follow, "follow", false, "Follow agent session logs") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + capiClient, err := opts.CapiClient() + if err != nil { + return err + } + + ctx := context.Background() + cs := opts.IO.ColorScheme() + + opts.IO.StartProgressIndicatorWithLabel("Fetching agent session...") + defer opts.IO.StopProgressIndicator() + + var session *capi.Session + + if opts.SessionID != "" { + sess, err := capiClient.GetSession(ctx, opts.SessionID) + if err != nil { + if errors.Is(err, capi.ErrSessionNotFound) { + fmt.Fprintln(opts.IO.ErrOut, "session not found") + return cmdutil.SilentError + } + return err + } + + opts.IO.StopProgressIndicator() + + if opts.Web { + var webURL string + if sess.PullRequest != nil { + webURL = fmt.Sprintf("%s/agent-sessions/%s", sess.PullRequest.URL, url.PathEscape(sess.ID)) + } else { + // Currently the web Copilot Agents home GUI does not support focusing + // on a given session, so we should just navigate to the home page. + webURL = capi.AgentsHomeURL + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) + } + return opts.Browser.Browse(webURL) + } + + session = sess + } else { + var prID int64 + var prURL string + + if opts.SelectorArg != "" { + // Finder does not support the PR/issue reference format (e.g. owner/repo#123) + // so we need to check if the selector arg is a reference and fetch the PR + // directly. + if repo, num, err := prShared.ParseFullReference(opts.SelectorArg); err == nil { + // Since the selector was a reference (i.e. without hostname data), we need to + // check the base repo to get the hostname. + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + hostname := baseRepo.RepoHost() + if hostname != ghinstance.Default() { + return fmt.Errorf("agent tasks are not supported on this host: %s", hostname) + } + + prID, prURL, err = capiClient.GetPullRequestDatabaseID(ctx, hostname, repo.RepoOwner(), repo.RepoName(), num) + if err != nil { + return fmt.Errorf("failed to fetch pull request: %w", err) + } + } + } + + if prID == 0 { + findOptions := prShared.FindOptions{ + Selector: opts.SelectorArg, + Fields: []string{"id", "url", "fullDatabaseId"}, + DisableProgress: true, + } + + pr, repo, err := opts.Finder.Find(findOptions) + if err != nil { + return err + } + + if repo.RepoHost() != ghinstance.Default() { + return fmt.Errorf("agent tasks are not supported on this host: %s", repo.RepoHost()) + } + + databaseID, err := strconv.ParseInt(pr.FullDatabaseID, 10, 64) + if err != nil { + return fmt.Errorf("failed to parse pull request: %w", err) + } + + prID = databaseID + prURL = pr.URL + } + + sessions, err := capiClient.ListSessionsByResourceID(ctx, "pull", prID, defaultLimit) + if err != nil { + return fmt.Errorf("failed to list sessions for pull request: %w", err) + } + + if len(sessions) == 0 { + fmt.Fprintln(opts.IO.ErrOut, "no session found for pull request") + return cmdutil.SilentError + } + + opts.IO.StopProgressIndicator() + + if opts.Web { + // Note that, we needed to make sure the PR exists and it has at least one session + // associated with it, other wise the `/agent-sessions` page would display the 404 + // error. + + // We don't need to navigate to a specific session; if there's only one session + // then the GUI will automatically show it, otherwise the user can select from the + // list. This is to avoid unnecessary prompting. + webURL := prURL + "/agent-sessions" + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) + } + return opts.Browser.Browse(webURL) + } + + 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 • updated %s", + shared.SessionSymbol(cs, session.State), + session.Name, + text.FuzzyAgo(now, session.LastUpdatedAt), + )) + } + + selected, err := opts.Prompter.Select("Select a session", "", options) + if err != nil { + return err + } + + 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 { + return printLogs(opts, capiClient, session.ID) + } + + printSession(opts, session) + return nil +} + +func printSession(opts *ViewOptions, session *capi.Session) { + cs := opts.IO.ColorScheme() + + 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)) + } else { + // Should never happen, but we need to cover the path + fmt.Fprintf(opts.IO.Out, "Started %s\n", text.FuzzyAgo(time.Now(), session.CreatedAt)) + } + + 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) { + durationNote = fmt.Sprintf(" • Duration %s", session.CompletedAt.Sub(session.CreatedAt).Round(time.Second).String()) + } + + 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 { + 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.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") + // because the error message already contains all the information. + fmt.Fprintf(opts.IO.Out, "%s\n", workflowRunURL) + } + } + + if !opts.Log { + 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.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 { + 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)))) + } +} + +func printLogs(opts *ViewOptions, capiClient capi.CapiClient, sessionID string) error { + ctx := context.Background() + + 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) { + if called { + opts.Sleep(defaultLogPollInterval) + } + called = true + raw, err := capiClient.GetSessionLogs(ctx, sessionID) + if err != nil { + return nil, err + } + return raw, nil + } + + return renderer.Follow(fetcher, opts.IO.Out, opts.IO) + } + + raw, err := capiClient.GetSessionLogs(ctx, sessionID) + if err != nil { + return fmt.Errorf("failed to fetch session logs: %w", err) + } + + _, 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 new file mode 100644 index 000000000..68cc377e3 --- /dev/null +++ b/pkg/cmd/agent-task/view/view_test.go @@ -0,0 +1,1260 @@ +package view + +import ( + "bytes" + "context" + "errors" + "io" + "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/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" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + tty bool + args string + wantOpts ViewOptions + wantBaseRepo ghrepo.Interface + wantErr string + }{ + { + name: "no arg tty", + tty: true, + args: "", + wantOpts: ViewOptions{}, + }, + { + name: "session ID arg tty", + tty: true, + args: "00000000-0000-0000-0000-000000000000", + wantOpts: ViewOptions{ + SelectorArg: "00000000-0000-0000-0000-000000000000", + 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, + 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", + }, + }, + { + 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, + args: "some-arg -w", + wantOpts: ViewOptions{ + SelectorArg: "some-arg", + Web: true, + }, + }, + } + + 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, + } + + 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) + 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)) + } + }) + } +} + +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 + 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)", + 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, _ string) (*capi.Session, error) { + return nil, capi.ErrSessionNotFound + } + }, + wantStderr: "session not found\n", + wantErr: cmdutil.SilentError, + }, + { + name: "with session id, api 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, _ string) (*capi.Session, error) { + return nil, errors.New("some error") + } + }, + wantErr: 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", + }, + 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", + Name: "session one", + 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 + } + }, + wantOut: heredoc.Doc(` + 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 + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + // 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{ + 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", + Name: "session one", + 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", + }, + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + 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 + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + // 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{ + 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", + Name: "session one", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, + User: &api.GitHubUser{ + Login: "octocat", + }, + }, nil + } + }, + wantOut: heredoc.Doc(` + Ready for review • session one + Started on behalf of octocat about 6 hours ago + Used 1.5 premium request(s) • Duration 5m0s + + For detailed session logs, try: + gh agent-task view 'some-session-id' --log + `), + }, + { + // 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{ + 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", + Name: "session one", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, + }, nil + } + }, + wantOut: heredoc.Doc(` + Ready for review • session one + Started about 6 hours ago + Used 1.5 premium request(s) • Duration 5m0s + + For detailed session logs, try: + 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", + Name: "session one", + 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 • 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 + + 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", + Name: "session one", + 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 • 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 + + 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 (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", + Name: "session one", + 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 • 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: + 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", + Name: "session one", + 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 • 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 + + 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, + 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", + Name: "session one", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, + // 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", + Name: "session one", + 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 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, + opts: ViewOptions{ + SelectorArg: "101", + Finder: prShared.NewMockFinder( + "101", + &api.PullRequest{ + FullDatabaseID: "999999", + URL: "https://github.com/OWNER/REPO/pull/101", + }, + 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#101", + 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#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, _ string, _ string, _ string, _ int) (int64, string, 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#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, _ 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") + } + }, + wantErr: errors.New("failed to list sessions for pull request: some error"), + }, + { + name: "with pr number, success, single session (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "101", + Finder: prShared.NewMockFinder( + "101", + &api.PullRequest{ + FullDatabaseID: "999999", + URL: "https://github.com/OWNER/REPO/pull/101", + }, + 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", + 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", + Name: "session one", + 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 + } + }, + wantOut: heredoc.Doc(` + 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 + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + name: "with pr number, success, multiple sessions (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "101", + Finder: prShared.NewMockFinder( + "101", + &api.PullRequest{ + FullDatabaseID: "999999", + URL: "https://github.com/OWNER/REPO/pull/101", + }, + 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", + 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", + 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 + } + }, + promptStubs: func(t *testing.T, pm *prompter.MockPrompter) { + pm.RegisterSelect( + "Select a session", + []string{ + "✓ 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 • updated about 5 hours ago") + }, + ) + }, + wantOut: heredoc.Doc(` + 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 + + View this session on GitHub: + https://github.com/OWNER/REPO/pull/101/agent-sessions/some-session-id + `), + }, + { + name: "with pr reference, success, multiple sessions (tty)", + tty: true, + opts: ViewOptions{ + 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, 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", + 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", + 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 + } + }, + promptStubs: func(t *testing.T, pm *prompter.MockPrompter) { + pm.RegisterSelect( + "Select a session", + []string{ + "✓ 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 • updated about 5 hours ago") + }, + ) + }, + wantOut: heredoc.Doc(` + 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 + + View this session on GitHub: + 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: "101", + Finder: prShared.NewMockFinder( + "101", + &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, web mode (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "101", + Finder: prShared.NewMockFinder( + "101", + &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", + Name: "session one", + 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 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 sessions, web mode (tty)", + tty: true, + opts: ViewOptions{ + SelectorArg: "101", + Finder: prShared.NewMockFinder( + "101", + &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, + 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 data is irrelevant in this case + }, + { + 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 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, 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, + 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 data is irrelevant in this case + }, + { + 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 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 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", + Name: "session one", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, + 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, ios *iostreams.IOStreams) (bool, error) { + w.Write([]byte("(rendered:) " + string(raw) + "\n")) + return false, nil + } + }, + wantOut: heredoc.Doc(` + (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", + Name: "session one", + CreatedAt: sampleDate, + CompletedAt: sampleCompletedAt, + PremiumRequests: 1.5, + 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, 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 + } + }, + wantOut: heredoc.Doc(` + (rendered:) + (rendered:) + `), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + capiClientMock := &capi.CapiClientMock{} + if tt.capiStubs != nil { + tt.capiStubs(t, capiClientMock) + } + + prompter := prompter.NewMockPrompter(t) + if tt.promptStubs != nil { + tt.promptStubs(t, prompter) + } + + logRenderer := &shared.LogRendererMock{} + if tt.logRendererStubs != nil { + tt.logRendererStubs(t, logRenderer) + } + + 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 + } + opts.LogRenderer = func() shared.LogRenderer { + return logRenderer + } + + err := viewRun(&opts) + 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, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + assert.Equal(t, tt.wantBrowserURL, browser.BrowsedURL()) + }) + } +} 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 df8cc0fd4..1879fff62 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 } @@ -112,7 +114,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 } @@ -196,8 +198,9 @@ 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 f.progress != nil { + if !opts.DisableProgress && f.progress != nil { f.progress.StartProgressIndicator() defer f.progress.StopProgressIndicator() } @@ -300,32 +303,59 @@ 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) { +// 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) + 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 + tail := m[4] + return repo, prNumber, tail, 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) { diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 0f6da5a6e..3f9ba6f50 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" ) @@ -20,6 +21,7 @@ func TestParseURL(t *testing.T) { arg string wantRepo ghrepo.Interface wantNum int + wantRest string wantErr string }{ { @@ -34,15 +36,46 @@ 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 .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/", + 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", @@ -62,7 +95,7 @@ func TestParseURL(t *testing.T) { 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) @@ -72,19 +105,96 @@ 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)) }) } } +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) gitConfigClient stubGitConfigClient + progress *stubProgressIndicator selector string fields []string baseBranch string + disableProgress bool } func TestFind(t *testing.T) { @@ -120,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", @@ -716,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) { @@ -739,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 @@ -839,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 +} 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)) 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.