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