Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,13 @@ Do not hand-roll HTTP calls if an SDK client can be introduced through this proc
### Implementing Commands with JSON and Table/Plain Output

- **JSON and Table/Plain output are handled via separate code paths.**
- Use `util.AddJSONFlags(cmd, &opts.exporter, ...)` in `NewCmd` to register JSON-related flags (`--json`, `--jq`, `--template`). This populates the `opts.exporter` field when a user specifies one of those flags.
- Use `util.AddJSONFlags(cmd, &opts.exporter, ...)` in `NewCmd` to register JSON-related flags (`--json`, `--jq`, `--template`). This populates the `opts.exporter` field when a user specifies one of those flags. The string slice you pass **must** list every JSON field you expose (matching the struct tag names) so users can filter output predictably.

- **JSON Output Logic:**
- In the command's `run...` function, check if `opts.exporter != nil`.
- If true, this indicates the user wants JSON output.
- Create and populate a struct (either named or anonymous) with the data to be exported. This struct should have `json:"..."` tags.
- Define a dedicated view struct (or slice of structs) that represents the JSON surface you intend to support. Field names must match the strings you register with `util.AddJSONFlags` and every optional field should use a pointer type with `json:"...,omitempty"` so unset values disappear from the payload.
- Populate that view struct from the SDK model (write small helper functions when mapping requires normalization—e.g., formatting `azuredevops.Time`, collapsing identities, adding derived counts). Avoid returning the raw SDK types directly; surface only the columns you are committed to supporting.
- Call `opts.exporter.Write(ios, result)` to serialize the struct and print it.

- **Table/Plain Output Logic:**
Expand Down Expand Up @@ -176,12 +177,12 @@ Do not hand-roll HTTP calls if an SDK client can be introduced through this proc
### Implementing Commands with JSON and Table/Plain Output

- **JSON and Table/Plain output are handled via separate code paths.**
- Use `util.AddJSONFlags(cmd, &opts.exporter, ...)` in `NewCmd` to register JSON-related flags (`--json`, `--jq`, `--template`). This populates the `opts.exporter` field when a user specifies one of those flags.
- Use `util.AddJSONFlags(cmd, &opts.exporter, ...)` in `NewCmd` to register JSON-related flags (`--json`, `--jq`, `--template`). This populates the `opts.exporter` field when a user specifies one of those flags, and the provided slice must enumerate every JSON field (matching the struct tags) that the command supports so consumers can filter reliably.

- **JSON Output Logic:**
- In the command's `run...` function, check if `opts.exporter != nil`.
- If true, this indicates the user wants JSON output.
- Create and populate a struct (either named or anonymous) with the data to be exported. This struct should have `json:"..."` tags. If there are optional fields in the struct use a pointer type and add `omitempty` to the JSON tag.
- Define a dedicated view struct (or slice of structs) with explicit `json:"..."` tags and register the matching field names with `util.AddJSONFlags`. Use pointer types plus `omitempty` for optional fields so unset data is omitted, and map values from the SDK into this view (formatting times, flattening identities, computing derived helpers). Do not expose raw SDK structs in the JSON response.
- Call `opts.exporter.Write(ios, result)` to serialize the struct and print it.

- **Table/Plain Output Logic:**
Expand Down
1 change: 1 addition & 0 deletions docs/azdo.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Work seamlessly with Azure DevOps from the command line.
### Additional commands

* [azdo config](./azdo_config.md)
* [azdo pipelines](./azdo_pipelines.md)

### Options

Expand Down
40 changes: 40 additions & 0 deletions docs/azdo_help_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,46 @@ Aliases
ls
```

## `azdo pipelines`

Manage Azure DevOps pipelines

Aliases

```
p
```

### `azdo pipelines variable-groups`

Manage Azure DevOps variable groups

Aliases

```
variable-groups, variablegroups, vg
```

#### `azdo pipelines variable-groups list [ORGANIZATION/]PROJECT [flags]`

List variable groups

```
--action string Action filter string (e.g., 'manage', 'use'): {none|manage|use}
-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.
--max-items int Optional client-side cap on results; stop fetching once reached
--order string Order of variable groups (asc, desc): {desc|asc} (default "desc")
-t, --template string Format JSON output using a Go template; see "azdo help formatting"
--top int Server-side page size hint (positive integer)
```

Aliases

```
ls, l
```

## `azdo pr <command> [flags]`

Manage pull requests
Expand Down
15 changes: 15 additions & 0 deletions docs/azdo_pipelines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## Command `azdo pipelines`

Manage Azure DevOps pipelines

### Available commands

* [azdo pipelines variable-groups](./azdo_pipelines_variable-groups.md)

### ALIASES

- `p`

### See also

* [azdo](./azdo.md)
17 changes: 17 additions & 0 deletions docs/azdo_pipelines_variable-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Command `azdo pipelines variable-groups`

Manage Azure DevOps variable groups

### Available commands

* [azdo pipelines variable-groups list](./azdo_pipelines_variable-groups_list.md)

### ALIASES

- `variable-groups`
- `variablegroups`
- `vg`

### See also

* [azdo pipelines](./azdo_pipelines.md)
63 changes: 63 additions & 0 deletions docs/azdo_pipelines_variable-groups_list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
## Command `azdo pipelines variable-groups list`

```
azdo pipelines variable-groups list [ORGANIZATION/]PROJECT [flags]
```

List every variable group defined in a project with optional filtering.


### Options


* `--action` `string`

Action filter string (e.g., &#39;manage&#39;, &#39;use&#39;): {none|manage|use}

* `-q`, `--jq` `expression`

Filter JSON output using a jq expression

* `--json` `fields`

Output JSON with the specified fields. Prefix a field with &#39;-&#39; to exclude it.

* `--max-items` `int` (default `0`)

Optional client-side cap on results; stop fetching once reached

* `--order` `string` (default `&#34;desc&#34;`)

Order of variable groups (asc, desc): {desc|asc}

* `-t`, `--template` `string`

Format JSON output using a Go template; see &#34;azdo help formatting&#34;

* `--top` `int` (default `0`)

Server-side page size hint (positive integer)


### ALIASES

- `ls`
- `l`

### JSON Fields

`createdBy`, `createdOn`, `description`, `id`, `isShared`, `modifiedBy`, `modifiedOn`, `name`, `projectReferences`, `type`, `variables`

### Examples

```bash
# List all variable groups in a project
$ azdo pipelines variable-groups list "my-project"

# List variable groups with a specific name
$ azdo pipelines variable-groups list "my-project" --name "my-variable-group"
```

### See also

* [azdo pipelines variable-groups](./azdo_pipelines_variable-groups.md)
2 changes: 2 additions & 0 deletions internal/azdo/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/operations"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/security"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
"github.com/tmeckel/azdo-cli/internal/azdo/extensions"
)
Expand Down Expand Up @@ -52,6 +53,7 @@ type ClientFactory interface {
Operations(ctx context.Context, organization string) (operations.Client, error)
ServiceEndpoint(ctx context.Context, organization string) (serviceendpoint.Client, error)
Security(ctx context.Context, organization string) (security.Client, error)
TaskAgent(ctx context.Context, organization string) (taskagent.Client, error)
Extensions(ctx context.Context, organization string) (extensions.Client, error)
WorkItemTracking(ctx context.Context, organization string) (workitemtracking.Client, error)
}
3 changes: 3 additions & 0 deletions internal/azdo/extensions/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/microsoft/azure-devops-go-api/azuredevops/v7"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent"
"github.com/tmeckel/azdo-cli/internal/types"
)

Expand All @@ -20,6 +21,8 @@ type Client interface {
GetSelfID(ctx context.Context) (uuid.UUID, error)
// GetSubjectID resolves the storage key (UUID) for a given subject (user) name within the organization.
GetSubjectID(ctx context.Context, subject string) (uuid.UUID, error)
// GetVariableGroups fetches variable groups using the raw REST API, returning the continuation token with the response.
GetVariableGroups(ctx context.Context, args taskagent.GetVariableGroupsArgs) (*VariableGroupsResponse, error)
// FindGroupsByDisplayName locates Azure DevOps security groups that match the provided display name,
// optionally scoped to a project descriptor, and returns their full details.
FindGroupsByDisplayName(ctx context.Context, displayName string, scopeDescriptor *string) ([]*graph.GraphGroup, error)
Expand Down
79 changes: 79 additions & 0 deletions internal/azdo/extensions/variablegroups.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package extensions

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"

"github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent"
)

