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
79 changes: 48 additions & 31 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,25 +57,33 @@ For a complete guidance on how to implement tests refer to [TESTING.md](./TESTIN
- **Options Handling:** Use an unexported `opts` struct to bind CLI flags. Keep parsing and validation in `RunE` minimal, delegating logic to a separate `runX` function.
- **CmdContext Usage:** Always use the injected `util.CmdContext` to retrieve `IOStreams`, configuration (`ctx.Config()`), connection (`ctx.ConnectionFactory()`), and typed API clients via `ctx.ClientFactory()`.
- **Vendored API:** Access Azure DevOps endpoints via the vendored `azuredevops/v7` client packages instead of raw HTTP calls. Build the appropriate `Args` structs and call the client method (e.g., `git.Client.CreateRepository`).
- **CRITICAL: Verify Data Types:** Before using API response data, always inspect the struct definitions in the `vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/` directory. Mismatched types (e.g., `int64` vs `uint64`) between the API struct and your command's structs will cause compilation errors.
- **Output:** Use `ctx.Printer(format)` and repository’s standard `printer` helpers to format output in table or JSON, following patterns from existing commands.
- **Testing:** Create mocks for the relevant client interface methods under `internal/mocks` and write hermetic, table-driven tests alongside the command.
- **Output Formats (Table & JSON):**
- **JSON Support:**
- Use `util.AddJSONFlags(cmd, &exporter, []string{<fields>})` in `NewCmd` to register `--json`, `--jq`, and `--template` options.
- Maintain an `util.Exporter` field in the command’s `opts` struct to capture JSON export configuration.
- Pass the relevant response object to `exporter.Export()` when non-nil.
- Declare valid JSON fields based on the struct’s exported properties. Help annotations are added automatically by `AddJSONFlags`.
- **Table/Plain Output:**
- Default to human-friendly plain console output for single-object results.
- Use `ctx.Printer(format)` with `tp.AddColumns`/`tp.AddField` for tabular format when `--format table` is set or for multiple rows.
- Keep column names consistent (e.g., "ID", "Name", "WebUrl").
- **Format Flag:**
- Always include a `--format` flag via `util.StringEnumFlag` with supported formats (at least `json` and `table`).
- Output decision order:
1. If `exporter != nil` → JSON output.
2. Else if table explicitly requested or multiple rows → table output.
3. Else → plain text summary.
- **Documentation:**

### 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.

- **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.
- Call `opts.exporter.Write(ios, result)` to serialize the struct and print it.

- **Table/Plain Output Logic:**
- This is the default path, executed when `opts.exporter == nil`.
- **For creating horizontal tables:**
1. Get a printer: `tp, err := ctx.Printer("list")`.
2. Define all column headers: `tp.AddColumns("Header1", "Header2", ...)`.
3. Finalize the header row: `tp.EndRow()`.
4. For each data row, add cell values in order: `tp.AddField(value1)`, `tp.AddField(value2)`, etc.
5. Finalize the data row: `tp.EndRow()`.
6. Render the table: `tp.Render()`.
- **CRITICAL:** `AddField` populates a cell in a horizontal row corresponding to a column defined by `AddColumns`. It is **not** for creating vertical key-value lists (e.g., `Label: Value`). Do not pass a label to `AddField`.
- **For simple text output** (e.g., a success message), `fmt.Fprintf(ios.Out, "...")` is acceptable.
- **Documentation:**
- Add CLI help examples for using `--json` and `--format table`.
- Run `make docs` to regenerate markdown so output options appear in generated documentation.

Expand Down Expand Up @@ -126,20 +134,20 @@ For a complete guidance on how to implement tests refer to [TESTING.md](./TESTIN
- **Code Scope:** When you create, change or fix tests you only work on tests. You don't change any other code. When required prompt the user to deviate from that instruction.
- **Context7:** Always use context7 when I need code generation, setup or configuration steps, or library/API documentation. This means you should automatically use the Context7 MCP tools to resolve library id and get library docs without me having to explicitly ask.

### Implementing Commands with JSON and Table Output

- Do **NOT** call a nonexistent `Exporter.Export` method; in this codebase `util.Exporter` does not implement such a function. JSON and table output are unified through the `ctx.Printer` abstraction.
- For consistent output handling, define a `--format` flag using `util.StringEnumFlag` with `table` default and `json` as an option.
- Initialize a printer in `RunE` via:
```go
tp, err := ctx.Printer(opts.format)
if err != nil {
return err
}
```
- Populate table output with `tp.AddColumns(...)`, `tp.AddField(...)`, and `tp.EndRow()`; json formatting will use the same `tp.Render()` call.
- Treat `ios.Out` from `IOStreams` as an `io.Writer`, not a callable function: write directly with `fmt.Fprintln(ios.Out, ...)` or delegate to the printer’s `Render` method.
- Always call `tp.Render()` at the end of output preparation to emit results, whether in JSON or table format.
### 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.

- **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.
- Call `opts.exporter.Write(ios, result)` to serialize the struct and print it.

- **Table/Plain Output Logic:**
- This is the default path, executed when `opts.exporter == nil`.
- For tabular data, get a printer with `tp, err := ctx.Printer("list")`. Use `tp.AddColumns()`, `tp.AddField()`, and `tp.EndRow()` to build the table, then call `tp.Render()`.

## Code Generation Best Practices

Expand All @@ -164,4 +172,13 @@ To ensure high-quality, production-ready code and prevent common errors, adhere
// BAD:
// if !ctx.IOStreams().CanPrompt() { ... }
```
- **Safely Dereference Pointers:** The Azure DevOps API often returns pointers for fields that can be null. To prevent `nil pointer dereference` panics, use the generic helper `types.GetValue[T](ptr *T, defaultVal T) T`. This is the preferred way to safely access the value of a pointer.

```go
// BAD: This will panic if project.Description is nil
// description := *project.Description

// GOOD: This safely returns the description or an empty string
description := types.GetValue(project.Description, "")
```
- **User Cancellation:** For operations that can be cancelled by the user (e.g., confirmation prompts), prefer returning `util.ErrCancel` over `util.SilentExit` to clearly distinguish user-initiated cancellations from other silent exits.
5 changes: 3 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ This process ensures that all mocks are kept up-to-date and that the generation
- Use table‑driven tests where it adds clarity (e.g., sets of negative cases).

### Tips for reliable tests
- Use `iostreams.Test()` to capture stdout/stderr.
- Use `io, _, out, errOut := iostreams.Test()` to capture I/O streams. Assert against the string content of the `out` and `errOut` buffers (e.g., `assert.Equal(t, "expected", out.String())`).
- **Ensure Test Data Type Accuracy:** When creating test data structs (e.g., a fake `core.TeamProject`), ensure all field types exactly match the real API struct definitions from the `vendor/` directory. Mismatches (e.g., `core.Time` vs. `azuredevops.Time`) will cause compilation errors.
- Prefer `require` for preconditions and `assert` for value checks.
- Keep the number of expectations minimal; over‑specifying call sequences increases brittleness.
- Assert error text that the UX guarantees (don’t overfit on punctuation or variable content).
Expand Down Expand Up @@ -150,7 +151,7 @@ From recent issues encountered in `internal/cmd/repo/create/create_test.go`, the
- Verified argument types against actual definitions before setting up mocks.

**Conclusions to Prevent Future Mistakes:**
- **Review code under test before mocking** to know exactly which calls need expectations.
- **Review code under test before mocking** to know exactly which calls need expectations. Do not assume a function will fail early; if it proceeds, subsequent calls will need to be expected by your mocks, otherwise `gomock` will report an 'unexpected call' error.
- **Always use generated mocks** for interfaces unless replacements fully match signatures.
- **Match method signatures exactly**, minding pointer/value distinctions.
- **Return appropriate values** to avoid runtime exceptions.
Expand Down
10 changes: 10 additions & 0 deletions docs/azdo_help_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,16 @@ List the projects for an organization
--state string Project state filter: {deleting|new|wellFormed|createPending|all|unchanged|deleted}
````

### `azdo project show [ORGANIZATION/]PROJECT [flags]`

Show details of an Azure DevOps Project

```
-q, --jq expression Filter JSON output using a jq expression
--json fields Output JSON with the specified fields
-t, --template string Format JSON output using a Go template; see "azdo help formatting"
````

## `azdo repo <command>`

Manage repositories
Expand Down
1 change: 1 addition & 0 deletions docs/azdo_project.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Work with Azure DevOps Projects.
* [azdo project create](./azdo_project_create.md)
* [azdo project delete](./azdo_project_delete.md)
* [azdo project list](./azdo_project_list.md)
* [azdo project show](./azdo_project_show.md)

### Examples

Expand Down
37 changes: 37 additions & 0 deletions docs/azdo_project_show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## azdo project show
```
azdo project show [ORGANIZATION/]PROJECT [flags]
```
Shows details of an Azure DevOps project in the specified organization.

If the organization name is omitted from the project argument, the default configured organization is used.

### Options


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

Filter JSON output using a jq expression

* `--json` `fields`

Output JSON with the specified fields

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

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


### Examples

```bash
# Show project details in the default organization
azdo project show MyProject

# Show project details in a specific organization
azdo project show MyOrg/MyProject
```

### See also

* [azdo project](./azdo_project.md)
2 changes: 2 additions & 0 deletions internal/cmd/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/tmeckel/azdo-cli/internal/cmd/project/create"
"github.com/tmeckel/azdo-cli/internal/cmd/project/delete"
"github.com/tmeckel/azdo-cli/internal/cmd/project/list"
"github.com/tmeckel/azdo-cli/internal/cmd/project/show"
"github.com/tmeckel/azdo-cli/internal/cmd/util"
)

Expand All @@ -32,5 +33,6 @@ func NewCmdProject(ctx util.CmdContext) *cobra.Command {
cmd.AddCommand(list.NewCmdProjectList(ctx))
cmd.AddCommand(create.NewCmd(ctx))
cmd.AddCommand(delete.NewCmd(ctx))
cmd.AddCommand(show.NewCmd(ctx))
return cmd
}
182 changes: 182 additions & 0 deletions internal/cmd/project/show/show.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package show

import (
"fmt"

"github.com/MakeNowJust/heredoc"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/core"
"github.com/spf13/cobra"
"github.com/tmeckel/azdo-cli/internal/azdo"
"github.com/tmeckel/azdo-cli/internal/cmd/util"
"github.com/tmeckel/azdo-cli/internal/types"
)

type opts struct {
project string
exporter util.Exporter
}

type projectShowResult struct {
ID *string `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
State *string `json:"state,omitempty"`
Visibility *string `json:"visibility,omitempty"`
Process *string `json:"process,omitempty"`
SourceControl *string `json:"sourceControl,omitempty"`
LastUpdateTime *string `json:"lastUpdateTime,omitempty"`
Revision *uint64 `json:"revision,omitempty"`
Description *string `json:"description,omitempty"`
URL *string `json:"url,omitempty"`
DefaultTeamName *string `json:"defaultTeamName,omitempty"`
}

func NewCmd(ctx util.CmdContext) *cobra.Command {
o := &opts{}

cmd := &cobra.Command{
Use: "show [ORGANIZATION/]PROJECT",
Short: "Show details of an Azure DevOps Project",
Long: heredoc.Doc(`
Shows details of an Azure DevOps project in the specified organization.

If the organization name is omitted from the project argument, the default configured organization is used.
`),
Example: heredoc.Doc(`
# Show project details in the default organization
azdo project show MyProject

# Show project details in a specific organization
azdo project show MyOrg/MyProject
`),
Args: cobra.ExactArgs(1),
Aliases: []string{
"s",
},
RunE: func(cmd *cobra.Command, args []string) error {
o.project = args[0]
return runCommand(ctx, o)
},
}

util.AddJSONFlags(cmd, &o.exporter, []string{"id", "name", "state", "visibility", "process", "sourceControl", "lastUpdateTime", "revision", "description", "url", "defaultTeamName"})

return cmd
}

func runCommand(ctx util.CmdContext, o *opts) error {
prj, err := azdo.ProjectFromName(o.project)
if err != nil {
return err
}

ios, err := ctx.IOStreams()
if err != nil {
return err
}

ios.StartProgressIndicator()
defer ios.StopProgressIndicator()

coreClient, err := ctx.ClientFactory().Core(ctx.Context(), prj.Organization())
if err != nil {
return err
}

project, err := coreClient.GetProject(ctx.Context(), core.GetProjectArgs{
ProjectId: types.ToPtr(prj.Project()),
IncludeCapabilities: types.ToPtr(true),
})
if err != nil {
return err
}

ios.StopProgressIndicator()

if o.exporter != nil {
var processName, sourceControlType string
if project.Capabilities != nil {
if caps, ok := (*project.Capabilities)["processTemplate"]; ok {
processName = caps["templateName"]
}
if caps, ok := (*project.Capabilities)["versioncontrol"]; ok {
sourceControlType = caps["sourceControlType"]
}
}

var defaultTeamName string
if project.DefaultTeam != nil {
defaultTeamName = *project.DefaultTeam.Name
}

lastUpdateTime := project.LastUpdateTime.Time.String()

var state, visibility string
if project.State != nil {
state = string(*project.State)
}
if project.Visibility != nil {
visibility = string(*project.Visibility)
}

result := projectShowResult{
ID: types.ToPtr(project.Id.String()),
Name: project.Name,
State: types.ToPtr(state),
Visibility: types.ToPtr(visibility),
Process: types.ToPtr(processName),
SourceControl: types.ToPtr(sourceControlType),
LastUpdateTime: &lastUpdateTime,
Revision: project.Revision,
Description: project.Description,
URL: project.Url,
DefaultTeamName: &defaultTeamName,
}
return o.exporter.Write(ios, result)
}

tp, err := ctx.Printer("list")
if err != nil {
return err
}

tp.AddColumns("ID", "Name", "State", "Visibility", "Process", "Source Control", "Last Update Time", "Revision", "Description", "URL", "Default Team")
tp.EndRow()

var processName, sourceControlType string
if project.Capabilities != nil {
if caps, ok := (*project.Capabilities)["processTemplate"]; ok {
processName = caps["templateName"]
}
if caps, ok := (*project.Capabilities)["versioncontrol"]; ok {
sourceControlType = caps["sourceControlType"]
}
}

var defaultTeamName string
if project.DefaultTeam != nil {
defaultTeamName = *project.DefaultTeam.Name
}

var state, visibility string
if project.State != nil {
state = string(*project.State)
}
if project.Visibility != nil {
visibility = string(*project.Visibility)
}

tp.AddField(project.Id.String())
tp.AddField(types.GetValue(project.Name, ""))
tp.AddField(state)
tp.AddField(visibility)
tp.AddField(processName)
tp.AddField(sourceControlType)
tp.AddField(project.LastUpdateTime.Time.String())
tp.AddField(fmt.Sprintf("%d", types.GetValue(project.Revision, 0)))
tp.AddField(types.GetValue(project.Description, ""))
tp.AddField(types.GetValue(project.Url, ""))
tp.AddField(defaultTeamName)
tp.EndRow()

return tp.Render()
}
Loading
Loading