Merge pull request #11600 from cli/kw/997-gh-agent-task-responds-and-exits-if-user-not-authenticated-with-oauth

Introduce `gh agent-task`
This commit is contained in:
Kynan Ware 2025-08-27 17:27:18 -06:00 committed by GitHub
commit 09be17e18f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 213 additions and 0 deletions

View file

@ -0,0 +1,62 @@
package agent
import (
"errors"
"fmt"
"strings"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/go-gh/v2/pkg/auth"
"github.com/spf13/cobra"
)
// NewCmdAgentTask creates the base `agent-task` command.
func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "agent-task",
Aliases: []string{"agent-tasks", "agent", "agents"},
Short: "Manage agent tasks (preview)",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return requireOAuthToken(f)
},
// This is required to run this root command. We want to
// run it to test PersistentPreRunE behavior.
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
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
}

View file

@ -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)
}

View file

@ -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))