Skip to content

Add type-safe Jira issue models with fluent builders#1634

Open
yechielb2000 wants to merge 7 commits intoatlassian-api:masterfrom
yechielb2000:master
Open

Add type-safe Jira issue models with fluent builders#1634
yechielb2000 wants to merge 7 commits intoatlassian-api:masterfrom
yechielb2000:master

Conversation

@yechielb2000
Copy link
Copy Markdown

Motivation

Creating Jira issues today requires building raw dictionaries with no editor support, no validation, and no discoverability:

# Current: error-prone, no autocomplete, easy to mistype keys
jira.issue_create(fields={
    "project": {"key": "PROJ"},
    "issuetype": {"name": "Bug"},
    "summary": "Login broken on Safari",
    "priority": {"name": "High"},
    "labels": ["safari", "regression"],
    "components": [{"name": "Frontend"}],
    "assignee": {"accountId": "712020:abc-def"},
    "fixVersions": [{"name": "2.1"}],
    "customfield_10014": "PROJ-100",         # which field is this? epic link? story points?
    "description": {
        "version": 1,
        "type": "doc",
        "content": [{
            "type": "paragraph",
            "content": [{"type": "text", "text": "Steps to reproduce..."}]
        }]
    }
})

Problems with the current approach:

  • No type safety — typos in keys ("sumary", "issutype") silently produce 400 errors from Jira
  • No discoverability — developers must read Jira REST API docs to find field names and their nested structures
  • No validation — missing required fields are only caught at API call time
  • No abstraction over custom fields — raw customfield_NNNNN IDs are embedded everywhere
  • ADF construction is painful — building Atlassian Document Format JSON by hand is tedious and error-prone

Solution

This PR introduces atlassian.models.jira — a stdlib-only type-safe layer built on Python dataclasses. Zero new dependencies. Compatible with Python 3.9+.

The same issue, with the new API:

from atlassian.models.jira import bug, serialize

issue = (
    bug()
    .project("PROJ")
    .summary("Login broken on Safari")
    .priority("High")
    .labels("safari", "regression")
    .components("Frontend")
    .assignee(account_id="712020:abc-def")
    .fix_versions("2.1")
    .epic_link("PROJ-100")
    .description_builder()
        .text_paragraph("Steps to reproduce...")
    .done()
    .validate()
    .build()
)

jira.issue_create(fields=serialize(issue)["fields"])

Every method is typed, discoverable via autocomplete, and validated before hitting the API.

Features

1. Fluent Builders for all issue types

from atlassian.models.jira import task, bug, story, epic, subtask

# Each factory returns a typed builder with full autocomplete
payload = (
    task()
    .project("PLAT")
    .summary("Implement caching layer")
    .priority("Medium")
    .assignee(account_id="712020:abc-def")
    .due_date("2026-06-15")
    .story_points(5)
    .labels("backend", "performance")
    .build_payload()  # returns {"fields": {...}} ready for create_issue()
)

jira.create_issue(fields=payload["fields"])

2. Rich-text descriptions with ADF Builder

No more hand-crafting Atlassian Document Format JSON:

from atlassian.models.jira import bug, ADFBuilder, TextNode

issue = (
    bug()
    .project("PLAT")
    .summary("API returns 500 on empty payload")
    .description_builder()
        .heading("Steps to Reproduce", level=2)
        .ordered_list([
            "Send POST to /api/v1/resource",
            "With empty body {}",
            "Observe 500 response",
        ])
        .heading("Expected", level=2)
        .text_paragraph("Should return 400 with validation error")
        .heading("Actual", level=2)
        .text_paragraph("Returns 500 Internal Server Error")
        .code_block('{"error": "NullPointerException"}', language="json")
        .rule()
        .paragraph(TextNode("cc ").bold(), TextNode("@backend-team").italic())
    .done()
    .build()
)

Or use ADFBuilder standalone for any ADF field:

from atlassian.models.jira import ADFBuilder, TextNode, MentionNode

doc = (
    ADFBuilder()
    .heading("Release Notes")
    .bullet_list(["Fixed login bug", "Added dark mode", "Improved performance"])
    .table(
        headers=["Feature", "Status", "Owner"],
        rows=[
            ["Dark mode", "Done", "Alice"],
            ["Caching", "In Progress", "Bob"],
        ],
    )
    .blockquote(TextNode("Ship it!").bold())
    .build()
)

3. Typed value objects with validation

Every Jira entity is a frozen dataclass that validates on construction:

from atlassian.models.jira import Project, Priority, User, PriorityLevel

Project(key="PLAT")               # OK
Project()                          # ValueError: Project requires either 'key' or 'id'

Priority.from_level(PriorityLevel.HIGH)   # Priority(name="High")

User(account_id="712020:abc")     # Cloud
User(name="jdoe")                 # Server/DC
User()                             # ValueError: User requires either 'account_id' or 'name'

4. Pre-flight validation

Catch mistakes before making API calls:

from atlassian.models.jira import task, validate, validate_or_raise

issue = task().summary("Missing project!").build()

errors = validate(issue)
# [ValidationError(field_name='project', message='Project is required')]

validate_or_raise(issue)
# ValueError: Issue validation failed: project: Project is required

# Or chain it in the builder:
task().project("P").summary("S").validate().build()  # raises if invalid

5. Custom field mapping

Different Jira instances use different custom field IDs. Map them once:

from atlassian.models.jira import task, FieldMapping, serialize

mapping = FieldMapping(
    epic_link_field="customfield_10014",     # your instance's epic link
    story_points_field="customfield_10028",  # your instance's story points
    epic_name_field="customfield_10011",     # your instance's epic name
)

issue = task().project("P").summary("S").epic_link("E-1").story_points(3).build()
payload = serialize(issue, mapping=mapping)
# payload["fields"]["customfield_10014"] == "E-1"
# payload["fields"]["customfield_10028"] == 3

6. Issue updates

from atlassian.models.jira import UpdateBuilder

payload = (
    UpdateBuilder("PLAT-123")
    .set_summary("Updated title")
    .set_priority("Critical")
    .add_labels("hotfix", "urgent")
    .remove_label("stale")
    .set_assignee(account_id="712020:abc")
    .add_component("Backend")
    .add_comment("Escalated — see PR #42")
    .add_issue_link("Blocks", outward="PLAT-456")
    .build()
)

jira.issue_update(payload.issue_key, payload.fields, update=payload.update)

7. Transitions

from atlassian.models.jira import Transition, TransitionBuilder

# Simple
t = Transition("PLAT-123", "Done", resolution="Fixed")
jira.set_issue_status(**t.as_args())

# With builder for complex transitions
t = (
    TransitionBuilder("PLAT-123", "Done")
    .resolution("Won't Do")
    .set_field("timeSpent", "2h")
    .build()
)
jira.set_issue_status(**t.as_args())

8. Comments with visibility

from atlassian.models.jira import Comment, Visibility

c = Comment(
    body="Internal note: customer confirmed the bug",
    visibility=Visibility("role", "Developers"),
)
jira.issue_add_comment("PLAT-123", **c.as_args())

9. Bulk create

from atlassian.models.jira import task, bulk_serialize

issues = [
    task().project("P").summary(f"Task {i}").build()
    for i in range(10)
]
jira.create_issues(bulk_serialize(issues))

10. Deserialization (API response → typed objects)

from atlassian.models.jira import JiraIssue

response = jira.issue("PLAT-123")
issue = JiraIssue.from_dict(response)

isinstance(issue, Bug)          # True (if it's a bug)
issue.fields.summary            # "Login broken on Safari"
issue.fields.project.key        # "PLAT"
issue.fields.priority.name      # "High"

11. Auto-registry for custom issue types

from dataclasses import dataclass
from typing import ClassVar
from atlassian.models.jira import JiraIssue, issue_type_for

@dataclass
class SecurityBug(JiraIssue):
    _issue_type_name: ClassVar[str] = "Security Bug"

# Automatically registered — deserialization returns SecurityBug instances
cls = issue_type_for("Security Bug")  # <class 'SecurityBug'>

Architecture

atlassian/models/jira/
├── __init__.py       # Public API, re-exports everything
├── fields.py         # Frozen value-object dataclasses (Project, Priority, User, etc.)
├── issues.py         # JiraIssue base class + Task/Bug/Story/Epic/SubTask
├── builders.py       # Generic IssueBuilder[T] + per-type builders + ADF bridge
├── serializer.py     # FieldMapping + serialize()/to_fields_dict()/bulk_serialize()
├── adf.py            # ADFBuilder for Atlassian Document Format
├── validation.py     # Pre-flight validate()/validate_or_raise()
├── update.py         # UpdateBuilder for issue updates
├── transition.py     # Transition model + TransitionBuilder
├── comment.py        # Comment + Visibility dataclasses
└── py.typed          # PEP 561 marker

Design decisions

Decision Rationale
stdlib-only (dataclasses) Zero new dependencies — critical for a widely-used community library
from __future__ import annotations Enables modern type syntax while maintaining Python 3.9 compatibility
No slots=True or kw_only=True These require Python 3.10+; we support 3.9
Frozen value objects, mutable IssueFields Value objects (Project, Priority) are immutable for safety; IssueFields is mutable for builder ergonomics
Generic IssueBuilder[T] build() returns the concrete type (e.g., Bug, not JiraIssue) for downstream type narrowing
__init_subclass__ registry Custom issue types are auto-registered — from_dict() returns the correct class without manual wiring
Separate serializer module Decouples data representation from wire format; FieldMapping handles per-instance custom field IDs
py.typed marker Enables mypy/pyright support out of the box (PEP 561)

Tests

86 pytest tests covering all modules:

tests/test_jira_models.py .............................................................. [86 passed]

Categories:

  • Fields: creation, validation, to_dict(), from_dict()
  • ADF Builder: paragraphs, headings, lists, code blocks, tables, marks, mentions
  • Issues: ClassVar, __post_init__, registry, from_dict()
  • Serializer: serialize(), to_fields_dict(), bulk_serialize(), FieldMapping
  • Builders: chaining, factories, build_dict(), build_payload(), ADF bridge, validate()
  • Update: UpdateBuilder set/add/remove operations
  • Transitions: Transition, TransitionBuilder
  • Comments: Comment, Visibility
  • End-to-end: full builder → serialize → API shape assertions

Breaking changes

None. This is purely additive — no existing code is modified.

Checklist

  • Zero new dependencies
  • Python 3.9+ compatible
  • All 86 tests pass
  • PEP 561 typed (py.typed marker)
  • Comprehensive docstrings with usage examples
  • Backward compatible — no existing APIs changed

Yechiel Babani and others added 2 commits April 12, 2026 21:33
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.
Add type-safe Jira issue models with fluent builders
@yechielb2000
Copy link
Copy Markdown
Author

linked issue #1556

@yechielb2000 yechielb2000 marked this pull request as draft April 12, 2026 19:09
Yechiel Babani and others added 5 commits April 12, 2026 22:22
- 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
- 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.
Fix code duplication, pylint issues, and Codacy compliance
- 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
Fix ADF duplication, docstring sections, and simplify test imports
@yechielb2000 yechielb2000 marked this pull request as ready for review April 12, 2026 20:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant