diff --git a/README.md b/README.md index 4ff3f9c..7e4fcf2 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,44 @@ To run the actions from your yaml template file run: If you need more verbose output for debugging, define `JOFT_DEBUG=1` environment variable. +## Configuration + +JOFT uses a TOML configuration file (`joft.config.toml`) to manage its settings. You can create this file by copying the provided default configuration file: + +```bash +cp joft.config.toml.default joft.config.toml +``` + +The configuration file has the following sections: + +### Jira Server Settings + +```toml +[jira.server] +hostname = "" # e.g., "https://your-company.atlassian.net" +pat_token = "" # Your Personal Access Token +``` + +### Logging Configuration + +You can configure both console (stdout) and file logging: + +```toml +[logging.stdout] +log_level = "INFO" # One of: "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" + +[logging.file] +log_level = "DEBUG" # One of: "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" +log_dir = "/path/to/logs" # Optional: Directory where log files will be stored +``` + +- Both stdout and file logging sections are optional +- Log files are automatically named with timestamp (format: `joft_YYYYMMDD_HHMMSS.log`) +- For file logging: + - `log_dir` is optional and defaults to the current working directory + - Different log levels can be set for stdout and file logging + - Each log entry includes timestamp, logger name, log level, and message + ## Docs Documentation can be found [here](docs/introduction.md). diff --git a/joft.config.toml.default b/joft.config.toml.default index 2f8e551..defbeff 100644 --- a/joft.config.toml.default +++ b/joft.config.toml.default @@ -1,3 +1,10 @@ [jira.server] hostname = "" -pat_token = "" \ No newline at end of file +pat_token = "" + +[logging.stdout] +log_level = # "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" + +[logging.file] +log_level = # "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" +log_dir = "" \ No newline at end of file diff --git a/joft/actions.py b/joft/actions.py index cc9a152..5c1b35a 100644 --- a/joft/actions.py +++ b/joft/actions.py @@ -1,4 +1,4 @@ -import logging +from joft.logger import logger from typing import Dict, Union, List, cast, Any import jira @@ -27,12 +27,12 @@ def create_ticket( """ joft.base.update_reference_pool(action.reference_data, reference_pool) joft.base.apply_reference_pool_to_payload(reference_pool, action.fields) - logging.debug(f"Creating new ticket of type: {action.fields['issuetype']['name']}") - logging.debug(f"Payload:\n{action.fields}") + logger.debug(f"Creating new ticket of type: {action.fields['issuetype']['name']}") + logger.debug(f"Payload:\n{action.fields}") new_issue: jira.Issue = jira_session.create_issue(action.fields) - logging.info(f"New Jira ticket created: {new_issue.permalink()}") + logger.info(f"New Jira ticket created: {new_issue.permalink()}") if action.object_id: reference_pool[action.object_id] = new_issue @@ -67,11 +67,11 @@ def update_ticket( ticket_to: jira.Issue = cast(jira.Issue, reference_pool[action.reference_id]) - logging.debug(f"Updating ticket '{ticket_to.key}'") - logging.debug(f"Payload:\n{action.fields}") + logger.debug(f"Updating ticket '{ticket_to.key}'") + logger.debug(f"Payload:\n{action.fields}") ticket_to.update(action.fields) - logging.info(f"Ticket '{ticket_to.key}' updated.") + logger.info(f"Ticket '{ticket_to.key}' updated.") if action.object_id: reference_pool[action.object_id] = ticket_to @@ -95,10 +95,10 @@ def link_issues( joft.base.update_reference_pool(action.reference_data, reference_pool) joft.base.apply_reference_pool_to_payload(reference_pool, action.fields) - logging.info("Linking issues...") - logging.info(f"Link type: {action.fields['type']}") - logging.info(f"Linking From Issue: {action.fields['inward_issue']}") - logging.info(f"Linking To Issue: {action.fields['outward_issue']}") + logger.info("Linking issues...") + logger.info(f"Link type: {action.fields['type']}") + logger.info(f"Linking From Issue: {action.fields['inward_issue']}") + logger.info(f"Linking To Issue: {action.fields['outward_issue']}") jira_session.create_issue_link( action.fields["type"], @@ -136,11 +136,11 @@ def transition_issue( ticket_to: jira.Issue = cast(jira.Issue, reference_pool[action.reference_id]) - logging.info(f"Transitioning issue '{ticket_to.key}'...") - logging.info( + logger.info(f"Transitioning issue '{ticket_to.key}'...") + logger.info( f"Changing status from '{ticket_to.fields.status}' to '{action.transition}'" ) - logging.info(f"With comment: \n{action.comment}") + logger.info(f"With comment: \n{action.comment}") jira_session.transition_issue( ticket_to, action.transition, action.fields, action.comment diff --git a/joft/base.py b/joft/base.py index 1ec3ce6..a4c1a06 100644 --- a/joft/base.py +++ b/joft/base.py @@ -1,5 +1,4 @@ import copy -import logging from typing import Dict, Union, List, Any, cast import jira @@ -7,6 +6,7 @@ import tabulate import joft.actions +from joft.logger import logger import joft.models import joft.utils @@ -40,7 +40,7 @@ def load_and_validate_template(template_file_path: str) -> joft.models.JiraTempl template: Dict[str, Any] = joft.utils.load_and_parse_yaml_file(template_file_path) jira_template = joft.models.JiraTemplate(**template) - logging.info("Yaml file loaded...") + logger.info("Yaml file loaded...") # we validate if the user entered unique object_ids for different actions in the # template. Each object_id references a action or a trigger, which in turn references # their results (a ticket or a results of a search) @@ -113,7 +113,7 @@ def execute_template(template_file_path: str, jira_session: jira.JIRA) -> int: trigger_result = search_issues(jira_template, jira_session) if not trigger_result: - logging.info( + logger.info( ( "No tickets found according to the provided jira query " f"'{jira_template.jira_search.jql}'!" @@ -194,7 +194,7 @@ def execute_actions( reference_pool, ) case _: - logging.warning(f"Unknown action type: {action.type}") + logger.warning(f"Unknown action type: {action.type}") def execute_actions_per_trigger_ticket( diff --git a/joft/cli.py b/joft/cli.py index af7412a..9cc8fca 100644 --- a/joft/cli.py +++ b/joft/cli.py @@ -1,5 +1,3 @@ -import logging -import os import sys from typing import Dict, Any, Optional @@ -8,12 +6,7 @@ import joft.base import joft.utils - - -if os.getenv("JOFT_DEBUG"): - logging_level = logging.DEBUG -else: - logging_level = logging.WARNING +from joft.logger import configure_logger, logger @click.group() @@ -24,6 +17,8 @@ def main(ctx: click.Context, config: Optional[str] = None) -> None: A CLI automation tool which interacts with a Jira instance and automates tasks. """ ctx.obj = joft.utils.load_toml_app_config(config_path=config) + if "logging" in ctx.obj: + configure_logger(logging_config=ctx.obj["logging"]) # TODO: refactor th CLI interface so it makes more sense @@ -38,8 +33,7 @@ def validate(template: str) -> int: @click.option("--template", help="File path to the template file.") @click.pass_obj def run(ctx: Dict[str, Dict[str, Any]], template: str) -> int: - logging.basicConfig(format="%(levelname)s:%(message)s", level=logging_level) - logging.info( + logger.info( f"Establishing session with jira server: {ctx['jira']['server']['hostname']}:" ) @@ -47,8 +41,8 @@ def run(ctx: Dict[str, Dict[str, Any]], template: str) -> int: ctx["jira"]["server"]["hostname"], token_auth=ctx["jira"]["server"]["pat_token"] ) - logging.info("Session established...") - logging.info(f"Executing Jira template: {template}") + logger.info("Session established...") + logger.info(f"Executing Jira template: {template}") ret_code = joft.base.execute_template(template, jira_session) @@ -59,8 +53,7 @@ def run(ctx: Dict[str, Dict[str, Any]], template: str) -> int: @click.option("--template", help="File path to the template file.") @click.pass_obj def list_issues(ctx: Dict[str, Dict[str, Any]], template: str) -> None: - logging.basicConfig(format="%(levelname)s:%(message)s", level=logging_level) - logging.info( + logger.info( f"Establishing session with jira server: {ctx['jira']['server']['hostname']}:" ) @@ -68,7 +61,7 @@ def list_issues(ctx: Dict[str, Dict[str, Any]], template: str) -> None: ctx["jira"]["server"]["hostname"], token_auth=ctx["jira"]["server"]["pat_token"] ) - logging.info("Session established...") - logging.info(f"Executing trigger from Jira template: {template}") + logger.info("Session established...") + logger.info(f"Executing trigger from Jira template: {template}") print(joft.base.list_issues(template, jira_session)) diff --git a/joft/logger.py b/joft/logger.py new file mode 100644 index 0000000..d5c6bbf --- /dev/null +++ b/joft/logger.py @@ -0,0 +1,59 @@ +from datetime import datetime +import logging +import os +from typing import Dict, Any + +if os.getenv("JOFT_DEBUG"): + log_level = logging.DEBUG +else: + log_level = logging.WARNING + +logger = logging.getLogger("joft") +logger.setLevel(log_level) + + +def configure_logger(logging_config: Dict[str, Any]) -> None: + """ + Configure the logger for the application according to the logging configuration. + + Args: + logging_config: The logging configuration. + """ + + logger = logging.getLogger("joft") + # Clear existing handlers + logger.handlers.clear() + + # Track minimum level across all handlers + min_level = logging.CRITICAL + + if "stdout" in logging_config: + log_level = getattr(logging, logging_config["stdout"]["log_level"]) + min_level = min(min_level, log_level) + stdout_handler = logging.StreamHandler() + stdout_handler.setLevel(log_level) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + stdout_handler.setFormatter(formatter) + logger.addHandler(stdout_handler) + + if "file" in logging_config: + log_level = getattr(logging, logging_config["file"]["log_level"]) + min_level = min(min_level, log_level) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + # Use current working directory if log_dir is not specified + log_dir = logging_config["file"].get("log_dir", os.getcwd()) + log_file = os.path.join(log_dir, f"joft_{timestamp}.log") + + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(log_level) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + # Set logger to minimum level from handlers + logger.setLevel(min_level) diff --git a/tests/test_base.py b/tests/test_base.py index fc5b732..f33342b 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -265,7 +265,7 @@ def test_multiple_references_in_str_field() -> None: assert mock_reference_pool["issue.key"] in mock_fields["summary"] -@unittest.mock.patch("logging.info") +@unittest.mock.patch("joft.base.logger") @unittest.mock.patch("joft.utils.load_and_parse_yaml_file") @unittest.mock.patch("joft.base.execute_actions_per_trigger_ticket") @unittest.mock.patch("joft.base.execute_actions") @@ -273,7 +273,7 @@ def test_execute_template_with_trigger( mock_execute_actions, mock_execute_actions_per_trigger_ticket, mock_load_and_parse_yaml, - mock_log_info, + mock_logger, ) -> None: """Comprehensive test that loads the whole yaml template. Checks if the functions returns correct CLI codes.""" @@ -302,10 +302,10 @@ def test_execute_template_with_trigger( mock_execute_actions_per_trigger_ticket.assert_called_once_with( trigger_result, jira_template, mock_jira_session ) - mock_log_info.assert_called_once_with("Yaml file loaded...") + mock_logger.info.assert_called_once_with("Yaml file loaded...") -@unittest.mock.patch("logging.info") +@unittest.mock.patch("joft.base.logger") @unittest.mock.patch("joft.utils.load_and_parse_yaml_file") @unittest.mock.patch("joft.base.execute_actions_per_trigger_ticket") @unittest.mock.patch("joft.base.execute_actions") @@ -313,7 +313,7 @@ def test_execute_template_exit_no_tickets( mock_execute_actions, mock_execute_actions_per_trigger_ticket, mock_load_and_parse_yaml, - mock_log_info, + mock_logger, ) -> None: """If there are no tickets returned from a JQL query the program should exit as soon as possible.""" @@ -337,9 +337,9 @@ def test_execute_template_exit_no_tickets( assert mock_execute_actions_per_trigger_ticket.call_count == 0 assert mock_execute_actions.call_count == 0 - assert mock_log_info.call_count == 2 - assert mock_log_info.mock_calls[0].args[0] == "Yaml file loaded..." - assert jira_template.jira_search.jql in mock_log_info.mock_calls[1].args[0] + assert mock_logger.info.call_count == 2 + assert mock_logger.info.mock_calls[0].args[0] == "Yaml file loaded..." + assert jira_template.jira_search.jql in mock_logger.info.mock_calls[1].args[0] @unittest.mock.patch("joft.base.validate_uniqueness_of_object_ids") diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..f56f7c6 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,165 @@ +import logging +import os +import tempfile +from datetime import datetime +from typing import Generator, Any +from unittest.mock import patch + +import pytest + +from joft.logger import configure_logger + + +@pytest.fixture +def temp_log_dir() -> Generator[str, None, None]: + with tempfile.TemporaryDirectory() as tmpdir: + yield tmpdir + + +def test_configure_logger_stdout(caplog: Any) -> None: + """Test if logger can be configured for stdout and logs messages correctly.""" + config: dict[str, dict[str, Any]] = {"stdout": {"log_level": "INFO"}} + + configure_logger(config) + logger = logging.getLogger("joft") + + test_message = "Test stdout message" + with caplog.at_level(logging.INFO): + logger.info(test_message) + + assert len(logger.handlers) == 1 + assert isinstance(logger.handlers[0], logging.StreamHandler) + assert test_message in caplog.text + + +def test_configure_logger_file(temp_log_dir: str) -> None: + """Test if logger can be configured for file logging and logs messages into a file.""" + config: dict[str, dict[str, Any]] = { + "file": {"log_level": "INFO", "log_dir": temp_log_dir} + } + + configure_logger(config) + logger = logging.getLogger("joft") + + test_message = "Test file message" + logger.info(test_message) + + # Find the log file + log_files = [f for f in os.listdir(temp_log_dir) if f.startswith("joft_")] + assert len(log_files) == 1 + + log_path = os.path.join(temp_log_dir, log_files[0]) + with open(log_path, "r") as f: + log_content = f.read() + assert test_message in log_content + + +def test_configure_logger_both_handlers(temp_log_dir: str, caplog: Any) -> None: + """Test if logger can be configured for both stdout and file logging.""" + config: dict[str, dict[str, Any]] = { + "stdout": {"log_level": "INFO"}, + "file": {"log_level": "INFO", "log_dir": temp_log_dir}, + } + + configure_logger(config) + logger = logging.getLogger("joft") + + test_message = "Test both handlers message" + logger.info(test_message) + + # Check stdout + assert test_message in caplog.text + + # Check file + log_files = [f for f in os.listdir(temp_log_dir) if f.startswith("joft_")] + assert len(log_files) == 1 + + log_path = os.path.join(temp_log_dir, log_files[0]) + with open(log_path, "r") as f: + log_content = f.read() + assert test_message in log_content + + +def test_configure_logger_different_levels(temp_log_dir: str, caplog: Any) -> None: + """Test if logger handles different log levels correctly for both handlers.""" + config: dict[str, dict[str, Any]] = { + "stdout": {"log_level": "WARNING"}, + "file": {"log_level": "DEBUG", "log_dir": temp_log_dir}, + } + + configure_logger(config) + logger = logging.getLogger("joft") + + debug_msg = "Debug message" + info_msg = "Info message" + warning_msg = "Warning message" + + with caplog.at_level(logging.WARNING): + logger.debug(debug_msg) + logger.info(info_msg) + logger.warning(warning_msg) + + # Check stdout (should only contain warning) + assert debug_msg not in caplog.text + assert info_msg not in caplog.text + assert warning_msg in caplog.text + + # Check file (should contain all messages) + log_files = [f for f in os.listdir(temp_log_dir) if f.startswith("joft_")] + log_path = os.path.join(temp_log_dir, log_files[0]) + with open(log_path, "r") as f: + log_content = f.read() + assert debug_msg in log_content + assert info_msg in log_content + assert warning_msg in log_content + + +def test_configure_logger_missing_log_dir() -> None: + """Test if logger uses current working directory when log_dir is not specified.""" + config: dict[str, dict[str, Any]] = {"file": {"log_level": "INFO"}} + + configure_logger(config) + logger = logging.getLogger("joft") + + test_message = "Test message" + logger.info(test_message) + + # Find the log file in current directory + log_files = [f for f in os.listdir() if f.startswith("joft_")] + assert len(log_files) == 1 + + log_path = os.path.join(os.getcwd(), log_files[0]) + with open(log_path, "r") as f: + log_content = f.read() + assert test_message in log_content + + # Cleanup the log file + os.remove(log_path) + + +def test_configure_logger_timestamp_format(temp_log_dir: str) -> None: + """Test if logger creates log files with correct timestamp format.""" + config: dict[str, dict[str, Any]] = { + "file": {"log_level": "INFO", "log_dir": temp_log_dir} + } + + current_time = datetime(2024, 3, 15, 10, 30, 45) + expected_timestamp = current_time.strftime("%Y%m%d_%H%M%S") + + with patch("joft.logger.datetime") as mock_datetime: + mock_datetime.now.return_value = current_time + configure_logger(config) + logger = logging.getLogger("joft") + logger.info("Test message") + + log_files = [f for f in os.listdir(temp_log_dir) if f.startswith("joft_")] + assert len(log_files) == 1 + assert f"joft_{expected_timestamp}.log" in log_files + + +@pytest.fixture(autouse=True) +def cleanup_handlers() -> Generator[None, None, None]: + """Cleanup fixture to remove handlers after each test.""" + yield + logger = logging.getLogger("joft") + logger.handlers.clear()