Add Atlassian Jira/Confluence integration skill with safe local API helpers#13
Add Atlassian Jira/Confluence integration skill with safe local API helpers#13lorischaveeb12 wants to merge 2 commits intomasterfrom
Conversation
There was a problem hiding this comment.
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.
| 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 | ||
| ) | ||
|
|
||
|
|
There was a problem hiding this comment.
_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.).
| 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) |
|
|
||
|
|
||
| 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) | ||
|
|
||
|
|
There was a problem hiding this comment.
_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.
| 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) |
| 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) |
There was a problem hiding this comment.
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.
| 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() |
| 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": |
There was a problem hiding this comment.
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.
| timeout = int(os.getenv("ATLASSIAN_TIMEOUT", str(DEFAULT_TIMEOUT))) | ||
| retries = int(os.getenv("ATLASSIAN_RETRIES", str(DEFAULT_RETRIES))) | ||
|
|
There was a problem hiding this comment.
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.
| 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 |
| result = subprocess.run( | ||
| ["op", "read", value], | ||
| capture_output=True, | ||
| text=True, | ||
| check=False, | ||
| ) |
There was a problem hiding this comment.
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.
VMinB12
left a comment
There was a problem hiding this comment.
@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.
|
@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 ! |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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).
| view.add_argument( | ||
| "--json", action="store_true", default=True, | ||
| help="JSON output (default: on)", | ||
| ) | ||
| view.set_defaults(func=command_view) |
There was a problem hiding this comment.
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.
| import json | ||
| import os | ||
| import subprocess | ||
| import sys | ||
| from typing import Any |
There was a problem hiding this comment.
json and Any are imported but unused in this module. Removing unused imports avoids lint noise and makes the dependencies clearer.
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| from typing import Any | |
| import os | |
| import subprocess | |
| import sys |
| 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: |
There was a problem hiding this comment.
_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).
| 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: |
|
@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. |
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