Skip to content
Open
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
15 changes: 13 additions & 2 deletions .claude/skills/atlassian-search/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ This skill enables you to search and retrieve information from Jira and Confluen

Before using this skill, ensure:
1. Environment variables are set:
- `ATLASSIAN_URL`: Your Atlassian instance URL (e.g., `https://your-domain.atlassian.net`)
- `ATLASSIAN_URL`: Jira instance URL (e.g., `https://your-domain.atlassian.net`)
- `ATLASSIAN_USERNAME`: Your email address
- `ATLASSIAN_API_TOKEN`: Your API token
- `ATLASSIAN_CLOUD`: Set to `true` for Cloud, `false` for Server/DC (default: true)
- `ATLASSIAN_JIRA_API_VERSION`: Optional Jira REST API version override (`2`, `3`, or `latest`)
- `CONFLUENCE_URL`: Optional separate Confluence host; falls back to `ATLASSIAN_URL`
- `CONFLUENCE_USERNAME`: Optional separate Confluence username; falls back to `ATLASSIAN_USERNAME`
- `CONFLUENCE_API_TOKEN`: Optional separate Confluence token; falls back to `ATLASSIAN_API_TOKEN`
- `CONFLUENCE_BASE_PATH`: Confluence API base path (default: `/wiki`)
2. The `atlassian-cli` binary is built and available in PATH

For split Jira/Confluence deployments, prefer setting the Confluence-specific variables explicitly.

## Available Commands

### Jira Commands
Expand Down Expand Up @@ -379,6 +385,7 @@ atlassian-cli jira search "updated >= -3d AND project=MYPROJECT" --max=30
- Verify your API token is valid
- Check that username (email) is correct
- Ensure token has not expired
- For Confluence, verify `CONFLUENCE_USERNAME` / `CONFLUENCE_API_TOKEN` if it uses separate credentials

