diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index efcea228..c8a9d174 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,6 +5,7 @@ default_install_hook_types: default_stages: - pre-commit fail_fast: true +exclude: ^vendor/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/docs/azdo_boards_iteration_project_show.md b/docs/azdo_boards_iteration_project_show.md index 91e10885..9811e23b 100644 --- a/docs/azdo_boards_iteration_project_show.md +++ b/docs/azdo_boards_iteration_project_show.md @@ -27,10 +27,6 @@ The iteration is identified by its fully-qualified path under /Iteration. Output JSON with the specified fields. Prefix a field with '-' to exclude it. -* `-r`, `--raw` - - Dump the raw SDK node to stderr. - * `-t`, `--template` `string` Format JSON output using a Go template; see "azdo help formatting" diff --git a/docs/azdo_boards_work-item.md b/docs/azdo_boards_work-item.md index 849d3d17..c7f385da 100644 --- a/docs/azdo_boards_work-item.md +++ b/docs/azdo_boards_work-item.md @@ -7,6 +7,7 @@ Work with Azure Boards work items. * [azdo boards work-item create](./azdo_boards_work-item_create.md) * [azdo boards work-item delete](./azdo_boards_work-item_delete.md) * [azdo boards work-item list](./azdo_boards_work-item_list.md) +* [azdo boards work-item show](./azdo_boards_work-item_show.md) * [azdo boards work-item update](./azdo_boards_work-item_update.md) ### Examples @@ -18,6 +19,9 @@ azdo boards work-item list Fabrikam # Create a work item azdo boards work-item create Fabrikam --type Bug --title "Login is broken" +# Show a work item's details +azdo boards work-item show Fabrikam/42 --comments + # Update a work item's title azdo boards work-item update Fabrikam/42 --title "New title" diff --git a/docs/azdo_boards_work-item_show.md b/docs/azdo_boards_work-item_show.md new file mode 100644 index 00000000..6d5dd504 --- /dev/null +++ b/docs/azdo_boards_work-item_show.md @@ -0,0 +1,65 @@ +## Command `azdo boards work-item show` + +``` +azdo boards work-item show [ORG:]PROJECT/ID [flags] +``` + +Display the details of a single Azure Boards work item by its integer +ID. The work item is fetched with Expand=All so relations, fields and +links are returned in one call. The description is rendered +format-aware: Markdown content is passed through, Html content is +converted to Markdown first. + + +### Options + + +* `--comments` + + Fetch and render the work item's comment thread + +* `-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. + +* `--relations` + + Render the work item's relations block + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `view` +- `status` + +### JSON Fields + +`_links`, `commentVersionRef`, `fields`, `id`, `relations`, `rev`, `url` + +### Examples + +```bash +# Show work item 12345 in the default organization's Fabrikam project +azdo boards work-item show Fabrikam/12345 + +# Show a work item in a specific organization +azdo boards work-item show myorg:Fabrikam/12345 + +# Include the work item's comment thread and relations +azdo boards work-item show Fabrikam/12345 --comments --relations + +# Export the work item as JSON +azdo boards work-item show Fabrikam/12345 --json +``` + +### See also + +* [azdo boards work-item](./azdo_boards_work-item.md) diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index e5524b53..21f07a63 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -193,7 +193,6 @@ Show an iteration in a project. --include-children Include child nodes in the template 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 the raw SDK node to stderr. -t, --template string Format JSON output using a Go template; see "azdo help formatting" ``` @@ -314,6 +313,24 @@ Aliases ls, l ``` +#### `azdo boards work-item show [ORG:]PROJECT/ID [flags]` + +Show work item details + +``` + --comments Fetch and render the work item's comment thread +-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. + --relations Render the work item's relations block +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +view, status +``` + #### `azdo boards work-item update [ORG:]PROJECT/ID [flags]` Update a work item. @@ -443,7 +460,6 @@ 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" ``` @@ -634,7 +650,6 @@ Show details of an agent pool ``` -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 pool object to stderr -t, --template string Format JSON output using a Go template; see "azdo help formatting" ``` @@ -1066,7 +1081,6 @@ View a pull request -C, --commits View pull request commits -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 View pull request raw -t, --template string Format JSON output using a Go template; see "azdo help formatting" ``` diff --git a/docs/azdo_pipelines_agent_show.md b/docs/azdo_pipelines_agent_show.md index 73f24684..f698eb38 100644 --- a/docs/azdo_pipelines_agent_show.md +++ b/docs/azdo_pipelines_agent_show.md @@ -24,10 +24,6 @@ an optional ORG: prefix. 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" diff --git a/docs/azdo_pipelines_pool_show.md b/docs/azdo_pipelines_pool_show.md index d77307b1..79c35e19 100644 --- a/docs/azdo_pipelines_pool_show.md +++ b/docs/azdo_pipelines_pool_show.md @@ -20,10 +20,6 @@ optional ORG: prefix. Output JSON with the specified fields. Prefix a field with '-' to exclude it. -* `-r`, `--raw` - - Dump raw pool object to stderr - * `-t`, `--template` `string` Format JSON output using a Go template; see "azdo help formatting" diff --git a/docs/azdo_pr_view.md b/docs/azdo_pr_view.md index ae3d84aa..54c07fae 100644 --- a/docs/azdo_pr_view.md +++ b/docs/azdo_pr_view.md @@ -37,10 +37,6 @@ is displayed. Output JSON with the specified fields. Prefix a field with '-' to exclude it. -* `-r`, `--raw` - - View pull request raw - * `-t`, `--template` `string` Format JSON output using a Go template; see "azdo help formatting" diff --git a/go.mod b/go.mod index 93466750..cb0882aa 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/PuerkitoBio/purell v1.2.2 github.com/briandowns/spinner v1.23.2 @@ -23,7 +24,6 @@ require ( github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 github.com/samber/lo v1.53.0 - github.com/spewerspew/spew v0.0.0-20230513223542-89b69fbbe2bd github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -32,11 +32,12 @@ require ( go.uber.org/zap v1.28.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 - golang.org/x/text v0.40.0 + golang.org/x/text v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/JohannesKaufmann/dom v0.3.1 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect @@ -56,16 +57,16 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/net v0.55.0 // indirect ) diff --git a/go.sum b/go.sum index 8fb223f8..a934701c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0= +github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA= github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= @@ -81,14 +85,12 @@ github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo79 github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= @@ -115,8 +117,10 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -github.com/spewerspew/spew v0.0.0-20230513223542-89b69fbbe2bd h1:csraKifkLpqDClUIbFTetjtraueL1KUhKBm6okL+ug4= -github.com/spewerspew/spew v0.0.0-20230513223542-89b69fbbe2bd/go.mod h1:cyGycBxnlCATLUv5jtPwaHnkiYomNWzXQougLmaCTzU= +github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= +github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -131,8 +135,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD 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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= @@ -155,8 +159,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -165,30 +169,18 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/azdo/workitem.go b/internal/azdo/workitem.go new file mode 100644 index 00000000..74bb3375 --- /dev/null +++ b/internal/azdo/workitem.go @@ -0,0 +1,52 @@ +package azdo + +import ( + "context" + "net/http" + "net/url" + "strconv" + + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" +) + +// workItemLocationID and workItemAPIVersion mirror the request issued by +// workitemtracking.Client.GetWorkItem so the raw payload matches the SDK +// response for the same work item. +var ( + workItemLocationID = uuid.MustParse("72c7ddf8-2cdc-4f60-90cd-ab71c14a399b") + workItemAPIVersion = "7.1-preview.3" +) + +// WorkItemEnvelope is the raw work item payload. The vendored SDK WorkItem +// model drops the unknown multilineFieldsFormat property, which commands need +// to render descriptions format-aware. +type WorkItemEnvelope struct { + workitemtracking.WorkItem + MultilineFieldsFormat *map[string]string `json:"multilineFieldsFormat,omitempty"` +} + +// GetWorkItemEnvelope fetches the raw work item payload for the given ID via +// the low-level client, capturing properties the SDK model drops. +func GetWorkItemEnvelope(ctx context.Context, client Client, project string, id int, expand *workitemtracking.WorkItemExpand) (*WorkItemEnvelope, error) { + routeValues := map[string]string{"id": strconv.Itoa(id)} + if project != "" { + routeValues["project"] = project + } + queryParams := url.Values{} + if expand != nil { + queryParams.Add("$expand", string(*expand)) + } + + resp, err := client.Send(ctx, http.MethodGet, workItemLocationID, workItemAPIVersion, routeValues, queryParams, nil, "", "application/json", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var envelope WorkItemEnvelope + if err := client.UnmarshalBody(resp, &envelope); err != nil { + return nil, err + } + return &envelope, nil +} diff --git a/internal/cmd/boards/iteration/project/show/show.go b/internal/cmd/boards/iteration/project/show/show.go index f4a8f66d..036508d6 100644 --- a/internal/cmd/boards/iteration/project/show/show.go +++ b/internal/cmd/boards/iteration/project/show/show.go @@ -8,7 +8,6 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" - "github.com/spewerspew/spew" "github.com/spf13/cobra" "go.uber.org/zap" @@ -22,7 +21,6 @@ type showOptions struct { scopeArg string depth int includeChildren bool - raw bool exporter util.Exporter } @@ -67,7 +65,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.Flags().IntVar(&opts.depth, "depth", 0, "Depth of child nodes to fetch (0-10).") cmd.Flags().BoolVar(&opts.includeChildren, "include-children", false, "Include child nodes in the template output.") - cmd.Flags().BoolVarP(&opts.raw, "raw", "r", false, "Dump the raw SDK node to stderr.") _ = cmd.MarkFlagRequired("path") util.AddJSONFlags(cmd, &opts.exporter, []string{ "id", "identifier", "name", "path", "structureType", @@ -138,11 +135,6 @@ func runShow(ctx util.CmdContext, opts *showOptions) error { ios.StopProgressIndicator() - if opts.raw { - spew.NewDefaultConfig().Fdump(ios.ErrOut, res) - return nil - } - if opts.exporter != nil { return opts.exporter.Write(ios, res) } diff --git a/internal/cmd/boards/iteration/project/show/show_test.go b/internal/cmd/boards/iteration/project/show/show_test.go index 81d95583..00ce8e08 100644 --- a/internal/cmd/boards/iteration/project/show/show_test.go +++ b/internal/cmd/boards/iteration/project/show/show_test.go @@ -28,7 +28,6 @@ type dependencies struct { clientFact *mocks.MockClientFactory wit *mocks.MockWorkItemTrackingClient stdout *bytes.Buffer - stderr *bytes.Buffer org string } @@ -44,7 +43,7 @@ func newDependenciesWithClientFactoryError(t *testing.T, organization string, fa ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) - io, _, out, errOut := iostreams.Test() + io, _, out, _ := iostreams.Test() io.SetStdoutTTY(false) io.SetStderrTTY(false) @@ -54,7 +53,6 @@ func newDependenciesWithClientFactoryError(t *testing.T, organization string, fa clientFact: mocks.NewMockClientFactory(ctrl), wit: mocks.NewMockWorkItemTrackingClient(ctrl), stdout: out, - stderr: errOut, org: organization, } @@ -329,19 +327,6 @@ func TestRunShow_JSONOutput(t *testing.T) { assert.Equal(t, "https://dev.azure.com/org/Fabrikam/_apis/wit/classificationNodes/iterations/42", selfJSON["href"]) } -func TestRunShow_RawFlag(t *testing.T) { - t.Parallel() - - deps := newDependencies(t, "org") - deps.wit.EXPECT().GetClassificationNode(gomock.Any(), gomock.Any()).Return(showNode(), nil) - - err := runShow(deps.cmd, &showOptions{scopeArg: "org:Fabrikam/Sprint 1", raw: true}) - - require.NoError(t, err) - assert.Empty(t, deps.stdout.String()) - assert.Contains(t, deps.stderr.String(), "WorkItemClassificationNode") -} - func TestRunShow_ProjectScopeParsing(t *testing.T) { t.Parallel() diff --git a/internal/cmd/boards/workitem/show/show.go b/internal/cmd/boards/workitem/show/show.go new file mode 100644 index 00000000..4cfa97c5 --- /dev/null +++ b/internal/cmd/boards/workitem/show/show.go @@ -0,0 +1,278 @@ +package show + +import ( + _ "embed" + "errors" + "fmt" + "strconv" + "strings" + "time" + + htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/azdo" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/template" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type showOptions struct { + scopeArg string + showComments bool + showRelations bool + exporter util.Exporter +} + +//go:embed show.tpl +var showTpl string + +type templateData struct { + WorkItem *workitemtracking.WorkItem + Comments *[]workitemtracking.Comment + Relations bool + Description string +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &showOptions{} + + cmd := &cobra.Command{ + Use: "show [ORG:]PROJECT/ID", + Short: "Show work item details", + Aliases: []string{"view", "status"}, + Long: heredoc.Doc(` + Display the details of a single Azure Boards work item by its integer + ID. The work item is fetched with Expand=All so relations, fields and + links are returned in one call. The description is rendered + format-aware: Markdown content is passed through, Html content is + converted to Markdown first. + `), + Example: heredoc.Doc(` + # Show work item 12345 in the default organization's Fabrikam project + azdo boards work-item show Fabrikam/12345 + + # Show a work item in a specific organization + azdo boards work-item show myorg:Fabrikam/12345 + + # Include the work item's comment thread and relations + azdo boards work-item show Fabrikam/12345 --comments --relations + + # Export the work item as JSON + azdo boards work-item show Fabrikam/12345 --json + `), + Args: util.ExactArgs(1, "project/work item target required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + return runShow(ctx, opts) + }, + } + + cmd.Flags().BoolVar(&opts.showComments, "comments", false, "Fetch and render the work item's comment thread") + cmd.Flags().BoolVar(&opts.showRelations, "relations", false, "Render the work item's relations block") + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "rev", "fields", "relations", "url", "_links", "commentVersionRef", + }) + + return cmd +} + +func runShow(ctx util.CmdContext, opts *showOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectTargetWithDefaultOrganization(ctx, opts.scopeArg) + if err != nil { + return util.FlagErrorWrap(err) + } + workItemID, err := parseWorkItemID(scope.Targets[0]) + if err != nil { + return err + } + + expand := workitemtracking.WorkItemExpandValues.All + zap.L().Debug( + "fetching work item", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.Int("workItemId", workItemID), + zap.String("expand", string(expand)), + zap.Bool("fetchComments", opts.showComments), + ) + + wit, err := ctx.ClientFactory().WorkItemTracking(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create work item tracking client: %w", err) + } + + res, err := wit.GetWorkItem(ctx.Context(), workitemtracking.GetWorkItemArgs{ + Project: types.ToPtr(scope.Project), + Id: types.ToPtr(workItemID), + Expand: &expand, + }) + if err != nil { + return fmt.Errorf("failed to get work item: %w", err) + } + if res == nil { + return errors.New("work item tracking API returned an empty response") + } + if !shared.BelongsToProject(res, scope.Project) { + return fmt.Errorf("work item %d does not belong to project %q", workItemID, scope.Project) + } + + var comments *[]workitemtracking.Comment + if opts.showComments { + commentList, err := wit.GetComments(ctx.Context(), workitemtracking.GetCommentsArgs{ + Project: types.ToPtr(scope.Project), + WorkItemId: types.ToPtr(workItemID), + }) + if err != nil { + return fmt.Errorf("failed to get work item comments: %w", err) + } + if commentList != nil { + comments = commentList.Comments + } + } + + if opts.exporter != nil { + ios.StopProgressIndicator() + return opts.exporter.Write(ios, res) + } + + // The vendored SDK WorkItem model drops /multilineFieldsFormat, so the + // raw payload is fetched a second time to learn the description format. + // ponytail: two GETs per show; ceiling: upstream SDK gains the property, + // then this call and GetWorkItemEnvelope can be deleted. + envelope, err := fetchWorkItemEnvelope(ctx, scope, workItemID) + if err != nil { + return err + } + description, err := descriptionMarkdown(envelope) + if err != nil { + return err + } + + ios.StopProgressIndicator() + + t := template.New( + ios.Out, + ios.TerminalWidth(), + ios.ColorEnabled(), + ). + WithTheme(ios.TerminalTheme()). + WithFuncs(map[string]any{ + "field": func(wi *workitemtracking.WorkItem, key string) string { + return shared.FieldString(types.GetValue(wi.Fields, map[string]any{}), key) + }, + "identity": func(wi *workitemtracking.WorkItem, key string) string { + return shared.FieldIdentityDisplay(types.GetValue(wi.Fields, map[string]any{}), key) + }, + "unique": func(wi *workitemtracking.WorkItem, key string) string { + fields := types.GetValue(wi.Fields, map[string]any{}) + v, ok := fields[key] + if !ok || v == nil { + return "" + } + if m, ok := v.(map[string]any); ok { + if uniqueName, ok := m["uniqueName"].(string); ok { + return uniqueName + } + if displayName, ok := m["displayName"].(string); ok { + return displayName + } + return "" + } + return fmt.Sprint(v) + }, + "cdate": func(c *workitemtracking.Comment) string { + if c == nil || c.CreatedDate == nil { + return "" + } + return c.CreatedDate.Time.Format(time.RFC3339) + }, + "cauthor": func(c *workitemtracking.Comment) string { + if c == nil || c.CreatedBy == nil { + return "" + } + return template.StringOrEmpty(c.CreatedBy.DisplayName) + }, + "hasText": template.HasText, + "s": template.StringOrEmpty, + }) + + if err := t.Parse(showTpl); err != nil { + return err + } + + return t.ExecuteData(templateData{ + WorkItem: res, + Comments: comments, + Relations: opts.showRelations, + Description: description, + }) +} + +// parseWorkItemID validates the positional target segment as a positive +// integer work item ID. +func parseWorkItemID(raw string) (int, error) { + id, err := strconv.Atoi(raw) + if err != nil { + return 0, util.FlagErrorf("work item id must be a positive integer, got %q", raw) + } + if id <= 0 { + return 0, util.FlagErrorf("work item id must be a positive integer, got %d", id) + } + return id, nil +} + +// fetchWorkItemEnvelope resolves the low-level client for the organization +// and fetches the raw work item payload, capturing the multilineFieldsFormat +// map the SDK model drops. +func fetchWorkItemEnvelope(ctx util.CmdContext, scope *util.Path, id int) (*azdo.WorkItemEnvelope, error) { + cfg, err := ctx.Config() + if err != nil { + return nil, fmt.Errorf("failed to load configuration: %w", err) + } + orgURL, err := cfg.Authentication().GetURL(scope.Organization) + if err != nil { + return nil, fmt.Errorf("failed to resolve organization URL: %w", err) + } + conn, err := ctx.ConnectionFactory().Connection(scope.Organization) + if err != nil { + return nil, fmt.Errorf("failed to create connection: %w", err) + } + client := conn.GetClientByUrl(strings.TrimRight(orgURL, "/")) + expand := workitemtracking.WorkItemExpandValues.All + return azdo.GetWorkItemEnvelope(ctx.Context(), client, scope.Project, id, &expand) +} + +// descriptionMarkdown returns the work item description as Markdown ready for +// the markdown template func. Markdown-format descriptions pass through +// unconverted; Html-format descriptions (and legacy items without a format +// map) are converted first so literal tags never reach glamour. +func descriptionMarkdown(envelope *azdo.WorkItemEnvelope) (string, error) { + fields := types.GetValue(envelope.Fields, map[string]any{}) + raw := shared.FieldString(fields, "System.Description") + if raw == "" { + return "", nil + } + if envelope.MultilineFieldsFormat != nil { + if format, ok := (*envelope.MultilineFieldsFormat)["System.Description"]; ok && strings.EqualFold(format, "Markdown") { + return raw, nil + } + } + markdown, err := htmltomarkdown.ConvertString(raw) + if err != nil { + return "", fmt.Errorf("failed to convert html description to markdown: %w", err) + } + return markdown, nil +} diff --git a/internal/cmd/boards/workitem/show/show.tpl b/internal/cmd/boards/workitem/show/show.tpl new file mode 100644 index 00000000..ece037e0 --- /dev/null +++ b/internal/cmd/boards/workitem/show/show.tpl @@ -0,0 +1,47 @@ +{{bold "url:"}} {{hyperlink (s .WorkItem.Url) (s .WorkItem.Url)}} +{{bold "id:"}} {{.WorkItem.Id}} +{{bold "rev:"}} {{.WorkItem.Rev}} +{{bold "type:"}} {{field .WorkItem "System.WorkItemType"}} +{{bold "state:"}} {{field .WorkItem "System.State"}} +{{bold "reason:"}} {{field .WorkItem "System.Reason"}} +{{bold "title:"}} {{field .WorkItem "System.Title"}} +{{ $assigned := identity .WorkItem "System.AssignedTo" -}} +{{if hasText $assigned}}{{bold "assigned to:"}} {{$assigned}} ({{unique .WorkItem "System.AssignedTo"}}) +{{end -}} +{{ $createdBy := identity .WorkItem "System.CreatedBy" -}} +{{if hasText $createdBy}}{{bold "created by:"}} {{$createdBy}} ({{unique .WorkItem "System.CreatedBy"}}) +{{end -}} +{{ $createdDate := field .WorkItem "System.CreatedDate" -}} +{{if hasText $createdDate}}{{bold "created on:"}} {{timeago $createdDate}} ({{timefmt "2006-01-02 15:04 MST" $createdDate}}) +{{end -}} +{{ $changedDate := field .WorkItem "System.ChangedDate" -}} +{{if hasText $changedDate}}{{bold "changed on:"}} {{timeago $changedDate}} ({{timefmt "2006-01-02 15:04 MST" $changedDate}}) +{{end -}} +{{bold "area:"}} {{field .WorkItem "System.AreaPath"}} +{{bold "iteration:"}} {{field .WorkItem "System.IterationPath"}} +{{ $tags := field .WorkItem "System.Tags" -}} +{{if hasText $tags}}{{bold "tags:"}} {{$tags}} +{{end -}} +{{ $priority := field .WorkItem "Microsoft.VSTS.Common.Priority" -}} +{{if hasText $priority}}{{bold "priority:"}} {{$priority}} +{{end -}} +{{ $severity := field .WorkItem "Microsoft.VSTS.Common.Severity" -}} +{{if hasText $severity}}{{bold "severity:"}} {{$severity}} +{{end -}} + +{{bold "description:" -}} +{{if .Description}}{{markdown .Description}}{{else}} + None given +{{end -}} +{{if .Relations}}{{with .WorkItem.Relations}} +{{bold "relations:"}} +{{range .}} - {{s .Rel}}: {{hyperlink (s .Url) (s .Url)}} +{{end -}} +{{end}}{{end -}} +{{if .Comments}} +{{bold "comments:"}} +{{range .Comments}} +-------------------------------------------------- +{{bold (cauthor .)}} commented {{timeago (cdate .)}}: +{{markdown (s .Text)}}{{end -}} +{{end -}} diff --git a/internal/cmd/boards/workitem/show/show_test.go b/internal/cmd/boards/workitem/show/show_test.go new file mode 100644 index 00000000..2f5f9552 --- /dev/null +++ b/internal/cmd/boards/workitem/show/show_test.go @@ -0,0 +1,700 @@ +package show + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" + "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" +) + +// ----- NewCmd structure tests ----- + +func TestNewCmd_RegistersAsShowLeaf(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "show", cmd.Name()) + assert.ElementsMatch(t, []string{"view", "status"}, cmd.Aliases) + assert.True(t, strings.HasPrefix(cmd.Use, "show [ORG:]PROJECT/ID")) + assert.NotNil(t, cmd.Flags().Lookup("comments")) + assert.NotNil(t, cmd.Flags().Lookup("relations")) + assert.Nil(t, cmd.Flags().Lookup("raw")) + assert.NotNil(t, cmd.Flags().Lookup("json")) +} + +func TestNewCmd_RequiresProjectTarget(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs([]string{}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "project/work item target required") +} + +func TestNewCmd_TooManyArgs(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + cmd.SetArgs([]string{"Fabrikam/12345", "extra"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "too many arguments") +} + +// ----- runShow tests via gomock ----- + +type fakeShowDeps struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + connFact *mocks.MockConnectionFactory + conn *mocks.MockConnection + client *mocks.MockClient + wit *mocks.MockWorkItemTrackingClient + authCfg *mocks.MockAuthConfig + stdout *bytes.Buffer +} + +func setupShowDeps(t *testing.T, organization string) *fakeShowDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &fakeShowDeps{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + connFact: mocks.NewMockConnectionFactory(ctrl), + conn: mocks.NewMockConnection(ctrl), + client: mocks.NewMockClient(ctrl), + wit: mocks.NewMockWorkItemTrackingClient(ctrl), + authCfg: mocks.NewMockAuthConfig(ctrl), + stdout: out, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + deps.cmd.EXPECT().ConnectionFactory().Return(deps.connFact).AnyTimes() + + cfg := mocks.NewMockConfig(ctrl) + deps.cmd.EXPECT().Config().Return(cfg, nil).AnyTimes() + cfg.EXPECT().Authentication().Return(deps.authCfg).AnyTimes() + deps.authCfg.EXPECT().GetURL(gomock.Any()).DoAndReturn(func(org string) (string, error) { + return "https://dev.azure.com/" + org, nil + }).AnyTimes() + deps.authCfg.EXPECT().GetDefaultOrganization().Return(organization, nil).AnyTimes() + + deps.connFact.EXPECT().Connection(gomock.Any()).Return(deps.conn, nil).AnyTimes() + deps.conn.EXPECT().GetClientByUrl(gomock.Any()).Return(deps.client).AnyTimes() + + return deps +} + +// stubWit wires the WorkItemTracking client factory call for tests that reach +// the SDK. Tests for factory errors omit it. +func stubWit(deps *fakeShowDeps) { + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), gomock.Any()).Return(deps.wit, nil).AnyTimes() +} + +// stubWorkItem stubs GetWorkItem, asserting the required SDK arguments and +// returning the given work item. +func stubWorkItem(t *testing.T, deps *fakeShowDeps, wi *workitemtracking.WorkItem) { + t.Helper() + + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetWorkItemArgs) (*workitemtracking.WorkItem, error) { + require.NotNil(t, args.Id) + require.NotNil(t, args.Project) + require.NotNil(t, args.Expand) + assert.Equal(t, 12345, *args.Id) + assert.Equal(t, "Fabrikam", *args.Project) + assert.Equal(t, workitemtracking.WorkItemExpandValues.All, *args.Expand) + return wi, nil + }, + ).AnyTimes() +} + +// stubEnvelope stubs the low-level raw payload fetch used for format-aware +// description rendering. payload is the raw work item JSON. +func stubEnvelope(t *testing.T, deps *fakeShowDeps, payload string) { + t.Helper() + + deps.client.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, _ any, _ string, _ any, _ any, _ io.Reader, _ string, _ string, _ any) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(payload))}, nil + }).AnyTimes() + deps.client.EXPECT().UnmarshalBody(gomock.Any(), gomock.Any()). + DoAndReturn(func(resp *http.Response, v any) error { + raw, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(raw, v) + }).AnyTimes() +} + +// envelopeJSON marshals the work item plus the optional multilineFieldsFormat +// map into the raw payload shape the low-level endpoint returns. +func envelopeJSON(t *testing.T, wi *workitemtracking.WorkItem, format *map[string]string) string { + t.Helper() + + env := struct { + *workitemtracking.WorkItem + MultilineFieldsFormat *map[string]string `json:"multilineFieldsFormat,omitempty"` + }{WorkItem: wi, MultilineFieldsFormat: format} + raw, err := json.Marshal(env) + require.NoError(t, err) + return string(raw) +} + +func sampleShowWorkItem(extraFields map[string]any) *workitemtracking.WorkItem { + fields := map[string]any{ + "System.WorkItemType": "Bug", + "System.State": "Active", + "System.Reason": "Investigation", + "System.Title": "Login broken", + "System.TeamProject": "Fabrikam", + "System.AssignedTo": map[string]any{"displayName": "Alice", "uniqueName": "alice@contoso.com"}, + "System.CreatedBy": map[string]any{"displayName": "Bob", "uniqueName": "bob@contoso.com"}, + "System.CreatedDate": "2024-01-15T10:30:00Z", + "System.ChangedDate": "2024-01-16T08:00:00Z", + "System.AreaPath": "Fabrikam\\Web", + "System.IterationPath": "Fabrikam\\Release 1\\Sprint 1", + "System.Tags": "web; login", + } + for k, v := range extraFields { + fields[k] = v + } + url := "https://dev.azure.com/myorg/Fabrikam/_apis/wit/workItems/12345" + return &workitemtracking.WorkItem{ + Id: types.ToPtr(12345), + Rev: types.ToPtr(3), + Url: &url, + Links: map[string]any{"self": map[string]any{"href": url}}, + Fields: &fields, + Relations: &[]workitemtracking.WorkItemRelation{ + {Rel: types.ToPtr("System.LinkTypes.Hierarchy-Forward"), Url: types.ToPtr("https://dev.azure.com/myorg/Fabrikam/_apis/wit/workItems/12346")}, + }, + CommentVersionRef: &workitemtracking.WorkItemCommentVersionRef{ + CommentId: types.ToPtr(1), + Version: types.ToPtr(1), + }, + } +} + +func showOpts(scopeArg string) *showOptions { + return &showOptions{scopeArg: scopeArg} +} + +// stubTemplatePath wires everything the template rendering path needs: +// SDK work item, raw envelope payload and comments. +func stubTemplatePath(t *testing.T, deps *fakeShowDeps, wi *workitemtracking.WorkItem, format *map[string]string) { + t.Helper() + + stubWit(deps) + stubWorkItem(t, deps, wi) + stubEnvelope(t, deps, envelopeJSON(t, wi, format)) +} + +func TestRunShow_IDMustBeInteger(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + + err := runShow(deps.cmd, showOpts("Fabrikam/abc")) + require.Error(t, err) + var flagErr *util.FlagError + require.ErrorAs(t, err, &flagErr) + assert.Contains(t, err.Error(), "positive integer") +} + +func TestRunShow_IDMustBePositive(t *testing.T) { + t.Parallel() + + for _, id := range []string{"0", "-1"} { + t.Run("id "+id, func(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + err := runShow(deps.cmd, showOpts("Fabrikam/"+id)) + require.Error(t, err) + var flagErr *util.FlagError + require.ErrorAs(t, err, &flagErr) + assert.Contains(t, err.Error(), "positive integer") + }) + } +} + +func TestRunShow_BasicCall(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + stubTemplatePath(t, deps, wi, nil) + + err := runShow(deps.cmd, showOpts("org:Fabrikam/12345")) + require.NoError(t, err) +} + +func TestRunShow_CommentsFlag_TriggersGetComments(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + stubTemplatePath(t, deps, wi, nil) + + deps.wit.EXPECT().GetComments(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args workitemtracking.GetCommentsArgs) (*workitemtracking.CommentList, error) { + require.NotNil(t, args.Project) + require.NotNil(t, args.WorkItemId) + assert.Equal(t, "Fabrikam", *args.Project) + assert.Equal(t, 12345, *args.WorkItemId) + return &workitemtracking.CommentList{}, nil + }, + ) + + opts := showOpts("org:Fabrikam/12345") + opts.showComments = true + require.NoError(t, runShow(deps.cmd, opts)) +} + +func TestRunShow_CommentsFlag_DefaultDoesNotCallGetComments(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + stubTemplatePath(t, deps, wi, nil) + deps.wit.EXPECT().GetComments(gomock.Any(), gomock.Any()).Times(0) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + assert.NotContains(t, deps.stdout.String(), "comments:") +} + +func TestRunShow_RelationsFlag_ControlsTemplateOnly(t *testing.T) { + t.Parallel() + + // The SDK call must always use Expand=All; --relations only toggles the + // template block. + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + wi.Relations = &[]workitemtracking.WorkItemRelation{ + {Rel: types.ToPtr("System.LinkTypes.Hierarchy-Forward"), Url: types.ToPtr("https://dev.azure.com/myorg/Fabrikam/_apis/wit/workItems/12346")}, + } + stubTemplatePath(t, deps, wi, nil) + + opts := showOpts("org:Fabrikam/12345") + opts.showRelations = true + require.NoError(t, runShow(deps.cmd, opts)) + assert.Contains(t, deps.stdout.String(), "relations:") +} + +func TestRunShow_TemplateOutput_BasicFields(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(map[string]any{ + "System.Description": "
Investigate the login flow
", + }) + stubTemplatePath(t, deps, wi, nil) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + + out := deps.stdout.String() + assert.Contains(t, out, "url:") + assert.Contains(t, out, "id:") + assert.Contains(t, out, "rev:") + assert.Contains(t, out, "type:") + assert.Contains(t, out, "Bug") + assert.Contains(t, out, "Active") + assert.Contains(t, out, "Investigation") + assert.Contains(t, out, "Login broken") + assert.Contains(t, out, "Alice (alice@contoso.com)") + assert.Contains(t, out, "Bob (bob@contoso.com)") + assert.Contains(t, out, "created on:") + assert.Contains(t, out, "changed on:") + assert.Contains(t, out, "Fabrikam\\Web") + assert.Contains(t, out, "iteration:") + assert.Contains(t, out, "Investigate the login flow") +} + +func TestRunShow_TemplateOutput_Hyperlink(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + stubTemplatePath(t, deps, wi, nil) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + + out := deps.stdout.String() + assert.Contains(t, out, "\x1b]8;;https://dev.azure.com/myorg/Fabrikam/_apis/wit/workItems/12345\x1b\\") +} + +func TestRunShow_TemplateOutput_AssignedTo_DisplayAndUnique(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(map[string]any{ + "System.AssignedTo": map[string]any{"displayName": "Alice", "uniqueName": "alice@contoso.com"}, + }) + stubTemplatePath(t, deps, wi, nil) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + assert.Contains(t, deps.stdout.String(), "Alice (alice@contoso.com)") +} + +func TestRunShow_TemplateOutput_DescriptionMarkdownFormat(t *testing.T) { + t.Parallel() + + // Markdown-format content passes through to the markdown template func + // unconverted: the literal markdown markers survive glamour's notty style. + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(map[string]any{ + "System.Description": "**Bold** intro", + }) + format := map[string]string{"System.Description": "Markdown"} + stubTemplatePath(t, deps, wi, &format) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + assert.Contains(t, deps.stdout.String(), "**Bold** intro") +} + +func TestRunShow_TemplateOutput_DescriptionHtmlFormat(t *testing.T) { + t.Parallel() + + // Html-format content is converted HTML->Markdown first, so the converted + // markdown markers appear instead of literal HTML tags. + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(map[string]any{ + "System.Description": "Bold intro", + }) + format := map[string]string{"System.Description": "Html"} + stubTemplatePath(t, deps, wi, &format) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + + out := deps.stdout.String() + assert.Contains(t, out, "**Bold** intro") + assert.NotContains(t, out, "") +} + +func TestRunShow_DescriptionFormatFallback(t *testing.T) { + t.Parallel() + + // A legacy work item without the multilineFieldsFormat map is treated as + // Html, so the description is converted. + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(map[string]any{ + "System.Description": "Bold intro", + }) + stubTemplatePath(t, deps, wi, nil) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + assert.Contains(t, deps.stdout.String(), "**Bold** intro") +} + +func TestRunShow_DescriptionFormatFromPayload(t *testing.T) { + t.Parallel() + + // The format is read from /multilineFieldsFormat/System.Description, not + // sniffed from content: HTML-looking content marked Markdown must not be + // converted (no markdown markers appear). + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(map[string]any{ + "System.Description": "Bold intro", + }) + format := map[string]string{"System.Description": "Markdown"} + stubTemplatePath(t, deps, wi, &format) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + + out := deps.stdout.String() + assert.Contains(t, out, "Bold") + assert.NotContains(t, out, "**Bold**") +} + +func TestRunShow_TemplateOutput_NoDescription(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + stubTemplatePath(t, deps, wi, nil) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + assert.Contains(t, deps.stdout.String(), "None given") +} + +func TestRunShow_TemplateOutput_Tags(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(map[string]any{"System.Tags": "tag1; tag2"}) + stubTemplatePath(t, deps, wi, nil) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + + out := deps.stdout.String() + assert.Contains(t, out, "tags:") + assert.Contains(t, out, "tag1; tag2") +} + +func TestRunShow_TemplateOutput_NoTags(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(map[string]any{"System.Tags": ""}) + stubTemplatePath(t, deps, wi, nil) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + assert.NotContains(t, deps.stdout.String(), "tags:") +} + +func TestRunShow_TemplateOutput_RelationsIncluded(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + wi.Relations = &[]workitemtracking.WorkItemRelation{ + {Rel: types.ToPtr("System.LinkTypes.Hierarchy-Forward"), Url: types.ToPtr("https://dev.azure.com/myorg/Fabrikam/_apis/wit/workItems/12346")}, + } + stubTemplatePath(t, deps, wi, nil) + + opts := showOpts("org:Fabrikam/12345") + opts.showRelations = true + require.NoError(t, runShow(deps.cmd, opts)) + + out := deps.stdout.String() + assert.Contains(t, out, "relations:") + assert.Contains(t, out, "System.LinkTypes.Hierarchy-Forward") + assert.Contains(t, out, "workItems/12346") +} + +func TestRunShow_TemplateOutput_RelationsOmitted_Default(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + wi.Relations = &[]workitemtracking.WorkItemRelation{ + {Rel: types.ToPtr("System.LinkTypes.Hierarchy-Forward"), Url: types.ToPtr("https://dev.azure.com/myorg/Fabrikam/_apis/wit/workItems/12346")}, + } + stubTemplatePath(t, deps, wi, nil) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + assert.NotContains(t, deps.stdout.String(), "relations:") +} + +func TestRunShow_TemplateOutput_CommentsIncluded(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + stubTemplatePath(t, deps, wi, nil) + + createdDate := "2024-01-15T11:00:00Z" + comments := []workitemtracking.Comment{ + { + CreatedBy: &webapi.IdentityRef{DisplayName: types.ToPtr("Alice")}, + CreatedDate: types.ToPtr(mustParseTime(t, createdDate)), + Text: types.ToPtr("**note** about the login"), + }, + } + + deps.wit.EXPECT().GetComments(gomock.Any(), gomock.Any()).Return( + &workitemtracking.CommentList{Comments: &comments}, nil, + ) + + opts := showOpts("org:Fabrikam/12345") + opts.showComments = true + require.NoError(t, runShow(deps.cmd, opts)) + + out := deps.stdout.String() + assert.Contains(t, out, "comments:") + assert.Contains(t, out, "Alice") + assert.Contains(t, out, "commented") + assert.Contains(t, out, "**note** about the login") +} + +func TestRunShow_TemplateOutput_CommentsOmitted_Default(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + stubTemplatePath(t, deps, wi, nil) + deps.wit.EXPECT().GetComments(gomock.Any(), gomock.Any()).Times(0) + + require.NoError(t, runShow(deps.cmd, showOpts("org:Fabrikam/12345"))) + assert.NotContains(t, deps.stdout.String(), "comments:") +} + +func TestRunShow_JSONOutput(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + stubWit(deps) + stubWorkItem(t, deps, wi) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"org:Fabrikam/12345", "--json"}) + require.NoError(t, cmd.Execute()) + + out := deps.stdout.String() + for _, key := range []string{"id", "rev", "fields", "relations", "url", "_links", "commentVersionRef"} { + assert.Contains(t, out, `"`+key+`"`) + } + var parsed map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + assert.Equal(t, float64(12345), parsed["id"]) + assert.Equal(t, float64(3), parsed["rev"]) +} + +func TestRunShow_ProjectScopeParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopeArg string + wantError string + }{ + {name: "explicit org", scopeArg: "myorg:Fabrikam/12345"}, + {name: "implicit org", scopeArg: "Fabrikam/12345"}, + {name: "legacy org slash rejected", scopeArg: "org/proj/extra", wantError: "legacy ORGANIZATION"}, + {name: "empty input", scopeArg: "", wantError: "project is required"}, + {name: "missing id", scopeArg: "Fabrikam", wantError: "targets"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "default-org") + if tc.wantError == "" { + wi := sampleShowWorkItem(nil) + stubTemplatePath(t, deps, wi, nil) + require.NoError(t, runShow(deps.cmd, showOpts(tc.scopeArg))) + } else { + err := runShow(deps.cmd, showOpts(tc.scopeArg)) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantError) + } + }) + } +} + +func TestRunShow_InvalidProjectScope(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + + err := runShow(deps.cmd, showOpts("org/proj/extra")) + require.Error(t, err) + var flagErr *util.FlagError + require.ErrorAs(t, err, &flagErr) + assert.Contains(t, err.Error(), "legacy ORGANIZATION/... form is not supported") +} + +func TestRunShow_ClientFactoryError(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), gomock.Any()). + Return(nil, errors.New("factory boom")) + + err := runShow(deps.cmd, showOpts("org:Fabrikam/12345")) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create work item tracking client") +} + +func TestRunShow_SDKError(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + stubWit(deps) + deps.wit.EXPECT().GetWorkItem(gomock.Any(), gomock.Any()). + Return(nil, errors.New("sdk boom")) + + err := runShow(deps.cmd, showOpts("org:Fabrikam/12345")) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get work item") +} + +func TestRunShow_GetCommentsError(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(nil) + stubTemplatePath(t, deps, wi, nil) + deps.wit.EXPECT().GetComments(gomock.Any(), gomock.Any()). + Return(nil, errors.New("comments boom")) + + opts := showOpts("org:Fabrikam/12345") + opts.showComments = true + err := runShow(deps.cmd, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get work item comments") +} + +func TestRunShow_OrganizationFromConfigDefault(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "default-org") + wi := sampleShowWorkItem(nil) + + var capturedOrg string + deps.clientFact.EXPECT().WorkItemTracking(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, org string) (workitemtracking.Client, error) { + capturedOrg = org + return deps.wit, nil + }).AnyTimes() + stubWorkItem(t, deps, wi) + stubEnvelope(t, deps, envelopeJSON(t, wi, nil)) + + require.NoError(t, runShow(deps.cmd, showOpts("Fabrikam/12345"))) + assert.Equal(t, "default-org", capturedOrg) +} + +func TestRunShow_WorkItemProjectMismatch(t *testing.T) { + t.Parallel() + + deps := setupShowDeps(t, "org") + wi := sampleShowWorkItem(map[string]any{"System.TeamProject": "OtherProject"}) + stubWit(deps) + stubWorkItem(t, deps, wi) + + err := runShow(deps.cmd, showOpts("org:Fabrikam/12345")) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not belong to project") + assert.Empty(t, deps.stdout.String()) +} + +func mustParseTime(t *testing.T, raw string) azuredevops.Time { + t.Helper() + + parsed, err := time.Parse(time.RFC3339, raw) + require.NoError(t, err) + return azuredevops.Time{Time: parsed} +} diff --git a/internal/cmd/boards/workitem/workitem.go b/internal/cmd/boards/workitem/workitem.go index 8fc7f6c1..af70fc65 100644 --- a/internal/cmd/boards/workitem/workitem.go +++ b/internal/cmd/boards/workitem/workitem.go @@ -6,6 +6,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/create" "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/delete" "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/list" + "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/show" "github.com/tmeckel/azdo-cli/internal/cmd/boards/workitem/update" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -22,6 +23,9 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { # Create a work item azdo boards work-item create Fabrikam --type Bug --title "Login is broken" + # Show a work item's details + azdo boards work-item show Fabrikam/42 --comments + # Update a work item's title azdo boards work-item update Fabrikam/42 --title "New title" @@ -32,6 +36,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(list.NewCmd(ctx)) cmd.AddCommand(create.NewCmd(ctx)) + cmd.AddCommand(show.NewCmd(ctx)) cmd.AddCommand(update.NewCmd(ctx)) cmd.AddCommand(delete.NewCmd(ctx)) diff --git a/internal/cmd/pipelines/agent/show/show.go b/internal/cmd/pipelines/agent/show/show.go index 466ef88c..d4735a0e 100644 --- a/internal/cmd/pipelines/agent/show/show.go +++ b/internal/cmd/pipelines/agent/show/show.go @@ -6,7 +6,6 @@ import ( "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" @@ -25,7 +24,6 @@ type templateData struct { type showOptions struct { targetArg string includeCapabilities bool - raw bool exporter util.Exporter } @@ -71,7 +69,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } 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", @@ -117,12 +114,6 @@ func runShow(cmdCtx util.CmdContext, opts *showOptions) error { 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) diff --git a/internal/cmd/pipelines/pool/show/show.go b/internal/cmd/pipelines/pool/show/show.go index 258bdee1..cb19239f 100644 --- a/internal/cmd/pipelines/pool/show/show.go +++ b/internal/cmd/pipelines/pool/show/show.go @@ -6,7 +6,6 @@ import ( "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" @@ -22,7 +21,6 @@ type templateData struct { type showOptions struct { targetArg string - raw bool exporter util.Exporter } @@ -58,7 +56,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { }, } - cmd.Flags().BoolVarP(&opts.raw, "raw", "r", false, "Dump raw pool object to stderr") util.AddJSONFlags(cmd, &opts.exporter, []string{ "id", "name", "poolType", "isHosted", "isLegacy", "autoProvision", "autoUpdate", "createdOn", "createdBy", @@ -113,12 +110,6 @@ func runShow(cmdCtx util.CmdContext, opts *showOptions) error { return fmt.Errorf("pool %q not found", poolTarget) } - if opts.raw { - ios.StopProgressIndicator() - spew.Dump(pool) - return nil - } - if opts.exporter != nil { ios.StopProgressIndicator() return opts.exporter.Write(ios, pool) diff --git a/internal/cmd/pipelines/pool/show/show_test.go b/internal/cmd/pipelines/pool/show/show_test.go index 6890d4e7..d5b9c8c5 100644 --- a/internal/cmd/pipelines/pool/show/show_test.go +++ b/internal/cmd/pipelines/pool/show/show_test.go @@ -395,20 +395,6 @@ func TestRunShow_JSONOutput(t *testing.T) { assert.True(t, strings.HasPrefix(strings.TrimSpace(output), "{")) } -func TestRunShow_RawFlag(t *testing.T) { - t.Parallel() - - deps := setupFakeDeps(t) - deps.clientFact.EXPECT().TaskAgent(gomock.Any(), "myorg").Return(deps.taskClient, nil) - - pool := samplePool() - deps.taskClient.EXPECT().GetAgentPool(gomock.Any(), gomock.Any()).Return(pool, nil) - - opts := &showOptions{targetArg: "myorg:/7", raw: true} - err := runShow(deps.cmd, opts) - require.NoError(t, err) -} - func TestRunShow_ClientFactoryError(t *testing.T) { t.Parallel() @@ -516,9 +502,7 @@ func TestNewCmd_HasFlags(t *testing.T) { t.Parallel() cmd := NewCmd(nil) - rawFlag := cmd.Flag("raw") - require.NotNil(t, rawFlag) - assert.Equal(t, "r", rawFlag.Shorthand) + assert.Nil(t, cmd.Flag("raw")) jsonFlag := cmd.Flag("json") require.NotNil(t, jsonFlag) diff --git a/internal/cmd/pr/view/view.go b/internal/cmd/pr/view/view.go index 16c41fcd..036437ae 100644 --- a/internal/cmd/pr/view/view.go +++ b/internal/cmd/pr/view/view.go @@ -12,7 +12,6 @@ import ( "github.com/google/uuid" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" - "github.com/spewerspew/spew" "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/pr/shared" "github.com/tmeckel/azdo-cli/internal/cmd/util" @@ -24,7 +23,6 @@ type viewOptions struct { selectorArg string showComments bool showCommits bool - showRaw bool commentType string commentSort string } @@ -133,7 +131,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.Flags().BoolVarP(&opts.showComments, "comments", "c", false, "View pull request comments") cmd.Flags().BoolVarP(&opts.showCommits, "commits", "C", false, "View pull request commits") - cmd.Flags().BoolVarP(&opts.showRaw, "raw", "r", false, "View pull request raw") util.StringEnumFlag(cmd, &opts.commentType, "comment-type", "", "text", []string{"text", "system", "all"}, "Filter comments by type; defaults to 'text'") util.StringEnumFlag(cmd, &opts.commentSort, "comment-sort", "", "desc", []string{"desc", "asc"}, "Sort comments by creation time; defaults to 'desc' (newest first)") util.AddJSONFlags(cmd, &opts.exporter, []string{ @@ -339,17 +336,6 @@ func runCmd(ctx util.CmdContext, opts *viewOptions) (err error) { commits = &commitsResponse.Value } - if opts.showRaw { - spew.Dump(pr) - if threads != nil { - spew.Dump(threads) - } - if commits != nil { - spew.Dump(commits) - } - return nil - } - if opts.exporter != nil { prJSON := &pullRequestJSON{ URL: pr.Url, diff --git a/scripts/checkin.sh b/scripts/checkin.sh index ab03c51a..3aca43a6 100644 --- a/scripts/checkin.sh +++ b/scripts/checkin.sh @@ -46,17 +46,25 @@ commit_if_staged() { fi fi - # The pre-commit hook may have modified files (e.g. an end-of-file - # fixer). Re-stage exactly the files it touched and retry the commit. - attempts=$((attempts + 1)) - if [[ $attempts -ge 5 ]]; then - echo "Error: commit failed $attempts times; giving up. Inspect the working tree and re-run." >&2 - return 1 - fi + # Only retry when a pre-commit hook actually modified staged files + # (e.g. end-of-file fixer). A failing hook that changed nothing is a + # fatal error (e.g. yamllint): bail out instantly, never retry. local modified modified=$(git diff --name-only | grep -Fxf <(printf '%s\n' "$staged_before") || true) if [[ -z "$modified" ]]; then - echo "Error: commit failed and no staged files were modified by a hook; fix manually." >&2 + echo "Error: pre-commit hook failed without modifying any staged file; aborting check-in." >&2 + echo " Fix the failing hook and re-run." >&2 + return 1 + fi + if typos_hook_modified_file "$modified"; then + echo "Error: typos pre-commit hook modified staged files; aborting check-in, no retry." >&2 + echo " Review the typo fixes, then re-run the check-in." >&2 + return 1 + fi + + attempts=$((attempts + 1)) + if [[ $attempts -ge 5 ]]; then + echo "Error: hooks modified files but the commit still failed $attempts times; giving up. Inspect the working tree and re-run." >&2 return 1 fi echo "> pre-commit hook modified files; re-staging and retrying:" @@ -66,6 +74,32 @@ commit_if_staged() { done } +# Detect whether the typos pre-commit hook rewrote any of the given files. +# A staged file whose working-tree version equals the staged version after +# running typos in fix mode was rewritten by the hook. Typo fixes must be +# reviewed deliberately, so callers abort instead of re-staging and retrying. +typos_hook_modified_file() { + command -v typos >/dev/null 2>&1 || return 1 + local cfg=() + if [ -f .typos.toml ]; then + cfg=(-c "$(pwd)/.typos.toml") + fi + local f staged_copy + while IFS= read -r f; do + [[ -n "$f" ]] || continue + staged_copy=$(mktemp) + if git show ":$f" > "$staged_copy" 2>/dev/null; then + typos --write-changes "${cfg[@]}" "$staged_copy" >/dev/null 2>&1 || true + if cmp -s "$staged_copy" "$f"; then + rm -f "$staged_copy" + return 0 + fi + fi + rm -f "$staged_copy" + done <<< "$1" + return 1 +} + is_tracked() { git ls-files --error-unmatch -- "$1" >/dev/null 2>&1 } diff --git a/vendor/github.com/spewerspew/spew/.gitignore b/vendor/github.com/JohannesKaufmann/dom/.gitignore similarity index 53% rename from vendor/github.com/spewerspew/spew/.gitignore rename to vendor/github.com/JohannesKaufmann/dom/.gitignore index 064a5d49..6f6f5e6a 100644 --- a/vendor/github.com/spewerspew/spew/.gitignore +++ b/vendor/github.com/JohannesKaufmann/dom/.gitignore @@ -1,3 +1,6 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# # Binaries for programs and plugins *.exe *.exe~ @@ -11,9 +14,9 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out -# Common files using profiling -*.cpu -*.mem - # Dependency directories (remove the comment below to include it) -# vendor/ \ No newline at end of file +# vendor/ + +# Go workspace file +go.work +go.work.sum diff --git a/vendor/github.com/JohannesKaufmann/dom/LICENSE b/vendor/github.com/JohannesKaufmann/dom/LICENSE new file mode 100644 index 00000000..56507dcc --- /dev/null +++ b/vendor/github.com/JohannesKaufmann/dom/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Johannes Kaufmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/JohannesKaufmann/dom/README.md b/vendor/github.com/JohannesKaufmann/dom/README.md new file mode 100644 index 00000000..bbc8ce6d --- /dev/null +++ b/vendor/github.com/JohannesKaufmann/dom/README.md @@ -0,0 +1,277 @@ +# dom + +[](https://pkg.go.dev/github.com/JohannesKaufmann/dom) + +Helper functions for "net/html" that make it easier to interact with `*html.Node`. + +🚀 [Getting Started](#getting-started) - 📚 [Documentation](#documentation) - 🧑💻 [Examples](/examples/) + +## Installation + +```bash +go get -u github.com/JohannesKaufmann/dom +``` + +> [!NOTE] +> This "dom" libary was developed for the needs of the [html-to-markdown](https://github.com/JohannesKaufmann/html-to-markdown) library. +> That beeing said, please submit any functions that you need. + +## Getting Started + +```go +package main + +import ( + "fmt" + "log" + "strings" + + "github.com/JohannesKaufmann/dom" + "golang.org/x/net/html" +) + +func main() { + input := ` +