jira2ai-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jira2ai_core-0.1.0/PKG-INFO +37 -0
- jira2ai_core-0.1.0/README.md +23 -0
- jira2ai_core-0.1.0/pyproject.toml +21 -0
- jira2ai_core-0.1.0/src/jira2ai_core/__init__.py +26 -0
- jira2ai_core-0.1.0/src/jira2ai_core/adf.py +167 -0
- jira2ai_core-0.1.0/src/jira2ai_core/client.py +18 -0
- jira2ai_core-0.1.0/src/jira2ai_core/errors.py +55 -0
- jira2ai_core-0.1.0/src/jira2ai_core/formatters.py +323 -0
- jira2ai_core-0.1.0/src/jira2ai_core/jql.py +162 -0
- jira2ai_core-0.1.0/src/jira2ai_core/models.py +218 -0
- jira2ai_core-0.1.0/src/jira2ai_core/operations/__init__.py +41 -0
- jira2ai_core-0.1.0/src/jira2ai_core/operations/attachments.py +174 -0
- jira2ai_core-0.1.0/src/jira2ai_core/operations/comments.py +94 -0
- jira2ai_core-0.1.0/src/jira2ai_core/operations/fields.py +98 -0
- jira2ai_core-0.1.0/src/jira2ai_core/operations/issues.py +192 -0
- jira2ai_core-0.1.0/src/jira2ai_core/operations/links.py +72 -0
- jira2ai_core-0.1.0/src/jira2ai_core/operations/projects.py +53 -0
- jira2ai_core-0.1.0/src/jira2ai_core/operations/search.py +47 -0
- jira2ai_core-0.1.0/src/jira2ai_core/operations/users.py +39 -0
- jira2ai_core-0.1.0/src/jira2ai_core/results.py +39 -0
- jira2ai_core-0.1.0/src/jira2ai_core/utils.py +60 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: jira2ai-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared core package for Jira AI integrations
|
|
5
|
+
Author: en-ver
|
|
6
|
+
Requires-Dist: httpx>=0.28.1
|
|
7
|
+
Requires-Dist: jira2py==0.5.0
|
|
8
|
+
Requires-Dist: marklassian>=0.1.0
|
|
9
|
+
Requires-Dist: pathvalidate>=3.3.1
|
|
10
|
+
Requires-Dist: pydantic>=2.12.5
|
|
11
|
+
Requires-Dist: pyadf>=0.3.0
|
|
12
|
+
Requires-Python: >=3.13
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# jira2ai-core
|
|
16
|
+
|
|
17
|
+
Shared Jira operations and formatting utilities used by the `jira2mcp` MCP adapter and the `jira2cli` CLI adapter.
|
|
18
|
+
|
|
19
|
+
This package is part of the workspace in this repository. It is not the end-user MCP entry point; for MCP installs, keep using `uvx jira2mcp`.
|
|
20
|
+
|
|
21
|
+
## Local development
|
|
22
|
+
|
|
23
|
+
From the workspace root:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv sync --all-packages --group dev
|
|
27
|
+
uv build --package jira2ai-core
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Maintainers
|
|
31
|
+
|
|
32
|
+
`jira2ai-core` has its own version and future tags use `jira2ai-core-vX.Y.Z`.
|
|
33
|
+
|
|
34
|
+
Release sequencing, stop gates, and Trusted Publishing boundaries:
|
|
35
|
+
|
|
36
|
+
- <https://github.com/en-ver/jira2ai/blob/main/docs/releasing.md>
|
|
37
|
+
- <https://github.com/en-ver/jira2ai/blob/main/CONTRIBUTING.md>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# jira2ai-core
|
|
2
|
+
|
|
3
|
+
Shared Jira operations and formatting utilities used by the `jira2mcp` MCP adapter and the `jira2cli` CLI adapter.
|
|
4
|
+
|
|
5
|
+
This package is part of the workspace in this repository. It is not the end-user MCP entry point; for MCP installs, keep using `uvx jira2mcp`.
|
|
6
|
+
|
|
7
|
+
## Local development
|
|
8
|
+
|
|
9
|
+
From the workspace root:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv sync --all-packages --group dev
|
|
13
|
+
uv build --package jira2ai-core
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Maintainers
|
|
17
|
+
|
|
18
|
+
`jira2ai-core` has its own version and future tags use `jira2ai-core-vX.Y.Z`.
|
|
19
|
+
|
|
20
|
+
Release sequencing, stop gates, and Trusted Publishing boundaries:
|
|
21
|
+
|
|
22
|
+
- <https://github.com/en-ver/jira2ai/blob/main/docs/releasing.md>
|
|
23
|
+
- <https://github.com/en-ver/jira2ai/blob/main/CONTRIBUTING.md>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jira2ai-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared core package for Jira AI integrations"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "en-ver" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx>=0.28.1",
|
|
12
|
+
"jira2py==0.5.0",
|
|
13
|
+
"marklassian>=0.1.0",
|
|
14
|
+
"pathvalidate>=3.3.1",
|
|
15
|
+
"pydantic>=2.12.5",
|
|
16
|
+
"pyadf>=0.3.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.10.4,<0.11.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Shared core package for Jira AI integrations."""
|
|
2
|
+
|
|
3
|
+
from .errors import (
|
|
4
|
+
AttachmentDownloadError,
|
|
5
|
+
AttachmentError,
|
|
6
|
+
AttachmentPathError,
|
|
7
|
+
Jira2AIConfigError,
|
|
8
|
+
Jira2AIError,
|
|
9
|
+
Jira2AIValidationError,
|
|
10
|
+
JiraOperationError,
|
|
11
|
+
)
|
|
12
|
+
from .results import OperationResult
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"__version__",
|
|
18
|
+
"AttachmentDownloadError",
|
|
19
|
+
"AttachmentError",
|
|
20
|
+
"AttachmentPathError",
|
|
21
|
+
"Jira2AIConfigError",
|
|
22
|
+
"Jira2AIError",
|
|
23
|
+
"Jira2AIValidationError",
|
|
24
|
+
"JiraOperationError",
|
|
25
|
+
"OperationResult",
|
|
26
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""ADF (Atlassian Document Format) conversion utilities.
|
|
2
|
+
|
|
3
|
+
Uses pyadf for ADF→Markdown (reading from Jira)
|
|
4
|
+
and marklassian for Markdown→ADF (writing to Jira).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from marklassian import AdfDocument, AdfNode
|
|
11
|
+
from marklassian import markdown_to_adf as _markdown_to_adf
|
|
12
|
+
from pyadf import Document
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def adf_to_markdown(adf: Any) -> str:
|
|
18
|
+
"""Convert ADF JSON to Markdown.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
adf: ADF document as a dictionary, or None.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Markdown string, or "(none)" if input is empty/invalid.
|
|
25
|
+
"""
|
|
26
|
+
if not adf or not isinstance(adf, dict):
|
|
27
|
+
return "(none)"
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
doc = Document(adf)
|
|
31
|
+
md = doc.to_markdown()
|
|
32
|
+
return md.strip() if md else "(none)"
|
|
33
|
+
except Exception:
|
|
34
|
+
logger.exception("ADF to Markdown conversion failed, using plain text fallback")
|
|
35
|
+
return _extract_text_fallback(adf)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def markdown_to_adf(markdown: str) -> AdfDocument:
|
|
39
|
+
"""Convert Markdown to ADF JSON.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
markdown: Markdown string.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
ADF document as a TypedDict.
|
|
46
|
+
"""
|
|
47
|
+
if not markdown or not markdown.strip():
|
|
48
|
+
return AdfDocument(type="doc", version=1, content=[])
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
return _markdown_to_adf(markdown)
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.exception("Markdown to ADF conversion failed, wrapping as plain text")
|
|
54
|
+
return AdfDocument(
|
|
55
|
+
type="doc",
|
|
56
|
+
version=1,
|
|
57
|
+
content=[
|
|
58
|
+
AdfNode(
|
|
59
|
+
type="paragraph",
|
|
60
|
+
content=[AdfNode(type="text", text=markdown)],
|
|
61
|
+
)
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- ADF field detection ---
|
|
67
|
+
|
|
68
|
+
# System fields known to use ADF format
|
|
69
|
+
_ADF_SYSTEM_FIELDS: set[str] = {"description", "environment"}
|
|
70
|
+
|
|
71
|
+
# Custom field schema suffix indicating ADF (rich-text paragraph)
|
|
72
|
+
_ADF_CUSTOM_SUFFIX = ":textarea"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_adf_value(value: Any) -> bool:
|
|
76
|
+
"""Check if a value looks like an ADF document.
|
|
77
|
+
|
|
78
|
+
ADF documents are dicts with ``{"type": "doc", "version": 1, "content": [...]}``.
|
|
79
|
+
"""
|
|
80
|
+
return isinstance(value, dict) and value.get("type") == "doc"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_adf_field(field_id: str, custom_schema: str = "") -> bool:
|
|
84
|
+
"""Check if a field is known to use ADF format.
|
|
85
|
+
|
|
86
|
+
Detection is based on:
|
|
87
|
+
- Known system fields (description, environment)
|
|
88
|
+
- Custom field schema containing ':textarea'
|
|
89
|
+
"""
|
|
90
|
+
if field_id in _ADF_SYSTEM_FIELDS:
|
|
91
|
+
return True
|
|
92
|
+
if custom_schema and _ADF_CUSTOM_SUFFIX in custom_schema:
|
|
93
|
+
return True
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def convert_adf_values(data: dict[str, Any]) -> dict[str, Any]:
|
|
98
|
+
"""Convert any ADF values in a dict to markdown strings.
|
|
99
|
+
|
|
100
|
+
Iterates over all values and converts those that look like ADF documents.
|
|
101
|
+
Non-ADF values are left unchanged.
|
|
102
|
+
"""
|
|
103
|
+
result = {}
|
|
104
|
+
for key, value in data.items():
|
|
105
|
+
if is_adf_value(value):
|
|
106
|
+
result[key] = adf_to_markdown(value)
|
|
107
|
+
else:
|
|
108
|
+
result[key] = value
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def detect_adf_field_ids(fields_metadata: list[dict[str, Any]]) -> set[str]:
|
|
113
|
+
"""Build a set of field IDs that use ADF format.
|
|
114
|
+
|
|
115
|
+
Examines field metadata from the Jira fields API and identifies
|
|
116
|
+
fields that use ADF based on known system fields and custom textarea schema.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
fields_metadata: List of field dicts from ``api.fields.get_fields()``.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Set of field IDs that expect ADF format.
|
|
123
|
+
"""
|
|
124
|
+
adf_ids = set(_ADF_SYSTEM_FIELDS)
|
|
125
|
+
for field in fields_metadata:
|
|
126
|
+
field_id = field.get("id", "")
|
|
127
|
+
schema = field.get("schema", {}) or {}
|
|
128
|
+
custom = schema.get("custom", "")
|
|
129
|
+
if custom and _ADF_CUSTOM_SUFFIX in custom:
|
|
130
|
+
adf_ids.add(field_id)
|
|
131
|
+
return adf_ids
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def convert_markdown_fields(
|
|
135
|
+
fields: dict[str, Any],
|
|
136
|
+
adf_field_ids: set[str],
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Convert markdown string values to ADF for known ADF fields.
|
|
139
|
+
|
|
140
|
+
Only converts values that are plain strings and whose field ID
|
|
141
|
+
is in the provided set of ADF field IDs.
|
|
142
|
+
"""
|
|
143
|
+
result = {}
|
|
144
|
+
for key, value in fields.items():
|
|
145
|
+
if key in adf_field_ids and isinstance(value, str):
|
|
146
|
+
result[key] = markdown_to_adf(value)
|
|
147
|
+
else:
|
|
148
|
+
result[key] = value
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _extract_text_fallback(adf: dict[str, Any]) -> str:
|
|
153
|
+
"""Extract plain text from ADF as a last resort."""
|
|
154
|
+
texts: list[str] = []
|
|
155
|
+
|
|
156
|
+
def walk(node: Any) -> None:
|
|
157
|
+
if isinstance(node, dict):
|
|
158
|
+
if node.get("type") == "text" and "text" in node:
|
|
159
|
+
texts.append(node["text"])
|
|
160
|
+
for child in node.get("content", []):
|
|
161
|
+
walk(child)
|
|
162
|
+
elif isinstance(node, list):
|
|
163
|
+
for item in node:
|
|
164
|
+
walk(item)
|
|
165
|
+
|
|
166
|
+
walk(adf)
|
|
167
|
+
return " ".join(texts).strip() or "(none)"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Client helpers for Jira AI integrations."""
|
|
2
|
+
|
|
3
|
+
from jira2py import JiraAPI
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_api() -> JiraAPI:
|
|
7
|
+
"""Create a JiraAPI instance.
|
|
8
|
+
|
|
9
|
+
Credentials are resolved by jira2py from environment variables:
|
|
10
|
+
JIRA_URL, JIRA_USER, and JIRA_API_TOKEN.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Configured JiraAPI instance.
|
|
14
|
+
|
|
15
|
+
Raises:
|
|
16
|
+
ValueError: If any required credential is missing.
|
|
17
|
+
"""
|
|
18
|
+
return JiraAPI()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Adapter-neutral error contracts for shared Jira operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Jira2AIError(Exception):
|
|
10
|
+
"""Base error for shared Jira AI core logic."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
message: str,
|
|
15
|
+
*,
|
|
16
|
+
details: Mapping[str, Any] | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.message = message
|
|
20
|
+
self.details = dict(details or {})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Jira2AIValidationError(Jira2AIError):
|
|
24
|
+
"""Raised when tool or operation input is invalid."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Jira2AIConfigError(Jira2AIError):
|
|
28
|
+
"""Raised when required Jira/client configuration is missing or invalid."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JiraOperationError(Jira2AIError):
|
|
32
|
+
"""Raised when a Jira API operation fails."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AttachmentError(Jira2AIError):
|
|
36
|
+
"""Base error for attachment-related failures."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AttachmentPathError(AttachmentError):
|
|
40
|
+
"""Raised when an attachment path is unsafe or outside allowed boundaries."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AttachmentDownloadError(AttachmentError):
|
|
44
|
+
"""Raised when an attachment download fails."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"AttachmentDownloadError",
|
|
49
|
+
"AttachmentError",
|
|
50
|
+
"AttachmentPathError",
|
|
51
|
+
"Jira2AIConfigError",
|
|
52
|
+
"Jira2AIError",
|
|
53
|
+
"Jira2AIValidationError",
|
|
54
|
+
"JiraOperationError",
|
|
55
|
+
]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Output formatting functions for Jira data."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .adf import adf_to_markdown, is_adf_value
|
|
7
|
+
from .models import (
|
|
8
|
+
FieldMeta,
|
|
9
|
+
IssueType,
|
|
10
|
+
JiraComment,
|
|
11
|
+
JiraIssue,
|
|
12
|
+
SearchResult,
|
|
13
|
+
user_display,
|
|
14
|
+
)
|
|
15
|
+
from .utils import format_date, format_size
|
|
16
|
+
|
|
17
|
+
DEFAULT_FIELDS = [
|
|
18
|
+
"summary",
|
|
19
|
+
"status",
|
|
20
|
+
"issuetype",
|
|
21
|
+
"priority",
|
|
22
|
+
"assignee",
|
|
23
|
+
"reporter",
|
|
24
|
+
"created",
|
|
25
|
+
"updated",
|
|
26
|
+
"labels",
|
|
27
|
+
"components",
|
|
28
|
+
"fixVersions",
|
|
29
|
+
"description",
|
|
30
|
+
"comment",
|
|
31
|
+
"attachment",
|
|
32
|
+
"subtasks",
|
|
33
|
+
"issuelinks",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Fields that format_issue_full knows how to render as markdown.
|
|
37
|
+
_FORMATTED_FIELDS = set(DEFAULT_FIELDS)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _section(title: str) -> str:
|
|
41
|
+
"""Return a decorated section heading: ``--- [TITLE] ---``."""
|
|
42
|
+
return f"--- [{title.upper()}] ---"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _field_label(field_id: str, names_map: dict[str, str]) -> str:
|
|
46
|
+
"""Return a display label for a field: 'Display Name (field_id)' or just field_id."""
|
|
47
|
+
display = names_map.get(field_id)
|
|
48
|
+
if display and display != field_id:
|
|
49
|
+
return f"{display} ({field_id})"
|
|
50
|
+
return field_id
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def format_issue_full(
|
|
54
|
+
issue: JiraIssue,
|
|
55
|
+
*,
|
|
56
|
+
url: str = "",
|
|
57
|
+
requested_fields: list[str] | None = None,
|
|
58
|
+
field_names: dict[str, str] | None = None,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Format a full issue for display.
|
|
61
|
+
|
|
62
|
+
Standard fields are rendered as readable markdown. Any extra fields
|
|
63
|
+
(requested but not in the standard set) are appended with display names.
|
|
64
|
+
ADF rich-text fields are auto-converted to markdown; others shown as JSON.
|
|
65
|
+
Comments are shown as a count only — use jira_comments for details.
|
|
66
|
+
"""
|
|
67
|
+
f = issue.fields
|
|
68
|
+
|
|
69
|
+
lines: list[str] = [
|
|
70
|
+
f"Key: {issue.key}",
|
|
71
|
+
f"Summary: {f.summary}",
|
|
72
|
+
f"Status: {_named(f.status)}",
|
|
73
|
+
f"Type: {_named(f.issuetype)}",
|
|
74
|
+
f"Priority: {_named(f.priority)}",
|
|
75
|
+
f"Assignee: {user_display(f.assignee)}",
|
|
76
|
+
f"Reporter: {user_display(f.reporter)}",
|
|
77
|
+
f"Created: {format_date(f.created)}",
|
|
78
|
+
f"Updated: {format_date(f.updated)}",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
if f.labels:
|
|
82
|
+
lines.append(f"Labels: {', '.join(f.labels)}")
|
|
83
|
+
|
|
84
|
+
if f.components:
|
|
85
|
+
lines.append(f"Components: {', '.join(c.name for c in f.components)}")
|
|
86
|
+
|
|
87
|
+
if f.fixVersions:
|
|
88
|
+
lines.append(f"Fix Versions: {', '.join(v.name for v in f.fixVersions)}")
|
|
89
|
+
|
|
90
|
+
if url:
|
|
91
|
+
lines.append(f"URL: {url}")
|
|
92
|
+
|
|
93
|
+
# Comments — count in header, direct to jira_comments tool for details
|
|
94
|
+
cp = f.comment
|
|
95
|
+
total = cp.total if cp else 0
|
|
96
|
+
if total > 0:
|
|
97
|
+
lines.append(f"Comments: {total} (use jira_comments tool to read them)")
|
|
98
|
+
else:
|
|
99
|
+
lines.append("Comments: none")
|
|
100
|
+
|
|
101
|
+
# Attachments
|
|
102
|
+
if f.attachment:
|
|
103
|
+
lines.append("")
|
|
104
|
+
lines.append(_section(f"Attachments ({len(f.attachment)})"))
|
|
105
|
+
lines.append("Use jira_attachment tool with the attachment id to download")
|
|
106
|
+
for att in f.attachment:
|
|
107
|
+
lines.append(
|
|
108
|
+
f"- {att.filename or '?'} (id: {att.id}, {att.mimeType}, {format_size(att.size)})"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Subtasks
|
|
112
|
+
if f.subtasks:
|
|
113
|
+
lines.append("")
|
|
114
|
+
lines.append(_section(f"Subtasks ({len(f.subtasks)})"))
|
|
115
|
+
for st in f.subtasks:
|
|
116
|
+
status = _named(st.fields.status)
|
|
117
|
+
lines.append(f"- {st.key}: {st.fields.summary} [{status}]")
|
|
118
|
+
|
|
119
|
+
# Issue Links
|
|
120
|
+
if f.issuelinks:
|
|
121
|
+
lines.append("")
|
|
122
|
+
lines.append(_section(f"Issue Links ({len(f.issuelinks)})"))
|
|
123
|
+
for link in f.issuelinks:
|
|
124
|
+
if link.outwardIssue:
|
|
125
|
+
target = link.outwardIssue
|
|
126
|
+
direction = link.type.outward
|
|
127
|
+
elif link.inwardIssue:
|
|
128
|
+
target = link.inwardIssue
|
|
129
|
+
direction = link.type.inward
|
|
130
|
+
else:
|
|
131
|
+
continue
|
|
132
|
+
status = _named(target.fields.status)
|
|
133
|
+
lines.append(
|
|
134
|
+
f"- {direction} {target.key}: {target.fields.summary} [{status}] (link id: {link.id})"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Description
|
|
138
|
+
lines.append("")
|
|
139
|
+
lines.append(_section("Description"))
|
|
140
|
+
lines.append(adf_to_markdown(f.description))
|
|
141
|
+
|
|
142
|
+
# Extra fields — anything requested but not in the standard formatted set
|
|
143
|
+
if requested_fields:
|
|
144
|
+
extra_names = [f for f in requested_fields if f not in _FORMATTED_FIELDS]
|
|
145
|
+
if extra_names:
|
|
146
|
+
extra_data: dict[str, Any] = {}
|
|
147
|
+
raw_fields = issue.fields.model_extra or {}
|
|
148
|
+
# Also check declared attributes in case of overlap
|
|
149
|
+
for name in extra_names:
|
|
150
|
+
if name in raw_fields:
|
|
151
|
+
extra_data[name] = raw_fields[name]
|
|
152
|
+
elif hasattr(issue.fields, name):
|
|
153
|
+
val = getattr(issue.fields, name)
|
|
154
|
+
if val is not None:
|
|
155
|
+
extra_data[name] = val
|
|
156
|
+
if extra_data:
|
|
157
|
+
names_map = field_names or {}
|
|
158
|
+
lines.append("")
|
|
159
|
+
lines.append(_section("Additional Fields"))
|
|
160
|
+
# Separate ADF fields (render as markdown) from plain fields (JSON)
|
|
161
|
+
adf_extra: dict[str, Any] = {}
|
|
162
|
+
plain_fields: dict[str, Any] = {}
|
|
163
|
+
for k, v in extra_data.items():
|
|
164
|
+
if is_adf_value(v):
|
|
165
|
+
adf_extra[k] = v
|
|
166
|
+
else:
|
|
167
|
+
plain_fields[k] = v
|
|
168
|
+
for k, v in adf_extra.items():
|
|
169
|
+
label = _field_label(k, names_map)
|
|
170
|
+
lines.append(_section(label))
|
|
171
|
+
lines.append(adf_to_markdown(v))
|
|
172
|
+
lines.append("")
|
|
173
|
+
if plain_fields:
|
|
174
|
+
# Remap keys to include display names
|
|
175
|
+
labeled = {
|
|
176
|
+
_field_label(k, names_map): v for k, v in plain_fields.items()
|
|
177
|
+
}
|
|
178
|
+
lines.append("```json")
|
|
179
|
+
lines.append(json.dumps(labeled, indent=2, default=str))
|
|
180
|
+
lines.append("```")
|
|
181
|
+
|
|
182
|
+
return "\n".join(lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def format_comment(comment: JiraComment) -> str:
|
|
186
|
+
"""Format a single comment.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
comment: Parsed Jira comment.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Formatted comment string.
|
|
193
|
+
"""
|
|
194
|
+
author = user_display(comment.author)
|
|
195
|
+
created = format_date(comment.created)
|
|
196
|
+
updated = format_date(comment.updated)
|
|
197
|
+
body = adf_to_markdown(comment.body)
|
|
198
|
+
|
|
199
|
+
date_str = created
|
|
200
|
+
if updated != created:
|
|
201
|
+
date_str += f" (edited {updated})"
|
|
202
|
+
|
|
203
|
+
return f"### {author} — {date_str}\n{body}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def format_search_results(result: SearchResult, jql: str = "") -> str:
|
|
207
|
+
"""Format search results as a compact list.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
result: Parsed search response.
|
|
211
|
+
jql: The JQL query that produced these results.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Formatted results string.
|
|
215
|
+
"""
|
|
216
|
+
if not result.issues:
|
|
217
|
+
return f"No issues found for JQL: {jql}" if jql else "No issues found."
|
|
218
|
+
|
|
219
|
+
lines: list[str] = []
|
|
220
|
+
for issue in result.issues:
|
|
221
|
+
f = issue.fields
|
|
222
|
+
status = _named(f.status)
|
|
223
|
+
lines.append(
|
|
224
|
+
f"{issue.key} — {f.summary} [{status}] ({user_display(f.assignee)})"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
output = f"Found {len(result.issues)} issue(s)\n\n" + "\n".join(lines)
|
|
228
|
+
|
|
229
|
+
if result.nextPageToken:
|
|
230
|
+
output += "\n\n(more results available — refine JQL or increase max_results)"
|
|
231
|
+
|
|
232
|
+
return output
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def format_issue_type_list(project_key: str, issue_types: list[IssueType]) -> str:
|
|
236
|
+
"""Format a list of issue types for display.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
project_key: Project key.
|
|
240
|
+
issue_types: Parsed issue type list.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Formatted string.
|
|
244
|
+
"""
|
|
245
|
+
if not issue_types:
|
|
246
|
+
return f"No issue types found for project {project_key}"
|
|
247
|
+
lines = [f"Issue types for {project_key}:\n"]
|
|
248
|
+
for it in issue_types:
|
|
249
|
+
subtask = " (subtask)" if it.subtask else ""
|
|
250
|
+
lines.append(f" • {it.name} (id: {it.id}){subtask}")
|
|
251
|
+
return "\n".join(lines)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def format_field_metadata(
|
|
255
|
+
project_key: str, type_name: str, fields: list[FieldMeta]
|
|
256
|
+
) -> str:
|
|
257
|
+
"""Format field metadata for display.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
project_key: Project key or issue key.
|
|
261
|
+
type_name: Issue type name or "edit".
|
|
262
|
+
fields: Parsed field metadata list.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Formatted string with required and optional fields.
|
|
266
|
+
"""
|
|
267
|
+
if not fields:
|
|
268
|
+
return f"No fields found for {project_key} / {type_name}"
|
|
269
|
+
|
|
270
|
+
required = [f for f in fields if f.required]
|
|
271
|
+
optional = [f for f in fields if not f.required]
|
|
272
|
+
|
|
273
|
+
lines = [f"Fields for {project_key} / {type_name}:\n"]
|
|
274
|
+
|
|
275
|
+
if required:
|
|
276
|
+
lines.append("Required:")
|
|
277
|
+
for f in required:
|
|
278
|
+
lines.extend(_format_field(f))
|
|
279
|
+
|
|
280
|
+
if optional:
|
|
281
|
+
lines.append("")
|
|
282
|
+
lines.append("Optional:")
|
|
283
|
+
for f in optional:
|
|
284
|
+
lines.extend(_format_field(f))
|
|
285
|
+
|
|
286
|
+
return "\n".join(lines)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _named(resource: Any) -> str:
|
|
290
|
+
"""Extract name from an optional NamedResource."""
|
|
291
|
+
return resource.name if resource else "—"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _format_field(f: FieldMeta) -> list[str]:
|
|
295
|
+
"""Format a single field's metadata."""
|
|
296
|
+
lines: list[str] = []
|
|
297
|
+
schema_type = f.schema.type if f.schema else "unknown"
|
|
298
|
+
custom = f.schema.custom if f.schema else ""
|
|
299
|
+
custom_suffix = f" ({custom.split(':')[-1]})" if custom else ""
|
|
300
|
+
lines.append(f' {f.resolved_id} "{f.name}" — {schema_type}{custom_suffix}')
|
|
301
|
+
|
|
302
|
+
if f.allowedValues:
|
|
303
|
+
values = []
|
|
304
|
+
for v in f.allowedValues[:30]:
|
|
305
|
+
if isinstance(v, dict):
|
|
306
|
+
values.append(v.get("name", v.get("value", json.dumps(v))))
|
|
307
|
+
else:
|
|
308
|
+
values.append(str(v))
|
|
309
|
+
suffix = (
|
|
310
|
+
f", ... ({len(f.allowedValues)} total)" if len(f.allowedValues) > 30 else ""
|
|
311
|
+
)
|
|
312
|
+
lines.append(f" Allowed values: {', '.join(values)}{suffix}")
|
|
313
|
+
|
|
314
|
+
if f.defaultValue is not None:
|
|
315
|
+
if isinstance(f.defaultValue, dict):
|
|
316
|
+
dv = f.defaultValue.get(
|
|
317
|
+
"name", f.defaultValue.get("value", json.dumps(f.defaultValue))
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
dv = str(f.defaultValue)
|
|
321
|
+
lines.append(f" Default: {dv}")
|
|
322
|
+
|
|
323
|
+
return lines
|