3. **"Permission denied"**
- You don't have access to the requested resource
Expand All @@ -393,7 +400,11 @@ atlassian-cli jira search "updated >= -3d AND project=MYPROJECT" --max=30
5. **"Connection error"**
- Check network connectivity
- Verify ATLASSIAN_URL is correct (include https://)
- For Confluence, ensure CONFLUENCE_BASE_PATH is set correctly (default: `/wiki`)
- For Confluence, verify `CONFLUENCE_URL` and `CONFLUENCE_BASE_PATH`

6. **Jira Server/DC returns not found for issue/search endpoints**
- Set `ATLASSIAN_CLOUD=false`
- If needed, set `ATLASSIAN_JIRA_API_VERSION=2` or `ATLASSIAN_JIRA_API_VERSION=latest`

## Limitations

Expand Down
4 changes: 4 additions & 0 deletions .envrc.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export ATLASSIAN_URL="https://your-domain.atlassian.net"
export ATLASSIAN_USERNAME="your-email@example.com"
export ATLASSIAN_API_TOKEN="your-api-token-here"
export ATLASSIAN_CLOUD="true" # true for Cloud, false for Server/DC
export ATLASSIAN_JIRA_API_VERSION="3" # Optional override: 2, 3, or latest

# Confluence-specific settings
export CONFLUENCE_URL="https://your-domain.atlassian.net" # Optional separate Confluence host
export CONFLUENCE_USERNAME="your-email@example.com" # Optional separate Confluence username
export CONFLUENCE_API_TOKEN="your-confluence-token-here" # Optional separate Confluence token
export CONFLUENCE_BASE_PATH="/wiki" # Default for Confluence Cloud
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ export ATLASSIAN_URL="https://your-domain.atlassian.net"
export ATLASSIAN_USERNAME="your-email@example.com"
export ATLASSIAN_API_TOKEN="your-api-token"
export ATLASSIAN_CLOUD="true" # true for Cloud, false for Server/DC
export ATLASSIAN_JIRA_API_VERSION="3" # optional override: 2, 3, or latest

# Confluence-specific settings
export CONFLUENCE_URL="https://your-confluence-domain.example.com" # optional separate Confluence host
export CONFLUENCE_USERNAME="your-email@example.com" # optional separate Confluence username
export CONFLUENCE_API_TOKEN="your-confluence-token" # optional separate Confluence token
export CONFLUENCE_BASE_PATH="/wiki"
```

### Creating an API Token
Expand All @@ -84,8 +91,19 @@ export ATLASSIAN_URL="https://your-domain.atlassian.net"
export ATLASSIAN_USERNAME="your-email@example.com"
export ATLASSIAN_API_TOKEN="your-api-token"
export ATLASSIAN_CLOUD="true"
export ATLASSIAN_JIRA_API_VERSION="3"

# Confluence-specific settings
export CONFLUENCE_URL="https://your-confluence-domain.example.com"
export CONFLUENCE_USERNAME="your-email@example.com"
export CONFLUENCE_API_TOKEN="your-confluence-token"
export CONFLUENCE_BASE_PATH="/wiki"
```

If `ATLASSIAN_JIRA_API_VERSION` is not set, the CLI defaults to `3` for Atlassian Cloud and `2` for Jira Server/Data Center. Set it explicitly if you want to force a specific version such as `latest`.
If `CONFLUENCE_URL` is not set, Confluence commands fall back to `ATLASSIAN_URL`.
If `CONFLUENCE_USERNAME` or `CONFLUENCE_API_TOKEN` are not set, Confluence commands fall back to `ATLASSIAN_USERNAME` and `ATLASSIAN_API_TOKEN`.

Then run:
```bash
direnv allow
Expand Down
17 changes: 14 additions & 3 deletions src/atlassian_client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub const AtlassianClient = struct {
base_url: []const u8,
username: []const u8,
api_token: []const u8,
jira_api_version: []const u8,
http_client: std.http.Client,
is_cloud: bool,

Expand All @@ -13,13 +14,15 @@ pub const AtlassianClient = struct {
base_url: []const u8,
username: []const u8,
api_token: []const u8,
jira_api_version: []const u8,
is_cloud: bool,
) AtlassianClient {
return .{
.allocator = allocator,
.base_url = base_url,
.username = username,
.api_token = api_token,
.jira_api_version = jira_api_version,
.http_client = std.http.Client{ .allocator = allocator },
.is_cloud = is_cloud,
};
Expand All @@ -29,6 +32,10 @@ pub const AtlassianClient = struct {
self.http_client.deinit();
}

fn printStatus(status: std.http.Status) void {
std.debug.print("HTTP Error: {d} {s}\n", .{ @intFromEnum(status), @tagName(status) });
}

/// Create Basic Auth header value (base64 encoded username:token)
fn createAuthHeader(self: *AtlassianClient, buffer: []u8) ![]const u8 {
var auth_buffer: [1024]u8 = undefined;
Expand Down Expand Up @@ -79,21 +86,24 @@ pub const AtlassianClient = struct {
});

switch (response.status) {
.ok => {},
.ok, .created, .accepted, .no_content => {},
.unauthorized => {
std.debug.print("Authentication failed. Check ATLASSIAN_USERNAME and ATLASSIAN_API_TOKEN\n", .{});
std.debug.print("Authentication failed. Check the configured username and API token\n", .{});
printStatus(response.status);
return error.AuthenticationFailed;
},
.forbidden => {
std.debug.print("Permission denied. Check user permissions\n", .{});
printStatus(response.status);
return error.PermissionDenied;
},
.not_found => {
std.debug.print("Resource not found\n", .{});
printStatus(response.status);
return error.NotFound;
},
else => {
std.debug.print("HTTP Error: {}\n", .{response.status});
printStatus(response.status);
return error.HttpError;
},
}
Expand All @@ -109,6 +119,7 @@ test "create auth header" {
"https://test.atlassian.net",
"test@example.com",
"test_token",
"3",
true,
);
defer client.deinit();
Expand Down
115 changes: 110 additions & 5 deletions src/formatter.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,63 @@ pub const OutputFormat = enum {
json,
};

fn confluenceUrlPrefix(base_path: []const u8) []const u8 {
if (base_path.len == 0 or std.mem.eql(u8, base_path, "/")) return "";
return if (base_path[base_path.len - 1] == '/') base_path[0 .. base_path.len - 1] else base_path;
}

fn appendJiraDocText(allocator: std.mem.Allocator, output: *std.ArrayList(u8), value: std.json.Value) !void {
switch (value) {
.string => |text| try output.appendSlice(allocator, text),
.array => |items| {
for (items.items) |item| {
try appendJiraDocText(allocator, output, item);
}
},
.object => |obj| {
if (obj.get("text")) |text| {
try appendJiraDocText(allocator, output, text);
}

if (obj.get("content")) |content| {
const before_len = output.items.len;
try appendJiraDocText(allocator, output, content);

if (obj.get("type")) |node_type| {
if (node_type == .string) {
const needs_separator = !std.mem.eql(u8, node_type.string, "text") and
output.items.len > before_len and
output.items[output.items.len - 1] != '\n';
if (needs_separator) {
try output.append(allocator, '\n');
}
}
}
}
},
else => {},
}
}

fn renderJiraDescription(allocator: std.mem.Allocator, value: std.json.Value) ![]u8 {
switch (value) {
.null => return try allocator.dupe(u8, ""),
.string => |text| return try allocator.dupe(u8, text),
else => {
var output = try std.ArrayList(u8).initCapacity(allocator, 256);
errdefer output.deinit(allocator);

try appendJiraDocText(allocator, &output, value);

const raw_text = try output.toOwnedSlice(allocator);
defer allocator.free(raw_text);

const cleaned = try cleanWhitespace(allocator, raw_text);
return cleaned;
},
}
}

/// Simple HTML tag stripper - removes HTML tags and decodes basic entities
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);
Expand Down Expand Up @@ -83,7 +140,13 @@ fn cleanWhitespace(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
}

/// Format Confluence search results as readable text
pub fn formatConfluenceSearchResults(allocator: std.mem.Allocator, json_str: []const u8, show_full_content: bool) ![]u8 {
pub fn formatConfluenceSearchResults(
allocator: std.mem.Allocator,
json_str: []const u8,
base_url: []const u8,
base_path: []const u8,
show_full_content: bool,
) ![]u8 {
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{});
defer parsed.deinit();

Expand All @@ -93,6 +156,7 @@ pub fn formatConfluenceSearchResults(allocator: std.mem.Allocator, json_str: []c
var output = try std.ArrayList(u8).initCapacity(allocator, 1024);
errdefer output.deinit(allocator);
const writer = output.writer(allocator);
const url_prefix = confluenceUrlPrefix(base_path);

const results_array = results.array;
try writer.print("Found {} result(s):\n\n", .{results_array.items.len});
Expand Down Expand Up @@ -132,7 +196,7 @@ pub fn formatConfluenceSearchResults(allocator: std.mem.Allocator, json_str: []c
if (obj.get("space")) |space| {
const space_obj = space.object;
if (space_obj.get("key")) |key| {
try writer.print(" URL: https://.atlassian.net/wiki/spaces/{s}/pages/{s}\n", .{ key.string, page_id });
try writer.print(" URL: {s}{s}/spaces/{s}/pages/{s}\n", .{ base_url, url_prefix, key.string, page_id });
}
}
}
Expand Down Expand Up @@ -339,18 +403,58 @@ pub fn formatJiraIssue(allocator: std.mem.Allocator, json_str: []const u8) ![]u8
// Description
if (fields_obj.get("description")) |description| {
if (description != .null) {
const rendered_description = try renderJiraDescription(allocator, description);
defer allocator.free(rendered_description);

try writer.writeAll("\nDescription:\n");
try writer.writeAll("─────────────────────────────────────────\n");
try writer.print("{s}\n", .{description.string});
try writer.print("{s}\n", .{rendered_description});
}
}

return output.toOwnedSlice(allocator);
}

test "formatJiraIssue handles Atlassian document format description" {
const allocator = std.testing.allocator;
const json =
\\{
\\ "key": "VSM-5743",
\\ "fields": {
\\ "summary": "Investigate formatter failure",
\\ "status": { "name": "In Progress" },
\\ "description": {
\\ "type": "doc",
\\ "version": 1,
\\ "content": [
\\ {
\\ "type": "paragraph",
\\ "content": [
\\ { "type": "text", "text": "First line." }
\\ ]
\\ },
\\ {
\\ "type": "paragraph",
\\ "content": [
\\ { "type": "text", "text": "Second line." }
\\ ]
\\ }
\\ ]
\\ }
\\ }
\\}
;

const formatted = try formatJiraIssue(allocator, json);
defer allocator.free(formatted);

try std.testing.expect(std.mem.indexOf(u8, formatted, "Issue: VSM-5743") != null);
try std.testing.expect(std.mem.indexOf(u8, formatted, "First line. Second line.") != null);
}

/// Format Confluence page as readable text
/// base_url: Atlassian base URL (e.g. https://your-domain.atlassian.net)
pub fn formatConfluencePage(allocator: std.mem.Allocator, json_str: []const u8, base_url: []const u8) ![]u8 {
pub fn formatConfluencePage(allocator: std.mem.Allocator, json_str: []const u8, base_url: []const u8, base_path: []const u8) ![]u8 {
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{});
defer parsed.deinit();

Expand All @@ -359,6 +463,7 @@ pub fn formatConfluencePage(allocator: std.mem.Allocator, json_str: []const u8,
var output = try std.ArrayList(u8).initCapacity(allocator, 1024);
errdefer output.deinit(allocator);
const writer = output.writer(allocator);
const url_prefix = confluenceUrlPrefix(base_path);

// Title
const title = if (root.get("title")) |t| t.string else "Untitled";
Expand Down Expand Up @@ -397,7 +502,7 @@ pub fn formatConfluencePage(allocator: std.mem.Allocator, json_str: []const u8,
const space_obj = space.object;
if (space_obj.get("key")) |key| {
// Dynamically generate Confluence page URL from base URL
try writer.print("URL: {s}/wiki/spaces/{s}/pages/{s}\n", .{ base_url, key.string, page_id });
try writer.print("URL: {s}{s}/spaces/{s}/pages/{s}\n", .{ base_url, url_prefix, key.string, page_id });
}
}
}
Expand Down
Loading