diff --git a/DevOps.Tests/ActionHelpersTests.cs b/DevOps.Tests/ActionHelpersTests.cs
index 1620f1c..623fad3 100644
--- a/DevOps.Tests/ActionHelpersTests.cs
+++ b/DevOps.Tests/ActionHelpersTests.cs
@@ -90,6 +90,17 @@ public void DescribeCount_ReportsTruncation()
=> Assert.Equal("Showing 50 of 312 items - use --top to fetch more.",
ActionHelpers.DescribeCount(50, 312, "item"));
+ [Theory]
+ [InlineData("
Hello
", "Hello")]
+ [InlineData("Line1
Line2", "Line1\nLine2")]
+ [InlineData("a
b
", "a\nb")]
+ [InlineData("a & b <c>", "a & b ")]
+ [InlineData("bold text", "bold text")]
+ [InlineData("", "")]
+ [InlineData(null, "")]
+ public void HtmlToText(string input, string expected)
+ => Assert.Equal(expected, ActionHelpers.HtmlToText(input));
+
[Fact]
public void ResolvePullRequestUrl_UsesWebLinkWhenPresent()
{
diff --git a/DevOps.Tests/ResolvePullRequestUrlConfigTests.cs b/DevOps.Tests/ResolvePullRequestUrlConfigTests.cs
index c321c97..ff72388 100644
--- a/DevOps.Tests/ResolvePullRequestUrlConfigTests.cs
+++ b/DevOps.Tests/ResolvePullRequestUrlConfigTests.cs
@@ -37,4 +37,9 @@ public void BuildsUrlFromOrgProjectRepoWhenWebLinkMissing()
Assert.Equal("https://dev.azure.com/acme/proj/_git/repo/pullrequest/7",
ActionHelpers.ResolvePullRequestUrl(pr));
}
+
+ [Fact]
+ public void WorkItemUrl_BuildsBrowserUrlFromOrgAndProject()
+ => Assert.Equal("https://dev.azure.com/acme/MyProject/_workitems/edit/123",
+ ActionHelpers.WorkItemUrl("MyProject", 123));
}
diff --git a/DevOps/Actions/ActionHelpers.cs b/DevOps/Actions/ActionHelpers.cs
index 25d8c3f..828780e 100644
--- a/DevOps/Actions/ActionHelpers.cs
+++ b/DevOps/Actions/ActionHelpers.cs
@@ -1,3 +1,4 @@
+using System.Text.RegularExpressions;
using DevOps.Responses;
using DevOps.Services;
using DevOps.Utils;
@@ -8,6 +9,30 @@ namespace DevOps.Actions;
internal static class ActionHelpers
{
+ /// Builds the browser URL for a work item from the configured org and its project.
+ internal static string WorkItemUrl(string project, int id)
+ {
+ var orgUrl = ConfigService.LoadConfig().OrgUrl.TrimEnd('/');
+ return $"{orgUrl}/{Uri.EscapeDataString(project)}/_workitems/edit/{id}";
+ }
+
+ ///
+ /// Converts the HTML that Azure DevOps stores for descriptions and comments into plain
+ /// text suitable for the terminal: block tags become newlines, other tags are stripped,
+ /// and entities are decoded.
+ ///
+ internal static string HtmlToText(string html)
+ {
+ if (string.IsNullOrEmpty(html)) return string.Empty;
+
+ var text = Regex.Replace(html, @"<\s*(br|/p|/div|/li)\s*/?\s*>", "\n", RegexOptions.IgnoreCase);
+ text = Regex.Replace(text, "<.*?>", string.Empty);
+ text = System.Net.WebUtility.HtmlDecode(text);
+ // Collapse runs of blank lines left behind by stripped markup.
+ text = Regex.Replace(text, @"\n{3,}", "\n\n");
+ return text.Trim();
+ }
+
///
/// Writes work items as machine-readable JSON or CSV to stdout. Returns a non-zero
/// exit code (with an error) for an unknown format.
diff --git a/DevOps/Actions/GetAction.cs b/DevOps/Actions/GetAction.cs
index f96988c..554c388 100644
--- a/DevOps/Actions/GetAction.cs
+++ b/DevOps/Actions/GetAction.cs
@@ -1,4 +1,5 @@
using DevOps.Options;
+using DevOps.Responses;
using DevOps.Services;
using DevOps.Utils;
@@ -16,16 +17,35 @@ internal static async Task Execute(GetOptions opts, CancellationToken ct)
if (!string.IsNullOrEmpty(opts.Output))
return ActionHelpers.WriteWorkItemsOutput([item], opts.Output);
- Console.WriteLine($"ID : {item.Id}");
- Console.WriteLine($"Type : {item.Fields.WorkItemType}");
- Console.WriteLine($"Title : {item.Fields.Title}");
- Console.WriteLine($"State : {item.Fields.State}");
- Console.WriteLine($"Assigned: {item.Fields.AssignedTo?.DisplayName ?? "(unassigned)"}");
- Console.WriteLine($"Priority: {item.Fields.Priority?.ToString() ?? "-"}");
- Console.WriteLine($"Created : {item.Fields.CreatedDate:yyyy-MM-dd}");
- Console.WriteLine($"Changed : {item.Fields.ChangedDate:yyyy-MM-dd}");
- Console.WriteLine($"Project : {item.Fields.TeamProject}");
- Console.WriteLine($"URL : {item.Url?.Replace("_apis/wit/workitems", "_workitems/edit")}");
+ var f = item.Fields;
+ var state = string.IsNullOrEmpty(f.Reason) ? f.State : $"{f.State} ({f.Reason})";
+
+ Console.WriteLine($"ID : {item.Id}");
+ Console.WriteLine($"Type : {f.WorkItemType}");
+ Console.WriteLine($"Title : {f.Title}");
+ Console.WriteLine($"State : {state}");
+ Console.WriteLine($"Assigned : {f.AssignedTo?.DisplayName ?? "(unassigned)"}");
+ Console.WriteLine($"Priority : {f.Priority?.ToString() ?? "-"}");
+ Console.WriteLine($"Area : {f.AreaPath ?? "-"}");
+ Console.WriteLine($"Iteration: {f.IterationPath ?? "-"}");
+ Console.WriteLine($"Tags : {(string.IsNullOrEmpty(f.Tags) ? "-" : f.Tags)}");
+ Console.WriteLine($"Parent : {(f.ParentId.HasValue ? $"#{f.ParentId}" : "-")}");
+ Console.WriteLine($"Created : {f.CreatedDate:yyyy-MM-dd}");
+ Console.WriteLine($"Changed : {f.ChangedDate:yyyy-MM-dd}");
+ Console.WriteLine($"Project : {f.TeamProject}");
+ Console.WriteLine($"URL : {ActionHelpers.WorkItemUrl(project, item.Id)}");
+
+ var description = ActionHelpers.HtmlToText(f.Description);
+ if (!string.IsNullOrEmpty(description))
+ {
+ Console.WriteLine();
+ Console.WriteLine("Description:");
+ foreach (var line in description.Split('\n'))
+ Console.WriteLine($" {line}");
+ }
+
+ if (opts.Comments)
+ await WriteComments(opts.Id, project, ct);
return 0;
}
@@ -35,4 +55,37 @@ internal static async Task Execute(GetOptions opts, CancellationToken ct)
return 1;
}
}
+
+ // Comments are a best-effort enrichment: a failure (e.g. the preview endpoint being
+ // unavailable) must not fail the whole 'get'.
+ private static async Task WriteComments(int id, string project, CancellationToken ct)
+ {
+ List comments;
+ try
+ {
+ comments = await HttpService.GetWorkItemComments(id, project, ct);
+ }
+ catch
+ {
+ return;
+ }
+
+ if (comments is null || comments.Count == 0)
+ return;
+
+ Console.WriteLine();
+ Console.WriteLine($"Comments ({comments.Count}):");
+ var first = true;
+ foreach (var comment in comments)
+ {
+ if (!first)
+ Console.WriteLine();
+ first = false;
+
+ var author = comment.CreatedBy?.DisplayName ?? "(unknown)";
+ Console.WriteLine($" - {author} ({comment.CreatedDate:yyyy-MM-dd HH:mm}):");
+ foreach (var line in ActionHelpers.HtmlToText(comment.Text).Split('\n'))
+ Console.WriteLine($" {line}");
+ }
+ }
}
diff --git a/DevOps/Options/GetOptions.cs b/DevOps/Options/GetOptions.cs
index 7e4b2d1..54576a6 100644
--- a/DevOps/Options/GetOptions.cs
+++ b/DevOps/Options/GetOptions.cs
@@ -13,4 +13,7 @@ public class GetOptions
[Option('o', "output", Required = false, HelpText = "Output format: 'json' or 'csv'. Defaults to a detailed view.")]
public string Output { get; set; }
+
+ [Option("comments", Required = false, HelpText = "Also fetch and show the discussion comments (extra API call).")]
+ public bool Comments { get; set; }
}
diff --git a/DevOps/Responses/AzureDevOpsResponses.cs b/DevOps/Responses/AzureDevOpsResponses.cs
index 35cb40b..48bd481 100644
--- a/DevOps/Responses/AzureDevOpsResponses.cs
+++ b/DevOps/Responses/AzureDevOpsResponses.cs
@@ -45,6 +45,39 @@ public class WorkItemFields
[JsonProperty("System.Parent")]
public int? ParentId { get; set; }
+
+ [JsonProperty("System.AreaPath")]
+ public string AreaPath { get; set; }
+
+ [JsonProperty("System.IterationPath")]
+ public string IterationPath { get; set; }
+
+ [JsonProperty("System.Tags")]
+ public string Tags { get; set; }
+
+ [JsonProperty("System.Reason")]
+ public string Reason { get; set; }
+}
+
+public class WorkItemCommentsResponse
+{
+ [JsonProperty("count")]
+ public int Count { get; set; }
+
+ [JsonProperty("comments")]
+ public List Comments { get; set; }
+}
+
+public class WorkItemComment
+{
+ [JsonProperty("text")]
+ public string Text { get; set; }
+
+ [JsonProperty("createdBy")]
+ public AssignedTo CreatedBy { get; set; }
+
+ [JsonProperty("createdDate")]
+ public DateTime CreatedDate { get; set; }
}
public class AssignedTo
diff --git a/DevOps/Services/HttpService.cs b/DevOps/Services/HttpService.cs
index 6d607c6..900a76e 100644
--- a/DevOps/Services/HttpService.cs
+++ b/DevOps/Services/HttpService.cs
@@ -65,6 +65,22 @@ public static async Task GetWorkItem(int id, string project, C
return JsonConvert.DeserializeObject(response.Content);
}
+ /// Fetches the discussion comments of a work item, oldest first.
+ public static async Task> GetWorkItemComments(int id, string project, CancellationToken cancellationToken = default)
+ {
+ using var client = await CreateClientAsync(cancellationToken);
+ var request = new RestRequest($"{project}/_apis/wit/workItems/{id}/comments", Method.Get);
+ // The comments endpoint is still preview-only, so it needs its own api-version.
+ request.AddQueryParameter("api-version", "7.1-preview.4");
+
+ var response = await client.ExecuteAsync(request, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ throw new Exception($"Failed to get comments for work item {id}. Status: {response.StatusCode}. {response.Content}");
+
+ return JsonConvert.DeserializeObject(response.Content)?.Comments ?? [];
+ }
+
///
/// Runs a WIQL query and fetches the matching work items, returning the requested
/// page along with how many items matched in total (so callers can report truncation).
diff --git a/README.md b/README.md
index fddf1e9..ccf69e2 100644
--- a/README.md
+++ b/README.md
@@ -82,17 +82,25 @@ When you sign in (`--login`) or provide a `--pat`, the CLI automatically fetches
### `get` — Get work item details
+Shows the core fields plus area, iteration, tags, parent, reason, the description, and a
+browser URL that opens the item directly. Add `--comments` to also fetch the discussion.
+
```powershell
devops get -i 1234
+devops get -i 1234 --comments # also show the discussion comments
devops get -i 1234 -p AnotherProject
+devops get -i 1234 -o json # flat projection for scripting
```
| Option | Alias | Description |
|---|---|---|
| `--id` | `-i` | Work item ID (required) |
| `--project` | `-p` | Project name (uses default if configured) |
+| `--comments` | | Also fetch and show the discussion comments (extra API call) |
| `--output` | `-o` | Output format: `json` or `csv`. Defaults to a detailed view |
+> Comments require a separate (preview) API call, so they are opt-in via `--comments`. The `--output` json/csv modes always return the flat field projection.
+
---
### `mine` — List work items assigned to me