0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonでシンプルにjiraからチケット情報を取得する2025

Last updated at Posted at 2024-07-28

はじめに

現場がJiraを使っていて、気に入ったからAPIの使い方をさらっておきたい。

2025では 公式ドキュメント を見て REST-API で書いています。ライブラリを入れなくてもよくなったみたい。

TODO: CRUDの作成

参考

Jira側でAPIトークンの生成

image.png
image.png
image.png
image.png
image.png

source

.env.example
  :
EMAIL_HOST_USER=abc@example.com
JIRA_YOUR_DOMAIN=
JIRA_API_KEY=
  :
lib/jira/jira_service.py
import os

import requests
from requests import HTTPError
from requests.auth import HTTPBasicAuth

from lib.jira.valueobject.ticket import (
    ProjectVO,
    IssueVO,
    SubTaskVO,
    # CreateIssuePayload,
)


class JiraService:
    """
    Service class for interacting with the JIRA API.
    """

    def __init__(self, domain: str = None, email: str = None, api_token: str = None):
        """
        Initialize the JiraService with domain, email, and API token.

        Args:
            domain (str): The JIRA domain (e.g., "your-domain").
            email (str): The email address for authentication.
            api_token (str): The API token for authentication.
        """
        missing_envs = []
        if not domain:
            missing_envs.append("YOUR_DOMAIN")
        if not email:
            missing_envs.append("EMAIL")
        if not api_token:
            missing_envs.append("API_TOKEN")

        if missing_envs:
            error_message = f"400 Bad Request: Missing required environment variable(s): {', '.join(missing_envs)}"
            raise HTTPError(error_message)

        self.base_url = f"https://{domain}.atlassian.net"
        # TODO: Basic認証の問題なのか401は出ない。OAuth 2.0化が必要か
        self.auth = HTTPBasicAuth(email, api_token)
        self.headers = {"Accept": "application/json"}

    def fetch_projects(self) -> list[ProjectVO]:
        """
        Fetch all projects from the JIRA API using `isLast` for termination.

        Returns:
            List[ProjectVO]: A list of ProjectVOs containing project key and name.

        Raises:
            HTTPError: If the HTTP request returns an error response.
        """
        url = f"{self.base_url}/rest/api/3/project/search"
        all_projects = []

        while url:
            response = requests.get(url, headers=self.headers, auth=self.auth)

            # Automatically raise an exception for HTTP error responses
            response.raise_for_status()

            # Parse the response JSON
            data = response.json()

            # Extract the necessary fields from each project in the 'values'
            for project in data.get("values", []):
                all_projects.append(
                    ProjectVO(
                        key=project.get("key", "invalid key"),
                        name=project.get("name", "invalid name"),
                    )
                )

            # Exit the loop if this is the last page
            if data.get("isLast", False):
                break

            # Update the URL for the next page
            url = data.get("nextPage", None)

        return all_projects

    def fetch_issues(self, project_key: str) -> dict[str, list[IssueVO]]:
        """
        Fetch all issues for a given project from the JIRA API.

        Args:
            project_key (str): The key of the project (e.g., "HSP").

        Returns:
            Dict[str, List[IssueVO]]: A dictionary with project keys as keys and lists of issues as values.

        Raises:
            HTTPError: If the HTTP request returns an error response.
        """
        url = f"{self.base_url}/rest/api/3/search"
        query = {
            "jql": f"project = {project_key} AND issuetype = Task",
            "maxResults": 50,  # Adjust as needed
            "fields": "key,issuetype,priority,assignee,status,description,summary,subtasks",
        }
        issues_by_project = {}

        while url:
            response = requests.get(
                url, headers=self.headers, auth=self.auth, params=query
            )

            # Automatically raise an exception for HTTP error responses
            response.raise_for_status()

            # Parse the response JSON
            data = response.json()

            # Extract issues
            for issue in data.get("issues", []):
                fields = issue.get("fields", {})
                issue_key = issue.get("key", "No issue key")

                # Check if the current issue is a regular issue or a subtask
                is_subtask = fields.get("issuetype", {}).get("subtask", False)
                issue_name = fields.get("summary", "No summary")
                issue_status = fields.get("status", {}).get("name", "No status")
                issue_priority = fields.get("priority", {}).get("name", "No priority")

                # "assignee" の処理: None なら None, そうでなければ displayName 取得
                issue_assignee = None
                if fields.get("assignee"):
                    issue_assignee = fields.get("assignee").get(
                        "displayName", "Unassigned"
                    )

                issue_description = "No description"

                # `description` が辞書形式の場合、正しく _parse_content_field を呼ぶ
                raw_description = fields.get("description")
                if isinstance(raw_description, dict) and raw_description.get("content"):
                    issue_description = self._parse_content_field(
                        raw_description.get("content")
                    )

                # If it's not a subtask, process as a regular issue (IssueVO)
                if not is_subtask:
                    sub_tasks = []
                    for sub_task in fields.get("subtasks", []):
                        sub_task_key = sub_task.get("key", "No sub-task")
                        sub_task_name = sub_task.get("fields").get(
                            "summary", "No summary"
                        )
                        sub_task_status = (
                            sub_task.get("fields", {})
                            .get("status", {})
                            .get("name", "No status")
                        )
                        sub_task_priority = (
                            sub_task.get("fields", {})
                            .get("priority", {})
                            .get("name", "No priority")
                        )
                        sub_tasks.append(
                            SubTaskVO(
                                key=sub_task_key,
                                name=sub_task_name,
                                status=sub_task_status,
                                priority=sub_task_priority,
                            )
                        )

                    issue_obj = IssueVO(
                        key=issue_key,
                        name=issue_name,
                        description=issue_description,
                        priority=issue_priority,
                        assignee=issue_assignee,
                        status=issue_status,
                        sub_tasks=sub_tasks,
                    )

                    # Add the issue object to the dictionary under the project key
                    if project_key not in issues_by_project:
                        issues_by_project[project_key] = []
                    issues_by_project.get(project_key).append(issue_obj)

            # Update the URL for the next page if available
            if "nextPage" in data:
                url = data["nextPage"]
            else:
                url = None

        return issues_by_project

    @staticmethod
    def _parse_content_field(content: list) -> str:
        """
        Helper function to extract text from the 'content' field in the description.

        Args:
            content (list): The 'content' field from the issue description.

        Returns:
            str: Extracted plain text description.
        """
        description_text = []

        for block in content:
            # Ensure the block has "type" and "content"
            if not isinstance(block, dict) or "content" not in block:
                continue

            for inner_block in block.get("content", []):
                # Look for 'text' in inner blocks
                if isinstance(inner_block, dict) and inner_block.get("type") == "text":
                    description_text.append(inner_block.get("text", ""))

        return " ".join(description_text)

    # def create_issue(self, payload: CreateIssuePayload):
    #     """
    #     JIRAチケットを作成する
    #
    #     Args:
    #         payload (CreateIssuePayload): ペイロードデータを表すValue Object
    #
    #     Returns:
    #         dict: 作成されたチケットの詳細
    #     """
    #     url = f"{self.base_url}/rest/api/3/issue"
    #
    #     # POSTリクエストを送信
    #     response = requests.post(
    #         url,
    #         data=json.dumps(payload.to_dict()),
    #         headers=self.headers,
    #         auth=self.auth,
    #     )
    #
    #     if response.status_code != 201:  # 201は「作成成功」を表す
    #         raise HTTPError(
    #             f"Failed to create issue: {response.status_code} {response.text}"
    #         )
    #
    #     return response.json()  # 作成されたチケットのJSONレスポンスを返す