// VariableGroupsResponse represents the REST payload returned by
// GET https://dev.azure.com/{organization}/{project}/_apis/distributedtask/variablegroups
type VariableGroupsResponse struct {
Count *int `json:"count,omitempty"`
ContinuationToken *string `json:"continuationToken,omitempty"`
Value []taskagent.VariableGroup `json:"value,omitempty"`
}

// GetVariableGroups issues a raw REST request that mirrors the Azure DevOps GetVariableGroups
// endpoint. The SDK's 7.1 wrapper currently mis-shapes the response body, so we decode the REST
// payload directly and hand the caller the continuation token alongside the variable groups.
func (c *extensionClient) GetVariableGroups(ctx context.Context, args taskagent.GetVariableGroupsArgs) (*VariableGroupsResponse, error) {
if args.Project == nil || strings.TrimSpace(*args.Project) == "" {
return nil, fmt.Errorf("project is required to list variable groups")
}

baseURL := strings.TrimRight(c.conn.BaseUrl, "/")
project := url.PathEscape(strings.TrimSpace(*args.Project))
requestURL := fmt.Sprintf("%s/%s/_apis/distributedtask/variablegroups", baseURL, project)

query := url.Values{}
query.Set("api-version", "7.1")
if args.GroupName != nil && strings.TrimSpace(*args.GroupName) != "" {
query.Set("groupName", strings.TrimSpace(*args.GroupName))
}
if args.ActionFilter != nil && string(*args.ActionFilter) != "" {
query.Set("actionFilter", string(*args.ActionFilter))
}
if args.Top != nil {
query.Set("$top", strconv.Itoa(*args.Top))
}
if args.ContinuationToken != nil {
query.Set("continuationToken", strconv.Itoa(*args.ContinuationToken))
}
if args.QueryOrder != nil && string(*args.QueryOrder) != "" {
query.Set("queryOrder", string(*args.QueryOrder))
}

if encoded := query.Encode(); encoded != "" {
requestURL = fmt.Sprintf("%s?%s", requestURL, encoded)
}

client := c.conn.GetClientByUrl(requestURL)
req, err := client.CreateRequestMessage(ctx, http.MethodGet, requestURL, "", nil, "", "", nil)
if err != nil {
return nil, err
}

resp, err := client.SendRequest(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, client.UnwrapError(resp)
}

var payload VariableGroupsResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}

return &payload, nil
}
21 changes: 16 additions & 5 deletions internal/azdo/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/operations"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/security"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
"github.com/tmeckel/azdo-cli/internal/azdo/extensions"
"github.com/tmeckel/azdo-cli/internal/config"
Expand All @@ -23,9 +24,10 @@ type connectionFactory struct {

func NewConnectionFactory(cfg config.Config, auth Authenticator) (ConnectionFactory, error) {
return &connectionFactory{
cfg: cfg,
auth: auth,
}, nil
cfg: cfg,
auth: auth,
},
nil
}

func (c *connectionFactory) Connection(organization string) (client Connection, err error) {
Expand All @@ -49,8 +51,9 @@ type clientFactory struct {

func NewClientFactory(factory ConnectionFactory) (ClientFactory, error) {
return &clientFactory{
factory: factory,
}, nil
factory: factory,
},
nil
}

func (c *clientFactory) Git(ctx context.Context, org string) (git.Client, error) {
Expand Down Expand Up @@ -109,6 +112,14 @@ func (c *clientFactory) Security(ctx context.Context, org string) (security.Clie
return security.NewClient(ctx, conn.(*connectionAdapter).conn), nil
}

func (c *clientFactory) TaskAgent(ctx context.Context, org string) (taskagent.Client, error) {
conn, err := c.factory.Connection(org)
if err != nil {
return nil, err
}
return taskagent.NewClient(ctx, conn.(*connectionAdapter).conn)
}

func (c *clientFactory) Extensions(ctx context.Context, org string) (extensions.Client, error) {
conn, err := c.factory.Connection(org)
if err != nil {
Expand Down
Loading
Loading