diff --git a/src/formatter.zig b/src/formatter.zig
index f91c664..41e7ef0 100644
--- a/src/formatter.zig
+++ b/src/formatter.zig
@@ -5,57 +5,128 @@ pub const OutputFormat = enum {
json,
};
-/// Simple HTML tag stripper - removes HTML tags and decodes basic entities
+/// Extract the value of an HTML attribute from the inner content of a tag.
+/// E.g., given `a href="https://example.com" class="link"` and "href",
+/// returns "https://example.com" (a slice into tag_inner).
+fn extractHtmlAttr(tag_inner: []const u8, attr_name: []const u8) ?[]const u8 {
+ var pos: usize = 0;
+ while (pos + attr_name.len <= tag_inner.len) : (pos += 1) {
+ if (!std.mem.startsWith(u8, tag_inner[pos..], attr_name)) continue;
+ // Must be preceded by whitespace to avoid partial matches (e.g. "data-href")
+ if (pos > 0 and !std.ascii.isWhitespace(tag_inner[pos - 1])) continue;
+ var p = pos + attr_name.len;
+ // Skip optional whitespace around =
+ while (p < tag_inner.len and std.ascii.isWhitespace(tag_inner[p])) : (p += 1) {}
+ if (p >= tag_inner.len or tag_inner[p] != '=') continue;
+ p += 1;
+ while (p < tag_inner.len and std.ascii.isWhitespace(tag_inner[p])) : (p += 1) {}
+ if (p >= tag_inner.len) continue;
+ const quote = tag_inner[p];
+ if (quote != '"' and quote != '\'') continue;
+ const val_start = p + 1;
+ const val_end = std.mem.indexOfScalarPos(u8, tag_inner, val_start, quote) orelse continue;
+ return tag_inner[val_start..val_end];
+ }
+ return null;
+}
+
+/// HTML tag stripper that preserves links and images.
+/// text → text (url) [or just text if text == url]
+///
→ [image: url]
fn stripHtmlTags(allocator: std.mem.Allocator, html: []const u8) ![]u8 {
var result = std.ArrayList(u8).initCapacity(allocator, html.len) catch return try allocator.dupe(u8, html);
errdefer result.deinit(allocator);
- var in_tag = false;
+ var i: usize = 0;
var in_entity = false;
var entity_buffer: [10]u8 = undefined;
var entity_len: usize = 0;
- var i: usize = 0;
- while (i < html.len) : (i += 1) {
+ // Track current href to append URL after link text if it differs
+ var link_href: ?[]const u8 = null;
+ var link_text_start: usize = 0;
+
+ while (i < html.len) {
const c = html[i];
if (c == '<') {
- in_tag = true;
- } else if (c == '>') {
- in_tag = false;
- // Add space after closing tag for readability
- if (i + 1 < html.len and html[i + 1] != ' ' and html[i + 1] != '\n') {
- result.append(allocator, ' ') catch {};
- }
- } else if (!in_tag) {
- if (c == '&') {
- in_entity = true;
- entity_len = 0;
- } else if (in_entity) {
- if (c == ';') {
- in_entity = false;
- const entity = entity_buffer[0..entity_len];
- if (std.mem.eql(u8, entity, "nbsp")) {
- result.append(allocator, ' ') catch {};
- } else if (std.mem.eql(u8, entity, "lt")) {
- result.append(allocator, '<') catch {};
- } else if (std.mem.eql(u8, entity, "gt")) {
- result.append(allocator, '>') catch {};
- } else if (std.mem.eql(u8, entity, "amp")) {
- result.append(allocator, '&') catch {};
- } else if (std.mem.eql(u8, entity, "quot")) {
- result.append(allocator, '"') catch {};
- } else {
- // Unknown entity, just skip
+ const close = std.mem.indexOfScalarPos(u8, html, i + 1, '>') orelse {
+ i += 1;
+ continue;
+ };
+ const tag_inner = html[i + 1 .. close];
+ const trimmed = std.mem.trimLeft(u8, tag_inner, " \t\n\r");
+
+ if (trimmed.len > 0 and trimmed[0] == 'a' and
+ (trimmed.len == 1 or trimmed[1] == ' ' or trimmed[1] == '\t' or trimmed[1] == '\n'))
+ {
+ // Opening tag - extract href
+ link_href = extractHtmlAttr(tag_inner, "href");
+ link_text_start = result.items.len;
+ } else if (trimmed.len >= 2 and trimmed[0] == '/' and trimmed[1] == 'a' and
+ (trimmed.len == 2 or trimmed[2] == ' ' or trimmed[2] == '\t'))
+ {
+ // Closing tag - append URL if link text differs from href
+ if (link_href) |href| {
+ const link_text = result.items[link_text_start..result.items.len];
+ const trimmed_text = std.mem.trim(u8, link_text, " \t\n\r");
+ if (!std.mem.eql(u8, trimmed_text, href)) {
+ try result.appendSlice(allocator, " (");
+ try result.appendSlice(allocator, href);
+ try result.appendSlice(allocator, ")");
}
- } else if (entity_len < entity_buffer.len) {
- entity_buffer[entity_len] = c;
- entity_len += 1;
+ link_href = null;
+ }
+ } else if (trimmed.len >= 3 and std.mem.startsWith(u8, trimmed, "img") and
+ (trimmed.len == 3 or trimmed[3] == ' ' or trimmed[3] == '/' or trimmed[3] == '>'))
+ {
+ //
tag - show src URL
+ if (extractHtmlAttr(tag_inner, "src")) |src| {
+ try result.appendSlice(allocator, "[image: ");
+ try result.appendSlice(allocator, src);
+ try result.appendSlice(allocator, "]");
}
} else {
- result.append(allocator, c) catch {};
+ // Other tag - add space after for readability
+ if (close + 1 < html.len and html[close + 1] != ' ' and html[close + 1] != '\n') {
+ try result.append(allocator, ' ');
+ }
+ }
+
+ i = close + 1;
+ continue;
+ }
+
+ // Entity handling
+ if (c == '&') {
+ in_entity = true;
+ entity_len = 0;
+ } else if (in_entity) {
+ if (c == ';') {
+ in_entity = false;
+ const entity = entity_buffer[0..entity_len];
+ if (std.mem.eql(u8, entity, "nbsp")) {
+ try result.append(allocator, ' ');
+ } else if (std.mem.eql(u8, entity, "lt")) {
+ try result.append(allocator, '<');
+ } else if (std.mem.eql(u8, entity, "gt")) {
+ try result.append(allocator, '>');
+ } else if (std.mem.eql(u8, entity, "amp")) {
+ try result.append(allocator, '&');
+ } else if (std.mem.eql(u8, entity, "quot")) {
+ try result.append(allocator, '"');
+ } else {
+ // Unknown entity, skip
+ }
+ } else if (entity_len < entity_buffer.len) {
+ entity_buffer[entity_len] = c;
+ entity_len += 1;
}
+ } else {
+ try result.append(allocator, c);
}
+
+ i += 1;
}
return result.toOwnedSlice(allocator);
@@ -82,6 +153,160 @@ fn cleanWhitespace(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
return result.toOwnedSlice(allocator);
}
+/// Write plain text extracted from a Jira ADF (Atlassian Document Format) node.
+/// Iterative DFS to avoid per-recursion allocations.
+fn writeAdfText(writer: anytype, node: std.json.Value) !void {
+ const max_stack = 64;
+ var stack: [max_stack]struct { items: []const std.json.Value, idx: usize, is_block: bool } = undefined;
+ var depth: usize = 0;
+
+ // Seed with the root node's content array
+ if (node != .object) return;
+ const root_content = node.object.get("content") orelse return;
+ if (root_content != .array) return;
+ stack[0] = .{ .items = root_content.array.items, .idx = 0, .is_block = false };
+ depth = 1;
+
+ while (depth > 0) {
+ const frame = &stack[depth - 1];
+ if (frame.idx >= frame.items.len) {
+ if (frame.is_block) {
+ try writer.writeAll("\n");
+ }
+ depth -= 1;
+ continue;
+ }
+ const child = frame.items[frame.idx];
+ frame.idx += 1;
+
+ if (child != .object) continue;
+ const obj = child.object;
+
+ // Emit text content
+ if (obj.get("text")) |text_val| {
+ if (text_val == .string) {
+ try writer.writeAll(text_val.string);
+ // Check for link marks and append URL if it differs from text
+ if (obj.get("marks")) |marks_val| {
+ if (marks_val == .array) {
+ for (marks_val.array.items) |mark| {
+ if (mark != .object) continue;
+ const mt = if (mark.object.get("type")) |t| if (t == .string) t.string else null else null;
+ if (mt) |mark_type| {
+ if (std.mem.eql(u8, mark_type, "link")) {
+ if (mark.object.get("attrs")) |attrs| {
+ if (attrs == .object) {
+ if (attrs.object.get("href")) |href| {
+ if (href == .string and !std.mem.eql(u8, text_val.string, href.string)) {
+ try writer.writeAll(" (");
+ try writer.writeAll(href.string);
+ try writer.writeAll(")");
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Handle inlineCard (smart links / embedded URLs)
+ {
+ const nt = if (obj.get("type")) |t| if (t == .string) t.string else null else null;
+ if (nt) |node_type| {
+ if (std.mem.eql(u8, node_type, "inlineCard") or std.mem.eql(u8, node_type, "embedCard")) {
+ if (obj.get("attrs")) |attrs| {
+ if (attrs == .object) {
+ if (attrs.object.get("url")) |url| {
+ if (url == .string) {
+ try writer.writeAll(url.string);
+ try writer.writeAll("\n");
+ }
+ }
+ }
+ }
+ } else if (std.mem.eql(u8, node_type, "mention")) {
+ if (obj.get("attrs")) |attrs| {
+ if (attrs == .object) {
+ if (attrs.object.get("text")) |text| {
+ if (text == .string) {
+ try writer.writeAll(text.string);
+ }
+ }
+ }
+ }
+ } else if (std.mem.eql(u8, node_type, "hardBreak")) {
+ try writer.writeAll("\n");
+ } else if (std.mem.eql(u8, node_type, "media")) {
+ if (obj.get("attrs")) |attrs| {
+ if (attrs == .object) {
+ const mt = if (attrs.object.get("type")) |t| if (t == .string) t.string else null else null;
+ if (mt) |media_type| {
+ if (std.mem.eql(u8, media_type, "external")) {
+ if (attrs.object.get("url")) |url| {
+ if (url == .string) {
+ try writer.writeAll("[image: ");
+ try writer.writeAll(url.string);
+ try writer.writeAll("]");
+ } else {
+ try writer.writeAll("[image]");
+ }
+ } else {
+ try writer.writeAll("[image]");
+ }
+ } else {
+ const alt = if (attrs.object.get("alt")) |a| if (a == .string) a.string else null else null;
+ if (alt) |alt_text| {
+ try writer.writeAll("[image: ");
+ try writer.writeAll(alt_text);
+ try writer.writeAll("]");
+ } else {
+ try writer.writeAll("[image]");
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Push child's content array onto stack
+ if (obj.get("content")) |content_val| {
+ if (content_val == .array and depth < max_stack) {
+ // Track whether this is a block-level node so we emit a newline after its children
+ const is_block = if (obj.get("type")) |tv| if (tv == .string) blk: {
+ const t = tv.string;
+ break :blk (std.mem.eql(u8, t, "paragraph") or
+ std.mem.eql(u8, t, "heading") or
+ std.mem.eql(u8, t, "bulletList") or
+ std.mem.eql(u8, t, "orderedList") or
+ std.mem.eql(u8, t, "listItem") or
+ std.mem.eql(u8, t, "codeBlock") or
+ std.mem.eql(u8, t, "blockquote") or
+ std.mem.eql(u8, t, "rule"));
+ } else false else false;
+ stack[depth] = .{ .items = content_val.array.items, .idx = 0, .is_block = is_block };
+ depth += 1;
+ continue;
+ }
+ }
+
+ // Newline after leaf block-level nodes (no content array)
+ if (obj.get("type")) |type_val| {
+ if (type_val == .string) {
+ const t = type_val.string;
+ if (std.mem.eql(u8, t, "rule")) {
+ try writer.writeAll("\n");
+ }
+ }
+ }
+ }
+}
+
/// Format Confluence search results as readable text
pub fn formatConfluenceSearchResults(allocator: std.mem.Allocator, json_str: []const u8, show_full_content: bool) ![]u8 {
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{});
@@ -336,12 +561,17 @@ pub fn formatJiraIssue(allocator: std.mem.Allocator, json_str: []const u8) ![]u8
try writer.print("Updated: {s}\n", .{updated.string});
}
- // Description
+ // Description (can be a plain string or an ADF object in Jira Cloud)
if (fields_obj.get("description")) |description| {
if (description != .null) {
try writer.writeAll("\nDescription:\n");
try writer.writeAll("─────────────────────────────────────────\n");
- try writer.print("{s}\n", .{description.string});
+ if (description == .string) {
+ try writer.print("{s}\n", .{description.string});
+ } else if (description == .object) {
+ try writeAdfText(writer, description);
+ try writer.writeAll("\n");
+ }
}
}
@@ -484,3 +714,63 @@ pub fn formatGenericList(allocator: std.mem.Allocator, json_str: []const u8, ite
return try allocator.dupe(u8, "No items found.\n");
}
+
+/// Format Jira issue comments as readable text
+pub fn formatJiraComments(allocator: std.mem.Allocator, json_str: []const u8, issue_key: []const u8) ![]u8 {
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{});
+ defer parsed.deinit();
+
+ const root = parsed.value.object;
+ const comments = root.get("comments") orelse return try allocator.dupe(u8, "No comments found.\n");
+ const comments_array = comments.array;
+
+ var output = try std.ArrayList(u8).initCapacity(allocator, 1024);
+ errdefer output.deinit(allocator);
+ const writer = output.writer(allocator);
+
+ try writer.print("Comments for {s} ({} comment{s})\n", .{
+ issue_key,
+ comments_array.items.len,
+ if (comments_array.items.len == 1) @as([]const u8, "") else "s",
+ });
+ try writer.writeAll("─────────────────────────────────────────\n");
+
+ for (comments_array.items) |comment| {
+ const obj = comment.object;
+
+ // Date — extract "YYYY-MM-DD HH:MM" from ISO 8601
+ if (obj.get("created")) |created| {
+ if (created == .string) {
+ const s = created.string;
+ if (s.len >= 16) {
+ try writer.print("{s} {s}", .{ s[0..10], s[11..16] });
+ } else {
+ try writer.print("{s}", .{s});
+ }
+ }
+ }
+
+ // Author
+ if (obj.get("author")) |author| {
+ if (author == .object) {
+ if (author.object.get("displayName")) |name| {
+ if (name == .string) {
+ try writer.print(" \u{2014} {s}", .{name.string});
+ }
+ }
+ }
+ }
+ try writer.writeAll(":\n");
+
+ // Body (ADF)
+ if (obj.get("body")) |body| {
+ if (body == .object) {
+ try writeAdfText(writer, body);
+ }
+ }
+
+ try writer.writeAll("\n");
+ }
+
+ return output.toOwnedSlice(allocator);
+}
diff --git a/src/main.zig b/src/main.zig
index bf84628..4d8102f 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -22,6 +22,7 @@ const JiraCommand = enum {
boards,
sprints,
@"sprint-issues",
+ comments,
user,
help,
};
@@ -69,6 +70,7 @@ fn printHelp() !void {
\\ boards [--type=scrum] List agile boards
\\ sprints [--state=active] List sprints
\\ sprint-issues [--max=50] Get issues in sprint
+ \\ comments Get issue comments
\\ user Get current user info
\\
\\Confluence Commands:
@@ -106,6 +108,7 @@ fn printJiraHelp() !void {
\\ boards [--type=scrum] List agile boards
\\ sprints [--state=active] List sprints
\\ sprint-issues [--max=50] Get issues in sprint
+ \\ comments Get issue comments
\\ user Get current user info
\\
\\JQL Examples:
@@ -189,7 +192,9 @@ fn handleJiraCommand(allocator: std.mem.Allocator, client: *AtlassianClient, arg
std.debug.print("Usage: jira issue [--format=text|json]\n", .{});
return;
}
- const response = try jira.getIssue(args[1], null);
+ const json_issue_fields = "summary,description,status,assignee,reporter,labels,priority,created,updated,issuetype,comment";
+ const issue_fields: ?[]const u8 = if (output_format == .json) json_issue_fields else null;
+ const response = try jira.getIssue(args[1], issue_fields);
defer allocator.free(response);
if (output_format == .text) {
@@ -292,6 +297,22 @@ fn handleJiraCommand(allocator: std.mem.Allocator, client: *AtlassianClient, arg
defer allocator.free(response);
std.debug.print("{s}\n", .{response});
},
+ .comments => {
+ if (args.len < 2) {
+ std.debug.print("Usage: jira comments [--format=text|json]\n", .{});
+ return;
+ }
+ const response = try jira.getComments(args[1]);
+ defer allocator.free(response);
+
+ if (output_format == .text) {
+ const formatted = try formatter.formatJiraComments(allocator, response, args[1]);
+ defer allocator.free(formatted);
+ std.debug.print("{s}", .{formatted});
+ } else {
+ std.debug.print("{s}\n", .{response});
+ }
+ },
.user => {
const response = try jira.getCurrentUser();
defer allocator.free(response);