Skip to content

Add Atlassian Jira/Confluence integration skill with safe local API helpers#13

Open
lorischaveeb12 wants to merge 2 commits intomasterfrom
lchav/gather-context-from-jira-confluence
Open

Add Atlassian Jira/Confluence integration skill with safe local API helpers#13
lorischaveeb12 wants to merge 2 commits intomasterfrom
lchav/gather-context-from-jira-confluence

Conversation

@lorischaveeb12
Copy link
Copy Markdown

@lorischaveeb12 lorischaveeb12 commented Apr 1, 2026

Description

This PR adds a reusable Atlassian integration skill focused on Jira and Confluence workflows for coding agents. It combines clear operating guidance in the skill definition, reference documents for query and payload authoring, and local Python CLIs that handle authentication, read-only safety, and product-specific operations.

For Jira, the skill wraps the official Atlassian CLI (acli) with auto-authentication, read-only enforcement, and consistent JSON output. For Confluence, it wraps the REST API directly. Both share the same 1Password-backed credential resolution and are supported by JQL/CQL query patterns and ADF payload references.

File details

  • SKILL.md: Defines when the skill should be used, prerequisites (acli installation), the 1Password-backed authentication flow via acli_auth.py, default operating model, read-only and write-enable behavior, common commands for Jira (view, search, create, edit, transition, comment, assign), progressive disclosure rules for reference files, workflow checklists, and the role of each bundled script/reference file.

  • atlassian_format.md: Documents how to build Atlassian-compatible rich text payloads. Explains Atlassian Document Format usage for Jira descriptions and Confluence page bodies, shows minimal JSON structures for headings, bullet lists, and code blocks, and covers storage-format HTML for Confluence comments.
    jql_cql_guide.md: Provides a compact query-writing reference for Jira Query Language and Confluence Query Language. Includes common fields, operators, practical examples, and a simple debugging workflow to build queries incrementally before using them in the CLI helpers.

  • schemas.md: Contains minimal payload templates for common Confluence operations, including page creation, page update, and comments. Jira payload schemas have been removed since jira_ops.py now builds payloads internally through acli flags.

  • run_with_1password.sh: Shell wrapper that runs any command through 1Password using the local .env file as the source of op:// references. Verifies that the 1Password CLI is available, ensures the .env file exists, and executes the requested command with secrets injected at runtime.

  • acli_auth.py: New authentication helper that logs in to the Atlassian CLI using 1Password-backed credentials. Reuses auth_client.load_config("jira") for credential resolution (environment variables, .env file, op:// secret references), extracts the site from the Jira URL, and pipes the API token into acli jira auth login. Supports --status to check the current session and --verbose for diagnostic output.

  • auth_client.py: Shared HTTP client layer used by both Jira and Confluence CLIs. Loads configuration from environment variables or the nearest .env file, resolves 1Password secret references, enforces read-only mode unless writes are explicitly enabled, builds authentication headers for Cloud and Server/DC, handles retries and TLS options, sanitizes error output, and supports both JSON requests and multipart attachment uploads. Now includes improved macOS SSL compatibility using certifi when available.

  • jira_ops.py: CLI entry point for Jira operations, rewritten to wrap acli instead of direct REST API calls. Supports subcommands: view, search, create, edit, transition, comment, and assign. Automatically detects whether acli is authenticated and triggers auto-login via acli_auth when needed. Enforces read-only mode for write commands unless explicitly opted in. Supports --json output and --from-json payload files for create/edit operations.

  • confluence_ops.py: CLI entry point for Confluence operations. Supports CQL search, page retrieval, page creation, page update, comments, attachment listing, and attachment upload. Includes helpers for extracting readable page content from ADF or storage HTML, generating markdown summaries, and merging page version metadata required for safe updates.

How to test

  • Install the Atlassian CLI (acli) for your platform and verify with acli --help
  • Create an Atlassian API token on your account (https://id.atlassian.com/manage-profile/security/api-tokens)
  • Create 1Password vaults and items as described in SKILL.md
  • Define a .env file according to the procedure in SKILL.md
  • Authenticate acli:
eval "$(op signin)"scripts/run_with_1password.sh python scripts/acli_auth.py
  • Try fetching a Jira issue using your favorite coding agent:
  • Try fetching an existing Confluence page using your favorite coding agen

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new atlassian-integration-skill that provides documented guidance plus local CLI helpers for Jira/Confluence API access (Cloud + Server/DC), with safe defaults (read-only + dry-run) and optional 1Password-backed secret resolution.

Changes:

  • Introduces the Atlassian skill definition (SKILL.md) and reference docs (ADF formatting, JQL/CQL guide, payload schemas).
  • Adds Python CLIs for Jira/Confluence operations built on a shared auth/retry/read-only HTTP client.
  • Adds a 1Password wrapper script and repo-wide ignore rules for local env files.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
skills/atlassian-integration-skill/SKILL.md Skill operating model, env setup, safety defaults, and usage examples for the bundled CLIs
skills/atlassian-integration-skill/scripts/run_with_1password.sh Runs commands with op run --env-file using the skill’s .env
skills/atlassian-integration-skill/scripts/auth_client.py Shared config loading, 1Password resolution, read-only enforcement, retries, and HTTP helpers
skills/atlassian-integration-skill/scripts/jira_ops.py Jira CLI: search/get/create/update/transition/comment with dry-run support
skills/atlassian-integration-skill/scripts/confluence_ops.py Confluence CLI: search/get/create/update/comment/attachments with body extraction helpers
skills/atlassian-integration-skill/references/schemas.md Minimal payload templates for common Jira/Confluence operations
skills/atlassian-integration-skill/references/jql_cql_guide.md Quick reference for composing/debugging JQL and CQL
skills/atlassian-integration-skill/references/atlassian_format.md ADF + Confluence body/comment format guidance
.gitignore Ignores .env and related local-only files

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +17 to +22
def _join_extracted_text(items: list[Any]) -> str:
return "\n".join(
part for part in (extract_jira_text(item) for item in items) if part
)


Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_join_extracted_text joins extracted ADF fragments with newlines. In ADF, inline text nodes within a paragraph are often split across multiple nodes, so joining with \n can turn single sentences into one-word-per-line output (hurting the primary “context” output). Consider joining inline text nodes without newlines (or with spaces) and only inserting newlines between block-level nodes (paragraphs, headings, list items, etc.).

Suggested change
def _join_extracted_text(items: list[Any]) -> str:
return "\n".join(
part for part in (extract_jira_text(item) for item in items) if part
)
BLOCK_NODE_TYPES: set[str] = {
"paragraph",
"heading",
"listItem",
"bulletList",
"orderedList",
"panel",
"blockquote",
"rule",
}
def _join_extracted_text(items: list[Any]) -> str:
# Decide whether this list represents block-level nodes (paragraphs, headings,
# list items, etc.) or inline content. Inline content should not be split by
# newlines, otherwise single sentences can become one-word-per-line.
has_block_nodes = any(
isinstance(item, dict) and item.get("type") in BLOCK_NODE_TYPES
for item in items
)
separator = "\n" if has_block_nodes else " "
parts: list[str] = []
for item in items:
text = extract_jira_text(item)
if not text:
continue
text = text.strip()
if text:
parts.append(text)
return separator.join(parts)

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +33


def _join_extracted_text(items: list[Any]) -> str:
return "\n".join(part for part in (adf_to_text(item) for item in items) if part)


Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_join_extracted_text uses "\n".join(...) for all child nodes. For Atlassian Document Format, this tends to insert line breaks between inline text nodes inside the same paragraph, producing unreadable output (e.g., “Hello\nworld”). Consider differentiating inline vs block nodes and only adding newlines between block-level nodes.

Suggested change
def _join_extracted_text(items: list[Any]) -> str:
return "\n".join(part for part in (adf_to_text(item) for item in items) if part)
BLOCK_NODE_TYPES = TEXT_BLOCK_NODE_TYPES | CONTAINER_NODE_TYPES | {"codeBlock"}
def _join_extracted_text(items: list[Any]) -> str:
"""Join extracted text from child nodes, adding newlines only between blocks.
Inline nodes (for example, ``{"type": "text"}``) are concatenated directly,
while block-level nodes are separated by single newline characters.
"""
parts: list[str] = []
for item in items:
if isinstance(item, dict):
node_type = item.get("type")
is_block = node_type in BLOCK_NODE_TYPES
else:
# Treat non-dict structured items (like nested lists) as blocks
is_block = isinstance(item, list)
text = adf_to_text(item)
if not text:
continue
if parts and is_block:
parts.append("\n")
parts.append(text)
return "".join(parts)

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +111
def __init__(self) -> None:
super().__init__()
self.parts: list[str] = []

def handle_data(self, data: str) -> None:
if data.strip():
self.parts.append(data.strip())

def text(self) -> str:
return "\n".join(self.parts)
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HTML-to-text extractor strips each handle_data chunk and then joins chunks with newlines. For common inline markup (e.g., <p>Hello <b>world</b></p>), this can produce Hello\nworld (newline inserted mid-sentence) and also loses significant whitespace. Consider accumulating text with preserved spacing for inline segments and inserting newlines only on block-level tags (p, br, li, etc.), or using a more structure-aware conversion routine.

Suggested change
def __init__(self) -> None:
super().__init__()
self.parts: list[str] = []
def handle_data(self, data: str) -> None:
if data.strip():
self.parts.append(data.strip())
def text(self) -> str:
return "\n".join(self.parts)
"""Simple HTML-to-text extractor that preserves inline spacing.
Newlines are only inserted around block-level tags, while inline content
is separated by spaces as needed to keep words from running together.
"""
# A minimal set of block-level tags for our use case.
_BLOCK_TAGS = {
"p",
"div",
"li",
"ul",
"ol",
"table",
"tr",
"td",
"th",
"blockquote",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
}
def __init__(self) -> None:
super().__init__()
self.parts: list[str] = []
def _ensure_newline(self) -> None:
"""Append a newline if the last emitted character is not already one."""
if not self.parts:
return
last = self.parts[-1]
if not last.endswith("\n"):
self.parts.append("\n")
def handle_starttag(self, tag: str, attrs: Any) -> None: # type: ignore[override]
# For <br>, force a line break.
if tag == "br":
self.parts.append("\n")
return
# For other block-level tags, ensure we start on a new line.
if tag in self._BLOCK_TAGS:
self._ensure_newline()
def handle_endtag(self, tag: str) -> None: # type: ignore[override]
# End of block-level tags should end with a newline to separate blocks.
if tag in self._BLOCK_TAGS:
self._ensure_newline()
def handle_data(self, data: str) -> None: # type: ignore[override]
# Normalize internal whitespace while preserving needed separation.
normalized = " ".join(data.split())
if not normalized:
return
# If the last character isn't whitespace or a newline, add a space
# before this new text to avoid "wordword" concatenation.
if self.parts:
last = self.parts[-1]
if last and not last.endswith((" ", "\n")):
self.parts.append(" ")
self.parts.append(normalized)
def text(self) -> str:
# We explicitly add newlines and spaces as needed while parsing.
return "".join(self.parts).strip()

Copilot uses AI. Check for mistakes.
Comment on lines +210 to +216
if args.output in {"body", "markdown"}:
body_text = extract_page_body_text(data)
if not body_text:
raise AtlassianClientError(
"No readable page body was found in the Confluence response."
)
if args.output == "body":
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

command_get raises an error when --output markdown but the response lacks a readable body, even if --include-body is not set. Since the markdown summary doesn’t require body text, consider only requiring a readable body for --output body (or when --include-body is requested), and allow metadata-only markdown output otherwise.

Copilot uses AI. Check for mistakes.
Comment on lines +223 to +225
timeout = int(os.getenv("ATLASSIAN_TIMEOUT", str(DEFAULT_TIMEOUT)))
retries = int(os.getenv("ATLASSIAN_RETRIES", str(DEFAULT_RETRIES)))

Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ATLASSIAN_TIMEOUT and ATLASSIAN_RETRIES are cast with int(...) without validation. If these env vars are set to a non-integer value, the script will crash with an unhandled ValueError instead of a user-friendly AtlassianClientError. Consider catching ValueError here and raising AtlassianClientError with a clear message about the invalid setting.

Suggested change
timeout = int(os.getenv("ATLASSIAN_TIMEOUT", str(DEFAULT_TIMEOUT)))
retries = int(os.getenv("ATLASSIAN_RETRIES", str(DEFAULT_RETRIES)))
timeout_raw = os.getenv("ATLASSIAN_TIMEOUT")
if timeout_raw is None or timeout_raw.strip() == "":
timeout = DEFAULT_TIMEOUT
else:
try:
timeout = int(timeout_raw)
except ValueError as exc:
raise AtlassianClientError(
f"ATLASSIAN_TIMEOUT must be an integer number of seconds, got {timeout_raw!r}."
) from exc
retries_raw = os.getenv("ATLASSIAN_RETRIES")
if retries_raw is None or retries_raw.strip() == "":
retries = DEFAULT_RETRIES
else:
try:
retries = int(retries_raw)
except ValueError as exc:
raise AtlassianClientError(
f"ATLASSIAN_RETRIES must be an integer, got {retries_raw!r}."
) from exc

Copilot uses AI. Check for mistakes.
Comment on lines +191 to +196
result = subprocess.run(
["op", "read", value],
capture_output=True,
text=True,
check=False,
)
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subprocess.run(["op", "read", ...]) has no timeout. If the 1Password CLI hangs (agent socket issues, auth prompts, etc.), these scripts can block indefinitely. Consider adding a reasonable timeout and converting TimeoutExpired into an AtlassianClientError that explains how to re-authenticate or retry.

Copilot uses AI. Check for mistakes.
@lorischaveeb12 lorischaveeb12 self-assigned this Apr 1, 2026
@lorischaveeb12 lorischaveeb12 added the enhancement New feature or request label Apr 1, 2026
Copy link
Copy Markdown
Contributor

@VMinB12 VMinB12 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lorischaveeb12 Thanks for taking the time to add this skill!
I had the idea myself too to allow agents to interface directly with Jira.
We should find ways of making more of the product development process accessible to agents.
It seems to me that most of the files here are reproducing the jira cli:
https://www.atlassian.com/blog/jira/atlassian-command-line-interface
Did you consider using that?
I feel that this will be more documented and thus agents will have an easier time using it.
It also removes the maintenance burden from us to maintain this skill.
Interested to hear if you agree we should use the jira cli and significantly slim down this PR.

@lorischaveeb12
Copy link
Copy Markdown
Author

@VMinB12 I'll have a look at the Atlassian CLI ! I was not even aware it existed so yes, it might reduce the size of the skill significantly !

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +255 to +265
create = subparsers.add_parser("create", help="Create a Jira work item")
create.add_argument("--project", help="Project key (e.g. DEMO)")
create.add_argument("--type", help="Work item type (e.g. Task, Bug, Story)")
create.add_argument("--summary", help="Summary text")
create.add_argument("--description", default=None, help="Description text or ADF")
create.add_argument("--assignee", default=None, help="Assignee email or @me")
create.add_argument("--label", default=None, help="Comma-separated labels")
create.add_argument("--parent", default=None, help="Parent work item key")
create.add_argument("--from-json", default=None, help="Create from JSON file")
create.add_argument("--json", action="store_true", default=True, help="JSON output")
create.set_defaults(func=command_create)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create subcommand arguments (--project, --type, --summary) are optional in argparse, but command_create() always inserts them into the acli command when --from-json is not provided. Running create without these flags will pass None values into the subprocess argv and crash. Make these args required when --from-json is not used (e.g., via a mutually exclusive group, subparser validation, or explicit checks that raise a user-friendly error).

Copilot uses AI. Check for mistakes.
Comment on lines +240 to +244
view.add_argument(
"--json", action="store_true", default=True,
help="JSON output (default: on)",
)
view.set_defaults(func=command_view)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All --json flags are defined as action="store_true" with default=True, which makes the flag a no-op and provides no way to turn JSON output off. Either remove the flag entirely (and always force JSON), or switch to a pair like --json/--no-json (or --output json|text) so the CLI behavior matches the help text.

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +28
import json
import os
import subprocess
import sys
from typing import Any
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json and Any are imported but unused in this module. Removing unused imports avoids lint noise and makes the dependencies clearer.

Suggested change
import json
import os
import subprocess
import sys
from typing import Any
import os
import subprocess
import sys

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +129
paths: list[Path] = [current]

if current == skill_root or skill_root in current.parents:
cursor = current
while cursor != skill_root:
cursor = cursor.parent
if cursor not in paths:
paths.append(cursor)
elif skill_root not in paths:
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_dotenv_search_paths() always checks the current working directory for a .env, even when the script is executed from outside the skill directory. That can accidentally load unrelated credentials/config from an arbitrary directory and contradicts the documented intent of searching “up to this skill's root”. Consider only including Path.cwd() when it is inside the skill root; otherwise search only within the skill root (or require an explicit env file path).

Suggested change
paths: list[Path] = [current]
if current == skill_root or skill_root in current.parents:
cursor = current
while cursor != skill_root:
cursor = cursor.parent
if cursor not in paths:
paths.append(cursor)
elif skill_root not in paths:
paths: list[Path] = []
if current == skill_root or skill_root in current.parents:
paths.append(current)
cursor = current
while cursor != skill_root:
cursor = cursor.parent
if cursor not in paths:
paths.append(cursor)
else:

Copilot uses AI. Check for mistakes.
@lorischaveeb12
Copy link
Copy Markdown
Author

@VMinB12 I updated the skill. It now supports the Atlassian CLI for Jira. However, the Atlassian CLI doesn't support Confluence interactions so I had to leave this part untouched.

I made sure that the same authentication pipeline was used for Jira & Confluence through secured 1Password vaults. Initially, the coding agents were struggling with the Atlassian CLI so to ensure that they were efficients when using commands, I add to write some code in jira_ops.py. In the end, this new version of the skill is not really slimmed down.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants