Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions atlassian/jira_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Convenience alias for atlassian.models.jira.
Allows shorter imports:
from atlassian.jira_models import task, serialize
"""

from __future__ import annotations

from atlassian.models.jira import * # noqa: F401,F403
from atlassian.models.jira import __all__ # noqa: F401
5 changes: 5 additions & 0 deletions atlassian/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Atlassian data models."""

from __future__ import annotations

from atlassian.models.jira import * # noqa: F401,F403
109 changes: 109 additions & 0 deletions atlassian/models/jira/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Type-safe Jira issue models with fluent builders.

Quick start:
from atlassian.models.jira import task, serialize

issue = (
task()
.project("PROJ")
.summary("My task")
.priority("High")
.build()
)
jira.issue_create(fields=serialize(issue)["fields"])
"""

from __future__ import annotations

from atlassian.models.jira.adf import ADFBuilder, InlineNode, MentionNode, TextNode
from atlassian.models.jira.builders import (
EpicBuilder,
IssueBuilder,
StoryBuilder,
SubTaskBuilder,
TaskBuilder,
BugBuilder,
bug,
epic,
story,
subtask,
task,
)
from atlassian.models.jira.fields import (
Component,
CustomField,
IssueFields,
IssueLink,
IssueType,
Parent,
Priority,
PriorityLevel,
Project,
User,
Version,
)
from atlassian.models.jira.issues import (
Bug,
Epic,
JiraIssue,
Story,
SubTask,
Task,
get_issue_type_registry,
issue_type_for,
)
from atlassian.models.jira.comment import Comment, Visibility
from atlassian.models.jira.serializer import FieldMapping, bulk_serialize, serialize, to_fields_dict
from atlassian.models.jira.transition import Transition, TransitionBuilder
from atlassian.models.jira.update import UpdateBuilder, UpdatePayload
from atlassian.models.jira.validation import ValidationError, validate, validate_or_raise

__all__ = [
"ADFBuilder",
"Bug",
"BugBuilder",
"Comment",
"Component",
"CustomField",
"Epic",
"EpicBuilder",
"FieldMapping",
"InlineNode",
"IssueBuilder",
"IssueFields",
"IssueLink",
"IssueType",
"JiraIssue",
"MentionNode",
"Parent",
"Priority",
"PriorityLevel",
"Project",
"Story",
"StoryBuilder",
"SubTask",
"SubTaskBuilder",
"Task",
"TaskBuilder",
"TextNode",
"Transition",
"TransitionBuilder",
"UpdateBuilder",
"UpdatePayload",
"User",
"ValidationError",
"Version",
"Visibility",
"bug",
"bulk_serialize",
"epic",
"get_issue_type_registry",
"issue_type_for",
"serialize",
"story",
"subtask",
"task",
"to_fields_dict",
"validate",
"validate_or_raise",
]
188 changes: 188 additions & 0 deletions atlassian/models/jira/adf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Optional, Union


@dataclass
class TextNode:
text: str
marks: list[dict[str, Any]] = field(default_factory=list)

def bold(self) -> TextNode:
self.marks.append({"type": "strong"})
return self

def italic(self) -> TextNode:
self.marks.append({"type": "em"})
return self

def code(self) -> TextNode:
self.marks.append({"type": "code"})
return self

def link(self, href: str) -> TextNode:
self.marks.append({"type": "link", "attrs": {"href": href}})
return self

def strike(self) -> TextNode:
self.marks.append({"type": "strike"})
return self

def to_dict(self) -> dict[str, Any]:
node: dict[str, Any] = {"type": "text", "text": self.text}
if self.marks:
node["marks"] = [dict(m) for m in self.marks]
return node


@dataclass(frozen=True)
class MentionNode:
account_id: str
display_text: str = ""

def to_dict(self) -> dict[str, Any]:
return {
"type": "mention",
"attrs": {"id": self.account_id, "text": self.display_text},
}


InlineNode = Union[TextNode, MentionNode]


class ADFBuilder:
"""Fluent builder that produces an Atlassian Document Format dict."""

def __init__(self) -> None:
"""Initialize an empty ADF document builder."""
self._content: list[dict[str, Any]] = []

def paragraph(self, *nodes: InlineNode) -> ADFBuilder:
self._content.append(
{
"type": "paragraph",
"content": [n.to_dict() for n in nodes],
}
)
return self

def text_paragraph(self, text: str) -> ADFBuilder:
return self.paragraph(TextNode(text))

def heading(self, text: str, level: int = 1) -> ADFBuilder:
if level not in range(1, 7):
raise ValueError(f"Heading level must be 1-6, got {level}")
self._content.append(
{
"type": "heading",
"attrs": {"level": level},
"content": [{"type": "text", "text": text}],
}
)
return self

def bullet_list(self, items: list[str]) -> ADFBuilder:
list_items = [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": item}],
}
],
}
for item in items
]
self._content.append({"type": "bulletList", "content": list_items})
return self

def ordered_list(self, items: list[str]) -> ADFBuilder:
list_items = [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": item}],
}
],
}
for item in items
]
self._content.append({"type": "orderedList", "content": list_items})
return self

def code_block(self, code: str, language: Optional[str] = None) -> ADFBuilder:
node: dict[str, Any] = {
"type": "codeBlock",
"content": [{"type": "text", "text": code}],
}
if language:
node["attrs"] = {"language": language}
self._content.append(node)
return self

def rule(self) -> ADFBuilder:
self._content.append({"type": "rule"})
return self

def blockquote(self, *nodes: InlineNode) -> ADFBuilder:
self._content.append(
{
"type": "blockquote",
"content": [
{
"type": "paragraph",
"content": [n.to_dict() for n in nodes],
}
],
}
)
return self

def table(self, headers: list[str], rows: list[list[str]]) -> ADFBuilder:
header_row = {
"type": "tableRow",
"content": [
{
"type": "tableHeader",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": h}]}],
}
for h in headers
],
}
data_rows = [
{
"type": "tableRow",
"content": [
{
"type": "tableCell",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": cell}]}],
}
for cell in row
],
}
for row in rows
]
self._content.append(
{
"type": "table",
"attrs": {"isNumberColumnEnabled": False, "layout": "default"},
"content": [header_row] + data_rows,
}
)
return self

def raw_node(self, node: dict[str, Any]) -> ADFBuilder:
"""Escape hatch for ADF node types not covered by dedicated methods."""
self._content.append(node)
return self

def build(self) -> dict[str, Any]:
return {
"version": 1,
"type": "doc",
"content": list(self._content),
}
Loading