From 30c2093c9973305552a6a117b66fb073574d578b Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 7 May 2026 14:02:47 -0400 Subject: [PATCH] Move agent skills to .agents --- .../atlassian-readonly-skills/REFERENCE.md | 34 +- .../skills/atlassian-readonly-skills/SKILL.md | 60 +- .../requirements.txt | 0 .../scripts/__init__.py | 0 .../scripts/_common.py | 218 +++--- .../scripts/bitbucket_commits.py | 0 .../scripts/bitbucket_files.py | 0 .../scripts/bitbucket_projects.py | 0 .../scripts/bitbucket_pull_requests.py | 0 .../scripts/confluence_comments.py | 0 .../scripts/confluence_labels.py | 0 .../scripts/confluence_pages.py | 0 .../scripts/confluence_search.py | 0 .../atlassian-readonly-skills/scripts/jira.py | 102 +++ .../scripts/jira_agile.py | 0 .../scripts/jira_issues.py | 307 +++++++++ .../scripts/jira_links.py | 0 .../scripts/jira_projects.py | 0 .../scripts/jira_search.py | 0 .../scripts/jira_users.py | 0 .../scripts/jira_workflow.py | 29 +- .../scripts/jira_worklog.py | 0 .../skills/atlassian-skills/REFERENCE.md | 37 +- .../skills/atlassian-skills/SKILL.md | 91 ++- .../skills/atlassian-skills/requirements.txt | 0 .../atlassian-skills/scripts/__init__.py | 0 .../atlassian-skills}/scripts/_common.py | 218 +++--- .../scripts/bitbucket_commits.py | 0 .../scripts/bitbucket_files.py | 0 .../scripts/bitbucket_projects.py | 0 .../scripts/bitbucket_pull_requests.py | 0 .../scripts/confluence_comments.py | 0 .../scripts/confluence_labels.py | 0 .../scripts/confluence_pages.py | 0 .../scripts/confluence_search.py | 0 .../skills/atlassian-skills/scripts/jira.py | 185 +++++ .../atlassian-skills/scripts/jira_agile.py | 0 .../atlassian-skills/scripts/jira_issues.py | 651 ++++++++++++++++++ .../atlassian-skills/scripts/jira_links.py | 0 .../atlassian-skills/scripts/jira_projects.py | 0 .../atlassian-skills/scripts/jira_search.py | 0 .../atlassian-skills/scripts/jira_users.py | 0 .../atlassian-skills/scripts/jira_workflow.py | 0 .../atlassian-skills/scripts/jira_worklog.py | 0 .../skills/code-review-skill-main/.gitignore | 0 .../code-review-skill-main/CONTRIBUTING.md | 0 .../skills/code-review-skill-main/LICENSE | 0 .../skills/code-review-skill-main/README.md | 0 .../skills/code-review-skill-main/SKILL.md | 0 .../assets/pr-review-template.md | 0 .../assets/review-checklist.md | 0 .../reference/architecture-review-guide.md | 0 .../code-review-skill-main/reference/c.md | 0 .../reference/code-review-best-practices.md | 0 .../reference/common-bugs-checklist.md | 0 .../code-review-skill-main/reference/cpp.md | 0 .../reference/css-less-sass.md | 0 .../code-review-skill-main/reference/go.md | 0 .../code-review-skill-main/reference/java.md | 0 .../reference/performance-review-guide.md | 0 .../reference/python.md | 0 .../code-review-skill-main/reference/react.md | 0 .../code-review-skill-main/reference/rust.md | 0 .../reference/security-review-guide.md | 0 .../reference/typescript.md | 0 .../code-review-skill-main/reference/vue.md | 0 .../scripts/pr-analyzer.py | 0 .../skills/execute-implement/SKILL.md | 0 .../skills/fieldworks-winapp/SKILL.md | 0 .../navigation/launch-or-attach.md | 0 .../navigation/project-loading.md | 0 .../navigation/screenshot-evidence.md | 0 .../navigation/styles-font-tab.md | 0 .../navigation/writing-system-font-options.md | 0 .../references/how-to-update.md | 0 .../references/mcp-selection.md | 0 .../fieldworks-winapp/references/research.md | 0 .agents/skills/jira-issue/SKILL.md | 177 +++++ .../skills/openspec-apply-change/SKILL.md | 0 .../skills/openspec-archive-change/SKILL.md | 0 .../openspec-bulk-archive-change/SKILL.md | 0 .../skills/openspec-continue-change/SKILL.md | 0 .../skills/openspec-explore/SKILL.md | 0 .../skills/openspec-ff-change/SKILL.md | 0 .../skills/openspec-new-change/SKILL.md | 0 .../skills/openspec-onboard/SKILL.md | 0 .../skills/openspec-sync-specs/SKILL.md | 0 .../skills/openspec-verify-change/SKILL.md | 0 .../skills/plan-design/SKILL.md | 0 .../skills/powershell/SKILL.md | 0 {.github => .agents}/skills/review/SKILL.md | 0 .../skills/rubric-verify/SKILL.md | 0 .../skills/smart-screenshot-capture/SKILL.md | 0 .../skills/verify-test/SKILL.md | 0 .github/AGENTS.md | 2 +- .../atlassian-readonly-skills/.env.example | 78 --- .../scripts/jira_issues.py | 68 -- .github/skills/atlassian-skills/.env.example | 78 --- .../atlassian-skills/scripts/jira_issues.py | 332 --------- .github/skills/jira-bugfix/SKILL.md | 434 ------------ AGENTS.md | 2 +- openspec/config.yaml | 2 +- 102 files changed, 1852 insertions(+), 1253 deletions(-) rename {.github => .agents}/skills/atlassian-readonly-skills/REFERENCE.md (91%) rename {.github => .agents}/skills/atlassian-readonly-skills/SKILL.md (85%) rename {.github => .agents}/skills/atlassian-readonly-skills/requirements.txt (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/__init__.py (100%) rename {.github/skills/atlassian-skills => .agents/skills/atlassian-readonly-skills}/scripts/_common.py (90%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/bitbucket_commits.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/bitbucket_files.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/bitbucket_projects.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/bitbucket_pull_requests.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/confluence_comments.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/confluence_labels.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/confluence_pages.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/confluence_search.py (100%) create mode 100644 .agents/skills/atlassian-readonly-skills/scripts/jira.py rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/jira_agile.py (100%) create mode 100644 .agents/skills/atlassian-readonly-skills/scripts/jira_issues.py rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/jira_links.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/jira_projects.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/jira_search.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/jira_users.py (100%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/jira_workflow.py (96%) rename {.github => .agents}/skills/atlassian-readonly-skills/scripts/jira_worklog.py (100%) rename {.github => .agents}/skills/atlassian-skills/REFERENCE.md (91%) rename {.github => .agents}/skills/atlassian-skills/SKILL.md (84%) rename {.github => .agents}/skills/atlassian-skills/requirements.txt (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/__init__.py (100%) rename {.github/skills/atlassian-readonly-skills => .agents/skills/atlassian-skills}/scripts/_common.py (90%) rename {.github => .agents}/skills/atlassian-skills/scripts/bitbucket_commits.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/bitbucket_files.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/bitbucket_projects.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/bitbucket_pull_requests.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/confluence_comments.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/confluence_labels.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/confluence_pages.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/confluence_search.py (100%) create mode 100644 .agents/skills/atlassian-skills/scripts/jira.py rename {.github => .agents}/skills/atlassian-skills/scripts/jira_agile.py (100%) create mode 100644 .agents/skills/atlassian-skills/scripts/jira_issues.py rename {.github => .agents}/skills/atlassian-skills/scripts/jira_links.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/jira_projects.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/jira_search.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/jira_users.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/jira_workflow.py (100%) rename {.github => .agents}/skills/atlassian-skills/scripts/jira_worklog.py (100%) rename {.github => .agents}/skills/code-review-skill-main/.gitignore (100%) rename {.github => .agents}/skills/code-review-skill-main/CONTRIBUTING.md (100%) rename {.github => .agents}/skills/code-review-skill-main/LICENSE (100%) rename {.github => .agents}/skills/code-review-skill-main/README.md (100%) rename {.github => .agents}/skills/code-review-skill-main/SKILL.md (100%) rename {.github => .agents}/skills/code-review-skill-main/assets/pr-review-template.md (100%) rename {.github => .agents}/skills/code-review-skill-main/assets/review-checklist.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/architecture-review-guide.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/c.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/code-review-best-practices.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/common-bugs-checklist.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/cpp.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/css-less-sass.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/go.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/java.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/performance-review-guide.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/python.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/react.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/rust.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/security-review-guide.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/typescript.md (100%) rename {.github => .agents}/skills/code-review-skill-main/reference/vue.md (100%) rename {.github => .agents}/skills/code-review-skill-main/scripts/pr-analyzer.py (100%) rename {.github => .agents}/skills/execute-implement/SKILL.md (100%) rename {.github => .agents}/skills/fieldworks-winapp/SKILL.md (100%) rename {.github => .agents}/skills/fieldworks-winapp/navigation/launch-or-attach.md (100%) rename {.github => .agents}/skills/fieldworks-winapp/navigation/project-loading.md (100%) rename {.github => .agents}/skills/fieldworks-winapp/navigation/screenshot-evidence.md (100%) rename {.github => .agents}/skills/fieldworks-winapp/navigation/styles-font-tab.md (100%) rename {.github => .agents}/skills/fieldworks-winapp/navigation/writing-system-font-options.md (100%) rename {.github => .agents}/skills/fieldworks-winapp/references/how-to-update.md (100%) rename {.github => .agents}/skills/fieldworks-winapp/references/mcp-selection.md (100%) rename {.github => .agents}/skills/fieldworks-winapp/references/research.md (100%) create mode 100644 .agents/skills/jira-issue/SKILL.md rename {.github => .agents}/skills/openspec-apply-change/SKILL.md (100%) rename {.github => .agents}/skills/openspec-archive-change/SKILL.md (100%) rename {.github => .agents}/skills/openspec-bulk-archive-change/SKILL.md (100%) rename {.github => .agents}/skills/openspec-continue-change/SKILL.md (100%) rename {.github => .agents}/skills/openspec-explore/SKILL.md (100%) rename {.github => .agents}/skills/openspec-ff-change/SKILL.md (100%) rename {.github => .agents}/skills/openspec-new-change/SKILL.md (100%) rename {.github => .agents}/skills/openspec-onboard/SKILL.md (100%) rename {.github => .agents}/skills/openspec-sync-specs/SKILL.md (100%) rename {.github => .agents}/skills/openspec-verify-change/SKILL.md (100%) rename {.github => .agents}/skills/plan-design/SKILL.md (100%) rename {.github => .agents}/skills/powershell/SKILL.md (100%) rename {.github => .agents}/skills/review/SKILL.md (100%) rename {.github => .agents}/skills/rubric-verify/SKILL.md (100%) rename {.github => .agents}/skills/smart-screenshot-capture/SKILL.md (100%) rename {.github => .agents}/skills/verify-test/SKILL.md (100%) delete mode 100644 .github/skills/atlassian-readonly-skills/.env.example delete mode 100644 .github/skills/atlassian-readonly-skills/scripts/jira_issues.py delete mode 100644 .github/skills/atlassian-skills/.env.example delete mode 100644 .github/skills/atlassian-skills/scripts/jira_issues.py delete mode 100644 .github/skills/jira-bugfix/SKILL.md diff --git a/.github/skills/atlassian-readonly-skills/REFERENCE.md b/.agents/skills/atlassian-readonly-skills/REFERENCE.md similarity index 91% rename from .github/skills/atlassian-readonly-skills/REFERENCE.md rename to .agents/skills/atlassian-readonly-skills/REFERENCE.md index aa3de774ad..3a430fe007 100644 --- a/.github/skills/atlassian-readonly-skills/REFERENCE.md +++ b/.agents/skills/atlassian-readonly-skills/REFERENCE.md @@ -4,6 +4,22 @@ Detailed usage examples and API documentation for read-only Jira, Confluence, an > **Note**: This is a read-only variant. For write operations (create, update, delete), use `atlassian-skills`. +## Jira CLI Quick Reference + +Run from the repository root. The CLI uses `.agents/skills/atlassian-readonly-skills/.env` or `JIRA_*` environment variables and accepts either an issue key or a Jira browse URL. + +```powershell +python .agents/skills/atlassian-readonly-skills/scripts/jira.py issue LT-22324 +python .agents/skills/atlassian-readonly-skills/scripts/jira.py issue LT-22324 --all-fields +python .agents/skills/atlassian-readonly-skills/scripts/jira.py comments LT-22324 +python .agents/skills/atlassian-readonly-skills/scripts/jira.py attachments LT-22324 +python .agents/skills/atlassian-readonly-skills/scripts/jira.py download-attachments LT-22324 --out Output/Jira/LT-22324 +python .agents/skills/atlassian-readonly-skills/scripts/jira.py search "project = LT AND key = LT-22324" +python .agents/skills/atlassian-readonly-skills/scripts/jira.py transitions LT-22324 +``` + +Avoid long PowerShell blocks and `python -c` for normal Jira issue work. The plain `issue` command returns a readable common field set; use `--all-fields` only when you need every Jira field. Use the writable skill only when the user explicitly requests comments, edits, transitions, or uploads. + ## Configuration Modes All functions support two configuration modes: @@ -463,12 +479,12 @@ credentials = AtlassianCredentials( jira_url="https://company.atlassian.net", jira_username="user@company.com", jira_api_token="jira_token_here", - + # Confluence configuration confluence_url="https://company.atlassian.net/wiki", confluence_username="user@company.com", confluence_api_token="confluence_token_here", - + # Bitbucket configuration (optional) bitbucket_url="https://bitbucket.company.com", bitbucket_pat_token="bitbucket_pat_here" @@ -487,10 +503,10 @@ if "jira" in availability["available_services"]: credentials=credentials ) issue = json.loads(result) - + if not issue.get("error"): print(f"Issue: {issue['key']} - {issue['summary']}") - + # Search for issues result = jira_search( jql="project = PROJ AND status = 'In Progress'", @@ -573,23 +589,23 @@ AtlassianCredentials( jira_api_token: Optional[str] = None, jira_pat_token: Optional[str] = None, jira_api_version: Optional[str] = None, # '2' or '3', auto-detected if not set - jira_ssl_verify: bool = False, - + jira_ssl_verify: bool = True, + # Confluence confluence_url: Optional[str] = None, confluence_username: Optional[str] = None, confluence_api_token: Optional[str] = None, confluence_pat_token: Optional[str] = None, confluence_api_version: Optional[str] = None, - confluence_ssl_verify: bool = False, - + confluence_ssl_verify: bool = True, + # Bitbucket bitbucket_url: Optional[str] = None, bitbucket_username: Optional[str] = None, bitbucket_api_token: Optional[str] = None, bitbucket_pat_token: Optional[str] = None, bitbucket_api_version: Optional[str] = None, - bitbucket_ssl_verify: bool = False + bitbucket_ssl_verify: bool = True ) ``` diff --git a/.github/skills/atlassian-readonly-skills/SKILL.md b/.agents/skills/atlassian-readonly-skills/SKILL.md similarity index 85% rename from .github/skills/atlassian-readonly-skills/SKILL.md rename to .agents/skills/atlassian-readonly-skills/SKILL.md index cc61df316b..4d1baa1bb4 100644 --- a/.github/skills/atlassian-readonly-skills/SKILL.md +++ b/.agents/skills/atlassian-readonly-skills/SKILL.md @@ -22,27 +22,34 @@ Read-only Python utilities for Jira, Confluence, and Bitbucket integration, supp - References to `jira.sil.org` URLs - Requests to "look up" or "check" a JIRA ticket -### ⚠️ Critical: Always Use Python Scripts +### Critical: Use the Small Python CLI First **NEVER** attempt to: - Browse to `jira.sil.org` URLs directly (requires authentication) - Use `fetch_webpage` or similar tools on JIRA URLs - Use GitHub issue tools for LT-* tickets +- Build large inline PowerShell commands or quote-heavy `python -c` snippets for normal Jira work -**ALWAYS** use these Python modules. The scripts are Python modules (not CLI tools), so use them via inline Python or import: +Run commands from the repository root. For normal read-only Jira issue work, use `scripts/jira.py`; it accepts either `LT-22324` or `https://jira.sil.org/browse/LT-22324`. ```powershell -# Get a single issue (inline Python one-liner) -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-readonly-skills/scripts'); from jira_issues import jira_get_issue; print(jira_get_issue('LT-22382'))" - -# Search for issues (JQL query) -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-readonly-skills/scripts'); from jira_search import jira_search; print(jira_search('project = LT AND status = Open'))" - -# Get issue workflow transitions -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-readonly-skills/scripts'); from jira_workflow import jira_get_transitions; print(jira_get_transitions('LT-22382'))" +# Read the sample issue +python .agents/skills/atlassian-readonly-skills/scripts/jira.py issue LT-22324 +python .agents/skills/atlassian-readonly-skills/scripts/jira.py issue LT-22324 --all-fields + +# Review comments and attachments +python .agents/skills/atlassian-readonly-skills/scripts/jira.py comments LT-22324 +python .agents/skills/atlassian-readonly-skills/scripts/jira.py attachments LT-22324 +python .agents/skills/atlassian-readonly-skills/scripts/jira.py download-attachments LT-22324 --out Output/Jira/LT-22324 + +# Search and workflow inspection +python .agents/skills/atlassian-readonly-skills/scripts/jira.py search "project = LT AND key = LT-22324" +python .agents/skills/atlassian-readonly-skills/scripts/jira.py transitions LT-22324 ``` -Use the script modules in this skill directly. +Use the Python modules directly only when a task genuinely needs custom composition beyond the CLI. For write operations such as adding comments or uploading screenshots, switch to `atlassian-skills` and get explicit user intent before modifying Jira. + +By default, `issue` returns the fields agents usually need: summary, description, status, type, priority, assignee, reporter, timestamps, labels, and components. Use `--fields` for a custom field list, or `--all-fields` only when you really need every Jira field. ## Configuration @@ -58,7 +65,9 @@ Set environment variables based on your deployment type. This mode is used when # SIL JIRA instance for LT-* tickets JIRA_URL=https://jira.sil.org # Personal Access Token - generate at: https://jira.sil.org/secure/ViewProfile.jspa → Personal Access Tokens -JIRA_PAT_TOKEN=your_jira_pat_token_here +JIRA_PAT_TOKEN= +JIRA_API_VERSION=2 +JIRA_SSL_VERIFY=true ``` #### Cloud (API Token) @@ -82,22 +91,26 @@ Generate API tokens at: https://id.atlassian.com/manage-profile/security/api-tok ```bash # Jira Data Center JIRA_URL=https://jira.your-company.com -JIRA_PAT_TOKEN=your_pat_token +JIRA_PAT_TOKEN= # Confluence Data Center CONFLUENCE_URL=https://confluence.your-company.com CONFLUENCE_PAT_TOKEN=your_pat_token -# Bitbucket Server/Data Center -BITBUCKET_URL=https://bitbucket.your-company.com -BITBUCKET_PAT_TOKEN=your_pat_token +# Bitbucket Server/Data Center, only if needed +# BITBUCKET_URL=https://bitbucket.your-company.com +# BITBUCKET_PAT_TOKEN= ``` > **Note**: PAT Token takes precedence if both are provided. ### Mode 2: Parameter-Based (Agent Environments) -Alternatively, call the scripts in this skill directly. +When environment variables are not available, pass credentials directly to the Python functions. + +```python +from scripts._common import AtlassianCredentials, check_available_skills +from scripts.jira_issues import jira_get_issue # Create credentials object credentials = AtlassianCredentials( @@ -225,13 +238,24 @@ result = confluence_get_page( ### Jira Issue Management (`scripts.jira_issues`) ```python -from scripts.jira_issues import jira_get_issue +from scripts.jira_issues import ( + jira_download_attachment, + jira_download_attachments, + jira_get_attachments, + jira_get_comments, + jira_get_issue, +) # Get issue by key jira_get_issue( issue_key="PROJ-123", credentials=credentials # Optional ) + +# Get comments and attachments +jira_get_comments(issue_key="PROJ-123") +jira_get_attachments(issue_key="PROJ-123") +jira_download_attachments(issue_key="PROJ-123", output_dir="Output/Jira/PROJ-123") ``` ### Jira Search (`scripts.jira_search`) diff --git a/.github/skills/atlassian-readonly-skills/requirements.txt b/.agents/skills/atlassian-readonly-skills/requirements.txt similarity index 100% rename from .github/skills/atlassian-readonly-skills/requirements.txt rename to .agents/skills/atlassian-readonly-skills/requirements.txt diff --git a/.github/skills/atlassian-readonly-skills/scripts/__init__.py b/.agents/skills/atlassian-readonly-skills/scripts/__init__.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/__init__.py rename to .agents/skills/atlassian-readonly-skills/scripts/__init__.py diff --git a/.github/skills/atlassian-skills/scripts/_common.py b/.agents/skills/atlassian-readonly-skills/scripts/_common.py similarity index 90% rename from .github/skills/atlassian-skills/scripts/_common.py rename to .agents/skills/atlassian-readonly-skills/scripts/_common.py index 176a395dfa..925d38054e 100644 --- a/.github/skills/atlassian-skills/scripts/_common.py +++ b/.agents/skills/atlassian-readonly-skills/scripts/_common.py @@ -13,7 +13,8 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse import requests from dotenv import load_dotenv @@ -69,12 +70,12 @@ class NetworkError(Exception): def format_error_response(error_type: str, message: str, details: str = "") -> str: """Format an error as a JSON response. - + Args: error_type: Type of error (e.g., 'AuthenticationError', 'ValidationError') message: Main error message details: Additional error details (optional) - + Returns: JSON string with error information """ @@ -90,10 +91,10 @@ def format_error_response(error_type: str, message: str, details: str = "") -> s def format_json_response(data: Any) -> str: """Format data as a JSON string with UTF-8 encoding. - + Args: data: Data to serialize (dict, list, or other JSON-serializable type) - + Returns: JSON formatted string with proper UTF-8 encoding """ @@ -107,45 +108,45 @@ def format_json_response(data: Any) -> str: @dataclass class AtlassianCredentials: """Unified credentials configuration for all Atlassian services. - + This class wraps authentication credentials for Jira, Confluence, and Bitbucket. When deployed in an Agent environment without environment variables, pass this object to skill functions to provide credentials programmatically. - + For each service, provide either: - PAT Token (for Data Center/Server), or - Username + API Token (for Cloud) - + If a service's credentials are not provided, that service will be unavailable. """ - + # Jira configuration jira_url: Optional[str] = None jira_username: Optional[str] = None jira_api_token: Optional[str] = None jira_pat_token: Optional[str] = None jira_api_version: Optional[str] = None - jira_ssl_verify: bool = False - + jira_ssl_verify: bool = True + # Confluence configuration confluence_url: Optional[str] = None confluence_username: Optional[str] = None confluence_api_token: Optional[str] = None confluence_pat_token: Optional[str] = None confluence_api_version: Optional[str] = None - confluence_ssl_verify: bool = False - + confluence_ssl_verify: bool = True + # Bitbucket configuration bitbucket_url: Optional[str] = None bitbucket_username: Optional[str] = None bitbucket_api_token: Optional[str] = None bitbucket_pat_token: Optional[str] = None bitbucket_api_version: Optional[str] = None - bitbucket_ssl_verify: bool = False - + bitbucket_ssl_verify: bool = True + def is_jira_available(self) -> bool: """Check if Jira credentials are complete and valid. - + Returns: True if Jira can be used, False otherwise """ @@ -154,10 +155,10 @@ def is_jira_available(self) -> bool: has_pat = bool(self.jira_pat_token) has_basic = bool(self.jira_username and self.jira_api_token) return has_pat or has_basic - + def is_confluence_available(self) -> bool: """Check if Confluence credentials are complete and valid. - + Returns: True if Confluence can be used, False otherwise """ @@ -166,10 +167,10 @@ def is_confluence_available(self) -> bool: has_pat = bool(self.confluence_pat_token) has_basic = bool(self.confluence_username and self.confluence_api_token) return has_pat or has_basic - + def is_bitbucket_available(self) -> bool: """Check if Bitbucket credentials are complete and valid. - + Returns: True if Bitbucket can be used, False otherwise """ @@ -178,10 +179,10 @@ def is_bitbucket_available(self) -> bool: has_pat = bool(self.bitbucket_pat_token) has_basic = bool(self.bitbucket_username and self.bitbucket_api_token) return has_pat or has_basic - + def get_available_services(self) -> List[str]: """Get list of available services based on provided credentials. - + Returns: List of service names that have complete credentials Example: ["jira", "confluence"] @@ -194,34 +195,34 @@ def get_available_services(self) -> List[str]: if self.is_bitbucket_available(): services.append("bitbucket") return services - + def get_unavailable_services(self) -> Dict[str, str]: """Get dictionary of unavailable services with reasons. - + Returns: Dictionary mapping service name to reason for unavailability Example: {"bitbucket": "Missing bitbucket_url"} """ unavailable = {} - + if not self.is_jira_available(): if not self.jira_url: unavailable["jira"] = "Missing jira_url" else: unavailable["jira"] = "Missing authentication credentials (provide jira_pat_token or jira_username + jira_api_token)" - + if not self.is_confluence_available(): if not self.confluence_url: unavailable["confluence"] = "Missing confluence_url" else: unavailable["confluence"] = "Missing authentication credentials (provide confluence_pat_token or confluence_username + confluence_api_token)" - + if not self.is_bitbucket_available(): if not self.bitbucket_url: unavailable["bitbucket"] = "Missing bitbucket_url" else: unavailable["bitbucket"] = "Missing authentication credentials (provide bitbucket_pat_token or bitbucket_username + bitbucket_api_token)" - + return unavailable @@ -239,7 +240,7 @@ def __init__( api_token: Optional[str] = None, pat_token: Optional[str] = None, api_version: Optional[str] = None, - ssl_verify: bool = False + ssl_verify: bool = True ): """Initialize configuration. @@ -249,7 +250,7 @@ def __init__( api_token: API token for Cloud authentication (optional) pat_token: Personal Access Token for Data Center authentication (optional) api_version: API version to use ('2' or '3'). If not specified, will auto-detect (optional) - ssl_verify: Whether to verify SSL certificates (default: False) + ssl_verify: Whether to verify SSL certificates (default: True) """ self.url = url.rstrip('/') if url else "" self.username = username @@ -264,7 +265,7 @@ def __init__( self.auth_type = "basic" else: self.auth_type = "unknown" - + @classmethod def from_env(cls, prefix: str) -> "AtlassianConfig": """Load configuration from environment variables. @@ -283,7 +284,7 @@ def from_env(cls, prefix: str) -> "AtlassianConfig": api_token = os.getenv(f"{prefix}_API_TOKEN") pat_token = os.getenv(f"{prefix}_PAT_TOKEN") api_version = os.getenv(f"{prefix}_API_VERSION") - ssl_verify_str = os.getenv(f"{prefix}_SSL_VERIFY", "false").lower() + ssl_verify_str = os.getenv(f"{prefix}_SSL_VERIFY", "true").lower() ssl_verify = ssl_verify_str in ("true", "1", "yes") config = cls( @@ -296,23 +297,23 @@ def from_env(cls, prefix: str) -> "AtlassianConfig": ) config._validate(prefix) return config - + @classmethod def from_credentials(cls, credentials: AtlassianCredentials, service: str) -> "AtlassianConfig": """Create configuration from AtlassianCredentials object. - + Args: credentials: AtlassianCredentials instance with service credentials service: Service name ('jira', 'confluence', or 'bitbucket') - + Returns: AtlassianConfig instance - + Raises: ConfigurationError: If credentials for the service are incomplete """ service = service.lower() - + if service == "jira": if not credentials.is_jira_available(): raise ConfigurationError( @@ -357,9 +358,9 @@ def from_credentials(cls, credentials: AtlassianCredentials, service: str) -> "A ) else: raise ConfigurationError(f"Unknown service: {service}. Must be 'jira', 'confluence', or 'bitbucket'.") - + return config - + def _validate(self, prefix: Optional[str] = None) -> None: """Validate configuration.""" if not self.url: @@ -367,10 +368,10 @@ def _validate(self, prefix: Optional[str] = None) -> None: f"Missing required configuration: {prefix}_URL. " f"Please set this environment variable." ) - + has_pat = bool(self.pat_token) has_basic = bool(self.username and self.api_token) - + if not has_pat and not has_basic: raise ConfigurationError( f"Missing authentication credentials for {prefix}. " @@ -378,7 +379,7 @@ def _validate(self, prefix: Optional[str] = None) -> None: f" - {prefix}_PAT_TOKEN (for Data Center/Server), or\n" f" - {prefix}_USERNAME and {prefix}_API_TOKEN (for Cloud)" ) - + def get_auth_header(self) -> dict: """Get the appropriate Authorization header.""" if self.auth_type == "pat": @@ -389,7 +390,7 @@ def get_auth_header(self) -> dict: return {"Authorization": f"Basic {encoded}"} else: raise ConfigurationError("Cannot generate auth header: unknown auth type.") - + @property def is_cloud(self) -> bool: """Check if this is a Cloud instance.""" @@ -441,10 +442,19 @@ def __init__(self, config: AtlassianConfig): self.session.auth = (config.username, config.api_token) self.session.headers.update({ - "Accept": "application/json", - "Content-Type": "application/json" + "Accept": "application/json" }) + def _build_url(self, path_or_url: str) -> str: + """Build an absolute URL and keep credentials scoped to the configured host.""" + configured = urlparse(self.config.url) + candidate = urlparse(path_or_url) + if candidate.scheme and candidate.netloc: + if candidate.netloc.lower() != configured.netloc.lower(): + raise ValidationError("Refusing to send Atlassian credentials to a different host") + return path_or_url + return f"{self.config.url}{path_or_url}" + def api_path(self, endpoint: str) -> str: """Build API path with the correct version. @@ -457,10 +467,10 @@ def api_path(self, endpoint: str) -> str: # Remove leading slash if present endpoint = endpoint.lstrip('/') return f"/rest/api/{self.api_version}/{endpoint}" - + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any: """Perform a GET request.""" - url = f"{self.config.url}{path}" + url = self._build_url(path) try: response = self.session.get(url, params=params, timeout=30, verify=self.ssl_verify) self._handle_error(response) @@ -471,10 +481,10 @@ def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any: raise NetworkError(f"Connection failed: {str(e)}") except requests.exceptions.RequestException as e: raise NetworkError(f"Network error: {str(e)}") - + def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any: """Perform a POST request.""" - url = f"{self.config.url}{path}" + url = self._build_url(path) try: response = self.session.post(url, json=json, timeout=30, verify=self.ssl_verify) self._handle_error(response) @@ -485,10 +495,10 @@ def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any: raise NetworkError(f"Connection failed: {str(e)}") except requests.exceptions.RequestException as e: raise NetworkError(f"Network error: {str(e)}") - + def put(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any: """Perform a PUT request.""" - url = f"{self.config.url}{path}" + url = self._build_url(path) try: response = self.session.put(url, json=json, timeout=30, verify=self.ssl_verify) self._handle_error(response) @@ -499,10 +509,10 @@ def put(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any: raise NetworkError(f"Connection failed: {str(e)}") except requests.exceptions.RequestException as e: raise NetworkError(f"Network error: {str(e)}") - + def delete(self, path: str) -> bool: """Perform a DELETE request.""" - url = f"{self.config.url}{path}" + url = self._build_url(path) try: response = self.session.delete(url, timeout=30, verify=self.ssl_verify) self._handle_error(response) @@ -513,12 +523,44 @@ def delete(self, path: str) -> bool: raise NetworkError(f"Connection failed: {str(e)}") except requests.exceptions.RequestException as e: raise NetworkError(f"Network error: {str(e)}") - + + def download(self, path_or_url: str) -> Tuple[bytes, Dict[str, str]]: + """Download binary content from an Atlassian URL or API path.""" + url = self._build_url(path_or_url) + try: + response = self.session.get(url, timeout=60, verify=self.ssl_verify) + self._handle_error(response) + return response.content, dict(response.headers) + except requests.exceptions.Timeout: + raise NetworkError("Request timed out") + except requests.exceptions.ConnectionError as e: + raise NetworkError(f"Connection failed: {str(e)}") + except requests.exceptions.RequestException as e: + raise NetworkError(f"Network error: {str(e)}") + + def post_files(self, path: str, files: Dict[str, Any]) -> Any: + """Perform a multipart file upload request.""" + url = self._build_url(path) + headers = { + "Accept": "application/json", + "X-Atlassian-Token": "no-check" + } + try: + response = self.session.post(url, files=files, headers=headers, timeout=60, verify=self.ssl_verify) + self._handle_error(response) + return response.json() if response.content else {} + except requests.exceptions.Timeout: + raise NetworkError("Request timed out") + except requests.exceptions.ConnectionError as e: + raise NetworkError(f"Connection failed: {str(e)}") + except requests.exceptions.RequestException as e: + raise NetworkError(f"Network error: {str(e)}") + def _handle_error(self, response: requests.Response) -> None: """Handle HTTP error responses.""" if response.status_code < 400: return - + error_message = "" try: error_data = response.json() @@ -530,7 +572,7 @@ def _handle_error(self, response: requests.Response) -> None: ) except Exception: error_message = response.text or response.reason - + if response.status_code == 401: raise AuthenticationError(f"Authentication failed: {error_message}") elif response.status_code == 400: @@ -549,14 +591,14 @@ def _handle_error(self, response: requests.Response) -> None: def get_jira_client(credentials: Optional[AtlassianCredentials] = None) -> AtlassianClient: """Get configured Jira client. - + Args: credentials: Optional AtlassianCredentials object. If not provided, configuration will be loaded from environment variables. - + Returns: Configured AtlassianClient instance - + Raises: ConfigurationError: If configuration is missing or invalid """ @@ -569,14 +611,14 @@ def get_jira_client(credentials: Optional[AtlassianCredentials] = None) -> Atlas def get_confluence_client(credentials: Optional[AtlassianCredentials] = None) -> AtlassianClient: """Get configured Confluence client. - + Args: credentials: Optional AtlassianCredentials object. If not provided, configuration will be loaded from environment variables. - + Returns: Configured AtlassianClient instance - + Raises: ConfigurationError: If configuration is missing or invalid """ @@ -589,14 +631,14 @@ def get_confluence_client(credentials: Optional[AtlassianCredentials] = None) -> def get_bitbucket_client(credentials: Optional[AtlassianCredentials] = None) -> AtlassianClient: """Get configured Bitbucket client. - + Args: credentials: Optional AtlassianCredentials object. If not provided, configuration will be loaded from environment variables. - + Returns: Configured AtlassianClient instance - + Raises: ConfigurationError: If configuration is missing or invalid """ @@ -609,13 +651,13 @@ def get_bitbucket_client(credentials: Optional[AtlassianCredentials] = None) -> def check_available_skills(credentials: AtlassianCredentials) -> Dict[str, Any]: """Check which Atlassian skills are available based on provided credentials. - + This function helps Agents determine which skills can be used before attempting to call them. Services without complete credentials will be listed as unavailable. - + Args: credentials: AtlassianCredentials object with service credentials - + Returns: Dictionary with availability information: { @@ -624,7 +666,7 @@ def check_available_skills(credentials: AtlassianCredentials) -> Dict[str, Any]: "bitbucket": "Missing bitbucket_url" } } - + Example: >>> creds = AtlassianCredentials( ... jira_url="https://company.atlassian.net", @@ -643,25 +685,25 @@ def check_available_skills(credentials: AtlassianCredentials) -> Dict[str, Any]: def simplify_issue(issue_data: Dict[str, Any]) -> Dict[str, Any]: """Simplify issue data to essential fields. - + Args: issue_data: Raw issue data from Jira API - + Returns: Simplified issue dictionary with essential fields """ fields = issue_data.get('fields', {}) - + assignee = fields.get('assignee') assignee_email = assignee.get('emailAddress', '') if assignee else None - + reporter = fields.get('reporter') reporter_email = reporter.get('emailAddress', '') if reporter else None - + status = fields.get('status', {}) issue_type = fields.get('issuetype', {}) priority = fields.get('priority', {}) - + simplified = { 'key': issue_data.get('key', ''), 'id': issue_data.get('id', ''), @@ -677,40 +719,40 @@ def simplify_issue(issue_data: Dict[str, Any]) -> Dict[str, Any]: 'labels': fields.get('labels', []), 'components': [c.get('name', '') for c in fields.get('components', [])], } - + custom_fields = {} for key, value in fields.items(): if key.startswith('customfield_'): custom_fields[key] = value if custom_fields: simplified['custom_fields'] = custom_fields - + return simplified def parse_time_spent(time_spent: str) -> int: """Parse time spent string into seconds. - + Args: time_spent: Time spent string (e.g., '1h 30m', '2d', '1w') - + Returns: Time spent in seconds - + Raises: ValidationError: If time format is invalid """ if not time_spent or not time_spent.strip(): raise ValidationError('time_spent is required and cannot be empty') - + time_spent = time_spent.strip() - + if time_spent.endswith('s'): try: return int(time_spent[:-1]) except ValueError: raise ValidationError(f'Invalid seconds format: {time_spent}') - + total_seconds = 0 time_units = { 'w': 7 * 24 * 60 * 60, @@ -718,24 +760,24 @@ def parse_time_spent(time_spent: str) -> int: 'h': 60 * 60, 'm': 60, } - + pattern = r'(\d+)([wdhm])' matches = re.findall(pattern, time_spent.lower()) - + for value, unit in matches: seconds = int(value) * time_units[unit] total_seconds += seconds - + if total_seconds > 0: return total_seconds - + try: raw_value = int(float(time_spent)) if raw_value > 0: return raw_value except ValueError: pass - + raise ValidationError( f'Invalid time format: {time_spent}. ' 'Use formats like "1h 30m", "2d", "1w", "45m", or seconds as a number.' diff --git a/.github/skills/atlassian-readonly-skills/scripts/bitbucket_commits.py b/.agents/skills/atlassian-readonly-skills/scripts/bitbucket_commits.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/bitbucket_commits.py rename to .agents/skills/atlassian-readonly-skills/scripts/bitbucket_commits.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/bitbucket_files.py b/.agents/skills/atlassian-readonly-skills/scripts/bitbucket_files.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/bitbucket_files.py rename to .agents/skills/atlassian-readonly-skills/scripts/bitbucket_files.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/bitbucket_projects.py b/.agents/skills/atlassian-readonly-skills/scripts/bitbucket_projects.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/bitbucket_projects.py rename to .agents/skills/atlassian-readonly-skills/scripts/bitbucket_projects.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/bitbucket_pull_requests.py b/.agents/skills/atlassian-readonly-skills/scripts/bitbucket_pull_requests.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/bitbucket_pull_requests.py rename to .agents/skills/atlassian-readonly-skills/scripts/bitbucket_pull_requests.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/confluence_comments.py b/.agents/skills/atlassian-readonly-skills/scripts/confluence_comments.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/confluence_comments.py rename to .agents/skills/atlassian-readonly-skills/scripts/confluence_comments.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/confluence_labels.py b/.agents/skills/atlassian-readonly-skills/scripts/confluence_labels.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/confluence_labels.py rename to .agents/skills/atlassian-readonly-skills/scripts/confluence_labels.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/confluence_pages.py b/.agents/skills/atlassian-readonly-skills/scripts/confluence_pages.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/confluence_pages.py rename to .agents/skills/atlassian-readonly-skills/scripts/confluence_pages.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/confluence_search.py b/.agents/skills/atlassian-readonly-skills/scripts/confluence_search.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/confluence_search.py rename to .agents/skills/atlassian-readonly-skills/scripts/confluence_search.py diff --git a/.agents/skills/atlassian-readonly-skills/scripts/jira.py b/.agents/skills/atlassian-readonly-skills/scripts/jira.py new file mode 100644 index 0000000000..be925b08d5 --- /dev/null +++ b/.agents/skills/atlassian-readonly-skills/scripts/jira.py @@ -0,0 +1,102 @@ +"""Small Jira command wrapper for the read-only Atlassian skill.""" + +import argparse +import re +import sys +from typing import List, Optional + +from jira_issues import ( + jira_download_attachment, + jira_download_attachments, + jira_get_attachments, + jira_get_comments, + jira_get_issue, +) +from jira_search import jira_search +from jira_workflow import jira_get_transitions + + +DEFAULT_ISSUE_FIELDS = 'summary,description,status,issuetype,priority,assignee,reporter,created,updated,labels,components' + + +def normalize_issue_key(value: str) -> str: + match = re.search(r'([A-Z][A-Z0-9]+-\d+)', value) + if not match: + raise argparse.ArgumentTypeError(f"Could not find an issue key in: {value}") + return match.group(1) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Read Jira issue data using .env or JIRA_* environment variables." + ) + subparsers = parser.add_subparsers(dest='command', required=True) + + issue = subparsers.add_parser('issue', help='Get one issue') + issue.add_argument('issue_key', type=normalize_issue_key) + issue.add_argument('--fields', help='Comma-separated Jira fields to return') + issue.add_argument('--all-fields', action='store_true', help='Return every Jira field instead of the readable default set') + issue.add_argument('--expand') + + comments = subparsers.add_parser('comments', help='List issue comments') + comments.add_argument('issue_key', type=normalize_issue_key) + comments.add_argument('--limit', type=int, default=50) + comments.add_argument('--start-at', type=int, default=0) + + attachments = subparsers.add_parser('attachments', help='List issue attachments') + attachments.add_argument('issue_key', type=normalize_issue_key) + + download_attachment = subparsers.add_parser('download-attachment', help='Download one attachment by ID') + download_attachment.add_argument('attachment_id') + download_attachment.add_argument('--out', default='.') + download_attachment.add_argument('--filename') + + download_attachments = subparsers.add_parser('download-attachments', help='Download all issue attachments') + download_attachments.add_argument('issue_key', type=normalize_issue_key) + download_attachments.add_argument('--out', required=True) + + search = subparsers.add_parser('search', help='Run a JQL search') + search.add_argument('jql') + search.add_argument('--fields') + search.add_argument('--limit', type=int, default=10) + search.add_argument('--start-at', type=int, default=0) + + transitions = subparsers.add_parser('transitions', help='List available issue transitions') + transitions.add_argument('issue_key', type=normalize_issue_key) + + return parser + + +def issue_fields(args: argparse.Namespace) -> Optional[str]: + if args.all_fields: + return args.fields + return args.fields or DEFAULT_ISSUE_FIELDS + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == 'issue': + result = jira_get_issue(args.issue_key, fields=issue_fields(args), expand=args.expand) + elif args.command == 'comments': + result = jira_get_comments(args.issue_key, limit=args.limit, start_at=args.start_at) + elif args.command == 'attachments': + result = jira_get_attachments(args.issue_key) + elif args.command == 'download-attachment': + result = jira_download_attachment(args.attachment_id, output_dir=args.out, filename=args.filename) + elif args.command == 'download-attachments': + result = jira_download_attachments(args.issue_key, output_dir=args.out) + elif args.command == 'search': + result = jira_search(args.jql, fields=args.fields, limit=args.limit, start_at=args.start_at) + elif args.command == 'transitions': + result = jira_get_transitions(args.issue_key) + else: + parser.error(f"Unknown command: {args.command}") + + print(result) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_agile.py b/.agents/skills/atlassian-readonly-skills/scripts/jira_agile.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/jira_agile.py rename to .agents/skills/atlassian-readonly-skills/scripts/jira_agile.py diff --git a/.agents/skills/atlassian-readonly-skills/scripts/jira_issues.py b/.agents/skills/atlassian-readonly-skills/scripts/jira_issues.py new file mode 100644 index 0000000000..67d0fad7b7 --- /dev/null +++ b/.agents/skills/atlassian-readonly-skills/scripts/jira_issues.py @@ -0,0 +1,307 @@ +"""Jira issue management tools. + +Tools: + - jira_get_issue: Retrieve an issue by key + - jira_get_comments: Retrieve comments for an issue + - jira_get_attachments: Retrieve attachment metadata + - jira_download_attachment: Download one attachment by ID + - jira_download_attachments: Download all issue attachments +""" + +import sys +import re +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent)) + +from typing import Any, Dict, List, Optional + +from _common import ( + AtlassianCredentials, + get_jira_client, + simplify_issue, + format_json_response, + format_error_response, + ConfigurationError, + AuthenticationError, + ValidationError, + NotFoundError, + APIError, + NetworkError, +) + + +def _body_to_text(body: Any) -> str: + """Extract readable text from Jira Server strings or Jira Cloud ADF bodies.""" + if body is None: + return "" + if isinstance(body, str): + return body + if isinstance(body, list): + return "\n".join(part for part in (_body_to_text(item) for item in body) if part) + if isinstance(body, dict): + text = body.get('text') + if isinstance(text, str): + return text + return _body_to_text(body.get('content', [])) + return str(body) + + +def _user_label(user_data: Optional[Dict[str, Any]]) -> Optional[str]: + """Return a stable, human-readable Jira user label.""" + if not user_data: + return None + return ( + user_data.get('displayName') + or user_data.get('emailAddress') + or user_data.get('name') + or user_data.get('accountId') + ) + + +def _simplify_comment(comment_data: Dict[str, Any]) -> Dict[str, Any]: + """Simplify Jira comment data to the fields agents usually need.""" + return { + 'id': comment_data.get('id', ''), + 'author': _user_label(comment_data.get('author')), + 'update_author': _user_label(comment_data.get('updateAuthor')), + 'body': _body_to_text(comment_data.get('body')), + 'created': comment_data.get('created', ''), + 'updated': comment_data.get('updated', '') + } + + +def _simplify_attachment(attachment_data: Dict[str, Any]) -> Dict[str, Any]: + """Simplify Jira attachment metadata.""" + return { + 'id': attachment_data.get('id', ''), + 'filename': attachment_data.get('filename', ''), + 'size': attachment_data.get('size', 0), + 'mime_type': attachment_data.get('mimeType', ''), + 'author': _user_label(attachment_data.get('author')), + 'created': attachment_data.get('created', ''), + 'content': attachment_data.get('content', ''), + 'thumbnail': attachment_data.get('thumbnail', '') + } + + +def _safe_filename(filename: str) -> str: + """Keep downloaded attachment names inside the requested directory.""" + leaf_name = Path(filename).name or 'attachment' + return re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', leaf_name) + + +def _get_issue_attachments(client: Any, issue_key: str) -> List[Dict[str, Any]]: + issue_data = client.get(client.api_path(f'issue/{issue_key}'), params={'fields': 'attachment'}) + return issue_data.get('fields', {}).get('attachment', []) + + +def _download_attachment_data( + client: Any, + attachment_data: Dict[str, Any], + output_dir: str, + filename: Optional[str] = None +) -> Dict[str, Any]: + attachment = _simplify_attachment(attachment_data) + content_url = attachment.get('content') + if not content_url: + raise ValidationError('attachment content URL is missing') + + content, _headers = client.download(content_url) + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + target_name = _safe_filename(filename or attachment.get('filename') or f"attachment-{attachment.get('id')}") + target_path = output_path / target_name + target_path.write_bytes(content) + + attachment['path'] = str(target_path) + attachment['bytes_written'] = len(content) + return attachment + + +def jira_get_issue( + issue_key: str, + fields: Optional[str] = None, + expand: Optional[str] = None, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Retrieve a Jira issue by key. + + Args: + issue_key: Issue key (e.g., 'PROJ-123') + fields: Comma-separated list of fields to return (optional) + expand: Comma-separated list of entities to expand (optional) + credentials: Optional AtlassianCredentials for Agent environments. + If not provided, uses environment variables. + + Returns: + JSON string with issue data or error information + """ + try: + client = get_jira_client(credentials) + + params: Dict[str, Any] = {} + if fields: + params['fields'] = fields + if expand: + params['expand'] = expand + + issue_data = client.get(client.api_path(f'issue/{issue_key}'), params=params) + simplified = simplify_issue(issue_data) + return format_json_response(simplified) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_get_comments( + issue_key: str, + limit: int = 50, + start_at: int = 0, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Retrieve comments for a Jira issue.""" + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + if limit < 0: + raise ValidationError('limit must be non-negative') + if start_at < 0: + raise ValidationError('start_at must be non-negative') + + params: Dict[str, Any] = {'maxResults': limit, 'startAt': start_at} + response = client.get(client.api_path(f'issue/{issue_key}/comment'), params=params) + comments = [_simplify_comment(comment) for comment in response.get('comments', [])] + return format_json_response({ + 'issue_key': issue_key, + 'comments': comments, + 'total': response.get('total', len(comments)), + 'start_at': response.get('startAt', start_at), + 'max_results': response.get('maxResults', limit) + }) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_get_attachments( + issue_key: str, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Retrieve attachment metadata for a Jira issue.""" + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + + attachments = [_simplify_attachment(item) for item in _get_issue_attachments(client, issue_key)] + return format_json_response({ + 'issue_key': issue_key, + 'attachments': attachments, + 'count': len(attachments) + }) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_download_attachment( + attachment_id: str, + output_dir: str = '.', + filename: Optional[str] = None, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Download one Jira attachment by ID.""" + try: + client = get_jira_client(credentials) + + if not attachment_id: + raise ValidationError('attachment_id is required') + if not output_dir: + raise ValidationError('output_dir is required') + + attachment_data = client.get(client.api_path(f'attachment/{attachment_id}')) + attachment = _download_attachment_data(client, attachment_data, output_dir, filename) + return format_json_response({'success': True, 'attachment': attachment}) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_download_attachments( + issue_key: str, + output_dir: str, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Download all attachments from a Jira issue.""" + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + if not output_dir: + raise ValidationError('output_dir is required') + + attachments = [ + _download_attachment_data(client, item, output_dir) + for item in _get_issue_attachments(client, issue_key) + ] + return format_json_response({ + 'success': True, + 'issue_key': issue_key, + 'attachments': attachments, + 'count': len(attachments) + }) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_links.py b/.agents/skills/atlassian-readonly-skills/scripts/jira_links.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/jira_links.py rename to .agents/skills/atlassian-readonly-skills/scripts/jira_links.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_projects.py b/.agents/skills/atlassian-readonly-skills/scripts/jira_projects.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/jira_projects.py rename to .agents/skills/atlassian-readonly-skills/scripts/jira_projects.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_search.py b/.agents/skills/atlassian-readonly-skills/scripts/jira_search.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/jira_search.py rename to .agents/skills/atlassian-readonly-skills/scripts/jira_search.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_users.py b/.agents/skills/atlassian-readonly-skills/scripts/jira_users.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/jira_users.py rename to .agents/skills/atlassian-readonly-skills/scripts/jira_users.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_workflow.py b/.agents/skills/atlassian-readonly-skills/scripts/jira_workflow.py similarity index 96% rename from .github/skills/atlassian-readonly-skills/scripts/jira_workflow.py rename to .agents/skills/atlassian-readonly-skills/scripts/jira_workflow.py index 4ba1570a93..8ac093ba05 100644 --- a/.github/skills/atlassian-readonly-skills/scripts/jira_workflow.py +++ b/.agents/skills/atlassian-readonly-skills/scripts/jira_workflow.py @@ -8,9 +8,10 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) -from typing import Any, Dict +from typing import Any, Dict, Optional from _common import ( + AtlassianCredentials, get_jira_client, format_json_response, format_error_response, @@ -29,15 +30,15 @@ def _simplify_transition(transition_data: Dict[str, Any]) -> Dict[str, Any]: 'id': transition_data.get('id', ''), 'name': transition_data.get('name', ''), } - + to_status = transition_data.get('to', {}) if to_status and isinstance(to_status, dict): simplified['to_status'] = to_status.get('name', '') simplified['to_status_id'] = to_status.get('id', '') - + has_screen = transition_data.get('hasScreen', False) simplified['has_screen'] = has_screen - + fields = transition_data.get('fields', {}) if fields: required_fields = [] @@ -53,47 +54,47 @@ def _simplify_transition(transition_data: Dict[str, Any]) -> Dict[str, Any]: required_fields.append(field_data) else: optional_fields.append(field_data) - + if required_fields: simplified['required_fields'] = required_fields if optional_fields: simplified['optional_fields'] = optional_fields - + return simplified def jira_get_transitions(issue_key: str, credentials: Optional[AtlassianCredentials] = None) -> str: """Get available status transitions for a Jira issue. - + Args: issue_key: Issue key (e.g., 'PROJ-123') - + Returns: JSON string with list of available transitions or error information """ try: client = get_jira_client(credentials) - + if not issue_key: raise ValidationError('issue_key is required') - + params = {'expand': 'transitions.fields'} response = client.get( client.api_path(f'issue/{issue_key}/transitions'), params=params ) - + transitions = response.get('transitions', []) simplified_transitions = [_simplify_transition(t) for t in transitions] - + result = { 'issue_key': issue_key, 'transitions': simplified_transitions, 'count': len(simplified_transitions) } - + return format_json_response(result) - + except ConfigurationError as e: return format_error_response('ConfigurationError', str(e)) except AuthenticationError as e: diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_worklog.py b/.agents/skills/atlassian-readonly-skills/scripts/jira_worklog.py similarity index 100% rename from .github/skills/atlassian-readonly-skills/scripts/jira_worklog.py rename to .agents/skills/atlassian-readonly-skills/scripts/jira_worklog.py diff --git a/.github/skills/atlassian-skills/REFERENCE.md b/.agents/skills/atlassian-skills/REFERENCE.md similarity index 91% rename from .github/skills/atlassian-skills/REFERENCE.md rename to .agents/skills/atlassian-skills/REFERENCE.md index 0dfa0218d6..81458a6440 100644 --- a/.github/skills/atlassian-skills/REFERENCE.md +++ b/.agents/skills/atlassian-skills/REFERENCE.md @@ -2,6 +2,23 @@ Detailed usage examples and API documentation for Jira, Confluence, and Bitbucket tools. +## Jira CLI Quick Reference + +Run from the repository root. The CLI uses `.agents/skills/atlassian-skills/.env` or `JIRA_*` environment variables and accepts either an issue key or a Jira browse URL. + +```powershell +python .agents/skills/atlassian-skills/scripts/jira.py issue LT-22324 +python .agents/skills/atlassian-skills/scripts/jira.py issue LT-22324 --all-fields +python .agents/skills/atlassian-skills/scripts/jira.py comments LT-22324 +python .agents/skills/atlassian-skills/scripts/jira.py attachments LT-22324 +python .agents/skills/atlassian-skills/scripts/jira.py download-attachments LT-22324 --out Output/Jira/LT-22324 +python .agents/skills/atlassian-skills/scripts/jira.py add-comment LT-22324 --body-file Output/Jira/LT-22324/comment.md +python .agents/skills/atlassian-skills/scripts/jira.py update-comment LT-22324 123456 --body-file Output/Jira/LT-22324/comment.md +python .agents/skills/atlassian-skills/scripts/jira.py attach LT-22324 Output/Jira/LT-22324/screenshot.png Output/Jira/LT-22324/log.txt +``` + +Use `--body-file` and `--description-file` for multi-line text. Avoid inline tokens, long PowerShell blocks, and `python -c` for normal Jira issue work. The plain `issue` command returns a readable common field set; use `--all-fields` only when you need every Jira field. + ## Configuration Modes All functions support two configuration modes: @@ -495,12 +512,12 @@ credentials = AtlassianCredentials( jira_url="https://company.atlassian.net", jira_username="user@company.com", jira_api_token="jira_token_here", - + # Confluence configuration confluence_url="https://company.atlassian.net/wiki", confluence_username="user@company.com", confluence_api_token="confluence_token_here", - + # Bitbucket configuration (optional) bitbucket_url="https://bitbucket.company.com", bitbucket_pat_token="bitbucket_pat_here" @@ -521,16 +538,16 @@ if "jira" in availability["available_services"]: credentials=credentials # Pass credentials ) issue = json.loads(result) - + if not issue.get("error"): print(f"Created issue: {issue['key']}") - + # Get the issue result = jira_get_issue( issue_key=issue['key'], credentials=credentials ) - + # Search for issues result = jira_search( jql=f"project = PROJ AND key = {issue['key']}", @@ -599,23 +616,23 @@ AtlassianCredentials( jira_api_token: Optional[str] = None, jira_pat_token: Optional[str] = None, jira_api_version: Optional[str] = None, # '2' or '3', auto-detected if not set - jira_ssl_verify: bool = False, - + jira_ssl_verify: bool = True, + # Confluence confluence_url: Optional[str] = None, confluence_username: Optional[str] = None, confluence_api_token: Optional[str] = None, confluence_pat_token: Optional[str] = None, confluence_api_version: Optional[str] = None, - confluence_ssl_verify: bool = False, - + confluence_ssl_verify: bool = True, + # Bitbucket bitbucket_url: Optional[str] = None, bitbucket_username: Optional[str] = None, bitbucket_api_token: Optional[str] = None, bitbucket_pat_token: Optional[str] = None, bitbucket_api_version: Optional[str] = None, - bitbucket_ssl_verify: bool = False + bitbucket_ssl_verify: bool = True ) ``` diff --git a/.github/skills/atlassian-skills/SKILL.md b/.agents/skills/atlassian-skills/SKILL.md similarity index 84% rename from .github/skills/atlassian-skills/SKILL.md rename to .agents/skills/atlassian-skills/SKILL.md index f5efec7fd8..465f5b46b2 100644 --- a/.github/skills/atlassian-skills/SKILL.md +++ b/.agents/skills/atlassian-skills/SKILL.md @@ -22,30 +22,39 @@ Python utilities for Jira, Confluence, and Bitbucket integration, supporting bot > **Note**: Default to `atlassian-readonly-skills` for read operations. Use this full skill set only when the user explicitly requests create/update/delete operations. -### ⚠️ Critical: Always Use Python Scripts +### Critical: Use the Small Python CLI First **NEVER** attempt to: - Browse to `jira.sil.org` URLs directly (requires authentication) - Use `fetch_webpage` or similar tools on JIRA URLs - Use GitHub issue tools for LT-* tickets +- Build large inline PowerShell commands or quote-heavy `python -c` snippets for normal Jira work -**ALWAYS** use these Python modules. The scripts are Python modules (not CLI tools), so use them via inline Python or import: +Run commands from the repository root. For normal Jira issue work, use `scripts/jira.py`; it accepts either `LT-22324` or `https://jira.sil.org/browse/LT-22324`. ```powershell -# Create a new issue -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-skills/scripts'); from jira_issues import jira_create_issue; print(jira_create_issue('LT', 'Issue title', 'Bug'))" - -# Update an existing issue -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-skills/scripts'); from jira_issues import jira_update_issue; print(jira_update_issue('LT-22382', summary='Updated title'))" - -# Add a comment -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-skills/scripts'); from jira_issues import jira_add_comment; print(jira_add_comment('LT-22382', 'Comment text'))" - -# Transition issue status -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-skills/scripts'); from jira_workflow import jira_transition_issue; print(jira_transition_issue('LT-22382', 'In Progress'))" +# Read the sample issue +python .agents/skills/atlassian-skills/scripts/jira.py issue LT-22324 +python .agents/skills/atlassian-skills/scripts/jira.py issue LT-22324 --all-fields + +# Review comments and attachments +python .agents/skills/atlassian-skills/scripts/jira.py comments LT-22324 +python .agents/skills/atlassian-skills/scripts/jira.py attachments LT-22324 +python .agents/skills/atlassian-skills/scripts/jira.py download-attachments LT-22324 --out Output/Jira/LT-22324 + +# Write operations, only when the user explicitly asks +python .agents/skills/atlassian-skills/scripts/jira.py add-comment LT-22324 --body-file Output/Jira/LT-22324/comment.md +python .agents/skills/atlassian-skills/scripts/jira.py update-comment LT-22324 123456 --body-file Output/Jira/LT-22324/comment.md +python .agents/skills/atlassian-skills/scripts/jira.py attach LT-22324 Output/Jira/LT-22324/screenshot.png Output/Jira/LT-22324/log.txt + +# Transition after checking IDs with the transitions command +python .agents/skills/atlassian-skills/scripts/jira.py transitions LT-22324 +python .agents/skills/atlassian-skills/scripts/jira.py transition LT-22324 31 --comment "Moving to review" ``` -For read-only operations (get issue, search, get comments), use `atlassian-readonly-skills` instead. +For long comments or descriptions, put the text in a file and pass `--body-file` or `--description-file`. This avoids brittle shell quoting and keeps credentials out of command history. For read-only operations (get issue, search, get comments, download attachments), prefer `atlassian-readonly-skills`. + +By default, `issue` returns the fields agents usually need: summary, description, status, type, priority, assignee, reporter, timestamps, labels, and components. Use `--fields` for a custom field list, or `--all-fields` only when you really need every Jira field. ## Configuration @@ -61,7 +70,9 @@ Set environment variables based on your deployment type. This mode is used when # SIL JIRA instance for LT-* tickets JIRA_URL=https://jira.sil.org # Personal Access Token - generate at: https://jira.sil.org/secure/ViewProfile.jspa → Personal Access Tokens -JIRA_PAT_TOKEN=your_jira_pat_token_here +JIRA_PAT_TOKEN= +JIRA_API_VERSION=2 +JIRA_SSL_VERIFY=true ``` #### Cloud (API Token) @@ -85,15 +96,15 @@ Generate API tokens at: https://id.atlassian.com/manage-profile/security/api-tok ```bash # Jira Data Center JIRA_URL=https://jira.your-company.com -JIRA_PAT_TOKEN=your_pat_token +JIRA_PAT_TOKEN= # Confluence Data Center CONFLUENCE_URL=https://confluence.your-company.com CONFLUENCE_PAT_TOKEN=your_pat_token -# Bitbucket Server/Data Center -BITBUCKET_URL=https://bitbucket.your-company.com -BITBUCKET_PAT_TOKEN=your_pat_token +# Bitbucket Server/Data Center, only if needed +# BITBUCKET_URL=https://bitbucket.your-company.com +# BITBUCKET_PAT_TOKEN= ``` > **Note**: PAT Token takes precedence if both are provided. @@ -112,12 +123,12 @@ credentials = AtlassianCredentials( jira_url="https://your-company.atlassian.net", jira_username="your.email@company.com", jira_api_token="your_api_token", - + # Confluence configuration (optional) confluence_url="https://your-company.atlassian.net/wiki", confluence_username="your.email@company.com", confluence_api_token="your_api_token", - + # Bitbucket configuration (optional) # bitbucket_url="https://bitbucket.your-company.com", # bitbucket_pat_token="your_pat_token" @@ -166,23 +177,23 @@ AtlassianCredentials( jira_api_token: Optional[str] = None, jira_pat_token: Optional[str] = None, jira_api_version: Optional[str] = None, # '2' or '3', auto-detected if not set - jira_ssl_verify: bool = False, - + jira_ssl_verify: bool = True, + # Confluence confluence_url: Optional[str] = None, confluence_username: Optional[str] = None, confluence_api_token: Optional[str] = None, confluence_pat_token: Optional[str] = None, confluence_api_version: Optional[str] = None, - confluence_ssl_verify: bool = False, - + confluence_ssl_verify: bool = True, + # Bitbucket bitbucket_url: Optional[str] = None, bitbucket_username: Optional[str] = None, bitbucket_api_token: Optional[str] = None, bitbucket_pat_token: Optional[str] = None, bitbucket_api_version: Optional[str] = None, - bitbucket_ssl_verify: bool = False + bitbucket_ssl_verify: bool = True ) ``` @@ -274,11 +285,17 @@ result = confluence_create_page( ```python from scripts.jira_issues import ( - jira_get_issue, # Get issue by key - jira_create_issue, # Create new issue - jira_update_issue, # Update existing issue - jira_delete_issue, # Delete issue - jira_add_comment # Add comment to issue + jira_add_attachment, # Upload attachment + jira_add_comment, # Add comment to issue + jira_create_issue, # Create new issue + jira_delete_issue, # Delete issue + jira_download_attachment, # Download attachment by ID + jira_download_attachments, # Download all issue attachments + jira_get_attachments, # Get attachment metadata + jira_get_comments, # Get issue comments + jira_get_issue, # Get issue by key + jira_update_comment, # Update comment on issue + jira_update_issue, # Update existing issue ) # Create issue with full options @@ -295,6 +312,14 @@ jira_create_issue( "customfield_10002": 123 } ) + +# Comments and files/screenshots +jira_get_comments(issue_key="PROJ-123") +jira_add_comment(issue_key="PROJ-123", comment="Verified locally") +jira_update_comment(issue_key="PROJ-123", comment_id="123456", comment="Updated text") +jira_get_attachments(issue_key="PROJ-123") +jira_download_attachments(issue_key="PROJ-123", output_dir="Output/Jira/PROJ-123") +jira_add_attachment(issue_key="PROJ-123", file_path="Output/Jira/PROJ-123/screenshot.png") ``` ### Jira Search (`scripts.jira_search`) @@ -614,14 +639,14 @@ def skill_function( credentials: Optional[AtlassianCredentials] = None # Always last parameter ) -> str: """Function description. - + Args: required_param1: Description required_param2: Description optional_param: Description (optional) credentials: Optional AtlassianCredentials for Agent environments. If not provided, uses environment variables. - + Returns: JSON string with result or error information """ diff --git a/.github/skills/atlassian-skills/requirements.txt b/.agents/skills/atlassian-skills/requirements.txt similarity index 100% rename from .github/skills/atlassian-skills/requirements.txt rename to .agents/skills/atlassian-skills/requirements.txt diff --git a/.github/skills/atlassian-skills/scripts/__init__.py b/.agents/skills/atlassian-skills/scripts/__init__.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/__init__.py rename to .agents/skills/atlassian-skills/scripts/__init__.py diff --git a/.github/skills/atlassian-readonly-skills/scripts/_common.py b/.agents/skills/atlassian-skills/scripts/_common.py similarity index 90% rename from .github/skills/atlassian-readonly-skills/scripts/_common.py rename to .agents/skills/atlassian-skills/scripts/_common.py index 176a395dfa..925d38054e 100644 --- a/.github/skills/atlassian-readonly-skills/scripts/_common.py +++ b/.agents/skills/atlassian-skills/scripts/_common.py @@ -13,7 +13,8 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse import requests from dotenv import load_dotenv @@ -69,12 +70,12 @@ class NetworkError(Exception): def format_error_response(error_type: str, message: str, details: str = "") -> str: """Format an error as a JSON response. - + Args: error_type: Type of error (e.g., 'AuthenticationError', 'ValidationError') message: Main error message details: Additional error details (optional) - + Returns: JSON string with error information """ @@ -90,10 +91,10 @@ def format_error_response(error_type: str, message: str, details: str = "") -> s def format_json_response(data: Any) -> str: """Format data as a JSON string with UTF-8 encoding. - + Args: data: Data to serialize (dict, list, or other JSON-serializable type) - + Returns: JSON formatted string with proper UTF-8 encoding """ @@ -107,45 +108,45 @@ def format_json_response(data: Any) -> str: @dataclass class AtlassianCredentials: """Unified credentials configuration for all Atlassian services. - + This class wraps authentication credentials for Jira, Confluence, and Bitbucket. When deployed in an Agent environment without environment variables, pass this object to skill functions to provide credentials programmatically. - + For each service, provide either: - PAT Token (for Data Center/Server), or - Username + API Token (for Cloud) - + If a service's credentials are not provided, that service will be unavailable. """ - + # Jira configuration jira_url: Optional[str] = None jira_username: Optional[str] = None jira_api_token: Optional[str] = None jira_pat_token: Optional[str] = None jira_api_version: Optional[str] = None - jira_ssl_verify: bool = False - + jira_ssl_verify: bool = True + # Confluence configuration confluence_url: Optional[str] = None confluence_username: Optional[str] = None confluence_api_token: Optional[str] = None confluence_pat_token: Optional[str] = None confluence_api_version: Optional[str] = None - confluence_ssl_verify: bool = False - + confluence_ssl_verify: bool = True + # Bitbucket configuration bitbucket_url: Optional[str] = None bitbucket_username: Optional[str] = None bitbucket_api_token: Optional[str] = None bitbucket_pat_token: Optional[str] = None bitbucket_api_version: Optional[str] = None - bitbucket_ssl_verify: bool = False - + bitbucket_ssl_verify: bool = True + def is_jira_available(self) -> bool: """Check if Jira credentials are complete and valid. - + Returns: True if Jira can be used, False otherwise """ @@ -154,10 +155,10 @@ def is_jira_available(self) -> bool: has_pat = bool(self.jira_pat_token) has_basic = bool(self.jira_username and self.jira_api_token) return has_pat or has_basic - + def is_confluence_available(self) -> bool: """Check if Confluence credentials are complete and valid. - + Returns: True if Confluence can be used, False otherwise """ @@ -166,10 +167,10 @@ def is_confluence_available(self) -> bool: has_pat = bool(self.confluence_pat_token) has_basic = bool(self.confluence_username and self.confluence_api_token) return has_pat or has_basic - + def is_bitbucket_available(self) -> bool: """Check if Bitbucket credentials are complete and valid. - + Returns: True if Bitbucket can be used, False otherwise """ @@ -178,10 +179,10 @@ def is_bitbucket_available(self) -> bool: has_pat = bool(self.bitbucket_pat_token) has_basic = bool(self.bitbucket_username and self.bitbucket_api_token) return has_pat or has_basic - + def get_available_services(self) -> List[str]: """Get list of available services based on provided credentials. - + Returns: List of service names that have complete credentials Example: ["jira", "confluence"] @@ -194,34 +195,34 @@ def get_available_services(self) -> List[str]: if self.is_bitbucket_available(): services.append("bitbucket") return services - + def get_unavailable_services(self) -> Dict[str, str]: """Get dictionary of unavailable services with reasons. - + Returns: Dictionary mapping service name to reason for unavailability Example: {"bitbucket": "Missing bitbucket_url"} """ unavailable = {} - + if not self.is_jira_available(): if not self.jira_url: unavailable["jira"] = "Missing jira_url" else: unavailable["jira"] = "Missing authentication credentials (provide jira_pat_token or jira_username + jira_api_token)" - + if not self.is_confluence_available(): if not self.confluence_url: unavailable["confluence"] = "Missing confluence_url" else: unavailable["confluence"] = "Missing authentication credentials (provide confluence_pat_token or confluence_username + confluence_api_token)" - + if not self.is_bitbucket_available(): if not self.bitbucket_url: unavailable["bitbucket"] = "Missing bitbucket_url" else: unavailable["bitbucket"] = "Missing authentication credentials (provide bitbucket_pat_token or bitbucket_username + bitbucket_api_token)" - + return unavailable @@ -239,7 +240,7 @@ def __init__( api_token: Optional[str] = None, pat_token: Optional[str] = None, api_version: Optional[str] = None, - ssl_verify: bool = False + ssl_verify: bool = True ): """Initialize configuration. @@ -249,7 +250,7 @@ def __init__( api_token: API token for Cloud authentication (optional) pat_token: Personal Access Token for Data Center authentication (optional) api_version: API version to use ('2' or '3'). If not specified, will auto-detect (optional) - ssl_verify: Whether to verify SSL certificates (default: False) + ssl_verify: Whether to verify SSL certificates (default: True) """ self.url = url.rstrip('/') if url else "" self.username = username @@ -264,7 +265,7 @@ def __init__( self.auth_type = "basic" else: self.auth_type = "unknown" - + @classmethod def from_env(cls, prefix: str) -> "AtlassianConfig": """Load configuration from environment variables. @@ -283,7 +284,7 @@ def from_env(cls, prefix: str) -> "AtlassianConfig": api_token = os.getenv(f"{prefix}_API_TOKEN") pat_token = os.getenv(f"{prefix}_PAT_TOKEN") api_version = os.getenv(f"{prefix}_API_VERSION") - ssl_verify_str = os.getenv(f"{prefix}_SSL_VERIFY", "false").lower() + ssl_verify_str = os.getenv(f"{prefix}_SSL_VERIFY", "true").lower() ssl_verify = ssl_verify_str in ("true", "1", "yes") config = cls( @@ -296,23 +297,23 @@ def from_env(cls, prefix: str) -> "AtlassianConfig": ) config._validate(prefix) return config - + @classmethod def from_credentials(cls, credentials: AtlassianCredentials, service: str) -> "AtlassianConfig": """Create configuration from AtlassianCredentials object. - + Args: credentials: AtlassianCredentials instance with service credentials service: Service name ('jira', 'confluence', or 'bitbucket') - + Returns: AtlassianConfig instance - + Raises: ConfigurationError: If credentials for the service are incomplete """ service = service.lower() - + if service == "jira": if not credentials.is_jira_available(): raise ConfigurationError( @@ -357,9 +358,9 @@ def from_credentials(cls, credentials: AtlassianCredentials, service: str) -> "A ) else: raise ConfigurationError(f"Unknown service: {service}. Must be 'jira', 'confluence', or 'bitbucket'.") - + return config - + def _validate(self, prefix: Optional[str] = None) -> None: """Validate configuration.""" if not self.url: @@ -367,10 +368,10 @@ def _validate(self, prefix: Optional[str] = None) -> None: f"Missing required configuration: {prefix}_URL. " f"Please set this environment variable." ) - + has_pat = bool(self.pat_token) has_basic = bool(self.username and self.api_token) - + if not has_pat and not has_basic: raise ConfigurationError( f"Missing authentication credentials for {prefix}. " @@ -378,7 +379,7 @@ def _validate(self, prefix: Optional[str] = None) -> None: f" - {prefix}_PAT_TOKEN (for Data Center/Server), or\n" f" - {prefix}_USERNAME and {prefix}_API_TOKEN (for Cloud)" ) - + def get_auth_header(self) -> dict: """Get the appropriate Authorization header.""" if self.auth_type == "pat": @@ -389,7 +390,7 @@ def get_auth_header(self) -> dict: return {"Authorization": f"Basic {encoded}"} else: raise ConfigurationError("Cannot generate auth header: unknown auth type.") - + @property def is_cloud(self) -> bool: """Check if this is a Cloud instance.""" @@ -441,10 +442,19 @@ def __init__(self, config: AtlassianConfig): self.session.auth = (config.username, config.api_token) self.session.headers.update({ - "Accept": "application/json", - "Content-Type": "application/json" + "Accept": "application/json" }) + def _build_url(self, path_or_url: str) -> str: + """Build an absolute URL and keep credentials scoped to the configured host.""" + configured = urlparse(self.config.url) + candidate = urlparse(path_or_url) + if candidate.scheme and candidate.netloc: + if candidate.netloc.lower() != configured.netloc.lower(): + raise ValidationError("Refusing to send Atlassian credentials to a different host") + return path_or_url + return f"{self.config.url}{path_or_url}" + def api_path(self, endpoint: str) -> str: """Build API path with the correct version. @@ -457,10 +467,10 @@ def api_path(self, endpoint: str) -> str: # Remove leading slash if present endpoint = endpoint.lstrip('/') return f"/rest/api/{self.api_version}/{endpoint}" - + def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any: """Perform a GET request.""" - url = f"{self.config.url}{path}" + url = self._build_url(path) try: response = self.session.get(url, params=params, timeout=30, verify=self.ssl_verify) self._handle_error(response) @@ -471,10 +481,10 @@ def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any: raise NetworkError(f"Connection failed: {str(e)}") except requests.exceptions.RequestException as e: raise NetworkError(f"Network error: {str(e)}") - + def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any: """Perform a POST request.""" - url = f"{self.config.url}{path}" + url = self._build_url(path) try: response = self.session.post(url, json=json, timeout=30, verify=self.ssl_verify) self._handle_error(response) @@ -485,10 +495,10 @@ def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any: raise NetworkError(f"Connection failed: {str(e)}") except requests.exceptions.RequestException as e: raise NetworkError(f"Network error: {str(e)}") - + def put(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any: """Perform a PUT request.""" - url = f"{self.config.url}{path}" + url = self._build_url(path) try: response = self.session.put(url, json=json, timeout=30, verify=self.ssl_verify) self._handle_error(response) @@ -499,10 +509,10 @@ def put(self, path: str, json: Optional[Dict[str, Any]] = None) -> Any: raise NetworkError(f"Connection failed: {str(e)}") except requests.exceptions.RequestException as e: raise NetworkError(f"Network error: {str(e)}") - + def delete(self, path: str) -> bool: """Perform a DELETE request.""" - url = f"{self.config.url}{path}" + url = self._build_url(path) try: response = self.session.delete(url, timeout=30, verify=self.ssl_verify) self._handle_error(response) @@ -513,12 +523,44 @@ def delete(self, path: str) -> bool: raise NetworkError(f"Connection failed: {str(e)}") except requests.exceptions.RequestException as e: raise NetworkError(f"Network error: {str(e)}") - + + def download(self, path_or_url: str) -> Tuple[bytes, Dict[str, str]]: + """Download binary content from an Atlassian URL or API path.""" + url = self._build_url(path_or_url) + try: + response = self.session.get(url, timeout=60, verify=self.ssl_verify) + self._handle_error(response) + return response.content, dict(response.headers) + except requests.exceptions.Timeout: + raise NetworkError("Request timed out") + except requests.exceptions.ConnectionError as e: + raise NetworkError(f"Connection failed: {str(e)}") + except requests.exceptions.RequestException as e: + raise NetworkError(f"Network error: {str(e)}") + + def post_files(self, path: str, files: Dict[str, Any]) -> Any: + """Perform a multipart file upload request.""" + url = self._build_url(path) + headers = { + "Accept": "application/json", + "X-Atlassian-Token": "no-check" + } + try: + response = self.session.post(url, files=files, headers=headers, timeout=60, verify=self.ssl_verify) + self._handle_error(response) + return response.json() if response.content else {} + except requests.exceptions.Timeout: + raise NetworkError("Request timed out") + except requests.exceptions.ConnectionError as e: + raise NetworkError(f"Connection failed: {str(e)}") + except requests.exceptions.RequestException as e: + raise NetworkError(f"Network error: {str(e)}") + def _handle_error(self, response: requests.Response) -> None: """Handle HTTP error responses.""" if response.status_code < 400: return - + error_message = "" try: error_data = response.json() @@ -530,7 +572,7 @@ def _handle_error(self, response: requests.Response) -> None: ) except Exception: error_message = response.text or response.reason - + if response.status_code == 401: raise AuthenticationError(f"Authentication failed: {error_message}") elif response.status_code == 400: @@ -549,14 +591,14 @@ def _handle_error(self, response: requests.Response) -> None: def get_jira_client(credentials: Optional[AtlassianCredentials] = None) -> AtlassianClient: """Get configured Jira client. - + Args: credentials: Optional AtlassianCredentials object. If not provided, configuration will be loaded from environment variables. - + Returns: Configured AtlassianClient instance - + Raises: ConfigurationError: If configuration is missing or invalid """ @@ -569,14 +611,14 @@ def get_jira_client(credentials: Optional[AtlassianCredentials] = None) -> Atlas def get_confluence_client(credentials: Optional[AtlassianCredentials] = None) -> AtlassianClient: """Get configured Confluence client. - + Args: credentials: Optional AtlassianCredentials object. If not provided, configuration will be loaded from environment variables. - + Returns: Configured AtlassianClient instance - + Raises: ConfigurationError: If configuration is missing or invalid """ @@ -589,14 +631,14 @@ def get_confluence_client(credentials: Optional[AtlassianCredentials] = None) -> def get_bitbucket_client(credentials: Optional[AtlassianCredentials] = None) -> AtlassianClient: """Get configured Bitbucket client. - + Args: credentials: Optional AtlassianCredentials object. If not provided, configuration will be loaded from environment variables. - + Returns: Configured AtlassianClient instance - + Raises: ConfigurationError: If configuration is missing or invalid """ @@ -609,13 +651,13 @@ def get_bitbucket_client(credentials: Optional[AtlassianCredentials] = None) -> def check_available_skills(credentials: AtlassianCredentials) -> Dict[str, Any]: """Check which Atlassian skills are available based on provided credentials. - + This function helps Agents determine which skills can be used before attempting to call them. Services without complete credentials will be listed as unavailable. - + Args: credentials: AtlassianCredentials object with service credentials - + Returns: Dictionary with availability information: { @@ -624,7 +666,7 @@ def check_available_skills(credentials: AtlassianCredentials) -> Dict[str, Any]: "bitbucket": "Missing bitbucket_url" } } - + Example: >>> creds = AtlassianCredentials( ... jira_url="https://company.atlassian.net", @@ -643,25 +685,25 @@ def check_available_skills(credentials: AtlassianCredentials) -> Dict[str, Any]: def simplify_issue(issue_data: Dict[str, Any]) -> Dict[str, Any]: """Simplify issue data to essential fields. - + Args: issue_data: Raw issue data from Jira API - + Returns: Simplified issue dictionary with essential fields """ fields = issue_data.get('fields', {}) - + assignee = fields.get('assignee') assignee_email = assignee.get('emailAddress', '') if assignee else None - + reporter = fields.get('reporter') reporter_email = reporter.get('emailAddress', '') if reporter else None - + status = fields.get('status', {}) issue_type = fields.get('issuetype', {}) priority = fields.get('priority', {}) - + simplified = { 'key': issue_data.get('key', ''), 'id': issue_data.get('id', ''), @@ -677,40 +719,40 @@ def simplify_issue(issue_data: Dict[str, Any]) -> Dict[str, Any]: 'labels': fields.get('labels', []), 'components': [c.get('name', '') for c in fields.get('components', [])], } - + custom_fields = {} for key, value in fields.items(): if key.startswith('customfield_'): custom_fields[key] = value if custom_fields: simplified['custom_fields'] = custom_fields - + return simplified def parse_time_spent(time_spent: str) -> int: """Parse time spent string into seconds. - + Args: time_spent: Time spent string (e.g., '1h 30m', '2d', '1w') - + Returns: Time spent in seconds - + Raises: ValidationError: If time format is invalid """ if not time_spent or not time_spent.strip(): raise ValidationError('time_spent is required and cannot be empty') - + time_spent = time_spent.strip() - + if time_spent.endswith('s'): try: return int(time_spent[:-1]) except ValueError: raise ValidationError(f'Invalid seconds format: {time_spent}') - + total_seconds = 0 time_units = { 'w': 7 * 24 * 60 * 60, @@ -718,24 +760,24 @@ def parse_time_spent(time_spent: str) -> int: 'h': 60 * 60, 'm': 60, } - + pattern = r'(\d+)([wdhm])' matches = re.findall(pattern, time_spent.lower()) - + for value, unit in matches: seconds = int(value) * time_units[unit] total_seconds += seconds - + if total_seconds > 0: return total_seconds - + try: raw_value = int(float(time_spent)) if raw_value > 0: return raw_value except ValueError: pass - + raise ValidationError( f'Invalid time format: {time_spent}. ' 'Use formats like "1h 30m", "2d", "1w", "45m", or seconds as a number.' diff --git a/.github/skills/atlassian-skills/scripts/bitbucket_commits.py b/.agents/skills/atlassian-skills/scripts/bitbucket_commits.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/bitbucket_commits.py rename to .agents/skills/atlassian-skills/scripts/bitbucket_commits.py diff --git a/.github/skills/atlassian-skills/scripts/bitbucket_files.py b/.agents/skills/atlassian-skills/scripts/bitbucket_files.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/bitbucket_files.py rename to .agents/skills/atlassian-skills/scripts/bitbucket_files.py diff --git a/.github/skills/atlassian-skills/scripts/bitbucket_projects.py b/.agents/skills/atlassian-skills/scripts/bitbucket_projects.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/bitbucket_projects.py rename to .agents/skills/atlassian-skills/scripts/bitbucket_projects.py diff --git a/.github/skills/atlassian-skills/scripts/bitbucket_pull_requests.py b/.agents/skills/atlassian-skills/scripts/bitbucket_pull_requests.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/bitbucket_pull_requests.py rename to .agents/skills/atlassian-skills/scripts/bitbucket_pull_requests.py diff --git a/.github/skills/atlassian-skills/scripts/confluence_comments.py b/.agents/skills/atlassian-skills/scripts/confluence_comments.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/confluence_comments.py rename to .agents/skills/atlassian-skills/scripts/confluence_comments.py diff --git a/.github/skills/atlassian-skills/scripts/confluence_labels.py b/.agents/skills/atlassian-skills/scripts/confluence_labels.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/confluence_labels.py rename to .agents/skills/atlassian-skills/scripts/confluence_labels.py diff --git a/.github/skills/atlassian-skills/scripts/confluence_pages.py b/.agents/skills/atlassian-skills/scripts/confluence_pages.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/confluence_pages.py rename to .agents/skills/atlassian-skills/scripts/confluence_pages.py diff --git a/.github/skills/atlassian-skills/scripts/confluence_search.py b/.agents/skills/atlassian-skills/scripts/confluence_search.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/confluence_search.py rename to .agents/skills/atlassian-skills/scripts/confluence_search.py diff --git a/.agents/skills/atlassian-skills/scripts/jira.py b/.agents/skills/atlassian-skills/scripts/jira.py new file mode 100644 index 0000000000..edb76a9c08 --- /dev/null +++ b/.agents/skills/atlassian-skills/scripts/jira.py @@ -0,0 +1,185 @@ +"""Small Jira command wrapper for the writable Atlassian skill.""" + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import List, Optional + +from jira_issues import ( + jira_add_attachment, + jira_add_comment, + jira_download_attachment, + jira_download_attachments, + jira_get_attachments, + jira_get_comments, + jira_get_issue, + jira_update_comment, + jira_update_issue, +) +from jira_search import jira_search +from jira_workflow import jira_get_transitions, jira_transition_issue + + +DEFAULT_ISSUE_FIELDS = 'summary,description,status,issuetype,priority,assignee,reporter,created,updated,labels,components' + + +def normalize_issue_key(value: str) -> str: + match = re.search(r'([A-Z][A-Z0-9]+-\d+)', value) + if not match: + raise argparse.ArgumentTypeError(f"Could not find an issue key in: {value}") + return match.group(1) + + +def read_text_argument(body: Optional[str], body_file: Optional[str]) -> str: + if body_file: + return Path(body_file).read_text(encoding='utf-8') + return body or '' + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Read and update Jira issues using .env or JIRA_* environment variables." + ) + subparsers = parser.add_subparsers(dest='command', required=True) + + issue = subparsers.add_parser('issue', help='Get one issue') + issue.add_argument('issue_key', type=normalize_issue_key) + issue.add_argument('--fields', help='Comma-separated Jira fields to return') + issue.add_argument('--all-fields', action='store_true', help='Return every Jira field instead of the readable default set') + issue.add_argument('--expand') + + comments = subparsers.add_parser('comments', help='List issue comments') + comments.add_argument('issue_key', type=normalize_issue_key) + comments.add_argument('--limit', type=int, default=50) + comments.add_argument('--start-at', type=int, default=0) + + attachments = subparsers.add_parser('attachments', help='List issue attachments') + attachments.add_argument('issue_key', type=normalize_issue_key) + + download_attachment = subparsers.add_parser('download-attachment', help='Download one attachment by ID') + download_attachment.add_argument('attachment_id') + download_attachment.add_argument('--out', default='.') + download_attachment.add_argument('--filename') + + download_attachments = subparsers.add_parser('download-attachments', help='Download all issue attachments') + download_attachments.add_argument('issue_key', type=normalize_issue_key) + download_attachments.add_argument('--out', required=True) + + search = subparsers.add_parser('search', help='Run a JQL search') + search.add_argument('jql') + search.add_argument('--fields') + search.add_argument('--limit', type=int, default=10) + search.add_argument('--start-at', type=int, default=0) + + transitions = subparsers.add_parser('transitions', help='List available issue transitions') + transitions.add_argument('issue_key', type=normalize_issue_key) + + transition = subparsers.add_parser('transition', help='Transition an issue') + transition.add_argument('issue_key', type=normalize_issue_key) + transition.add_argument('transition') + transition.add_argument('--comment') + + update = subparsers.add_parser('update', help='Update simple issue fields') + update.add_argument('issue_key', type=normalize_issue_key) + update.add_argument('--summary') + update.add_argument('--description') + update.add_argument('--description-file') + update.add_argument('--priority') + update.add_argument('--labels', nargs='*') + + add_comment = subparsers.add_parser('add-comment', help='Add a comment') + add_comment.add_argument('issue_key', type=normalize_issue_key) + comment_body = add_comment.add_mutually_exclusive_group(required=True) + comment_body.add_argument('--body') + comment_body.add_argument('--body-file') + + update_comment = subparsers.add_parser('update-comment', help='Update a comment') + update_comment.add_argument('issue_key', type=normalize_issue_key) + update_comment.add_argument('comment_id') + update_body = update_comment.add_mutually_exclusive_group(required=True) + update_body.add_argument('--body') + update_body.add_argument('--body-file') + + attach = subparsers.add_parser('attach', help='Upload one or more file attachments') + attach.add_argument('issue_key', type=normalize_issue_key) + attach.add_argument('file_paths', nargs='+') + + return parser + + +def issue_fields(args: argparse.Namespace) -> Optional[str]: + if args.all_fields: + return args.fields + return args.fields or DEFAULT_ISSUE_FIELDS + + +def resolve_transition(issue_key: str, transition: str) -> str: + if transition.isdigit(): + return transition + + transitions = json.loads(jira_get_transitions(issue_key)) + if not transitions.get('success', True): + return transition + + transition_lower = transition.lower() + for item in transitions.get('transitions', []): + names = [item.get('id', ''), item.get('name', ''), item.get('to_status', '')] + if any(name and name.lower() == transition_lower for name in names): + return item.get('id') or transition + return transition + + +def attach_files(issue_key: str, file_paths: List[str]) -> str: + results = [] + for file_path in file_paths: + results.append(json.loads(jira_add_attachment(issue_key, file_path))) + success = all(item.get('success', True) for item in results) + return json.dumps({'success': success, 'issue_key': issue_key, 'results': results}, ensure_ascii=False, indent=2) + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == 'issue': + result = jira_get_issue(args.issue_key, fields=issue_fields(args), expand=args.expand) + elif args.command == 'comments': + result = jira_get_comments(args.issue_key, limit=args.limit, start_at=args.start_at) + elif args.command == 'attachments': + result = jira_get_attachments(args.issue_key) + elif args.command == 'download-attachment': + result = jira_download_attachment(args.attachment_id, output_dir=args.out, filename=args.filename) + elif args.command == 'download-attachments': + result = jira_download_attachments(args.issue_key, output_dir=args.out) + elif args.command == 'search': + result = jira_search(args.jql, fields=args.fields, limit=args.limit, start_at=args.start_at) + elif args.command == 'transitions': + result = jira_get_transitions(args.issue_key) + elif args.command == 'transition': + result = jira_transition_issue(args.issue_key, resolve_transition(args.issue_key, args.transition), comment=args.comment) + elif args.command == 'update': + description = read_text_argument(args.description, args.description_file) + result = jira_update_issue( + args.issue_key, + summary=args.summary, + description=description or None, + priority=args.priority, + labels=args.labels, + ) + elif args.command == 'add-comment': + result = jira_add_comment(args.issue_key, read_text_argument(args.body, args.body_file)) + elif args.command == 'update-comment': + result = jira_update_comment(args.issue_key, args.comment_id, read_text_argument(args.body, args.body_file)) + elif args.command == 'attach': + result = attach_files(args.issue_key, args.file_paths) + else: + parser.error(f"Unknown command: {args.command}") + + print(result) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/skills/atlassian-skills/scripts/jira_agile.py b/.agents/skills/atlassian-skills/scripts/jira_agile.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/jira_agile.py rename to .agents/skills/atlassian-skills/scripts/jira_agile.py diff --git a/.agents/skills/atlassian-skills/scripts/jira_issues.py b/.agents/skills/atlassian-skills/scripts/jira_issues.py new file mode 100644 index 0000000000..bb9c17436a --- /dev/null +++ b/.agents/skills/atlassian-skills/scripts/jira_issues.py @@ -0,0 +1,651 @@ +"""Jira issue management tools. + +Tools: + - jira_get_issue: Retrieve an issue by key + - jira_create_issue: Create a new issue + - jira_update_issue: Update an existing issue + - jira_delete_issue: Delete an issue + - jira_add_comment: Add a comment to an issue + - jira_update_comment: Update a comment on an issue + - jira_get_comments: Retrieve comments for an issue + - jira_get_attachments: Retrieve attachment metadata + - jira_download_attachment: Download one attachment by ID + - jira_download_attachments: Download all issue attachments + - jira_add_attachment: Upload a file to an issue +""" + +import sys +import re +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent)) + +from typing import Any, Dict, List, Optional + +from _common import ( + AtlassianCredentials, + get_jira_client, + simplify_issue, + format_json_response, + format_error_response, + ConfigurationError, + AuthenticationError, + ValidationError, + NotFoundError, + APIError, + NetworkError, +) + + +def _body_to_text(body: Any) -> str: + """Extract readable text from Jira Server strings or Jira Cloud ADF bodies.""" + if body is None: + return "" + if isinstance(body, str): + return body + if isinstance(body, list): + return "\n".join(part for part in (_body_to_text(item) for item in body) if part) + if isinstance(body, dict): + text = body.get('text') + if isinstance(text, str): + return text + return _body_to_text(body.get('content', [])) + return str(body) + + +def _comment_payload(comment: str, api_version: str) -> Dict[str, Any]: + """Build the comment payload Jira expects for the selected API version.""" + if api_version == "3": + return { + 'body': { + 'type': 'doc', + 'version': 1, + 'content': [ + { + 'type': 'paragraph', + 'content': [ + {'type': 'text', 'text': comment} + ] + } + ] + } + } + return {'body': comment} + + +def _user_label(user_data: Optional[Dict[str, Any]]) -> Optional[str]: + """Return a stable, human-readable Jira user label.""" + if not user_data: + return None + return ( + user_data.get('displayName') + or user_data.get('emailAddress') + or user_data.get('name') + or user_data.get('accountId') + ) + + +def _simplify_comment(comment_data: Dict[str, Any]) -> Dict[str, Any]: + """Simplify Jira comment data to the fields agents usually need.""" + return { + 'id': comment_data.get('id', ''), + 'author': _user_label(comment_data.get('author')), + 'update_author': _user_label(comment_data.get('updateAuthor')), + 'body': _body_to_text(comment_data.get('body')), + 'created': comment_data.get('created', ''), + 'updated': comment_data.get('updated', '') + } + + +def _simplify_attachment(attachment_data: Dict[str, Any]) -> Dict[str, Any]: + """Simplify Jira attachment metadata.""" + return { + 'id': attachment_data.get('id', ''), + 'filename': attachment_data.get('filename', ''), + 'size': attachment_data.get('size', 0), + 'mime_type': attachment_data.get('mimeType', ''), + 'author': _user_label(attachment_data.get('author')), + 'created': attachment_data.get('created', ''), + 'content': attachment_data.get('content', ''), + 'thumbnail': attachment_data.get('thumbnail', '') + } + + +def _safe_filename(filename: str) -> str: + """Keep downloaded attachment names inside the requested directory.""" + leaf_name = Path(filename).name or 'attachment' + return re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', leaf_name) + + +def _get_issue_attachments(client: Any, issue_key: str) -> List[Dict[str, Any]]: + issue_data = client.get(client.api_path(f'issue/{issue_key}'), params={'fields': 'attachment'}) + return issue_data.get('fields', {}).get('attachment', []) + + +def _download_attachment_data( + client: Any, + attachment_data: Dict[str, Any], + output_dir: str, + filename: Optional[str] = None +) -> Dict[str, Any]: + attachment = _simplify_attachment(attachment_data) + content_url = attachment.get('content') + if not content_url: + raise ValidationError('attachment content URL is missing') + + content, _headers = client.download(content_url) + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + target_name = _safe_filename(filename or attachment.get('filename') or f"attachment-{attachment.get('id')}") + target_path = output_path / target_name + target_path.write_bytes(content) + + attachment['path'] = str(target_path) + attachment['bytes_written'] = len(content) + return attachment + + +def jira_get_issue( + issue_key: str, + fields: Optional[str] = None, + expand: Optional[str] = None, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Retrieve a Jira issue by key. + + Args: + issue_key: Issue key (e.g., 'PROJ-123') + fields: Comma-separated list of fields to return (optional) + expand: Comma-separated list of entities to expand (optional) + credentials: Optional AtlassianCredentials for Agent environments. + If not provided, uses environment variables. + + Returns: + JSON string with issue data or error information + """ + try: + client = get_jira_client(credentials) + + params: Dict[str, Any] = {} + if fields: + params['fields'] = fields + if expand: + params['expand'] = expand + + issue_data = client.get(client.api_path(f'issue/{issue_key}'), params=params) + simplified = simplify_issue(issue_data) + return format_json_response(simplified) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_create_issue( + project_key: str, + summary: str, + issue_type: str, + description: Optional[str] = None, + assignee: Optional[str] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + custom_fields: Optional[Dict[str, Any]] = None, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Create a new Jira issue. + + Args: + project_key: Project key (e.g., 'PROJ') + summary: Issue summary/title + issue_type: Issue type (e.g., 'Task', 'Bug', 'Story') + description: Issue description (optional) + assignee: Assignee account ID (optional) + priority: Priority name (e.g., 'High', 'Medium', 'Low') (optional) + labels: List of label strings (optional) + custom_fields: Dictionary of custom field IDs to values (optional) + e.g., {'customfield_10001': 'value', 'customfield_10002': 123} + credentials: Optional AtlassianCredentials for Agent environments. + If not provided, uses environment variables. + + Returns: + JSON string with created issue data or error information + """ + try: + client = get_jira_client(credentials) + + if not project_key: + raise ValidationError('project_key is required') + if not summary: + raise ValidationError('summary is required') + if not issue_type: + raise ValidationError('issue_type is required') + + fields: Dict[str, Any] = { + 'project': {'key': project_key}, + 'summary': summary, + 'issuetype': {'name': issue_type} + } + + if description: + fields['description'] = description + if assignee: + fields['assignee'] = {'accountId': assignee} + if priority: + fields['priority'] = {'name': priority} + if labels: + fields['labels'] = labels + if custom_fields: + fields.update(custom_fields) + + payload = {'fields': fields} + response = client.post(client.api_path('issue'), json=payload) + + issue_key = response.get('key') + if issue_key: + return jira_get_issue(issue_key, credentials=credentials) + return format_json_response(response) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_update_issue( + issue_key: str, + summary: Optional[str] = None, + description: Optional[str] = None, + assignee: Optional[str] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + custom_fields: Optional[Dict[str, Any]] = None, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Update an existing Jira issue. + + Args: + issue_key: Issue key (e.g., 'PROJ-123') + summary: New summary/title (optional) + description: New description (optional) + assignee: New assignee account ID (optional) + priority: New priority name (optional) + labels: New list of labels (optional) + custom_fields: Dictionary of custom field IDs to values (optional) + e.g., {'customfield_10001': 'value', 'customfield_10002': 123} + credentials: Optional AtlassianCredentials for Agent environments. + If not provided, uses environment variables. + + Returns: + JSON string with updated issue data or error information + """ + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + + fields: Dict[str, Any] = {} + if summary is not None: + fields['summary'] = summary + if description is not None: + fields['description'] = description + if assignee is not None: + fields['assignee'] = {'accountId': assignee} + if priority is not None: + fields['priority'] = {'name': priority} + if labels is not None: + fields['labels'] = labels + if custom_fields is not None: + fields.update(custom_fields) + + if not fields: + raise ValidationError('At least one field to update is required') + + payload: Dict[str, Any] = {'fields': fields} + client.put(client.api_path(f'issue/{issue_key}'), json=payload) + + return jira_get_issue(issue_key, credentials=credentials) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_delete_issue( + issue_key: str, + delete_subtasks: bool = False, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Delete a Jira issue. + + Args: + issue_key: Issue key (e.g., 'PROJ-123') + delete_subtasks: Whether to delete subtasks (default: False) + credentials: Optional AtlassianCredentials for Agent environments. + If not provided, uses environment variables. + + Returns: + JSON string with success message or error information + """ + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + + path = client.api_path(f'issue/{issue_key}') + if delete_subtasks: + path += '?deleteSubtasks=true' + + client.delete(path) + + return format_json_response({ + 'success': True, + 'message': f'Issue {issue_key} deleted successfully' + }) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_add_comment( + issue_key: str, + comment: str, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Add a comment to a Jira issue. + + Args: + issue_key: Issue key (e.g., 'PROJ-123') + comment: Comment text + credentials: Optional AtlassianCredentials for Agent environments. + If not provided, uses environment variables. + + Returns: + JSON string with comment data or error information + """ + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + if not comment: + raise ValidationError('comment text is required') + + payload = _comment_payload(comment, client.api_version) + response = client.post(client.api_path(f'issue/{issue_key}/comment'), json=payload) + simplified = _simplify_comment(response) + + return format_json_response(simplified) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_update_comment( + issue_key: str, + comment_id: str, + comment: str, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Update an existing Jira issue comment.""" + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + if not comment_id: + raise ValidationError('comment_id is required') + if not comment: + raise ValidationError('comment text is required') + + payload = _comment_payload(comment, client.api_version) + response = client.put(client.api_path(f'issue/{issue_key}/comment/{comment_id}'), json=payload) + return format_json_response(_simplify_comment(response)) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_get_comments( + issue_key: str, + limit: int = 50, + start_at: int = 0, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Retrieve comments for a Jira issue.""" + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + if limit < 0: + raise ValidationError('limit must be non-negative') + if start_at < 0: + raise ValidationError('start_at must be non-negative') + + params: Dict[str, Any] = {'maxResults': limit, 'startAt': start_at} + response = client.get(client.api_path(f'issue/{issue_key}/comment'), params=params) + comments = [_simplify_comment(comment) for comment in response.get('comments', [])] + return format_json_response({ + 'issue_key': issue_key, + 'comments': comments, + 'total': response.get('total', len(comments)), + 'start_at': response.get('startAt', start_at), + 'max_results': response.get('maxResults', limit) + }) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_get_attachments( + issue_key: str, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Retrieve attachment metadata for a Jira issue.""" + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + + attachments = [_simplify_attachment(item) for item in _get_issue_attachments(client, issue_key)] + return format_json_response({ + 'issue_key': issue_key, + 'attachments': attachments, + 'count': len(attachments) + }) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_download_attachment( + attachment_id: str, + output_dir: str = '.', + filename: Optional[str] = None, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Download one Jira attachment by ID.""" + try: + client = get_jira_client(credentials) + + if not attachment_id: + raise ValidationError('attachment_id is required') + if not output_dir: + raise ValidationError('output_dir is required') + + attachment_data = client.get(client.api_path(f'attachment/{attachment_id}')) + attachment = _download_attachment_data(client, attachment_data, output_dir, filename) + return format_json_response({'success': True, 'attachment': attachment}) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_download_attachments( + issue_key: str, + output_dir: str, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Download all attachments from a Jira issue.""" + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + if not output_dir: + raise ValidationError('output_dir is required') + + attachments = [ + _download_attachment_data(client, item, output_dir) + for item in _get_issue_attachments(client, issue_key) + ] + return format_json_response({ + 'success': True, + 'issue_key': issue_key, + 'attachments': attachments, + 'count': len(attachments) + }) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') + + +def jira_add_attachment( + issue_key: str, + file_path: str, + credentials: Optional[AtlassianCredentials] = None +) -> str: + """Upload one file attachment to a Jira issue.""" + try: + client = get_jira_client(credentials) + + if not issue_key: + raise ValidationError('issue_key is required') + if not file_path: + raise ValidationError('file_path is required') + + path = Path(file_path) + if not path.is_file(): + raise ValidationError(f'file not found: {file_path}') + + with path.open('rb') as file_handle: + response = client.post_files( + client.api_path(f'issue/{issue_key}/attachments'), + files={'file': (path.name, file_handle)} + ) + + uploaded = response if isinstance(response, list) else [response] + attachments = [_simplify_attachment(item) for item in uploaded] + return format_json_response({ + 'success': True, + 'issue_key': issue_key, + 'attachments': attachments, + 'count': len(attachments) + }) + + except ConfigurationError as e: + return format_error_response('ConfigurationError', str(e)) + except AuthenticationError as e: + return format_error_response('AuthenticationError', str(e)) + except ValidationError as e: + return format_error_response('ValidationError', str(e)) + except NotFoundError as e: + return format_error_response('NotFoundError', str(e)) + except (APIError, NetworkError) as e: + return format_error_response(type(e).__name__, str(e)) + except Exception as e: + return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') diff --git a/.github/skills/atlassian-skills/scripts/jira_links.py b/.agents/skills/atlassian-skills/scripts/jira_links.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/jira_links.py rename to .agents/skills/atlassian-skills/scripts/jira_links.py diff --git a/.github/skills/atlassian-skills/scripts/jira_projects.py b/.agents/skills/atlassian-skills/scripts/jira_projects.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/jira_projects.py rename to .agents/skills/atlassian-skills/scripts/jira_projects.py diff --git a/.github/skills/atlassian-skills/scripts/jira_search.py b/.agents/skills/atlassian-skills/scripts/jira_search.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/jira_search.py rename to .agents/skills/atlassian-skills/scripts/jira_search.py diff --git a/.github/skills/atlassian-skills/scripts/jira_users.py b/.agents/skills/atlassian-skills/scripts/jira_users.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/jira_users.py rename to .agents/skills/atlassian-skills/scripts/jira_users.py diff --git a/.github/skills/atlassian-skills/scripts/jira_workflow.py b/.agents/skills/atlassian-skills/scripts/jira_workflow.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/jira_workflow.py rename to .agents/skills/atlassian-skills/scripts/jira_workflow.py diff --git a/.github/skills/atlassian-skills/scripts/jira_worklog.py b/.agents/skills/atlassian-skills/scripts/jira_worklog.py similarity index 100% rename from .github/skills/atlassian-skills/scripts/jira_worklog.py rename to .agents/skills/atlassian-skills/scripts/jira_worklog.py diff --git a/.github/skills/code-review-skill-main/.gitignore b/.agents/skills/code-review-skill-main/.gitignore similarity index 100% rename from .github/skills/code-review-skill-main/.gitignore rename to .agents/skills/code-review-skill-main/.gitignore diff --git a/.github/skills/code-review-skill-main/CONTRIBUTING.md b/.agents/skills/code-review-skill-main/CONTRIBUTING.md similarity index 100% rename from .github/skills/code-review-skill-main/CONTRIBUTING.md rename to .agents/skills/code-review-skill-main/CONTRIBUTING.md diff --git a/.github/skills/code-review-skill-main/LICENSE b/.agents/skills/code-review-skill-main/LICENSE similarity index 100% rename from .github/skills/code-review-skill-main/LICENSE rename to .agents/skills/code-review-skill-main/LICENSE diff --git a/.github/skills/code-review-skill-main/README.md b/.agents/skills/code-review-skill-main/README.md similarity index 100% rename from .github/skills/code-review-skill-main/README.md rename to .agents/skills/code-review-skill-main/README.md diff --git a/.github/skills/code-review-skill-main/SKILL.md b/.agents/skills/code-review-skill-main/SKILL.md similarity index 100% rename from .github/skills/code-review-skill-main/SKILL.md rename to .agents/skills/code-review-skill-main/SKILL.md diff --git a/.github/skills/code-review-skill-main/assets/pr-review-template.md b/.agents/skills/code-review-skill-main/assets/pr-review-template.md similarity index 100% rename from .github/skills/code-review-skill-main/assets/pr-review-template.md rename to .agents/skills/code-review-skill-main/assets/pr-review-template.md diff --git a/.github/skills/code-review-skill-main/assets/review-checklist.md b/.agents/skills/code-review-skill-main/assets/review-checklist.md similarity index 100% rename from .github/skills/code-review-skill-main/assets/review-checklist.md rename to .agents/skills/code-review-skill-main/assets/review-checklist.md diff --git a/.github/skills/code-review-skill-main/reference/architecture-review-guide.md b/.agents/skills/code-review-skill-main/reference/architecture-review-guide.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/architecture-review-guide.md rename to .agents/skills/code-review-skill-main/reference/architecture-review-guide.md diff --git a/.github/skills/code-review-skill-main/reference/c.md b/.agents/skills/code-review-skill-main/reference/c.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/c.md rename to .agents/skills/code-review-skill-main/reference/c.md diff --git a/.github/skills/code-review-skill-main/reference/code-review-best-practices.md b/.agents/skills/code-review-skill-main/reference/code-review-best-practices.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/code-review-best-practices.md rename to .agents/skills/code-review-skill-main/reference/code-review-best-practices.md diff --git a/.github/skills/code-review-skill-main/reference/common-bugs-checklist.md b/.agents/skills/code-review-skill-main/reference/common-bugs-checklist.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/common-bugs-checklist.md rename to .agents/skills/code-review-skill-main/reference/common-bugs-checklist.md diff --git a/.github/skills/code-review-skill-main/reference/cpp.md b/.agents/skills/code-review-skill-main/reference/cpp.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/cpp.md rename to .agents/skills/code-review-skill-main/reference/cpp.md diff --git a/.github/skills/code-review-skill-main/reference/css-less-sass.md b/.agents/skills/code-review-skill-main/reference/css-less-sass.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/css-less-sass.md rename to .agents/skills/code-review-skill-main/reference/css-less-sass.md diff --git a/.github/skills/code-review-skill-main/reference/go.md b/.agents/skills/code-review-skill-main/reference/go.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/go.md rename to .agents/skills/code-review-skill-main/reference/go.md diff --git a/.github/skills/code-review-skill-main/reference/java.md b/.agents/skills/code-review-skill-main/reference/java.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/java.md rename to .agents/skills/code-review-skill-main/reference/java.md diff --git a/.github/skills/code-review-skill-main/reference/performance-review-guide.md b/.agents/skills/code-review-skill-main/reference/performance-review-guide.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/performance-review-guide.md rename to .agents/skills/code-review-skill-main/reference/performance-review-guide.md diff --git a/.github/skills/code-review-skill-main/reference/python.md b/.agents/skills/code-review-skill-main/reference/python.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/python.md rename to .agents/skills/code-review-skill-main/reference/python.md diff --git a/.github/skills/code-review-skill-main/reference/react.md b/.agents/skills/code-review-skill-main/reference/react.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/react.md rename to .agents/skills/code-review-skill-main/reference/react.md diff --git a/.github/skills/code-review-skill-main/reference/rust.md b/.agents/skills/code-review-skill-main/reference/rust.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/rust.md rename to .agents/skills/code-review-skill-main/reference/rust.md diff --git a/.github/skills/code-review-skill-main/reference/security-review-guide.md b/.agents/skills/code-review-skill-main/reference/security-review-guide.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/security-review-guide.md rename to .agents/skills/code-review-skill-main/reference/security-review-guide.md diff --git a/.github/skills/code-review-skill-main/reference/typescript.md b/.agents/skills/code-review-skill-main/reference/typescript.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/typescript.md rename to .agents/skills/code-review-skill-main/reference/typescript.md diff --git a/.github/skills/code-review-skill-main/reference/vue.md b/.agents/skills/code-review-skill-main/reference/vue.md similarity index 100% rename from .github/skills/code-review-skill-main/reference/vue.md rename to .agents/skills/code-review-skill-main/reference/vue.md diff --git a/.github/skills/code-review-skill-main/scripts/pr-analyzer.py b/.agents/skills/code-review-skill-main/scripts/pr-analyzer.py similarity index 100% rename from .github/skills/code-review-skill-main/scripts/pr-analyzer.py rename to .agents/skills/code-review-skill-main/scripts/pr-analyzer.py diff --git a/.github/skills/execute-implement/SKILL.md b/.agents/skills/execute-implement/SKILL.md similarity index 100% rename from .github/skills/execute-implement/SKILL.md rename to .agents/skills/execute-implement/SKILL.md diff --git a/.github/skills/fieldworks-winapp/SKILL.md b/.agents/skills/fieldworks-winapp/SKILL.md similarity index 100% rename from .github/skills/fieldworks-winapp/SKILL.md rename to .agents/skills/fieldworks-winapp/SKILL.md diff --git a/.github/skills/fieldworks-winapp/navigation/launch-or-attach.md b/.agents/skills/fieldworks-winapp/navigation/launch-or-attach.md similarity index 100% rename from .github/skills/fieldworks-winapp/navigation/launch-or-attach.md rename to .agents/skills/fieldworks-winapp/navigation/launch-or-attach.md diff --git a/.github/skills/fieldworks-winapp/navigation/project-loading.md b/.agents/skills/fieldworks-winapp/navigation/project-loading.md similarity index 100% rename from .github/skills/fieldworks-winapp/navigation/project-loading.md rename to .agents/skills/fieldworks-winapp/navigation/project-loading.md diff --git a/.github/skills/fieldworks-winapp/navigation/screenshot-evidence.md b/.agents/skills/fieldworks-winapp/navigation/screenshot-evidence.md similarity index 100% rename from .github/skills/fieldworks-winapp/navigation/screenshot-evidence.md rename to .agents/skills/fieldworks-winapp/navigation/screenshot-evidence.md diff --git a/.github/skills/fieldworks-winapp/navigation/styles-font-tab.md b/.agents/skills/fieldworks-winapp/navigation/styles-font-tab.md similarity index 100% rename from .github/skills/fieldworks-winapp/navigation/styles-font-tab.md rename to .agents/skills/fieldworks-winapp/navigation/styles-font-tab.md diff --git a/.github/skills/fieldworks-winapp/navigation/writing-system-font-options.md b/.agents/skills/fieldworks-winapp/navigation/writing-system-font-options.md similarity index 100% rename from .github/skills/fieldworks-winapp/navigation/writing-system-font-options.md rename to .agents/skills/fieldworks-winapp/navigation/writing-system-font-options.md diff --git a/.github/skills/fieldworks-winapp/references/how-to-update.md b/.agents/skills/fieldworks-winapp/references/how-to-update.md similarity index 100% rename from .github/skills/fieldworks-winapp/references/how-to-update.md rename to .agents/skills/fieldworks-winapp/references/how-to-update.md diff --git a/.github/skills/fieldworks-winapp/references/mcp-selection.md b/.agents/skills/fieldworks-winapp/references/mcp-selection.md similarity index 100% rename from .github/skills/fieldworks-winapp/references/mcp-selection.md rename to .agents/skills/fieldworks-winapp/references/mcp-selection.md diff --git a/.github/skills/fieldworks-winapp/references/research.md b/.agents/skills/fieldworks-winapp/references/research.md similarity index 100% rename from .github/skills/fieldworks-winapp/references/research.md rename to .agents/skills/fieldworks-winapp/references/research.md diff --git a/.agents/skills/jira-issue/SKILL.md b/.agents/skills/jira-issue/SKILL.md new file mode 100644 index 0000000000..e2769e52b3 --- /dev/null +++ b/.agents/skills/jira-issue/SKILL.md @@ -0,0 +1,177 @@ +--- +name: jira-issue +description: > + General FieldWorks JIRA issue workflow for LT-prefixed tickets: fetch and + summarize the issue, classify bug/feature/task/research work, reproduce or + validate the requested behavior, gather screenshots or image-sequence + evidence when useful, implement or document the change, test, commit, open a + PR, and update JIRA. Use this skill whenever the user references LT-XXXXX, + a jira.sil.org issue, or asks to work on, fix, investigate, verify, or + document a JIRA issue. +license: MIT +compatibility: Requires atlassian-readonly-skills for reading JIRA and atlassian-skills for JIRA writes. +metadata: + author: FieldWorks team + version: "2.0" +--- + +# JIRA Issue Workflow + +Use this skill for FieldWorks work sourced from SIL JIRA LT tickets. It covers +bugs, features, tasks, documentation work, research, and verification requests. +For visual or desktop UI issues, compose it with `fieldworks-winapp` to capture +reproduction and fix evidence from the live application. + +## Start By Reading The Issue + +Use `atlassian-readonly-skills` for LT-prefixed tickets. Do not browse directly +to `jira.sil.org` or use generic web fetch tools for JIRA. + +```powershell +python .agents/skills/atlassian-readonly-skills/scripts/jira.py issue LT-XXXXX +python .agents/skills/atlassian-readonly-skills/scripts/jira.py comments LT-XXXXX +python .agents/skills/atlassian-readonly-skills/scripts/jira.py attachments LT-XXXXX +``` + +Use `--all-fields` only when the common field set is not enough. Download screenshots or other evidence with `download-attachments` into `Output/Jira/LT-XXXXX`. + +Extract and summarize: + +- summary and description; +- issue type, status, priority, assignee, affected/fix versions; +- components, labels, attachments, and recent comments; +- reproduction steps, acceptance criteria, screenshots, or links; +- any explicit request for before/after evidence. + +If JIRA access is denied, continue from the context already available in the +workspace or user prompt and clearly record that limitation in the work notes. + +## Classify The Work + +Choose the workflow shape before editing: + +- Bug: reproduce first, preferably with a failing automated test or a manual + reproduction screenshot. +- Feature or improvement: confirm desired behavior, update OpenSpec or design + notes when the change is more than a tiny code edit. +- Task or cleanup: define the expected final state and validation signals. +- Research: collect findings, trade-offs, and recommendations without making + production edits unless the user asks. +- Verification: run the app/tests, capture evidence, and report pass/fail. + +## Branch And Scope + +Check the current branch. If it does not match the ticket, ask before creating +or switching branches. Do not create a new worktree automatically unless the +user explicitly asks; this repository has VS Code tasks for worktree setup. + +Keep changes scoped to the ticket. If the issue reveals unrelated problems, +record them as follow-ups rather than folding them into the current fix. + +## Reproduce Or Establish Baseline Evidence + +For bugs, reproduce before fixing whenever feasible: + +- Automated reproduction: add or identify a failing test and run it. +- UI reproduction: use `fieldworks-winapp` to launch FieldWorks, navigate to + the affected screen, and capture the broken state. +- Visual or timing bugs: capture an image sequence or GIF if a single screenshot + does not show the issue clearly. + +Evidence folder convention: + +`Output/ManualEvidence/LT-XXXXX-/` + +For OpenSpec or docs evidence that should be committed, copy selected files into +the relevant change folder, for example: + +`openspec/changes//evidence/manual-winapp/` + +Name evidence files so the story is obvious: + +- `01-before-.png` +- `02-after-.png` +- `03-after-.png` +- `sequence--001.png`, `sequence--002.png`, ... + +When a true before-state cannot be captured because only a fixed build is +available, document the reason and include exact steps for recreating the +before-state from an unfixed build or separate worktree. + +## Implement Or Document + +For code changes: + +1. Follow the repository instructions for the touched area. +2. Prefer the existing architecture and helper APIs. +3. Add focused tests that cover the ticket behavior. +4. Preserve user data and avoid project mutations during manual UI evidence + unless the user requested a data-changing workflow. + +For documentation, OpenSpec, or investigation-only tickets: + +1. Add the smallest durable note that future reviewers need. +2. Include links or relative paths to committed evidence. +3. Separate confirmed facts from assumptions and follow-ups. + +## Verify + +Use the repository scripts: + +```powershell +.\build.ps1 +.\test.ps1 +``` + +For targeted validation, run the smallest meaningful test set first, then widen +as risk increases. For native-only FieldWorks tests, use: + +```powershell +.\test.ps1 -SkipManaged -TestProject TestViews -StartedBy agent +``` + +For UI issues, verify with both the app screenshot evidence and the relevant +automated tests when feasible. + +## Review And Commit + +Review the diff as a code reviewer: + +- root cause addressed; +- evidence matches the issue claim; +- no unrelated refactors or generated churn; +- tests and manual evidence are named clearly; +- no secrets or private data in screenshots. + +Commit at sensible boundaries. Use an imperative subject and include the LT +ticket when the branch/task is ticket-specific, for example: + +```text +Fix LT-XXXXX: enable font options without Graphite +``` + +Do not stage unrelated files such as local tool state. + +## Pull Request And JIRA Update + +Create a PR with: + +- problem/request summary; +- root cause or design rationale; +- implementation summary; +- automated test results; +- manual evidence paths or uploaded screenshots/GIFs; +- known limitations or follow-ups. + +If JIRA write access is available, add a concise ticket comment with the PR link +and evidence summary. Do not transition to Done/Resolved unless the team process +or user explicitly asks. + +## Skill Composition + +- Use `atlassian-readonly-skills` to read issue details. +- Use `atlassian-skills` to assign, transition, or comment when requested. +- Use `fieldworks-winapp` for live FieldWorks reproduction and screenshots. +- Use `execute-implement` for implementation conventions. +- Use `verify-test` and `rubric-verify` for validation. +- Use `review` before finalizing changes or PR text. diff --git a/.github/skills/openspec-apply-change/SKILL.md b/.agents/skills/openspec-apply-change/SKILL.md similarity index 100% rename from .github/skills/openspec-apply-change/SKILL.md rename to .agents/skills/openspec-apply-change/SKILL.md diff --git a/.github/skills/openspec-archive-change/SKILL.md b/.agents/skills/openspec-archive-change/SKILL.md similarity index 100% rename from .github/skills/openspec-archive-change/SKILL.md rename to .agents/skills/openspec-archive-change/SKILL.md diff --git a/.github/skills/openspec-bulk-archive-change/SKILL.md b/.agents/skills/openspec-bulk-archive-change/SKILL.md similarity index 100% rename from .github/skills/openspec-bulk-archive-change/SKILL.md rename to .agents/skills/openspec-bulk-archive-change/SKILL.md diff --git a/.github/skills/openspec-continue-change/SKILL.md b/.agents/skills/openspec-continue-change/SKILL.md similarity index 100% rename from .github/skills/openspec-continue-change/SKILL.md rename to .agents/skills/openspec-continue-change/SKILL.md diff --git a/.github/skills/openspec-explore/SKILL.md b/.agents/skills/openspec-explore/SKILL.md similarity index 100% rename from .github/skills/openspec-explore/SKILL.md rename to .agents/skills/openspec-explore/SKILL.md diff --git a/.github/skills/openspec-ff-change/SKILL.md b/.agents/skills/openspec-ff-change/SKILL.md similarity index 100% rename from .github/skills/openspec-ff-change/SKILL.md rename to .agents/skills/openspec-ff-change/SKILL.md diff --git a/.github/skills/openspec-new-change/SKILL.md b/.agents/skills/openspec-new-change/SKILL.md similarity index 100% rename from .github/skills/openspec-new-change/SKILL.md rename to .agents/skills/openspec-new-change/SKILL.md diff --git a/.github/skills/openspec-onboard/SKILL.md b/.agents/skills/openspec-onboard/SKILL.md similarity index 100% rename from .github/skills/openspec-onboard/SKILL.md rename to .agents/skills/openspec-onboard/SKILL.md diff --git a/.github/skills/openspec-sync-specs/SKILL.md b/.agents/skills/openspec-sync-specs/SKILL.md similarity index 100% rename from .github/skills/openspec-sync-specs/SKILL.md rename to .agents/skills/openspec-sync-specs/SKILL.md diff --git a/.github/skills/openspec-verify-change/SKILL.md b/.agents/skills/openspec-verify-change/SKILL.md similarity index 100% rename from .github/skills/openspec-verify-change/SKILL.md rename to .agents/skills/openspec-verify-change/SKILL.md diff --git a/.github/skills/plan-design/SKILL.md b/.agents/skills/plan-design/SKILL.md similarity index 100% rename from .github/skills/plan-design/SKILL.md rename to .agents/skills/plan-design/SKILL.md diff --git a/.github/skills/powershell/SKILL.md b/.agents/skills/powershell/SKILL.md similarity index 100% rename from .github/skills/powershell/SKILL.md rename to .agents/skills/powershell/SKILL.md diff --git a/.github/skills/review/SKILL.md b/.agents/skills/review/SKILL.md similarity index 100% rename from .github/skills/review/SKILL.md rename to .agents/skills/review/SKILL.md diff --git a/.github/skills/rubric-verify/SKILL.md b/.agents/skills/rubric-verify/SKILL.md similarity index 100% rename from .github/skills/rubric-verify/SKILL.md rename to .agents/skills/rubric-verify/SKILL.md diff --git a/.github/skills/smart-screenshot-capture/SKILL.md b/.agents/skills/smart-screenshot-capture/SKILL.md similarity index 100% rename from .github/skills/smart-screenshot-capture/SKILL.md rename to .agents/skills/smart-screenshot-capture/SKILL.md diff --git a/.github/skills/verify-test/SKILL.md b/.agents/skills/verify-test/SKILL.md similarity index 100% rename from .github/skills/verify-test/SKILL.md rename to .agents/skills/verify-test/SKILL.md diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 6008ff8933..ae1eba0ecd 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -39,7 +39,7 @@ Short repo-level instructions for agents. - `.github/instructions/native.instructions.md` - `.github/instructions/installer.instructions.md` - `.github/rubrics/*.yaml` -- `.github/skills/rubric-verify/SKILL.md` +- `.agents/skills/rubric-verify/SKILL.md` - `Src/AGENTS.md` - `FLExInstaller/AGENTS.md` diff --git a/.github/skills/atlassian-readonly-skills/.env.example b/.github/skills/atlassian-readonly-skills/.env.example deleted file mode 100644 index f8c506218d..0000000000 --- a/.github/skills/atlassian-readonly-skills/.env.example +++ /dev/null @@ -1,78 +0,0 @@ -# ============================================ -# Atlassian Skills Configuration -# ============================================ -# Choose ONE authentication method per service: -# - Option 1: PAT Token (for Data Center/Server) -# - Option 2: Username + API Token (for Cloud) -# If both are provided, PAT Token takes precedence. - -# ============================================ -# JIRA CONFIGURATION -# ============================================ - -# Base URL for your Jira instance (REQUIRED) -# For SIL's JIRA (LT-* tickets): https://jira.sil.org -JIRA_URL=https://jira.sil.org - -# --- Option 1: Data Center / Server (PAT Token) --- -# Personal Access Token for Jira Data Center/Server -# For SIL JIRA, generate at: https://jira.sil.org/secure/ViewProfile.jspa → Personal Access Tokens -JIRA_PAT_TOKEN=your_jira_pat_token_here - -# --- Option 2: Cloud (API Token) --- -# Your Jira username (usually your email) -# JIRA_USERNAME=your.email@sil.org -# Your Jira API token -# Generate at: https://id.atlassian.com/manage-profile/security/api-tokens -# JIRA_API_TOKEN=your_jira_api_token_here - -# --- SSL Verification (OPTIONAL) --- -# Enable SSL certificate verification (default: false) -# Set to 'true', '1', or 'yes' to enable SSL verification -# JIRA_SSL_VERIFY=false - -# ============================================ -# CONFLUENCE CONFIGURATION -# ============================================ - -# Base URL for your Confluence instance (REQUIRED) -# For Cloud: https://your-company.atlassian.net/wiki -# For Data Center: https://confluence.your-company.com -CONFLUENCE_URL=https://your-company.atlassian.net/wiki - -# --- Option 1: Data Center / Server (PAT Token) --- -# Personal Access Token for Confluence Data Center/Server -# Generate at: Profile → Personal Access Tokens → Create token -# CONFLUENCE_PAT_TOKEN=your_confluence_pat_token_here - -# --- Option 2: Cloud (API Token) --- -# Your Confluence username (usually your email) -CONFLUENCE_USERNAME=your.email@company.com -# Your Confluence API token -# Generate at: https://id.atlassian.com/manage-profile/security/api-tokens -CONFLUENCE_API_TOKEN=your_confluence_api_token_here - -# --- SSL Verification (OPTIONAL) --- -# Enable SSL certificate verification (default: false) -# Set to 'true', '1', or 'yes' to enable SSL verification -# CONFLUENCE_SSL_VERIFY=false - -# ============================================ -# BITBUCKET CONFIGURATION -# ============================================ - -# Base URL for your Bitbucket Server/Data Center instance (REQUIRED) -BITBUCKET_URL=https://bitbucket.your-company.com - -# --- Option 1: Data Center / Server (PAT Token) --- -# Personal Access Token for Bitbucket Server/Data Center -# Generate at: Profile → Manage Account → Personal Access Tokens -# BITBUCKET_PAT_TOKEN=your_bitbucket_pat_token_here - -# --- Option 2: Basic Auth (Username + Password) --- -BITBUCKET_USERNAME=your.username -BITBUCKET_API_TOKEN=your_password_or_token - -# --- SSL Verification (OPTIONAL) --- -# Enable SSL certificate verification (default: false) -# BITBUCKET_SSL_VERIFY=false diff --git a/.github/skills/atlassian-readonly-skills/scripts/jira_issues.py b/.github/skills/atlassian-readonly-skills/scripts/jira_issues.py deleted file mode 100644 index 6c2e0af0f1..0000000000 --- a/.github/skills/atlassian-readonly-skills/scripts/jira_issues.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Jira issue management tools. - -Tools: - - jira_get_issue: Retrieve an issue by key -""" - -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent)) - -from typing import Any, Dict, Optional - -from _common import ( - AtlassianCredentials, - get_jira_client, - simplify_issue, - format_json_response, - format_error_response, - ConfigurationError, - AuthenticationError, - ValidationError, - NotFoundError, - APIError, - NetworkError, -) - - -def jira_get_issue( - issue_key: str, - fields: Optional[str] = None, - expand: Optional[str] = None, - credentials: Optional[AtlassianCredentials] = None -) -> str: - """Retrieve a Jira issue by key. - - Args: - issue_key: Issue key (e.g., 'PROJ-123') - fields: Comma-separated list of fields to return (optional) - expand: Comma-separated list of entities to expand (optional) - credentials: Optional AtlassianCredentials for Agent environments. - If not provided, uses environment variables. - - Returns: - JSON string with issue data or error information - """ - try: - client = get_jira_client(credentials) - - params: Dict[str, Any] = {} - if fields: - params['fields'] = fields - if expand: - params['expand'] = expand - - issue_data = client.get(client.api_path(f'issue/{issue_key}'), params=params) - simplified = simplify_issue(issue_data) - return format_json_response(simplified) - - except ConfigurationError as e: - return format_error_response('ConfigurationError', str(e)) - except AuthenticationError as e: - return format_error_response('AuthenticationError', str(e)) - except NotFoundError as e: - return format_error_response('NotFoundError', str(e)) - except (APIError, NetworkError) as e: - return format_error_response(type(e).__name__, str(e)) - except Exception as e: - return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') diff --git a/.github/skills/atlassian-skills/.env.example b/.github/skills/atlassian-skills/.env.example deleted file mode 100644 index f8c506218d..0000000000 --- a/.github/skills/atlassian-skills/.env.example +++ /dev/null @@ -1,78 +0,0 @@ -# ============================================ -# Atlassian Skills Configuration -# ============================================ -# Choose ONE authentication method per service: -# - Option 1: PAT Token (for Data Center/Server) -# - Option 2: Username + API Token (for Cloud) -# If both are provided, PAT Token takes precedence. - -# ============================================ -# JIRA CONFIGURATION -# ============================================ - -# Base URL for your Jira instance (REQUIRED) -# For SIL's JIRA (LT-* tickets): https://jira.sil.org -JIRA_URL=https://jira.sil.org - -# --- Option 1: Data Center / Server (PAT Token) --- -# Personal Access Token for Jira Data Center/Server -# For SIL JIRA, generate at: https://jira.sil.org/secure/ViewProfile.jspa → Personal Access Tokens -JIRA_PAT_TOKEN=your_jira_pat_token_here - -# --- Option 2: Cloud (API Token) --- -# Your Jira username (usually your email) -# JIRA_USERNAME=your.email@sil.org -# Your Jira API token -# Generate at: https://id.atlassian.com/manage-profile/security/api-tokens -# JIRA_API_TOKEN=your_jira_api_token_here - -# --- SSL Verification (OPTIONAL) --- -# Enable SSL certificate verification (default: false) -# Set to 'true', '1', or 'yes' to enable SSL verification -# JIRA_SSL_VERIFY=false - -# ============================================ -# CONFLUENCE CONFIGURATION -# ============================================ - -# Base URL for your Confluence instance (REQUIRED) -# For Cloud: https://your-company.atlassian.net/wiki -# For Data Center: https://confluence.your-company.com -CONFLUENCE_URL=https://your-company.atlassian.net/wiki - -# --- Option 1: Data Center / Server (PAT Token) --- -# Personal Access Token for Confluence Data Center/Server -# Generate at: Profile → Personal Access Tokens → Create token -# CONFLUENCE_PAT_TOKEN=your_confluence_pat_token_here - -# --- Option 2: Cloud (API Token) --- -# Your Confluence username (usually your email) -CONFLUENCE_USERNAME=your.email@company.com -# Your Confluence API token -# Generate at: https://id.atlassian.com/manage-profile/security/api-tokens -CONFLUENCE_API_TOKEN=your_confluence_api_token_here - -# --- SSL Verification (OPTIONAL) --- -# Enable SSL certificate verification (default: false) -# Set to 'true', '1', or 'yes' to enable SSL verification -# CONFLUENCE_SSL_VERIFY=false - -# ============================================ -# BITBUCKET CONFIGURATION -# ============================================ - -# Base URL for your Bitbucket Server/Data Center instance (REQUIRED) -BITBUCKET_URL=https://bitbucket.your-company.com - -# --- Option 1: Data Center / Server (PAT Token) --- -# Personal Access Token for Bitbucket Server/Data Center -# Generate at: Profile → Manage Account → Personal Access Tokens -# BITBUCKET_PAT_TOKEN=your_bitbucket_pat_token_here - -# --- Option 2: Basic Auth (Username + Password) --- -BITBUCKET_USERNAME=your.username -BITBUCKET_API_TOKEN=your_password_or_token - -# --- SSL Verification (OPTIONAL) --- -# Enable SSL certificate verification (default: false) -# BITBUCKET_SSL_VERIFY=false diff --git a/.github/skills/atlassian-skills/scripts/jira_issues.py b/.github/skills/atlassian-skills/scripts/jira_issues.py deleted file mode 100644 index c324305be3..0000000000 --- a/.github/skills/atlassian-skills/scripts/jira_issues.py +++ /dev/null @@ -1,332 +0,0 @@ -"""Jira issue management tools. - -Tools: - - jira_get_issue: Retrieve an issue by key - - jira_create_issue: Create a new issue - - jira_update_issue: Update an existing issue - - jira_delete_issue: Delete an issue - - jira_add_comment: Add a comment to an issue -""" - -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent)) - -from typing import Any, Dict, List, Optional - -from _common import ( - AtlassianCredentials, - get_jira_client, - simplify_issue, - format_json_response, - format_error_response, - ConfigurationError, - AuthenticationError, - ValidationError, - NotFoundError, - APIError, - NetworkError, -) - - -def jira_get_issue( - issue_key: str, - fields: Optional[str] = None, - expand: Optional[str] = None, - credentials: Optional[AtlassianCredentials] = None -) -> str: - """Retrieve a Jira issue by key. - - Args: - issue_key: Issue key (e.g., 'PROJ-123') - fields: Comma-separated list of fields to return (optional) - expand: Comma-separated list of entities to expand (optional) - credentials: Optional AtlassianCredentials for Agent environments. - If not provided, uses environment variables. - - Returns: - JSON string with issue data or error information - """ - try: - client = get_jira_client(credentials) - - params: Dict[str, Any] = {} - if fields: - params['fields'] = fields - if expand: - params['expand'] = expand - - issue_data = client.get(client.api_path(f'issue/{issue_key}'), params=params) - simplified = simplify_issue(issue_data) - return format_json_response(simplified) - - except ConfigurationError as e: - return format_error_response('ConfigurationError', str(e)) - except AuthenticationError as e: - return format_error_response('AuthenticationError', str(e)) - except NotFoundError as e: - return format_error_response('NotFoundError', str(e)) - except (APIError, NetworkError) as e: - return format_error_response(type(e).__name__, str(e)) - except Exception as e: - return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') - - -def jira_create_issue( - project_key: str, - summary: str, - issue_type: str, - description: Optional[str] = None, - assignee: Optional[str] = None, - priority: Optional[str] = None, - labels: Optional[List[str]] = None, - custom_fields: Optional[Dict[str, Any]] = None, - credentials: Optional[AtlassianCredentials] = None -) -> str: - """Create a new Jira issue. - - Args: - project_key: Project key (e.g., 'PROJ') - summary: Issue summary/title - issue_type: Issue type (e.g., 'Task', 'Bug', 'Story') - description: Issue description (optional) - assignee: Assignee account ID (optional) - priority: Priority name (e.g., 'High', 'Medium', 'Low') (optional) - labels: List of label strings (optional) - custom_fields: Dictionary of custom field IDs to values (optional) - e.g., {'customfield_10001': 'value', 'customfield_10002': 123} - credentials: Optional AtlassianCredentials for Agent environments. - If not provided, uses environment variables. - - Returns: - JSON string with created issue data or error information - """ - try: - client = get_jira_client(credentials) - - if not project_key: - raise ValidationError('project_key is required') - if not summary: - raise ValidationError('summary is required') - if not issue_type: - raise ValidationError('issue_type is required') - - fields: Dict[str, Any] = { - 'project': {'key': project_key}, - 'summary': summary, - 'issuetype': {'name': issue_type} - } - - if description: - fields['description'] = description - if assignee: - fields['assignee'] = {'accountId': assignee} - if priority: - fields['priority'] = {'name': priority} - if labels: - fields['labels'] = labels - if custom_fields: - fields.update(custom_fields) - - payload = {'fields': fields} - response = client.post(client.api_path('issue'), json=payload) - - issue_key = response.get('key') - if issue_key: - return jira_get_issue(issue_key, credentials=credentials) - return format_json_response(response) - - except ConfigurationError as e: - return format_error_response('ConfigurationError', str(e)) - except AuthenticationError as e: - return format_error_response('AuthenticationError', str(e)) - except ValidationError as e: - return format_error_response('ValidationError', str(e)) - except (APIError, NetworkError) as e: - return format_error_response(type(e).__name__, str(e)) - except Exception as e: - return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') - - -def jira_update_issue( - issue_key: str, - summary: Optional[str] = None, - description: Optional[str] = None, - assignee: Optional[str] = None, - priority: Optional[str] = None, - labels: Optional[List[str]] = None, - custom_fields: Optional[Dict[str, Any]] = None, - credentials: Optional[AtlassianCredentials] = None -) -> str: - """Update an existing Jira issue. - - Args: - issue_key: Issue key (e.g., 'PROJ-123') - summary: New summary/title (optional) - description: New description (optional) - assignee: New assignee account ID (optional) - priority: New priority name (optional) - labels: New list of labels (optional) - custom_fields: Dictionary of custom field IDs to values (optional) - e.g., {'customfield_10001': 'value', 'customfield_10002': 123} - credentials: Optional AtlassianCredentials for Agent environments. - If not provided, uses environment variables. - - Returns: - JSON string with updated issue data or error information - """ - try: - client = get_jira_client(credentials) - - if not issue_key: - raise ValidationError('issue_key is required') - - fields: Dict[str, Any] = {} - if summary is not None: - fields['summary'] = summary - if description is not None: - fields['description'] = description - if assignee is not None: - fields['assignee'] = {'accountId': assignee} - if priority is not None: - fields['priority'] = {'name': priority} - if labels is not None: - fields['labels'] = labels - if custom_fields is not None: - fields.update(custom_fields) - - if not fields: - raise ValidationError('At least one field to update is required') - - payload: Dict[str, Any] = {'fields': fields} - client.put(client.api_path(f'issue/{issue_key}'), json=payload) - - return jira_get_issue(issue_key, credentials=credentials) - - except ConfigurationError as e: - return format_error_response('ConfigurationError', str(e)) - except AuthenticationError as e: - return format_error_response('AuthenticationError', str(e)) - except ValidationError as e: - return format_error_response('ValidationError', str(e)) - except NotFoundError as e: - return format_error_response('NotFoundError', str(e)) - except (APIError, NetworkError) as e: - return format_error_response(type(e).__name__, str(e)) - except Exception as e: - return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') - - -def jira_delete_issue( - issue_key: str, - delete_subtasks: bool = False, - credentials: Optional[AtlassianCredentials] = None -) -> str: - """Delete a Jira issue. - - Args: - issue_key: Issue key (e.g., 'PROJ-123') - delete_subtasks: Whether to delete subtasks (default: False) - credentials: Optional AtlassianCredentials for Agent environments. - If not provided, uses environment variables. - - Returns: - JSON string with success message or error information - """ - try: - client = get_jira_client(credentials) - - if not issue_key: - raise ValidationError('issue_key is required') - - path = client.api_path(f'issue/{issue_key}') - if delete_subtasks: - path += '?deleteSubtasks=true' - - client.delete(path) - - return format_json_response({ - 'success': True, - 'message': f'Issue {issue_key} deleted successfully' - }) - - except ConfigurationError as e: - return format_error_response('ConfigurationError', str(e)) - except AuthenticationError as e: - return format_error_response('AuthenticationError', str(e)) - except ValidationError as e: - return format_error_response('ValidationError', str(e)) - except NotFoundError as e: - return format_error_response('NotFoundError', str(e)) - except (APIError, NetworkError) as e: - return format_error_response(type(e).__name__, str(e)) - except Exception as e: - return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') - - -def jira_add_comment( - issue_key: str, - comment: str, - credentials: Optional[AtlassianCredentials] = None -) -> str: - """Add a comment to a Jira issue. - - Args: - issue_key: Issue key (e.g., 'PROJ-123') - comment: Comment text - credentials: Optional AtlassianCredentials for Agent environments. - If not provided, uses environment variables. - - Returns: - JSON string with comment data or error information - """ - try: - client = get_jira_client(credentials) - - if not issue_key: - raise ValidationError('issue_key is required') - if not comment: - raise ValidationError('comment text is required') - - # Jira Cloud API v3 requires ADF (Atlassian Document Format) - if client.api_version == "3": - payload: Dict[str, Any] = { - 'body': { - 'type': 'doc', - 'version': 1, - 'content': [ - { - 'type': 'paragraph', - 'content': [ - {'type': 'text', 'text': comment} - ] - } - ] - } - } - else: - payload: Dict[str, Any] = {'body': comment} - response = client.post(client.api_path(f'issue/{issue_key}/comment'), json=payload) - - simplified = { - 'id': response.get('id', ''), - 'author': response.get('author', {}).get('emailAddress', ''), - 'body': response.get('body', ''), - 'created': response.get('created', ''), - 'updated': response.get('updated', '') - } - - return format_json_response(simplified) - - except ConfigurationError as e: - return format_error_response('ConfigurationError', str(e)) - except AuthenticationError as e: - return format_error_response('AuthenticationError', str(e)) - except ValidationError as e: - return format_error_response('ValidationError', str(e)) - except NotFoundError as e: - return format_error_response('NotFoundError', str(e)) - except (APIError, NetworkError) as e: - return format_error_response(type(e).__name__, str(e)) - except Exception as e: - return format_error_response('UnexpectedError', f'Unexpected error: {str(e)}') diff --git a/.github/skills/jira-bugfix/SKILL.md b/.github/skills/jira-bugfix/SKILL.md deleted file mode 100644 index 532b064490..0000000000 --- a/.github/skills/jira-bugfix/SKILL.md +++ /dev/null @@ -1,434 +0,0 @@ -```skill ---- -name: jira-bugfix -description: > - End-to-end JIRA bugfix workflow: fetch issue, assign, branch, - TDD, fix, test, update AGENTS.md, commit, PR, and update JIRA. - Use when the user says "fix LT-XXXXX" or references a JIRA bug - to resolve. -license: MIT -compatibility: Requires atlassian-skills, atlassian-readonly-skills. -metadata: - author: FieldWorks team - version: "1.0" ---- - -# JIRA Bugfix Workflow - -End-to-end skill for fixing bugs sourced from SIL's JIRA -(LT-prefixed tickets). Orchestrates the full lifecycle from -issue triage through PR creation and JIRA update. - -## When to Use - -Activate this skill when: -- The user says "fix LT-XXXXX" or "work on LT-XXXXX" -- The user references a JIRA bug they want resolved -- You are starting a bugfix session for a known defect - -## Announce the Plan - -Before starting any work, **always tell the user** the steps -you will follow, in order: - -> I will follow the JIRA bugfix workflow: -> -> 1. Fetch the issue details from JIRA -> 2. Ensure the issue is assigned to you and in progress -> 3. Verify or create a branch named after the ticket -> 4. Reproduce the bug with a failing test (TDD) -> 5. Implement the minimal fix to make the test pass -> 6. Assess whether additional test coverage is needed -> 7. Devil's advocate & code review -> 8. Check and update relevant AGENTS.md files -> 9. Commit, push, and create a PR -> 10. Update the JIRA ticket with a comment and PR link -> -> I will pause for your input at key decision points. - -Then proceed through the steps below. - -## Step 0: Fetch Issue from JIRA - -Use the `atlassian-readonly-skills` scripts to get the issue: - -```powershell -python -c "import sys; sys.path.insert(0, '.github/skills/atlassian-readonly-skills/scripts'); from jira_issues import jira_get_issue; print(jira_get_issue('LT-XXXXX'))" -``` - -Extract and present to the user: -- **Summary** and **description** -- **Status**, **priority**, **assignee** -- **Components** and **affected versions** -- **Comments** (recent ones may contain reproduction steps) - -If the issue is not a Bug type, note this and ask the user -whether to proceed with the bugfix workflow anyway. - -## Step 1: Assign and Transition to In Progress - -### Check current assignment - -If the issue is unassigned or assigned to someone else, ask -the user for their JIRA username and assign it: - -```powershell -# Assign the issue (Data Center uses 'name' field) -python -c " -import sys, json -sys.path.insert(0, '.github/skills/atlassian-skills/scripts') -from jira_issues import jira_update_issue -print(jira_update_issue('LT-XXXXX', assignee='username')) -" -``` - -> **Note**: SIL JIRA is Data Center, so the assignee field -> uses `name` (username string), not `accountId`. If the -> `jira_update_issue` call fails with assignee, fall back to -> setting it via `custom_fields`: -> `custom_fields={'assignee': {'name': 'username'}}` - -### Transition to In Progress - -```powershell -# Get available transitions -python -c " -import sys -sys.path.insert(0, '.github/skills/atlassian-readonly-skills/scripts') -from jira_workflow import jira_get_transitions -print(jira_get_transitions('LT-XXXXX')) -" - -# Transition (use the ID for "In Progress" from above) -python -c " -import sys -sys.path.insert(0, '.github/skills/atlassian-skills/scripts') -from jira_workflow import jira_transition_issue -print(jira_transition_issue('LT-XXXXX', 'TRANSITION_ID')) -" -``` - -If the issue is already In Progress, skip this step. - -## Step 2: Branch Management - -### Check current branch - -```powershell -git branch --show-current -``` - -**Decision tree:** - -1. If the current branch name contains the LT number - (e.g., `LT-22427`), you are already on the right branch. - Proceed. - -2. If not, **ask the user**: - - > The current branch is ``. This doesn't match - > LT-XXXXX. Options: - > - Create a new branch `LT-XXXXX` from `main` - > - Continue on the current branch - > - Switch to an existing branch (specify name) - - If creating a new branch: - ```powershell - git fetch origin - git checkout -b LT-XXXXX origin/main - ``` - -> **Important**: Do NOT create worktrees automatically. This -> repo uses worktrees but creating them involves workspace -> setup scripts. If a worktree is needed, tell the user to -> run the "Worktree: Create/Open from branch" VS Code task. - -## Step 3: Reproduce the Bug (TDD) - -**This is the most important step.** Default to test-driven -development: write a failing test that captures the bug -before writing any fix. - -### Process - -1. **Analyze the bug**: From the JIRA description and code - exploration, understand the root cause. - -2. **Find the right test file**: Locate existing tests for - the affected component. Follow the conventions in - `.github/instructions/testing.instructions.md`. - -3. **Write a failing test** that demonstrates the bug: - - Name it descriptively: - `MethodName_Scenario_ExpectedBehavior` - - The test should FAIL with the current code - - The test should PASS after the fix - -4. **Run the test** to confirm it fails: - ```powershell - .\test.ps1 -TestFilter "Name~TestMethodName" - ``` - -5. **If a test is impossible**, explain to the user WHY: - - The bug is purely visual/UI and untestable in NUnit - - The bug requires external services not available in - test harness - - The bug is in build/packaging infrastructure - - Then ask the user: - > I cannot write an automated test for this bug because - > [reason]. Would you like me to proceed with the fix - > anyway, or would you prefer to explore alternative - > verification approaches? - - Wait for confirmation before proceeding. - -## Step 4: Implement the Fix - -1. Apply the **minimal change** needed to make the failing - test pass. -2. Follow repo conventions: - - `.github/instructions/managed.instructions.md` for C# - - `.github/instructions/native.instructions.md` for C++ - - `.github/instructions/testing.instructions.md` for tests -3. Run the previously-failing test to confirm it passes: - ```powershell - .\test.ps1 -TestFilter "Name~TestMethodName" - ``` - -## Step 5: Assess Additional Test Coverage - -After the fix passes, evaluate whether additional tests are -needed. Ask yourself: - -- **Are there related edge cases** the fix might affect? -- **Are there other code paths** that use the same logic? -- **Is the existing test coverage** for this component - adequate? -- **Could a devil's advocate** argue the fix is incomplete? - -If gaps exist, add tests. Common high-value additions: -- Backward compatibility tests (old behavior still works) -- Isolation tests (fix doesn't leak to unrelated paths) -- Boundary/edge-case tests -- Tests for other call sites of modified code - -Run the full component tests: -```powershell -.\test.ps1 -TestProject "path/to/TestProject" -``` - -## Step 6: Devil's Advocate & Code Review - -Before finalizing, critically review your own work. Play -devil's advocate against the fix and the tests. - -### Challenge the fix - -Ask yourself and present findings to the user: - -- **Is this the best solution?** Are there simpler or more - robust alternatives? If multiple reasonable approaches - exist and the best choice is unclear, **present the - options to the user** with trade-offs and ask which they - prefer. -- **Does the fix introduce new risks?** Could it regress - other behavior, cause performance issues, or break - backward compatibility? -- **Is the scope right?** Is the fix too narrow (misses - related cases) or too broad (changes more than needed)? -- **Are there subtle edge cases** not covered by the tests? -- **Does the fix match the codebase style** and patterns? - Check naming, error handling, null safety, threading. - -### Clean code review - -Review the diff as if you were a code reviewer: - -- No dead code, commented-out code, or debug artifacts -- No unintended whitespace or formatting changes -- Method/variable names are clear and consistent -- Comments explain *why*, not *what* -- No accidental scope expansion beyond the bug fix -- Error paths are handled correctly - -### Decision point - -If the review surfaces **any uncertainties**, present them -to the user: - -> **Devil's advocate findings:** -> -> 1. [Finding and why it matters] -> 2. [Alternative approach and trade-offs] -> -> Would you like me to adjust the approach, or proceed -> as-is? - -Wait for the user's response before continuing. If no -uncertainties exist, briefly summarize why the fix is -solid and proceed. - -## Step 7: Update AGENTS.md - -Check whether the fix changes any contracts, behaviors, or -architecture documented in AGENTS.md files: - -1. Find relevant AGENTS.md files: - ```powershell - # Check for AGENTS.md in affected directories - Get-ChildItem -Path "Src/" -Filter "AGENTS.md" -Recurse - ``` - -2. If behavior or contracts changed, update the AGENTS.md - to reflect the new state. - -3. If no AGENTS.md exists for the affected area and the - change is architecturally significant, note this but - do not create one unless the user requests it. - -## Step 8: Commit and Push - -### Pre-commit checks - -```powershell -.\build.ps1 -.\test.ps1 -.\Build\Agent\check-and-fix-whitespace.ps1 -``` - -### Commit - -Follow `.github/instructions/commit-messages.instructions.md`: -- Subject: max 72 characters, imperative mood, no trailing - punctuation -- Body: wrap at 80 characters, explain what and why - -Pattern: -``` -Fix LT-XXXXX: - - - - - -``` - -### Push - -```powershell -git add -A -git commit -m "" -git push -u origin LT-XXXXX -``` - -If the branch already exists on the remote, just `git push`. - -## Step 9: Create a Pull Request - -Use the GitHub MCP tools (or the `mcp_github_create_pull_request` -tool) to create a PR: - -- **Title**: `Fix LT-XXXXX: ` -- **Base**: `main` (or the appropriate target branch) -- **Body** should include: - - Problem description (from JIRA) - - Root cause analysis - - Fix description with rationale - - Files changed table - - All tests added with descriptions - - Test results summary - - Design rationale for non-obvious choices - -Present the PR URL to the user. - -## Step 10: Update JIRA Ticket - -Add a comment to the JIRA ticket with the fix summary and -PR link: - -```powershell -python -c " -import sys -sys.path.insert(0, '.github/skills/atlassian-skills/scripts') -from jira_issues import jira_add_comment -comment = '''Fix implemented and PR created. - -*Root cause*: - -*Fix*: - -*PR*: https://github.com/sillsdev/FieldWorks/pull/NNN - -*Tests added*: -- : -- : -''' -print(jira_add_comment('LT-XXXXX', comment)) -" -``` - -Do NOT transition the ticket to "Done" or "Resolved" — that -happens after the PR is merged and verified. - -## IDE-Specific Notes - -### VS Code -- Use the terminal for all Python/git/build commands -- Use the GitHub MCP tools for PR creation -- Use the `mcp_github_*` tools for PR review operations - -### Visual Studio -- Use the Package Manager Console or Developer PowerShell - for Python/git commands -- The JIRA Python scripts work the same way -- For PR creation, use the GitHub CLI (`gh pr create`) or - the VS GitHub extension - -## Decision Points (Pause for User) - -This workflow pauses for user input at these points: -1. **Branch**: If current branch doesn't match the ticket -2. **Untestable bug**: If a failing test cannot be written -3. **Devil's advocate**: If the review surfaces - uncertainties or alternative approaches -4. **Scope expansion**: If the fix reveals larger issues - -All other steps proceed automatically. - -## Error Handling - -- **JIRA unreachable**: Ask user for issue details manually, - continue with the workflow, and update JIRA at the end -- **Tests fail after fix**: Present failures, ask user - whether to investigate or revert -- **Build fails**: Present errors, attempt to fix, or ask - user for guidance -- **Push rejected**: Pull with rebase, resolve conflicts, - retry - -## Integration with Other Skills - -This skill composes with: -- `atlassian-readonly-skills` — reading JIRA issues -- `atlassian-skills` — writing to JIRA (assign, comment) -- `session-workflow` — session management and handoff -- `execute-implement` — implementation conventions -- `verify-test` — test verification -- `rubric-verify` — weighted rubric scoring + hard-gate checks -- `review` — self-review before PR - -## Quick Reference - -``` -Skill chain: - jira-bugfix - ├── atlassian-readonly-skills (Step 0: read) - ├── atlassian-skills (Steps 1, 10: write) - ├── execute-implement (Step 4: fix) - ├── verify-test (Steps 3, 5: test) - ├── review (Step 6: devil's advocate) - ├── commit-messages.instructions (Step 8: commit) - └── mcp_github / gh CLI (Step 9: PR) -``` -``` diff --git a/AGENTS.md b/AGENTS.md index 340f53a961..a502c173e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ FieldWorks is built upon the `liblcm` (Language & Culture Model) repository, whi ## Issue tracking and Jira - Use GitHub issues/PRs (and Jira when required) for issue workflow. -- For `LT-` Jira tickets, use the Atlassian Python skill scripts under `.github/skills/atlassian-readonly-skills/scripts`. +- For `LT-` Jira tickets, use the Atlassian Python skill scripts under `.agents/skills/atlassian-readonly-skills/scripts`. - Do not attempt direct web access to Jira pages from agent tooling. - GitHub Copilot for Jira may be used with authenticated Jira Data Center access when the approved service-user/API policy limits which issues are exposed. - See `.github/copilot-jira-setup.md` for setup and secret guidance. diff --git a/openspec/config.yaml b/openspec/config.yaml index 8696e1a426..981e97f203 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -34,7 +34,7 @@ context: | ## Issue Tracking - Primary: GitHub issues/PRs - - Legacy: JIRA (LT-* tickets) via .github/skills/atlassian-* + - Legacy: JIRA (LT-* tickets) via .agents/skills/atlassian-* # Per-artifact rules rules: