diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index f5867b1..14edb94 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -235,6 +235,34 @@ Aliases p ``` +### `azdo pipelines agent` + +Manage Azure DevOps pipeline agents + +Aliases + +``` +agents, a +``` + +#### `azdo pipelines agent show [ORGANIZATION/]POOL/AGENT [flags]` + +Show details of a pipeline agent + +``` + --include-capabilities Include system and user capabilities in the output +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. +-r, --raw Dump raw agent object to stderr +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +view, status +``` + ### `azdo pipelines variable-group` Manage Azure DevOps variable groups diff --git a/docs/azdo_pipelines.md b/docs/azdo_pipelines.md index c0b9904..4ec1b9c 100644 --- a/docs/azdo_pipelines.md +++ b/docs/azdo_pipelines.md @@ -4,6 +4,7 @@ Manage Azure DevOps pipelines ### Available commands +* [azdo pipelines agent](./azdo_pipelines_agent.md) * [azdo pipelines variable-group](./azdo_pipelines_variable-group.md) ### ALIASES diff --git a/docs/azdo_pipelines_agent.md b/docs/azdo_pipelines_agent.md new file mode 100644 index 0000000..76eaab6 --- /dev/null +++ b/docs/azdo_pipelines_agent.md @@ -0,0 +1,39 @@ +## Command `azdo pipelines agent` + +Manage Azure DevOps pipeline agents. Agents are the compute targets +that run build, release, and other pipeline jobs. Each agent belongs +to an agent pool, which is identified by name or numeric ID. + +Targets are specified in POOL/AGENT format where each component can +be a numeric ID or a name. An optional organization prefix can be +included: [ORGANIZATION/]POOL/AGENT. + + +### Available commands + +* [azdo pipelines agent show](./azdo_pipelines_agent_show.md) + +### ALIASES + +- `agents` +- `a` + +### Examples + +```bash +# Show agent by pool ID and agent ID +azdo pipelines agent show 1/42 + +# Show agent by pool name and agent name +azdo pipelines agent show 'Default/my-agent' + +# Show agent in a different organization +azdo pipelines agent show 'myorg/1/42' + +# Show agent with system and user capabilities +azdo pipelines agent show 1/42 --include-capabilities +``` + +### See also + +* [azdo pipelines](./azdo_pipelines.md) diff --git a/docs/azdo_pipelines_agent_show.md b/docs/azdo_pipelines_agent_show.md new file mode 100644 index 0000000..cef895f --- /dev/null +++ b/docs/azdo_pipelines_agent_show.md @@ -0,0 +1,66 @@ +## Command `azdo pipelines agent show` + +``` +azdo pipelines agent show [ORGANIZATION/]POOL/AGENT [flags] +``` + +Display the details of a single Azure DevOps pipeline agent. +The agent is specified as a pool and agent ID or name, with +an optional organization prefix. + + +### Options + + +* `--include-capabilities` + + Include system and user capabilities in the output + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `-r`, `--raw` + + Dump raw agent object to stderr + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `view` +- `status` + +### JSON Fields + +`_links`, `accessPoint`, `assignedRequest`, `authorization`, `createdBy`, `createdOn`, `enabled`, `id`, `lastCompletedRequest`, `maxParallelism`, `name`, `osDescription`, `pendingUpdate`, `pool`, `properties`, `provisioningState`, `status`, `statusChangedOn`, `systemCapabilities`, `userCapabilities`, `version` + +### Examples + +```bash +# Show an agent by pool ID and agent ID +azdo pipelines agent show 1/42 + +# Show an agent by pool name and agent name +azdo pipelines agent show 'Default/my-agent' + +# Show an agent in a specific organization +azdo pipelines agent show 'myorg/Default/my-agent' + +# Show an agent with capabilities +azdo pipelines agent show 1/42 --include-capabilities + +# Show agent as JSON +azdo pipelines agent show 1/42 --json +``` + +### See also + +* [azdo pipelines agent](./azdo_pipelines_agent.md) diff --git a/internal/cmd/pipelines/agent/agent.go b/internal/cmd/pipelines/agent/agent.go new file mode 100644 index 0000000..db56c89 --- /dev/null +++ b/internal/cmd/pipelines/agent/agent.go @@ -0,0 +1,42 @@ +package agent + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent/show" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent", + Short: "Manage Azure DevOps pipeline agents", + Long: heredoc.Doc(` + Manage Azure DevOps pipeline agents. Agents are the compute targets + that run build, release, and other pipeline jobs. Each agent belongs + to an agent pool, which is identified by name or numeric ID. + + Targets are specified in POOL/AGENT format where each component can + be a numeric ID or a name. An optional organization prefix can be + included: [ORGANIZATION/]POOL/AGENT. + `), + Example: heredoc.Doc(` + # Show agent by pool ID and agent ID + azdo pipelines agent show 1/42 + + # Show agent by pool name and agent name + azdo pipelines agent show 'Default/my-agent' + + # Show agent in a different organization + azdo pipelines agent show 'myorg/1/42' + + # Show agent with system and user capabilities + azdo pipelines agent show 1/42 --include-capabilities + `), + Aliases: []string{"agents", "a"}, + } + + cmd.AddCommand(show.NewCmd(ctx)) + return cmd +} diff --git a/internal/cmd/pipelines/agent/shared/resolve.go b/internal/cmd/pipelines/agent/shared/resolve.go new file mode 100644 index 0000000..776d6d8 --- /dev/null +++ b/internal/cmd/pipelines/agent/shared/resolve.go @@ -0,0 +1,121 @@ +package shared + +import ( + "fmt" + "strconv" + "strings" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +func ResolvePoolAgent( + cmdCtx util.CmdContext, + client taskagent.Client, + org, poolTarget, agentTarget string, +) (*taskagent.TaskAgent, error) { + if strings.TrimSpace(poolTarget) == "" { + return nil, util.FlagErrorf("pool target cannot be empty") + } + if strings.TrimSpace(agentTarget) == "" { + return nil, util.FlagErrorf("agent target cannot be empty") + } + + poolID, err := ResolvePool(cmdCtx, client, poolTarget) + if err != nil { + return nil, err + } + + agentID, err := ResolveAgent(cmdCtx, client, poolID, agentTarget) + if err != nil { + return nil, err + } + + agent, err := client.GetAgent(cmdCtx.Context(), taskagent.GetAgentArgs{ + PoolId: types.ToPtr(poolID), + AgentId: types.ToPtr(agentID), + }) + if err != nil { + return nil, fmt.Errorf("failed to get agent: %w", err) + } + if agent == nil { + return nil, fmt.Errorf("agent %q not found", agentTarget) + } + + return agent, nil +} + +func ResolvePool(cmdCtx util.CmdContext, client taskagent.Client, target string) (int, error) { + if id, err := strconv.Atoi(target); err == nil { + if id < 0 { + return 0, util.FlagErrorf("invalid pool id %d", id) + } + return id, nil + } + + pools, err := client.GetAgentPools(cmdCtx.Context(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr(target), + }) + if err != nil { + return 0, fmt.Errorf("failed to list agent pools: %w", err) + } + + var matches []taskagent.TaskAgentPool + if pools != nil { + for _, p := range *pools { + if p.Name != nil && strings.EqualFold(*p.Name, target) { + matches = append(matches, p) + } + } + } + + if len(matches) == 0 { + return 0, fmt.Errorf("pool %q not found", target) + } + if len(matches) > 1 { + return 0, fmt.Errorf("multiple pools named %q found; specify the numeric ID", target) + } + if matches[0].Id == nil { + return 0, fmt.Errorf("pool %q returned without an ID", target) + } + return *matches[0].Id, nil +} + +func ResolveAgent(cmdCtx util.CmdContext, client taskagent.Client, poolID int, target string) (int, error) { + if id, err := strconv.Atoi(target); err == nil { + if id < 0 { + return 0, util.FlagErrorf("invalid agent id %d", id) + } + return id, nil + } + + agents, err := client.GetAgents(cmdCtx.Context(), taskagent.GetAgentsArgs{ + PoolId: types.ToPtr(poolID), + AgentName: types.ToPtr(target), + }) + if err != nil { + return 0, fmt.Errorf("failed to list agents in pool %d: %w", poolID, err) + } + + var matches []taskagent.TaskAgent + if agents != nil { + for _, a := range *agents { + if a.Name != nil && strings.EqualFold(*a.Name, target) { + matches = append(matches, a) + } + } + } + + if len(matches) == 0 { + return 0, fmt.Errorf("agent %q not found in pool %d", target, poolID) + } + if len(matches) > 1 { + return 0, fmt.Errorf("multiple agents named %q found in pool %d; specify the numeric ID", target, poolID) + } + if matches[0].Id == nil { + return 0, fmt.Errorf("agent %q returned without an ID", target) + } + return *matches[0].Id, nil +} diff --git a/internal/cmd/pipelines/agent/shared/resolve_test.go b/internal/cmd/pipelines/agent/shared/resolve_test.go new file mode 100644 index 0000000..3570c75 --- /dev/null +++ b/internal/cmd/pipelines/agent/shared/resolve_test.go @@ -0,0 +1,382 @@ +package shared + +import ( + "context" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/types" +) + +func newCtrl(t *testing.T) *gomock.Controller { + t.Helper() + c := gomock.NewController(t) + t.Cleanup(c.Finish) + return c +} + +func TestResolvePoolAgent_NumericPoolNumericAgent(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgent(gomock.Any(), taskagent.GetAgentArgs{ + PoolId: types.ToPtr(1), + AgentId: types.ToPtr(42), + }).Return(&taskagent.TaskAgent{ + Id: types.ToPtr(42), + Name: types.ToPtr("my-agent"), + Version: types.ToPtr("4.0.0"), + Enabled: types.ToPtr(true), + }, nil) + + agent, err := ResolvePoolAgent(ctx, client, "org", "1", "42") + require.NoError(t, err) + require.NotNil(t, agent) + assert.Equal(t, 42, *agent.Id) + assert.Equal(t, "my-agent", *agent.Name) +} + +func TestResolvePoolAgent_NamePoolNameAgent(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgentPools(gomock.Any(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr("Default"), + }).Return(&[]taskagent.TaskAgentPool{ + {Id: types.ToPtr(1), Name: types.ToPtr("Default")}, + }, nil) + + client.EXPECT().GetAgents(gomock.Any(), taskagent.GetAgentsArgs{ + PoolId: types.ToPtr(1), + AgentName: types.ToPtr("my-agent"), + }).Return(&[]taskagent.TaskAgent{ + {Id: types.ToPtr(42), Name: types.ToPtr("my-agent")}, + }, nil) + + client.EXPECT().GetAgent(gomock.Any(), taskagent.GetAgentArgs{ + PoolId: types.ToPtr(1), + AgentId: types.ToPtr(42), + }).Return(&taskagent.TaskAgent{ + Id: types.ToPtr(42), + Name: types.ToPtr("my-agent"), + Version: types.ToPtr("4.0.0"), + Enabled: types.ToPtr(true), + }, nil) + + agent, err := ResolvePoolAgent(ctx, client, "org", "Default", "my-agent") + require.NoError(t, err) + require.NotNil(t, agent) + assert.Equal(t, 42, *agent.Id) + assert.Equal(t, "my-agent", *agent.Name) +} + +func TestResolvePoolAgent_MixedNumericName(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgents(gomock.Any(), taskagent.GetAgentsArgs{ + PoolId: types.ToPtr(1), + AgentName: types.ToPtr("my-agent"), + }).Return(&[]taskagent.TaskAgent{ + {Id: types.ToPtr(42), Name: types.ToPtr("my-agent")}, + }, nil) + + client.EXPECT().GetAgent(gomock.Any(), taskagent.GetAgentArgs{ + PoolId: types.ToPtr(1), + AgentId: types.ToPtr(42), + }).Return(&taskagent.TaskAgent{ + Id: types.ToPtr(42), + Name: types.ToPtr("my-agent"), + Version: types.ToPtr("4.0.0"), + Enabled: types.ToPtr(true), + }, nil) + + agent, err := ResolvePoolAgent(ctx, client, "org", "1", "my-agent") + require.NoError(t, err) + require.NotNil(t, agent) + assert.Equal(t, 42, *agent.Id) +} + +func TestResolvePoolAgent_PoolNotFound(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgentPools(gomock.Any(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr("Nonexistent"), + }).Return(&[]taskagent.TaskAgentPool{}, nil) + + _, err := ResolvePoolAgent(ctx, client, "org", "Nonexistent", "42") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestResolvePoolAgent_AmbiguousPool(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgentPools(gomock.Any(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr("Default"), + }).Return(&[]taskagent.TaskAgentPool{ + {Id: types.ToPtr(1), Name: types.ToPtr("Default")}, + {Id: types.ToPtr(2), Name: types.ToPtr("Default")}, + }, nil) + + _, err := ResolvePoolAgent(ctx, client, "org", "Default", "42") + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple pools") +} + +func TestResolvePoolAgent_AgentNotFound(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgentPools(gomock.Any(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr("Default"), + }).Return(&[]taskagent.TaskAgentPool{ + {Id: types.ToPtr(1), Name: types.ToPtr("Default")}, + }, nil) + + client.EXPECT().GetAgents(gomock.Any(), taskagent.GetAgentsArgs{ + PoolId: types.ToPtr(1), + AgentName: types.ToPtr("ghost"), + }).Return(&[]taskagent.TaskAgent{}, nil) + + _, err := ResolvePoolAgent(ctx, client, "org", "Default", "ghost") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestResolvePoolAgent_AmbiguousAgent(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgentPools(gomock.Any(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr("Default"), + }).Return(&[]taskagent.TaskAgentPool{ + {Id: types.ToPtr(1), Name: types.ToPtr("Default")}, + }, nil) + + client.EXPECT().GetAgents(gomock.Any(), taskagent.GetAgentsArgs{ + PoolId: types.ToPtr(1), + AgentName: types.ToPtr("dup"), + }).Return(&[]taskagent.TaskAgent{ + {Id: types.ToPtr(10), Name: types.ToPtr("dup")}, + {Id: types.ToPtr(11), Name: types.ToPtr("dup")}, + }, nil) + + _, err := ResolvePoolAgent(ctx, client, "org", "Default", "dup") + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple agents") +} + +func TestResolvePool_Numeric(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + id, err := ResolvePool(ctx, client, "42") + require.NoError(t, err) + assert.Equal(t, 42, id) +} + +func TestResolvePool_NegativeNumeric(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + + _, err := ResolvePool(ctx, client, "-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid pool id") +} + +func TestResolvePool_ByName(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgentPools(gomock.Any(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr("Default"), + }).Return(&[]taskagent.TaskAgentPool{ + {Id: types.ToPtr(1), Name: types.ToPtr("Default")}, + }, nil) + + id, err := ResolvePool(ctx, client, "Default") + require.NoError(t, err) + assert.Equal(t, 1, id) +} + +func TestResolvePool_NotFound(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgentPools(gomock.Any(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr("Ghost"), + }).Return(&[]taskagent.TaskAgentPool{}, nil) + + _, err := ResolvePool(ctx, client, "Ghost") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestResolvePool_Ambiguous(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgentPools(gomock.Any(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr("Default"), + }).Return(&[]taskagent.TaskAgentPool{ + {Id: types.ToPtr(1), Name: types.ToPtr("Default")}, + {Id: types.ToPtr(2), Name: types.ToPtr("Default")}, + }, nil) + + _, err := ResolvePool(ctx, client, "Default") + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple pools") +} + +func TestResolveAgent_Numeric(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + + id, err := ResolveAgent(ctx, client, 1, "42") + require.NoError(t, err) + assert.Equal(t, 42, id) +} + +func TestResolveAgent_NegativeNumeric(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + + _, err := ResolveAgent(ctx, client, 1, "-5") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid agent id") +} + +func TestResolveAgent_ByName(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgents(gomock.Any(), taskagent.GetAgentsArgs{ + PoolId: types.ToPtr(1), + AgentName: types.ToPtr("my-agent"), + }).Return(&[]taskagent.TaskAgent{ + {Id: types.ToPtr(42), Name: types.ToPtr("my-agent")}, + }, nil) + + id, err := ResolveAgent(ctx, client, 1, "my-agent") + require.NoError(t, err) + assert.Equal(t, 42, id) +} + +func TestResolveAgent_NotFound(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgents(gomock.Any(), taskagent.GetAgentsArgs{ + PoolId: types.ToPtr(1), + AgentName: types.ToPtr("ghost"), + }).Return(&[]taskagent.TaskAgent{}, nil) + + _, err := ResolveAgent(ctx, client, 1, "ghost") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestResolveAgent_Ambiguous(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgents(gomock.Any(), taskagent.GetAgentsArgs{ + PoolId: types.ToPtr(1), + AgentName: types.ToPtr("dup"), + }).Return(&[]taskagent.TaskAgent{ + {Id: types.ToPtr(10), Name: types.ToPtr("dup")}, + {Id: types.ToPtr(11), Name: types.ToPtr("dup")}, + }, nil) + + _, err := ResolveAgent(ctx, client, 1, "dup") + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple agents") +} + + + +func TestResolvePoolAgent_NegativePoolID(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + _, err := ResolvePoolAgent(ctx, client, "org", "-1", "42") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid pool id") +} + +func TestResolvePoolAgent_NegativeAgentID(t *testing.T) { + t.Parallel() + ctrl := newCtrl(t) + ctx := mocks.NewMockCmdContext(ctrl) + client := mocks.NewMockTaskAgentClient(ctrl) + ctx.EXPECT().Context().Return(context.Background()).AnyTimes() + + client.EXPECT().GetAgentPools(gomock.Any(), taskagent.GetAgentPoolsArgs{ + PoolName: types.ToPtr("Default"), + }).Return(&[]taskagent.TaskAgentPool{ + {Id: types.ToPtr(1), Name: types.ToPtr("Default")}, + }, nil) + + _, err := ResolvePoolAgent(ctx, client, "org", "Default", "-5") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid agent id") +} diff --git a/internal/cmd/pipelines/agent/show/show.go b/internal/cmd/pipelines/agent/show/show.go new file mode 100644 index 0000000..b5e4a36 --- /dev/null +++ b/internal/cmd/pipelines/agent/show/show.go @@ -0,0 +1,152 @@ +package show + +import ( + _ "embed" + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/spewerspew/spew" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/template" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type templateData struct { + Agent *taskagent.TaskAgent + PoolName string + IncludeCapabilities bool +} + +type showOptions struct { + targetArg string + includeCapabilities bool + raw bool + exporter util.Exporter +} + +//go:embed show.tpl +var showTempl string + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &showOptions{} + + cmd := &cobra.Command{ + Use: "show [ORGANIZATION/]POOL/AGENT", + Short: "Show details of a pipeline agent", + Long: heredoc.Doc(` + Display the details of a single Azure DevOps pipeline agent. + The agent is specified as a pool and agent ID or name, with + an optional organization prefix. + `), + Example: heredoc.Doc(` + # Show an agent by pool ID and agent ID + azdo pipelines agent show 1/42 + + # Show an agent by pool name and agent name + azdo pipelines agent show 'Default/my-agent' + + # Show an agent in a specific organization + azdo pipelines agent show 'myorg/Default/my-agent' + + # Show an agent with capabilities + azdo pipelines agent show 1/42 --include-capabilities + + # Show agent as JSON + azdo pipelines agent show 1/42 --json + `), + Aliases: []string{ + "view", + "status", + }, + Args: util.ExactArgs(1, "agent target is required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runShow(ctx, opts) + }, + } + + cmd.Flags().BoolVar(&opts.includeCapabilities, "include-capabilities", false, "Include system and user capabilities in the output") + cmd.Flags().BoolVarP(&opts.raw, "raw", "r", false, "Dump raw agent object to stderr") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "name", "pool", "status", "enabled", "version", "osDescription", + "accessPoint", "provisioningState", "maxParallelism", + "createdOn", "statusChangedOn", "createdBy", "authorization", + "systemCapabilities", "userCapabilities", "assignedRequest", + "lastCompletedRequest", "pendingUpdate", "properties", "_links", + }) + + return cmd +} + +func runShow(cmdCtx util.CmdContext, opts *showOptions) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParsePoolAgentTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + poolTarget := scope.Targets[0] + agentTarget := scope.Targets[1] + + taskClient, err := cmdCtx.ClientFactory().TaskAgent(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create task agent client: %w", err) + } + + agent, err := shared.ResolvePoolAgent(cmdCtx, taskClient, scope.Organization, poolTarget, agentTarget) + if err != nil { + return err + } + + zap.L().Debug( + "resolved agent", + zap.String("organization", scope.Organization), + zap.String("pool", poolTarget), + zap.String("agent", agentTarget), + zap.Int("agentId", types.GetValue(agent.Id, 0)), + ) + + if opts.raw { + ios.StopProgressIndicator() + spew.Dump(agent) + return nil + } + + if opts.exporter != nil { + ios.StopProgressIndicator() + return opts.exporter.Write(ios, agent) + } + + ios.StopProgressIndicator() + + t := template.New( + ios.Out, + ios.TerminalWidth(), + ios.ColorEnabled(), + ). + WithTheme(ios.TerminalTheme()). + WithFuncs(map[string]any{ + "hasText": template.HasText, + }) + err = t.Parse(showTempl) + if err != nil { + return err + } + + return t.ExecuteData(templateData{ + Agent: agent, + PoolName: poolTarget, + IncludeCapabilities: opts.includeCapabilities, + }) +} diff --git a/internal/cmd/pipelines/agent/show/show.tpl b/internal/cmd/pipelines/agent/show/show.tpl new file mode 100644 index 0000000..2d4988a --- /dev/null +++ b/internal/cmd/pipelines/agent/show/show.tpl @@ -0,0 +1,35 @@ +{{- $a := .Agent -}} +{{- with $a.Links -}} +{{- $self := index . "self" -}} +{{- with $self -}} +{{- $href := index . "href" -}} +{{- with $href -}} +{{bold "url:"}} {{hyperlink . .}} +{{- end -}} +{{- end -}} +{{- end -}} +{{if hasText $a.Id}}{{bold "id:"}} {{$a.Id}}{{end}} +{{if hasText $a.Name}}{{bold "name:"}} {{$a.Name}}{{end}} +{{bold "pool:"}} {{.PoolName}} +{{if hasText $a.Status}}{{bold "status:"}} {{$a.Status}}{{end}} +{{if hasText $a.Enabled}}{{bold "enabled:"}} {{$a.Enabled}}{{end}} +{{if hasText $a.Version}}{{bold "version:"}} {{$a.Version}}{{end}} +{{if hasText $a.OsDescription}}{{bold "os description:"}} {{$a.OsDescription}}{{end}} +{{if hasText $a.AccessPoint}}{{bold "access point:"}} {{$a.AccessPoint}}{{end}} +{{if hasText $a.MaxParallelism}}{{bold "max parallelism:"}} {{$a.MaxParallelism}}{{end}} +{{if $a.CreatedOn}}{{bold "created on:"}} {{timeago $a.CreatedOn.Time}} ({{$a.CreatedOn.Time.Format "2006-01-02 15:04 MST"}}){{end}} +{{if $a.StatusChangedOn}}{{bold "last status change:"}} {{timeago $a.StatusChangedOn.Time}} ({{$a.StatusChangedOn.Time.Format "2006-01-02 15:04 MST"}}){{end}} +{{- if .IncludeCapabilities -}} +{{- if $a.SystemCapabilities -}} +{{bold "system capabilities:"}} +{{- range $key, $val := $a.SystemCapabilities}} + {{$key}}: {{$val}} +{{- end -}} +{{- end -}} +{{- if $a.UserCapabilities -}} +{{bold "user capabilities:"}} +{{- range $key, $val := $a.UserCapabilities}} + {{$key}}: {{$val}} +{{- end -}} +{{- end -}} +{{- end -}} diff --git a/internal/cmd/pipelines/agent/show/show_test.go b/internal/cmd/pipelines/agent/show/show_test.go new file mode 100644 index 0000000..fb74bbe --- /dev/null +++ b/internal/cmd/pipelines/agent/show/show_test.go @@ -0,0 +1,187 @@ +package show + +import ( + "context" + "errors" + "testing" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/types" +) + +func TestShowCmd_NumericTargets(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, outBuf, _ := iostreams.Test() + io.SetStdoutTTY(false) + + cmdCtx := mocks.NewMockCmdContext(ctrl) + clientFactory := mocks.NewMockClientFactory(ctrl) + taskClient := mocks.NewMockTaskAgentClient(ctrl) + + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + cmdCtx.EXPECT().ClientFactory().Return(clientFactory).AnyTimes() + clientFactory.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(taskClient, nil) + + taskClient.EXPECT().GetAgent(gomock.Any(), taskagent.GetAgentArgs{ + PoolId: types.ToPtr(1), + AgentId: types.ToPtr(42), + }).Return(&taskagent.TaskAgent{ + Id: types.ToPtr(42), + Name: types.ToPtr("my-agent"), + Version: types.ToPtr("4.0.0"), + Enabled: types.ToPtr(true), + Status: &taskagent.TaskAgentStatusValues.Online, + }, nil) + + cmd := NewCmd(cmdCtx) + cmd.SetArgs([]string{"myorg/1/42"}) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + out := outBuf.String() + assert.Contains(t, out, "my-agent") + assert.Contains(t, out, "42") + assert.Contains(t, out, "4.0.0") + assert.Contains(t, out, "online") +} + +func TestShowCmd_JSONOutput(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, outBuf, _ := iostreams.Test() + io.SetStdoutTTY(false) + + cmdCtx := mocks.NewMockCmdContext(ctrl) + clientFactory := mocks.NewMockClientFactory(ctrl) + taskClient := mocks.NewMockTaskAgentClient(ctrl) + + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + cmdCtx.EXPECT().ClientFactory().Return(clientFactory).AnyTimes() + clientFactory.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(taskClient, nil) + + taskClient.EXPECT().GetAgent(gomock.Any(), taskagent.GetAgentArgs{ + PoolId: types.ToPtr(1), + AgentId: types.ToPtr(42), + }).Return(&taskagent.TaskAgent{ + Id: types.ToPtr(42), + Name: types.ToPtr("my-agent"), + Version: types.ToPtr("4.0.0"), + Enabled: types.ToPtr(true), + Status: &taskagent.TaskAgentStatusValues.Online, + }, nil) + + cmd := NewCmd(cmdCtx) + cmd.SetArgs([]string{"myorg/1/42", "--json"}) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + + out := outBuf.String() + assert.Contains(t, out, `"id":42`) + assert.Contains(t, out, `"name":"my-agent"`) + assert.Contains(t, out, `"version":"4.0.0"`) +} + +func TestShowCmd_MissingArg(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + + cmdCtx := mocks.NewMockCmdContext(ctrl) + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + + cmd := NewCmd(cmdCtx) + cmd.SetArgs([]string{}) + + _, err := cmd.ExecuteC() + require.Error(t, err) + assert.Contains(t, err.Error(), "agent target is required") +} + +func TestShowCmd_GetAgentError(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + + cmdCtx := mocks.NewMockCmdContext(ctrl) + clientFactory := mocks.NewMockClientFactory(ctrl) + taskClient := mocks.NewMockTaskAgentClient(ctrl) + + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmdCtx.EXPECT().Context().Return(context.Background()).AnyTimes() + cmdCtx.EXPECT().ClientFactory().Return(clientFactory).AnyTimes() + clientFactory.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(taskClient, nil) + + taskClient.EXPECT().GetAgent(gomock.Any(), gomock.Any()).Return(nil, errors.New("API failure")) + + cmd := NewCmd(cmdCtx) + cmd.SetArgs([]string{"myorg/1/42"}) + + _, err := cmd.ExecuteC() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get agent") + assert.Contains(t, err.Error(), "API failure") +} + +func TestShowCmd_NoDefaultOrganization(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + + cmdCtx := mocks.NewMockCmdContext(ctrl) + mockConfig := mocks.NewMockConfig(ctrl) + mockAuth := mocks.NewMockAuthConfig(ctrl) + + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmdCtx.EXPECT().Config().Return(mockConfig, nil) + mockConfig.EXPECT().Authentication().Return(mockAuth).AnyTimes() + mockAuth.EXPECT().GetDefaultOrganization().Return("", nil) + + cmd := NewCmd(cmdCtx) + cmd.SetArgs([]string{"1/42"}) + + _, err := cmd.ExecuteC() + require.Error(t, err) + var flagErr *util.FlagError + assert.True(t, errors.As(err, &flagErr)) + assert.Contains(t, err.Error(), "no organization specified") +} + +func TestShowCmd_TooManyArgs(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + + cmdCtx := mocks.NewMockCmdContext(ctrl) + cmdCtx.EXPECT().IOStreams().Return(io, nil).AnyTimes() + + cmd := NewCmd(cmdCtx) + cmd.SetArgs([]string{"myorg/1/42", "extra"}) + + _, err := cmd.ExecuteC() + require.Error(t, err) + assert.Contains(t, err.Error(), "too many arguments") +} diff --git a/internal/cmd/pipelines/pipelines.go b/internal/cmd/pipelines/pipelines.go index e02fd10..bba3a62 100644 --- a/internal/cmd/pipelines/pipelines.go +++ b/internal/cmd/pipelines/pipelines.go @@ -2,6 +2,7 @@ package pipelines import ( "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -14,5 +15,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } cmd.AddCommand(variablegroup.NewCmd(ctx)) + cmd.AddCommand(agent.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/pipelines/variablegroup/show/show.go b/internal/cmd/pipelines/variablegroup/show/show.go index 382d81c..173b2d1 100644 --- a/internal/cmd/pipelines/variablegroup/show/show.go +++ b/internal/cmd/pipelines/variablegroup/show/show.go @@ -202,14 +202,9 @@ func run(cmdCtx util.CmdContext, o *opts) error { } return types.ToPtr(types.GetValue(permission.Authorized, false)) }, - "i": func(v *int) string { return strconv.Itoa(types.GetValue(v, 0)) }, - "s": func(v *string) string { return types.GetValue(v, "") }, - "b": func(v *bool) string { - if v == nil { - return "" - } - return fmt.Sprintf("%v", *v) - }, + "i": func(v *int) string { return strconv.Itoa(types.GetValue(v, 0)) }, + "s": template.StringOrEmpty, + "b": template.BoolString, "ts": func(v *azuredevops.Time) string { return types.GetValue(formatTimePtr(v), "") }, "identity": func(id *webapi.IdentityRef) string { if id == nil { @@ -230,11 +225,9 @@ func run(cmdCtx util.CmdContext, o *opts) error { return identifier } }, - "hasText": func(v *string) bool { return strings.TrimSpace(types.GetValue(v, "")) != "" }, + "hasText": template.HasText, "hasAny": func(v any) bool { return v != nil }, - "vars": func(v *map[string]any) []variableView { - return expandVariables(v) - }, + "vars": expandVariables, "pipelines": func() []authorizedPipelineView { return toAuthorizedPipelines(perms, idToName) }, diff --git a/internal/cmd/pipelines/variablegroup/variable/update/update.go b/internal/cmd/pipelines/variablegroup/variable/update/update.go index 49e821c..a2ed838 100644 --- a/internal/cmd/pipelines/variablegroup/variable/update/update.go +++ b/internal/cmd/pipelines/variablegroup/variable/update/update.go @@ -297,7 +297,7 @@ func run(cmdCtx util.CmdContext, cmd *cobra.Command, opts *opts) error { } // If finalValue is unset, try to preserve currentValue unless clear was requested - if finalValue == nil && !((fromJSONSet && fromPayload.ClearValue != nil && *fromPayload.ClearValue) || opts.clearValue) { + if finalValue == nil && (!fromJSONSet || fromPayload.ClearValue == nil || !*fromPayload.ClearValue) && !opts.clearValue { finalValue = currentValue } diff --git a/internal/cmd/pr/create/create.go b/internal/cmd/pr/create/create.go index fb95be1..8137eff 100644 --- a/internal/cmd/pr/create/create.go +++ b/internal/cmd/pr/create/create.go @@ -4,7 +4,6 @@ import ( "context" _ "embed" "fmt" - "reflect" "strings" "github.com/MakeNowJust/heredoc/v2" @@ -364,28 +363,8 @@ func runCmd(ctx util.CmdContext, opts *createOptions) (err error) { iostreams.ColorEnabled()). WithTheme(iostreams.TerminalTheme()). WithFuncs(map[string]any{ - "s": func(v any) string { - if v == nil { - return "" - } - - val := reflect.ValueOf(v) - if val.Kind() == reflect.Ptr { - if val.IsNil() { - return "" - } - val = val.Elem() - } - - if val.Kind() == reflect.String { - return val.String() - } - - return "" - }, - "notBlank": func(s string) bool { - return strings.TrimSpace(s) != "" - }, + "s": template.StringOrEmpty, + "hasText": template.HasText, "stripprefix": strings.TrimPrefix, }) if err := t.Parse(dryRunTpl); err != nil { diff --git a/internal/cmd/pr/create/create_dry_run.tpl b/internal/cmd/pr/create/create_dry_run.tpl index fba5f45..b24880a 100644 --- a/internal/cmd/pr/create/create_dry_run.tpl +++ b/internal/cmd/pr/create/create_dry_run.tpl @@ -9,6 +9,6 @@ {{end}} {{bold "Description:"}} -{{if notBlank (s .Description)}}{{markdown (s .Description)}}{{else}} +{{if hasText (s .Description)}}{{markdown (s .Description)}}{{else}} None {{end}} diff --git a/internal/cmd/pr/view/view.go b/internal/cmd/pr/view/view.go index a564c4c..91ac118 100644 --- a/internal/cmd/pr/view/view.go +++ b/internal/cmd/pr/view/view.go @@ -3,7 +3,6 @@ package view import ( _ "embed" "fmt" - "reflect" "regexp" "sort" "strings" @@ -499,28 +498,8 @@ func runCmd(ctx util.CmdContext, opts *viewOptions) (err error) { } return s[start:end] }, - "notBlank": func(s string) bool { - return strings.TrimSpace(s) != "" - }, - "s": func(v any) string { - if v == nil { - return "" - } - - val := reflect.ValueOf(v) - if val.Kind() == reflect.Ptr { - if val.IsNil() { - return "" - } - val = val.Elem() - } - - if val.Kind() == reflect.String { - return val.String() - } - - return "" - }, + "hasText": template.HasText, + "s": template.StringOrEmpty, "userReviewers": func(reviewers *[]git.IdentityRefWithVote) (*[]git.IdentityRefWithVote, error) { rl := []git.IdentityRefWithVote{} if len(*reviewers) > 0 { diff --git a/internal/cmd/pr/view/view.tpl b/internal/cmd/pr/view/view.tpl index 6e3588d..8d1a988 100644 --- a/internal/cmd/pr/view/view.tpl +++ b/internal/cmd/pr/view/view.tpl @@ -35,7 +35,7 @@ {{bold "Status:"}} {{.Status}} {{- range .Comments}} -{{bold (s .Author.DisplayName)}}{{if notBlank (s .Author.UniqueName)}} ({{s .Author.UniqueName}}){{end}} commented {{timeago .PublishedDate.Time}} (Type: {{s .CommentType}}): +{{bold (s .Author.DisplayName)}}{{if hasText (s .Author.UniqueName)}} ({{s .Author.UniqueName}}){{end}} commented {{timeago .PublishedDate.Time}} (Type: {{s .CommentType}}): {{markdown (s .Content)}}{{end -}}{{end -}} {{if .Commits -}} {{bold "commits:"}} diff --git a/internal/cmd/serviceendpoint/shared/output.go b/internal/cmd/serviceendpoint/shared/output.go index 35bfded..3fb4104 100644 --- a/internal/cmd/serviceendpoint/shared/output.go +++ b/internal/cmd/serviceendpoint/shared/output.go @@ -3,9 +3,7 @@ package shared import ( _ "embed" "fmt" - "strings" - "github.com/google/uuid" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" @@ -35,30 +33,10 @@ func Output(ctx util.CmdContext, endpoint *serviceendpoint.ServiceEndpoint, expo ios.ColorEnabled()). WithTheme(ios.TerminalTheme()). WithFuncs(map[string]any{ - "s": func(v *string) string { - if v == nil { - return "" - } - return *v - }, - "hasText": func(v *string) bool { - if v == nil { - return false - } - return strings.TrimSpace(*v) != "" - }, - "b": func(v *bool) string { - if v == nil { - return "" - } - return fmt.Sprintf("%v", *v) - }, - "u": func(v *uuid.UUID) string { - if v == nil { - return "" - } - return v.String() - }, + "s": template.StringOrEmpty, + "hasText": template.HasText, + "b": template.BoolString, + "u": template.UUIDString, "scheme": func(ep *serviceendpoint.EndpointAuthorization) string { // We wrap shared.AuthorizationScheme to work with just the authorization part if needed // or we can just pass the whole endpoint. diff --git a/internal/cmd/util/scope.go b/internal/cmd/util/scope.go index 90f7382..bd46d0c 100644 --- a/internal/cmd/util/scope.go +++ b/internal/cmd/util/scope.go @@ -174,6 +174,17 @@ func ParseProjectTargetWithDefaultOrganization(ctx CmdContext, target string) (* }) } +// ParsePoolAgentTargetWithDefaultOrganization resolves a pool/agent target that allows an implicit +// organization by falling back to the configured default. Accepted formats are +// [ORGANIZATION/]POOL/AGENT (2 or 3 segments). +func ParsePoolAgentTargetWithDefaultOrganization(ctx CmdContext, raw string) (*Path, error) { + return Parse(ctx, raw, ParseOptions{ + AllowImplicitOrg: true, + MinTargets: 2, + MaxTargets: 2, + }) +} + // ResolveScopeDescriptor fetches the descriptor representing the project scope when a project is supplied. // It returns the descriptor value along with the project ID string to support callers that need to distinguish // between identically named groups scoped to different projects. diff --git a/internal/template/template.go b/internal/template/template.go index 952827e..1cc3355 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -8,11 +8,13 @@ import ( "fmt" "io" "math" + "reflect" "strconv" "strings" "text/template" "time" + "github.com/google/uuid" color "github.com/mgutz/ansi" "github.com/tmeckel/azdo-cli/internal/markdown" "github.com/tmeckel/azdo-cli/internal/tableprinter" @@ -315,6 +317,62 @@ func truncateMultiline(maxWidth int, s string) string { return text.Truncate(maxWidth, s) } +// HasText returns true if v is a non-nil, non-whitespace string. For non-string pointer types it +// returns true if v is non-nil. Useful as a Go template helper via WithFuncs. +func HasText(v any) bool { + if v == nil { + return false + } + // Typed nil pointer (e.g. (*bool)(nil) stored as any). + if rv := reflect.ValueOf(v); rv.Kind() == reflect.Pointer && rv.IsNil() { + return false + } + if s, ok := v.(*string); ok { + return s != nil && strings.TrimSpace(*s) != "" + } + if s, ok := v.(string); ok { + return strings.TrimSpace(s) != "" + } + return true +} + +// StringOrEmpty is a nil-safe string dereference. Accepts any value: returns "" for nil, typed nil pointers, +// *string, or string; returns the string value otherwise. Useful as a Go template helper via WithFuncs. +func StringOrEmpty(v any) string { + if v == nil { + return "" + } + val := reflect.ValueOf(v) + if val.Kind() == reflect.Pointer { + if val.IsNil() { + return "" + } + val = val.Elem() + } + if val.Kind() == reflect.String { + return val.String() + } + return "" +} + +// BoolString formats a *bool as a string. Returns "" for nil, "true"/"false" otherwise. +// Useful as a Go template helper via WithFuncs. +func BoolString(v *bool) string { + if v == nil { + return "" + } + return fmt.Sprintf("%v", *v) +} + +// UUIDString formats a *uuid.UUID as a string. Returns "" for nil, the UUID string otherwise. +// Useful as a Go template helper via WithFuncs. +func UUIDString(v *uuid.UUID) string { + if v == nil { + return "" + } + return v.String() +} + func hyperlinkFunc(link, text string) string { if text == "" { text = link diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 5b38f1d..d05997a 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -9,10 +9,13 @@ import ( "time" "github.com/MakeNowJust/heredoc/v2" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func ptr(s string) *string { return &s } + func TestJsonScalarToString(t *testing.T) { tests := []struct { name string @@ -444,3 +447,167 @@ func TestFuncs(t *testing.T) { require.NoError(t, err) assert.Equal(t, "trunc \x1b[0;32mopen\x1b[0m test", w.String()) } + +func TestHasText(t *testing.T) { + tests := []struct { + name string + v any + want bool + }{ + {name: "nil", v: nil, want: false}, + {name: "non-nil non-pointer", v: 42, want: true}, + {name: "non-nil struct", v: struct{}{}, want: true}, + {name: "empty string", v: "", want: false}, + {name: "whitespace string", v: " \t\n ", want: false}, + {name: "non-empty string", v: "hello", want: true}, + {name: "nil *string", v: (*string)(nil), want: false}, + {name: "non-nil *string with empty value", v: ptr(""), want: false}, + {name: "non-nil *string with whitespace", v: ptr(" "), want: false}, + {name: "non-nil *string with text", v: ptr("hello"), want: true}, + {name: "nil *bool", v: (*bool)(nil), want: false}, + {name: "non-nil *bool", v: func() *bool { b := true; return &b }(), want: true}, + {name: "nil *int", v: (*int)(nil), want: false}, + {name: "pure whitespace string single char", v: " ", want: false}, + {name: "pure whitespace string tab", v: "\t", want: false}, + {name: "pure whitespace string newline", v: "\n", want: false}, + {name: "non-nil *string with newlines", v: ptr("\n\n"), want: false}, + {name: "empty interface value", v: any(""), want: false}, + {name: "interface with non-empty string", v: any("text"), want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, HasText(tt.v)) + }) + } +} + +func TestHasText_templateIntegration(t *testing.T) { + tests := []struct { + name string + tpl string + fields any + want string + }{ + { + name: "hasText returns false for empty string — block hidden", + tpl: `{{if hasText .Name}}SHOW{{end}}`, + fields: struct { + Name string + Val *string + }{Name: "", Val: nil}, + want: "", + }, + { + name: "hasText returns true for non-empty string — block shown", + tpl: `{{if hasText .Name}}{{.Name}}{{end}}`, + fields: struct { + Name string + }{Name: "hello"}, + want: "hello", + }, + { + name: "hasText returns false for nil *string — block hidden", + tpl: `{{if hasText .Val}}SHOW{{end}}`, + fields: struct { + Name string + Val *string + }{Name: "hello", Val: nil}, + want: "", + }, + { + name: "hasText returns false for whitespace-only *string — block hidden", + tpl: `{{if hasText .Val}}SHOW{{end}}`, + fields: struct { + Val *string + }{Val: ptr(" ")}, + want: "", + }, + { + name: "hasText returns true for non-empty *string — block shown", + tpl: `{{if hasText .Val}}{{.Val}}{{end}}`, + fields: struct { + Val *string + }{Val: ptr("world")}, + want: "world", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var w bytes.Buffer + tmpl := New(&w, 80, false) + tmpl = tmpl.WithFuncs(map[string]any{"hasText": HasText}) + err := tmpl.Parse(tt.tpl) + require.NoError(t, err) + err = tmpl.ExecuteData(tt.fields) + require.NoError(t, err) + err = tmpl.Flush() + require.NoError(t, err) + assert.Equal(t, tt.want, w.String()) + }) + } +} + +func TestStringOrEmpty(t *testing.T) { + tests := []struct { + name string + v any + want string + }{ + {name: "nil", v: nil, want: ""}, + {name: "non-nil non-string", v: 42, want: ""}, + {name: "non-nil struct", v: struct{}{}, want: ""}, + {name: "empty string", v: "", want: ""}, + {name: "non-empty string", v: "hello", want: "hello"}, + {name: "nil *string", v: (*string)(nil), want: ""}, + {name: "non-nil *string empty", v: ptr(""), want: ""}, + {name: "non-nil *string with text", v: ptr("world"), want: "world"}, + {name: "nil *int", v: (*int)(nil), want: ""}, + {name: "non-nil *int with value", v: func() *int { i := 42; return &i }(), want: ""}, + {name: "non-nil *bool", v: func() *bool { b := true; return &b }(), want: ""}, + {name: "whitespace string", v: " ", want: " "}, + {name: "nil **string", v: func() **string { return nil }(), want: ""}, + {name: "**string with inner nil", v: func() **string { s := (*string)(nil); return &s }(), want: ""}, + {name: "**string with inner value", v: func() **string { s := ptr("nested"); return &s }(), want: ""}, + {name: "nil map", v: map[string]string(nil), want: ""}, + {name: "empty slice", v: []int{}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, StringOrEmpty(tt.v)) + }) + } +} + +func TestBoolString(t *testing.T) { + tests := []struct { + name string + v *bool + want string + }{ + {name: "nil", v: nil, want: ""}, + {name: "true", v: func() *bool { b := true; return &b }(), want: "true"}, + {name: "false", v: func() *bool { b := false; return &b }(), want: "false"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, BoolString(tt.v)) + }) + } +} + +func TestUUIDString(t *testing.T) { + id := uuid.MustParse("12345678-1234-5678-1234-567812345678") + tests := []struct { + name string + v *uuid.UUID + want string + }{ + {name: "nil", v: nil, want: ""}, + {name: "valid UUID", v: &id, want: "12345678-1234-5678-1234-567812345678"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, UUIDString(tt.v)) + }) + } +}