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
11 changes: 11 additions & 0 deletions DevOps.Tests/ActionHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<div>Hello</div>", "Hello")]
[InlineData("Line1<br>Line2", "Line1\nLine2")]
[InlineData("<p>a</p><p>b</p>", "a\nb")]
[InlineData("a &amp; b &lt;c&gt;", "a & b <c>")]
[InlineData("<b>bold</b> text", "bold text")]
[InlineData("", "")]
[InlineData(null, "")]
public void HtmlToText(string input, string expected)
=> Assert.Equal(expected, ActionHelpers.HtmlToText(input));

[Fact]
public void ResolvePullRequestUrl_UsesWebLinkWhenPresent()
{
Expand Down
5 changes: 5 additions & 0 deletions DevOps.Tests/ResolvePullRequestUrlConfigTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
25 changes: 25 additions & 0 deletions DevOps/Actions/ActionHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using DevOps.Responses;
using DevOps.Services;
using DevOps.Utils;
Expand All @@ -8,6 +9,30 @@ namespace DevOps.Actions;

internal static class ActionHelpers
{
/// <summary>Builds the browser URL for a work item from the configured org and its project.</summary>
internal static string WorkItemUrl(string project, int id)
{
var orgUrl = ConfigService.LoadConfig().OrgUrl.TrimEnd('/');
return $"{orgUrl}/{Uri.EscapeDataString(project)}/_workitems/edit/{id}";
}

/// <summary>
/// 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.
/// </summary>
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();
}

/// <summary>
/// Writes work items as machine-readable JSON or CSV to stdout. Returns a non-zero
/// exit code (with an error) for an unknown format.
Expand Down
73 changes: 63 additions & 10 deletions DevOps/Actions/GetAction.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using DevOps.Options;
using DevOps.Responses;
using DevOps.Services;
using DevOps.Utils;

Expand All @@ -16,16 +17,35 @@ internal static async Task<int> 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;
}
Expand All @@ -35,4 +55,37 @@ internal static async Task<int> 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<WorkItemComment> 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}");
}
}
}
3 changes: 3 additions & 0 deletions DevOps/Options/GetOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
33 changes: 33 additions & 0 deletions DevOps/Responses/AzureDevOpsResponses.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkItemComment> 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
Expand Down
16 changes: 16 additions & 0 deletions DevOps/Services/HttpService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ public static async Task<WorkItemResponse> GetWorkItem(int id, string project, C
return JsonConvert.DeserializeObject<WorkItemResponse>(response.Content);
}

/// <summary>Fetches the discussion comments of a work item, oldest first.</summary>
public static async Task<List<WorkItemComment>> 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<WorkItemCommentsResponse>(response.Content)?.Comments ?? [];
}

/// <summary>
/// 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).
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading