Skip to content

Commit 521a67f

Browse files
yechielb2000Yechiel Babani
andauthored
Add type-safe Jira issue models with fluent builders (#1634)
* Add type-safe Jira issue models with fluent builders Introduce a new `atlassian.models.jira` package that eliminates manual JSON/dictionary construction for Jira operations in favor of typed dataclasses, fluent builders, and a centralized serializer. Core modules: - fields.py: frozen value-object dataclasses (Project, Priority, User, etc.) with to_dict()/from_dict() for (de)serialization - issues.py: JiraIssue base + Task/Bug/Story/Epic/SubTask with __init_subclass__ auto-registry - builders.py: generic IssueBuilder[T] with per-type builders, ADF bridge pattern, and .validate() chaining - serializer.py: FieldMapping + serialize()/to_fields_dict()/bulk_serialize() - adf.py: ADFBuilder for Atlassian Document Format rich-text descriptions - validation.py: pre-flight validate()/validate_or_raise() - update.py: UpdateBuilder for issue_update with set/add/remove operations - transition.py: Transition model for set_issue_status() - comment.py: Comment + Visibility for issue_add_comment() Also adds py.typed marker, convenience import alias (atlassian.jira_models), and 86 tests covering all modules. * [Jira] Fix static analysis issues and apply black formatting - Remove unused imports (CustomField in serializer.py, Any in validation.py) - Reduce cyclomatic complexity of serialize() and IssueFields.from_dict() by extracting helper functions - Apply black formatter (line-length=120, target py39/py310/py311) - All flake8 checks pass, all 86 tests pass * [Jira] Fix code duplication, pylint issues, and Codacy compliance - Eliminate code duplication in fields.py: extract _NameIdEntity and _KeyIdEntity base classes for 6 near-identical value-object dataclasses - Eliminate duplication in update.py: extract _entity_op and _set_entity_list helpers for repetitive add/remove/set methods - Fix W0622: rename id params to id_ in builders.py and update.py - Fix D401: reword to_fields_dict docstring to imperative mood - Fix D212: all multi-line docstrings use summary-on-first-line style - Fix D105: all magic methods (__post_init__, __init__, etc.) have docstrings - Fix D416: all section headers end with colon - Fix R0913: reduce _entity_op args from 6 to 4 - Suppress R0902 on IssueFields (18 attrs inherent to Jira data model) - Suppress R0904 on IssueBuilder/UpdateBuilder (fluent builder pattern) - Suppress C0415 on circular import workaround in IssueFields.from_dict - All pylint scores 10.00/10, black/flake8/bandit clean, 86 tests pass Note: D203 (blank line before class docstring) conflicts with Black formatter which is enforced in project CI. Black removes these blank lines (D211 style). This is an unresolvable Codacy/Black conflict. * [Jira] Fix duplication, docstring sections, and simplify test imports - Deduplicate bullet_list/ordered_list in adf.py via shared _list_node - Fix D407: use rST code-block syntax (::) for Example/Usage sections in update.py, comment.py, and transition.py - Simplify test imports: use unified atlassian.models.jira package instead of importing from individual submodules --------- Co-authored-by: Yechiel Babani <yechielbabani@users.noreply.github.com>
1 parent 40beeb8 commit 521a67f

14 files changed

Lines changed: 2165 additions & 0 deletions

File tree

atlassian/jira_models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Convenience alias for atlassian.models.jira.
2+
3+
Allows shorter imports:
4+
from atlassian.jira_models import task, serialize
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from atlassian.models.jira import * # noqa: F401,F403
10+
from atlassian.models.jira import __all__ # noqa: F401

atlassian/models/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Atlassian data models."""
2+
3+
from __future__ import annotations
4+
5+
from atlassian.models.jira import * # noqa: F401,F403

atlassian/models/jira/__init__.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Type-safe Jira issue models with fluent builders.
2+
3+
Quick start:
4+
from atlassian.models.jira import task, serialize
5+
6+
issue = (
7+
task()
8+
.project("PROJ")
9+
.summary("My task")
10+
.priority("High")
11+
.build()
12+
)
13+
jira.issue_create(fields=serialize(issue)["fields"])
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from atlassian.models.jira.adf import ADFBuilder, InlineNode, MentionNode, TextNode
19+
from atlassian.models.jira.builders import (
20+
EpicBuilder,
21+
IssueBuilder,
22+
StoryBuilder,
23+
SubTaskBuilder,
24+
TaskBuilder,
25+
BugBuilder,
26+
bug,
27+
epic,
28+
story,
29+
subtask,
30+
task,
31+
)
32+
from atlassian.models.jira.fields import (
33+
Component,
34+
CustomField,
35+
IssueFields,
36+
IssueLink,
37+
IssueType,
38+
Parent,
39+
Priority,
40+
PriorityLevel,
41+
Project,
42+
User,
43+
Version,
44+
)
45+
from atlassian.models.jira.issues import (
46+
Bug,
47+
Epic,
48+
JiraIssue,
49+
Story,
50+
SubTask,
51+
Task,
52+
get_issue_type_registry,
53+
issue_type_for,
54+
)
55+
from atlassian.models.jira.comment import Comment, Visibility
56+
from atlassian.models.jira.serializer import FieldMapping, bulk_serialize, serialize, to_fields_dict
57+
from atlassian.models.jira.transition import Transition, TransitionBuilder
58+
from atlassian.models.jira.update import UpdateBuilder, UpdatePayload
59+
from atlassian.models.jira.validation import ValidationError, validate, validate_or_raise
60+
61+
__all__ = [
62+
"ADFBuilder",
63+
"Bug",
64+
"BugBuilder",
65+
"Comment",
66+
"Component",
67+
"CustomField",
68+
"Epic",
69+
"EpicBuilder",
70+
"FieldMapping",
71+
"InlineNode",
72+
"IssueBuilder",
73+
"IssueFields",
74+
"IssueLink",
75+
"IssueType",
76+
"JiraIssue",
77+
"MentionNode",
78+
"Parent",
79+
"Priority",
80+
"PriorityLevel",
81+
"Project",
82+
"Story",
83+
"StoryBuilder",
84+
"SubTask",
85+
"SubTaskBuilder",
86+
"Task",
87+
"TaskBuilder",
88+
"TextNode",
89+
"Transition",
90+
"TransitionBuilder",
91+
"UpdateBuilder",
92+
"UpdatePayload",
93+
"User",
94+
"ValidationError",
95+
"Version",
96+
"Visibility",
97+
"bug",
98+
"bulk_serialize",
99+
"epic",
100+
"get_issue_type_registry",
101+
"issue_type_for",
102+
"serialize",
103+
"story",
104+
"subtask",
105+
"task",
106+
"to_fields_dict",
107+
"validate",
108+
"validate_or_raise",
109+
]

atlassian/models/jira/adf.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from typing import Any, Optional, Union
5+
6+
7+
@dataclass
8+
class TextNode:
9+
text: str
10+
marks: list[dict[str, Any]] = field(default_factory=list)
11+
12+
def bold(self) -> TextNode:
13+
self.marks.append({"type": "strong"})
14+
return self
15+
16+
def italic(self) -> TextNode:
17+
self.marks.append({"type": "em"})
18+
return self
19+
20+
def code(self) -> TextNode:
21+
self.marks.append({"type": "code"})
22+
return self
23+
24+
def link(self, href: str) -> TextNode:
25+
self.marks.append({"type": "link", "attrs": {"href": href}})
26+
return self
27+
28+
def strike(self) -> TextNode:
29+
self.marks.append({"type": "strike"})
30+
return self
31+
32+
def to_dict(self) -> dict[str, Any]:
33+
node: dict[str, Any] = {"type": "text", "text": self.text}
34+
if self.marks:
35+
node["marks"] = [dict(m) for m in self.marks]
36+
return node
37+
38+
39+
@dataclass(frozen=True)
40+
class MentionNode:
41+
account_id: str
42+
display_text: str = ""
43+
44+
def to_dict(self) -> dict[str, Any]:
45+
return {
46+
"type": "mention",
47+
"attrs": {"id": self.account_id, "text": self.display_text},
48+
}
49+
50+
51+
InlineNode = Union[TextNode, MentionNode]
52+
53+
54+
class ADFBuilder:
55+
"""Fluent builder that produces an Atlassian Document Format dict."""
56+
57+
def __init__(self) -> None:
58+
"""Initialize an empty ADF document builder."""
59+
self._content: list[dict[str, Any]] = []
60+
61+
def paragraph(self, *nodes: InlineNode) -> ADFBuilder:
62+
self._content.append(
63+
{
64+
"type": "paragraph",
65+
"content": [n.to_dict() for n in nodes],
66+
}
67+
)
68+
return self
69+
70+
def text_paragraph(self, text: str) -> ADFBuilder:
71+
return self.paragraph(TextNode(text))
72+
73+
def heading(self, text: str, level: int = 1) -> ADFBuilder:
74+
if level not in range(1, 7):
75+
raise ValueError(f"Heading level must be 1-6, got {level}")
76+
self._content.append(
77+
{
78+
"type": "heading",
79+
"attrs": {"level": level},
80+
"content": [{"type": "text", "text": text}],
81+
}
82+
)
83+
return self
84+
85+
def _list_node(self, list_type: str, items: list[str]) -> ADFBuilder:
86+
list_items = [
87+
{
88+
"type": "listItem",
89+
"content": [
90+
{
91+
"type": "paragraph",
92+
"content": [{"type": "text", "text": item}],
93+
}
94+
],
95+
}
96+
for item in items
97+
]
98+
self._content.append({"type": list_type, "content": list_items})
99+
return self
100+
101+
def bullet_list(self, items: list[str]) -> ADFBuilder:
102+
return self._list_node("bulletList", items)
103+
104+
def ordered_list(self, items: list[str]) -> ADFBuilder:
105+
return self._list_node("orderedList", items)
106+
107+
def code_block(self, code: str, language: Optional[str] = None) -> ADFBuilder:
108+
node: dict[str, Any] = {
109+
"type": "codeBlock",
110+
"content": [{"type": "text", "text": code}],
111+
}
112+
if language:
113+
node["attrs"] = {"language": language}
114+
self._content.append(node)
115+
return self
116+
117+
def rule(self) -> ADFBuilder:
118+
self._content.append({"type": "rule"})
119+
return self
120+
121+
def blockquote(self, *nodes: InlineNode) -> ADFBuilder:
122+
self._content.append(
123+
{
124+
"type": "blockquote",
125+
"content": [
126+
{
127+
"type": "paragraph",
128+
"content": [n.to_dict() for n in nodes],
129+
}
130+
],
131+
}
132+
)
133+
return self
134+
135+
def table(self, headers: list[str], rows: list[list[str]]) -> ADFBuilder:
136+
header_row = {
137+
"type": "tableRow",
138+
"content": [
139+
{
140+
"type": "tableHeader",
141+
"content": [{"type": "paragraph", "content": [{"type": "text", "text": h}]}],
142+
}
143+
for h in headers
144+
],
145+
}
146+
data_rows = [
147+
{
148+
"type": "tableRow",
149+
"content": [
150+
{
151+
"type": "tableCell",
152+
"content": [{"type": "paragraph", "content": [{"type": "text", "text": cell}]}],
153+
}
154+
for cell in row
155+
],
156+
}
157+
for row in rows
158+
]
159+
self._content.append(
160+
{
161+
"type": "table",
162+
"attrs": {"isNumberColumnEnabled": False, "layout": "default"},
163+
"content": [header_row] + data_rows,
164+
}
165+
)
166+
return self
167+
168+
def raw_node(self, node: dict[str, Any]) -> ADFBuilder:
169+
"""Escape hatch for ADF node types not covered by dedicated methods."""
170+
self._content.append(node)
171+
return self
172+
173+
def build(self) -> dict[str, Any]:
174+
return {
175+
"version": 1,
176+
"type": "doc",
177+
"content": list(self._content),
178+
}

0 commit comments

Comments
 (0)