if __name__ == "__main__":
    # API docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/#about

    # JIRA configuration: Replace with actual values or environment variables
    YOUR_DOMAIN = os.getenv("JIRA_YOUR_DOMAIN")
    EMAIL = os.getenv("EMAIL_HOST_USER")
    API_TOKEN = os.getenv("JIRA_API_KEY")

    jira_service = JiraService(domain=YOUR_DOMAIN, email=EMAIL, api_token=API_TOKEN)

    try:
        for pjt in jira_service.fetch_projects():
            print(pjt)
            issues = jira_service.fetch_issues(project_key=pjt.key)

            for project_xxx, project_issues in issues.items():
                print(f"Project: {project_xxx}")
                for issue_xxx in project_issues:
                    print(issue_xxx)

        print("process done")
    except requests.exceptions.HTTPError as http_err:
        print(f"[HTTP Error] {http_err}")

    # # TODO: チケットを作成する機能を作る https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post
    # payload_xxx = CreateIssuePayload(
    #     description_text="Order entry fails when selecting supplier.",
    #     issue_type_id="10000",
    #     labels=["bugfix"],
    #     parent_key="HEN",
    #     priority_id="20000",
    #     project_id="10000",
    #     reporter_id="5b10a2844c20165700ede21g",
    #     summary="Main order flow broken",
    # )
    # # チケットを作成
    # try:
    #     result = jira_service.create_issue(payload_xxx)
    #     print("チケットが作成されました:", result)
    # except HTTPError as e:
    #     print("エラーが発生しました:", str(e))
    #
    # # TODO: チケットを削除する
    # # TODO: チケットを編集する
    
lib/jira/test_jira_service.py
import unittest
from unittest.mock import patch, Mock

from requests import HTTPError

from lib.jira.jira_service import JiraService
from lib.jira.valueobject.ticket import IssueVO


class TestJiraServiceFetchIssues(unittest.TestCase):
    """
    JiraServiceクラスのfetch_issuesメソッドに関する単体テストを行います。
    - 各テストケースでは、APIのモックレスポンスを利用して、fetch_issuesの期待される動作を検証します。
    """

    def setUp(self):
        """
        各テストの実行前に共通のセットアップ処理を行います。
        JiraServiceのインスタンスを生成します。
        """
        self.service = JiraService(
            domain="test-domain", email="test@example.com", api_token="test-token"
        )

    @staticmethod
    def create_mock_response(json_data, status_code=200):
        """
        APIレスポンスをモックとして作成する共通のヘルパーメソッド。

        Args:
            json_data (dict): モックレスポンスとして返すJSONデータ
            status_code (int): HTTPステータスコード(デフォルトは200)

        Returns:
            Mock: モック化したレスポンスオブジェクト
        """
        mock_response = Mock()
        mock_response.json.return_value = json_data
        mock_response.status_code = status_code
        mock_response.raise_for_status = (
            Mock()
        )  # ステータスコードに応じた例外を発生させるモックを設定
        return mock_response

    @patch("requests.get")
    def test_fetch_issues_with_assignee(self, mock_get):
        """
        テスト内容:
            fetch_issuesが、「assignee」が指定されている課題を処理できることを確認する。
        シナリオ:
            - APIレスポンスに "assignee" フィールドが存在し、"displayName" を持つ場合をテストする。
            - "assignee" の情報が正しくIssueVOに設定されていることを検証する。
        """
        mock_get.return_value = self.create_mock_response(
            {
                "issues": [
                    {
                        "key": "HEN-1",
                        "fields": {
                            "summary": "Test Issue 1",
                            "description": None,
                            "assignee": {"displayName": "John Doe"},
                            "priority": {"name": "High"},
                            "status": {"name": "To Do"},
                            "subtasks": [],
                        },
                    }
                ]
            }
        )

        # 実行
        issues = self.service.fetch_issues("HEN")

        # 検証
        self.assertEqual(len(issues["HEN"]), 1)
        issue = issues["HEN"][0]
        self.assertIsInstance(issue, IssueVO)
        self.assertEqual(issue.assignee, "John Doe")
        self.assertEqual(issue.name, "Test Issue 1")
        self.assertEqual(issue.description, "No description")  # デフォルトの説明
        self.assertEqual(issue.priority, "High")
        self.assertEqual(issue.status, "To Do")

    @patch("requests.get")
    def test_fetch_issues_without_assignee(self, mock_get):
        """
        テスト内容:
            fetch_issuesが、「assignee」が指定されていない(None)の課題を処理できることを確認する。
        シナリオ:
            - APIレスポンスに "assignee" フィールドがNoneの場合をテストする。
            - IssueVOの「assignee」の値がNoneとなっていることを検証する。
        """
        mock_get.return_value = self.create_mock_response(
            {
                "issues": [
                    {
                        "key": "HEN-2",
                        "fields": {
                            "summary": "Test Issue 2",
                            "description": None,
                            "assignee": None,
                            "priority": {"name": "Medium"},
                            "status": {"name": "In Progress"},
                            "subtasks": [],
                        },
                    }
                ]
            }
        )

        # 実行
        issues = self.service.fetch_issues("HEN")

        # 検証
        self.assertEqual(len(issues["HEN"]), 1)
        issue = issues["HEN"][0]
        self.assertIsInstance(issue, IssueVO)
        self.assertIsNone(issue.assignee)  # AssigneeがNoneであることを検証
        self.assertEqual(issue.name, "Test Issue 2")
        self.assertEqual(issue.description, "No description")  # デフォルトの説明
        self.assertEqual(issue.priority, "Medium")
        self.assertEqual(issue.status, "In Progress")

    @patch("requests.get")
    def test_fetch_issues_with_multiple_issues(self, mock_get):
        """
        テスト内容:
            fetch_issuesが、複数の課題を正しく処理できることを確認する。
        シナリオ:
            - APIレスポンスに複数の課題が含まれる場合をテストする。
            - 各課題のフィールド(assignee, priority, statusなど)が正しく設定されていることを検証する。
        """
        mock_get.return_value = self.create_mock_response(
            {
                "issues": [
                    {
                        "key": "HEN-3",
                        "fields": {
                            "summary": "Issue 1",
                            "assignee": {"displayName": "Jane Smith"},
                            "priority": {"name": "Low"},
                            "status": {"name": "In Progress"},
                            "subtasks": [],
                        },
                    },
                    {
                        "key": "HEN-4",
                        "fields": {
                            "summary": "Issue 2",
                            "assignee": None,
                            "priority": {"name": "High"},
                            "status": {"name": "To Do"},
                            "subtasks": [],
                        },
                    },
                ]
            }
        )

        # 実行
        issues = self.service.fetch_issues("HEN")

        # 検証
        self.assertEqual(len(issues["HEN"]), 2)

        # 課題1
        issue1 = issues["HEN"][0]
        self.assertEqual(issue1.assignee, "Jane Smith")
        self.assertEqual(issue1.name, "Issue 1")
        self.assertEqual(issue1.priority, "Low")
        self.assertEqual(issue1.status, "In Progress")

        # 課題2
        issue2 = issues["HEN"][1]
        self.assertIsNone(issue2.assignee)  # AssigneeがNoneであることを検証
        self.assertEqual(issue2.name, "Issue 2")
        self.assertEqual(issue2.priority, "High")
        self.assertEqual(issue2.status, "To Do")

    @patch("requests.get")
    def test_fetch_issues_with_invalid_project_key(self, mock_get):
        """
        fetch_issuesに無効なプロジェクトキーを渡した場合に正しく例外をスローするかを確認する。
        """
        # モックAPIの404エラー設定
        mock_response = self.create_mock_response({}, status_code=404)
        mock_response.raise_for_status.side_effect = HTTPError(
            "404 Client Error: Not Found"
        )
        mock_get.return_value = mock_response

        # 実行と例外の検証
        with self.assertRaises(HTTPError) as context:
            self.service.fetch_issues("INVALID")  # 存在しないプロジェクトキー

        # エラーメッセージを確認
        self.assertIn("404 Client Error", str(context.exception))
        mock_get.assert_called_once()

    def test_missing_base_url(self):
        """
        base_urlが空の場合にHTTPErrorがスローされ、エラーメッセージに正しい環境変数名が表示されることを確認する。
        """
        with self.assertRaises(HTTPError) as context:
            JiraService(domain=None, email="test@example.com", api_token="test_token")
        self.assertIn("YOUR_DOMAIN", str(context.exception))

    def test_missing_email(self):
        """
        emailが空の場合にHTTPErrorがスローされ、エラーメッセージに正しい環境変数名が表示されることを確認する。
        """
        with self.assertRaises(HTTPError) as context:
            JiraService(domain="test", email=None, api_token="test_token")
        self.assertIn("EMAIL", str(context.exception))

    def test_missing_api_token(self):
        """
        api_tokenが空の場合にHTTPErrorがスローされ、エラーメッセージに正しい環境変数名が表示されることを確認する。
        """
        with self.assertRaises(HTTPError) as context:
            JiraService(
                domain="test",
                email="test@example.com",
                api_token=None,
            )
        self.assertIn("API_TOKEN", str(context.exception))

    def test_multiple_missing_env_vars(self):
        """
        複数の環境変数が空の場合に、HTTPErrorがスローされ、不足している環境変数名がすべて表示されることを確認する。
        """
        with self.assertRaises(HTTPError) as context:
            JiraService(domain=None, email=None, api_token="test_token")
        error_message = str(context.exception)
        self.assertIn("YOUR_DOMAIN", error_message)
        self.assertIn("EMAIL", error_message)

    def test_all_env_vars_present(self):
        """
        すべての引数が正しく設定されている場合、サービスが正常に初期化されることを確認する。
        """
        try:
            service = JiraService(
                domain="test",
                email="test@example.com",
                api_token="test_token",
            )
            self.assertIsNotNone(service)
        except HTTPError:
            self.fail(
                "HTTPErrorが発生しました。すべての引数が存在している場合でも失敗しています。"
            )
            
lib/jira/valueobject/ticket.py
from dataclasses import dataclass


@dataclass
class ProjectVO:
    """
    Value Object (VO) to represent a JIRA project.

    Attributes:
        key (str): The unique key of the project.
        name (str): The name of the project.
    """

    key: str
    name: str


@dataclass
class SubTaskVO:
    """
    Data class to represent sub-tasks of an issue.

    Attributes:
        key (str): The unique key of the sub-task.
        name (str): The name of the sub-task.
        status (str): The current status of the sub-task.
        priority (str): The priority of the sub-task.
    """

    key: str
    name: str
    status: str
    priority: str


@dataclass
class IssueVO:
    """
    Data class to represent an issue.

    Attributes:
        key (str): The unique key of the issue.
        name (str): The title or summary of the issue.
        description (str): The detailed description of the issue.
        priority (str): The priority of the issue.
        assignee (str): The display name of the assigned user.
        status (str): The current status of the issue.
        sub_tasks (list[SubTaskVO]): The list of sub-tasks associated with the issue.
    """

    key: str
    name: str
    description: str
    priority: str
    assignee: str
    status: str
    sub_tasks: list[SubTaskVO]


class CreateIssuePayload:
    def __init__(
        self,
        description_text: str,
        issue_type_id: str,
        labels: list,
        parent_key: str,
        priority_id: str,
        project_id: str,
        reporter_id: str,
        summary: str,
    ):
        """
        チケット作成ペイロードデータのValue Object

        Args:
            description_text (str): チケットの説明文
            issue_type_id (str): イシュ―タイプのID
            labels (list): ラベル一覧
            parent_key (str): 親チケットのキー
            priority_id (str): 優先度のID
            project_id (str): プロジェクトのID
            reporter_id (str): 報告者のID
            summary (str): チケットの概要
        """
        self.description_text = description_text
        self.issue_type_id = issue_type_id
        self.labels = labels
        self.parent_key = parent_key
        self.priority_id = priority_id
        self.project_id = project_id
        self.reporter_id = reporter_id
        self.summary = summary

    def to_dict(self) -> dict:
        """
        ペイロードをJira API形式の辞書データに変換

        Returns:
            dict: Jira用のペイロードデータ
        """
        return {
            "fields": {
                "description": self._format_description(),
                "issuetype": {"id": self.issue_type_id},
                "labels": self.labels,
                "parent": {"key": self.parent_key},
                "priority": {"id": self.priority_id},
                "project": {"id": self.project_id},
                "reporter": {"id": self.reporter_id},
                "summary": self.summary,
            }
        }

    def _format_description(self) -> dict:
        """
        descriptionフィールドをJira標準のリッチテキスト形式に変換

        Returns:
            dict: リッチテキスト形式のdescriptionフィールド
        """
        return {
            "type": "doc",
            "version": 1,
            "content": [
                {
                    "type": "paragraph",
                    "content": [
                        {
                            "type": "text",
                            "text": self.description_text,
                        }
                    ],
                }
            ],
        }
        

確認

3つチケットがある
image.png

console
ProjectVO(key='HEN', name='henojiya')
Project: HEN
IssueVO(key='HEN-18', name='テストチケット', description='テストチケットの説明', priority='Medium', assignee='yoshitaka okada', status='To Do', sub_tasks=[SubTaskVO(key='HEN-19', name='サブタスク1', status='To Do', priority='Medium')])
IssueVO(key='HEN-4', name='まだ先の作業', description='No description', priority='Medium', assignee=None, status='To Do', sub_tasks=[])
IssueVO(key='HEN-3', name='レビュー', description='No description', priority='Medium', assignee='yoshitaka okada', status='完了', sub_tasks=[])
process done
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?