diff --git a/living_documentation_generator/action_inputs.py b/living_documentation_generator/action_inputs.py index 7cd0007..26df0d7 100644 --- a/living_documentation_generator/action_inputs.py +++ b/living_documentation_generator/action_inputs.py @@ -102,7 +102,7 @@ def get_repositories() -> list[ConfigRepository]: sys.exit(1) except TypeError: - logger.error("Type error parsing input JSON repositories: `%s.`", repositories_json) + logger.error("Type error parsing input JSON repositories: %s.", repositories_json) sys.exit(1) return repositories diff --git a/living_documentation_generator/generator.py b/living_documentation_generator/generator.py index 96e5eb3..8060f2d 100644 --- a/living_documentation_generator/generator.py +++ b/living_documentation_generator/generator.py @@ -152,7 +152,7 @@ def _fetch_github_issues(self) -> dict[str, list[Issue]]: issues[repository_id] = self.__safe_call(repository.get_issues)(state=ISSUE_STATE_ALL) amount_of_issues_per_repo = len(list(issues[repository_id])) logger.debug( - "Fetched `%s` repository issues (%s)`.", + "Fetched `%i` repository issues (%s)`.", amount_of_issues_per_repo, repository.full_name, ) @@ -170,13 +170,13 @@ def _fetch_github_issues(self) -> dict[str, list[Issue]]: # Accumulate the count of issues total_issues_number += amount_of_issues_per_repo logger.info( - "Fetching repository GitHub issues - fetched `%s` repository issues (%s).", + "Fetching repository GitHub issues - fetched `%i` repository issues (%s).", amount_of_issues_per_repo, repository.full_name, ) logger.info( - "Fetching repository GitHub issues - loaded `%s` repository issues in total.", + "Fetching repository GitHub issues - loaded `%i` repository issues in total.", total_issues_number, ) return issues @@ -213,7 +213,7 @@ def _fetch_github_project_issues(self) -> dict[str, list[ProjectIssue]]: if projects: logger.info( - "Fetching GitHub project data - for repository `%s` found `%s` project/s.", + "Fetching GitHub project data - for repository `%s` found `%i` project/s.", repository.full_name, len(projects), ) @@ -278,8 +278,8 @@ def _consolidate_issues_data( for project_issue in project_issues[key]: consolidated_issue.update_with_project_data(project_issue.project_status) - logging.info( - "Issue and project data consolidation - consolidated `%s` repository issues with extra project data.", + logger.info( + "Issue and project data consolidation - consolidated `%i` repository issues with extra project data.", len(consolidated_issues), ) return consolidated_issues @@ -651,6 +651,7 @@ def _generate_index_directory_path(repository_id: Optional[str], topic: Optional Generates a directory path based on if structured output is required. @param repository_id: The repository id. + @param topic: The topic used for grouping issues. @return: The generated directory path. """ output_path: str = ActionInputs.get_output_directory() diff --git a/tests/conftest.py b/tests/conftest.py index dc989bd..eb344ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # - +import datetime import time import pytest from github import Github @@ -21,7 +21,11 @@ from github.RateLimit import RateLimit from github.Repository import Repository +from living_documentation_generator.generator import LivingDocumentationGenerator +from living_documentation_generator.model.config_repository import ConfigRepository +from living_documentation_generator.model.consolidated_issue import ConsolidatedIssue from living_documentation_generator.model.github_project import GithubProject +from living_documentation_generator.model.project_status import ProjectStatus from living_documentation_generator.utils.github_rate_limiter import GithubRateLimiter @@ -76,3 +80,83 @@ def repository_setup(mocker): repository.full_name = "test_owner/test_repo" return repository + + +@pytest.fixture +def load_all_templates_setup(mocker): + mock_load_all_templates = mocker.patch.object( + LivingDocumentationGenerator, + "_load_all_templates", + return_value=( + "Issue Page Template", + "Index Page Template", + "Root Level Page Template", + "Org Level Template", + "Repo Page Template", + "Data Level Template", + ), + ) + + return mock_load_all_templates + + +@pytest.fixture +def generator(mocker): + mock_github_class = mocker.patch("living_documentation_generator.generator.Github") + mock_github_instance = mock_github_class.return_value + + mock_rate_limit = mocker.Mock() + mock_rate_limit.remaining = 5000 + mock_rate_limit.reset = datetime.datetime.now() + datetime.timedelta(minutes=10) + + mock_github_instance.get_rate_limit.return_value = mocker.Mock(core=mock_rate_limit) + mock_github_instance.get_repo.return_value = mocker.Mock() + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_github_token", return_value="FakeGithubToken" + ) + return LivingDocumentationGenerator() + + +@pytest.fixture +def config_repository(mocker): + config_repository = mocker.Mock(spec=ConfigRepository) + config_repository.organization_name = "test_org" + config_repository.repository_name = "test_repo" + config_repository.labels = [] + config_repository.projects_title_filter = [] + + return config_repository + + +@pytest.fixture +def consolidated_issue(mocker): + consolidated_issue = mocker.Mock(spec=ConsolidatedIssue) + consolidated_issue.repository_id = "TestOrg/TestRepo" + consolidated_issue.organization_name = "TestOrg" + consolidated_issue.repository_name = "TestRepo" + consolidated_issue.number = 42 + consolidated_issue.title = "Sample Issue" + consolidated_issue.state = "OPEN" + consolidated_issue.html_url = "https://github.com/TestOrg/TestRepo/issues/42" + consolidated_issue.created_at = "2024-01-01T00:00:00Z" + consolidated_issue.updated_at = "2024-01-02T00:00:00Z" + consolidated_issue.closed_at = None + consolidated_issue.labels = ["bug", "urgent"] + consolidated_issue.body = "This is the issue content." + consolidated_issue.linked_to_project = False + consolidated_issue.topics = ["documentationTopic"] + + return consolidated_issue + + +@pytest.fixture +def project_status(mocker): + project_status = mocker.Mock(spec=ProjectStatus) + project_status.project_title = "Test Project" + project_status.status = "In Progress" + project_status.priority = "High" + project_status.size = "Large" + project_status.moscow = "Must Have" + + return project_status diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 2239a8e..aa34ba6 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -14,15 +14,73 @@ # limitations under the License. # import json +import os from living_documentation_generator.action_inputs import ActionInputs from living_documentation_generator.model.config_repository import ConfigRepository +# Check Action Inputs default values + + +def test_project_state_mining_default(): + # Arrange + os.environ.pop("INPUT_PROJECT_STATE_MINING", None) + + # Act + actual = ActionInputs.get_is_project_state_mining_enabled() + + # Assert + assert not actual + + +def test_verbose_logging_default(): + # Act + actual = os.getenv("INPUT_VERBOSE_LOGGING", "false").lower() == "true" + + # Assert + assert not actual + + +def test_output_path_default(): + # Arrange + os.environ.pop("INPUT_OUTPUT_PATH", None) + expected = os.path.abspath("./output") + + # Act + actual = ActionInputs.get_output_directory() + + # Assert + assert expected == actual + + +def test_structured_output_default(): + # Arrange + os.environ.pop("INPUT_STRUCTURED_OUTPUT", None) + + # Act + actual = ActionInputs.get_is_structured_output_enabled() + + # Assert + assert not actual + + +def test_group_output_by_topics_default(): + # Arrange + os.environ.pop("INPUT_GROUP_OUTPUT_BY_TOPICS", None) + + # Act + actual = ActionInputs.get_is_grouping_by_topics_enabled() + + # Assert + assert not actual + + # get_repositories def test_get_repositories_correct_behaviour(mocker): + # Arrange repositories_json = [ { "organization-name": "organizationABC", @@ -41,8 +99,10 @@ def test_get_repositories_correct_behaviour(mocker): "living_documentation_generator.action_inputs.get_action_input", return_value=json.dumps(repositories_json) ) + # Act actual = ActionInputs.get_repositories() + # Assert assert 2 == len(actual) assert isinstance(actual[0], ConfigRepository) assert "organizationABC" == actual[0].organization_name @@ -56,95 +116,100 @@ def test_get_repositories_correct_behaviour(mocker): assert ["wanted_project"] == actual[1].projects_title_filter -# FixMe: For some reason this test is called 11 times. Please help me to understand the reason. -# def test_get_repositories_error_parsing_json_from_json_string(mocker): -# mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") -# mocker.patch("living_documentation_generator.action_inputs.get_action_input", return_value="not a JSON string") -# mock_exit = mocker.patch("sys.exit") -# -# ActionInputs.get_repositories() -# -# mock_log_error.assert_called_once_with("Error parsing input JSON repositories: `%s.`", mocker.ANY, exc_info=True) -# mock_exit.assert_called_once_with(1) - - def test_get_repositories_default_value_as_json(mocker): + # Arrange mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") mocker.patch("living_documentation_generator.action_inputs.get_action_input", return_value="[]") mock_exit = mocker.patch("sys.exit") + # Act actual = ActionInputs.get_repositories() - assert actual == [] + # Assert + assert [] == actual mock_exit.assert_not_called() mock_log_error.assert_not_called() def test_get_repositories_empty_object_as_input(mocker): + # Arrange mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") mocker.patch("living_documentation_generator.action_inputs.get_action_input", return_value="{}") mock_exit = mocker.patch("sys.exit") + # Act actual = ActionInputs.get_repositories() - assert actual == [] + # Assert + assert [] == actual mock_exit.assert_not_called() mock_log_error.assert_not_called() def test_get_repositories_error_with_loading_repository_json(mocker): + # Arrange mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") mocker.patch("living_documentation_generator.action_inputs.get_action_input", return_value="[{}]") mocker.patch.object(ConfigRepository, "load_from_json", return_value=False) mock_exit = mocker.patch("sys.exit") + # Act ActionInputs.get_repositories() + # Assert mock_exit.assert_not_called() mock_log_error.assert_called_once_with("Failed to load repository from JSON: %s.", {}) def test_get_repositories_number_instead_of_json(mocker): + # Arrange mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") mocker.patch("living_documentation_generator.action_inputs.get_action_input", return_value=1) mock_exit = mocker.patch("sys.exit") + # Act ActionInputs.get_repositories() + # Assert mock_exit.assert_called_once_with(1) - mock_log_error.assert_called_once_with("Type error parsing input JSON repositories: `%s.`", mocker.ANY) + mock_log_error.assert_called_once_with("Type error parsing input JSON repositories: %s.", mocker.ANY) def test_get_repositories_empty_string_as_input(mocker): + # Arrange mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") mocker.patch("living_documentation_generator.action_inputs.get_action_input", return_value="") mock_exit = mocker.patch("sys.exit") + # Act actual = ActionInputs.get_repositories() - assert actual == [] + # Assert + assert [] == actual mock_exit.assert_called_once() mock_log_error.assert_called_once_with("Error parsing JSON repositories: %s.", mocker.ANY, exc_info=True) def test_get_repositories_invalid_string_as_input(mocker): + # Arrange mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") - mocker.patch("living_documentation_generator.action_inputs.get_action_input", return_value="string") + mocker.patch("living_documentation_generator.action_inputs.get_action_input", return_value="not a JSON string") mock_exit = mocker.patch("sys.exit") + # Act actual = ActionInputs.get_repositories() - assert actual == [] - mock_exit.assert_called_once() + # Assert + assert [] == actual mock_log_error.assert_called_once_with("Error parsing JSON repositories: %s.", mocker.ANY, exc_info=True) + mock_exit.assert_called_once_with(1) # validate_inputs def test_validate_inputs_correct_behaviour(mocker): - mock_log_debug = mocker.patch("living_documentation_generator.action_inputs.logger.debug") - mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") + # Arrange repositories_json = [ { "organization-name": "organizationABC", @@ -153,39 +218,52 @@ def test_validate_inputs_correct_behaviour(mocker): "projects-title-filter": [], } ] + mock_log_debug = mocker.patch("living_documentation_generator.action_inputs.logger.debug") + mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") + mocker.patch( "living_documentation_generator.action_inputs.ActionInputs.get_repositories", return_value=repositories_json ) mock_exit = mocker.patch("sys.exit") + # Act ActionInputs.validate_inputs("./output") + # Assert mock_exit.assert_not_called() mock_log_debug.assert_called_once_with("Action inputs validation successfully completed.") mock_log_error.assert_not_called() def test_validate_inputs_error_output_path_as_empty_string(mocker): + # Arrange mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") mock_exit = mocker.patch("sys.exit") + # Act ActionInputs.validate_inputs("") + # Assert mock_exit.assert_called_once_with(1) mock_log_error.assert_called_once_with("INPUT_OUTPUT_PATH can not be an empty string.") def test_validate_inputs_error_output_path_as_project_directory(mocker): + # Arrange mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") mock_exit = mocker.patch("sys.exit") + # Act ActionInputs.validate_inputs("./templates/template_subfolder") + # Assert mock_exit.assert_called_once_with(1) mock_log_error.assert_called_once_with("INPUT_OUTPUT_PATH cannot be chosen as a part of any project folder.") def test_validate_inputs_absolute_output_path_with_relative_project_directories(mocker): + # Arrange + absolute_out_path = "/root/project/dir1/subfolder" mock_log_error = mocker.patch("living_documentation_generator.action_inputs.logger.error") mock_exit = mocker.patch("sys.exit") mocker.patch( @@ -193,9 +271,10 @@ def test_validate_inputs_absolute_output_path_with_relative_project_directories( return_value=["project/dir1", "project/dir2"], ) mocker.patch("os.path.abspath", side_effect=lambda path: f"/root/{path}" if not path.startswith("/") else path) - absolute_out_path = "/root/project/dir1/subfolder" + # Act ActionInputs.validate_inputs(absolute_out_path) + # Assert mock_exit.assert_called_once_with(1) mock_log_error.assert_called_once_with("INPUT_OUTPUT_PATH cannot be chosen as a part of any project folder.") diff --git a/tests/test_generator.py b/tests/test_generator.py new file mode 100644 index 0000000..ea22845 --- /dev/null +++ b/tests/test_generator.py @@ -0,0 +1,1204 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from datetime import datetime + +from github.Issue import Issue + +from living_documentation_generator.generator import LivingDocumentationGenerator +from living_documentation_generator.model.consolidated_issue import ConsolidatedIssue +from living_documentation_generator.model.project_issue import ProjectIssue + + +# generate + + +def test_generate_correct_behaviour(mocker, generator): + # Arrange + mock_clean_output_directory = mocker.patch.object(generator, "_clean_output_directory") + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + mock_logger_debug = mocker.patch("living_documentation_generator.generator.logger.debug") + + mock_issue = mocker.Mock() + project_issue_mock = mocker.Mock() + consolidated_issue_mock = mocker.Mock() + + mock_fetch_github_issues = mocker.patch.object( + generator, "_fetch_github_issues", return_value={"test_org/test_repo": [mock_issue]} + ) + mock_fetch_github_project_issues = mocker.patch.object( + generator, "_fetch_github_project_issues", return_value={"test_org/test_repo#1": [project_issue_mock]} + ) + mock_consolidate_issues_data = mocker.patch.object( + generator, "_consolidate_issues_data", return_value={"test_org/test_repo#1": consolidated_issue_mock} + ) + mock_generate_markdown_pages = mocker.patch.object(generator, "_generate_markdown_pages") + + # Act + generator.generate() + + # Assert + mock_clean_output_directory.assert_called_once() + mock_fetch_github_issues.assert_called_once() + mock_fetch_github_project_issues.assert_called_once() + mock_consolidate_issues_data.assert_called_once_with( + {"test_org/test_repo": [mock_issue]}, {"test_org/test_repo#1": [project_issue_mock]} + ) + mock_generate_markdown_pages.assert_called_once_with({"test_org/test_repo#1": consolidated_issue_mock}) + mock_logger_debug.assert_called_once_with("Output directory cleaned.") + mock_logger_info.assert_has_calls( + [ + mocker.call("Fetching repository GitHub issues - started."), + mocker.call("Fetching repository GitHub issues - finished."), + mocker.call("Fetching GitHub project data - started."), + mocker.call("Fetching GitHub project data - finished."), + mocker.call("Issue and project data consolidation - started."), + mocker.call("Issue and project data consolidation - finished."), + mocker.call("Markdown page generation - started."), + mocker.call("Markdown page generation - finished."), + ], + any_order=True, + ) + + +# _clean_output_directory + + +def test_clean_output_directory_correct_behaviour(mocker, generator): + # Arrange + mock_output_path = "/test/output/path" + mock_get_output_directory = mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value=mock_output_path + ) + mock_exists = mocker.patch("os.path.exists", return_value=True) + mock_rmtree = mocker.patch("shutil.rmtree") + mock_makedirs = mocker.patch("os.makedirs") + + # Act + generator._clean_output_directory() + + # Assert + mock_get_output_directory.assert_called_once() + mock_exists.assert_called_once_with(mock_output_path) + mock_rmtree.assert_called_once_with(mock_output_path) + mock_makedirs.assert_called_once_with(mock_output_path) + + +# _fetch_github_issues + + +def test_fetch_github_issues_no_query_labels(mocker, generator, config_repository): + # Arrange + config_repository.query_labels = [] + repo = mocker.Mock() + repo.full_name = "test_org/test_repo" + issue1 = mocker.Mock() + issue2 = mocker.Mock() + issue3 = mocker.Mock() + + expected_issues = {"test_org/test_repo": [issue1, issue2, issue3]} + mock_get_repo = generator._LivingDocumentationGenerator__github_instance.get_repo + mock_get_repo.return_value = repo + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_repositories", return_value=[config_repository] + ) + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + mock_logger_debug = mocker.patch("living_documentation_generator.generator.logger.debug") + mock_get_issues = mocker.patch.object(repo, "get_issues", return_value=[issue1, issue2, issue3]) + + # Act + actual = generator._fetch_github_issues() + + # Assert + assert expected_issues == actual + assert 1 == len(actual) + assert 3 == len(actual["test_org/test_repo"]) + mock_get_repo.assert_called_once_with("test_org/test_repo") + mock_get_issues.assert_called_once_with(state="all") + mock_logger_info.assert_has_calls( + [ + mocker.call("Fetching repository GitHub issues - from `%s`.", "test_org/test_repo"), + mocker.call( + "Fetching repository GitHub issues - fetched `%i` repository issues (%s).", 3, "test_org/test_repo" + ), + mocker.call("Fetching repository GitHub issues - loaded `%i` repository issues in total.", 3), + ], + any_order=True, + ) + mock_logger_debug.assert_has_calls( + [ + mocker.call("Fetching all issues in the repository"), + mocker.call("Fetched `%i` repository issues (%s)`.", 3, "test_org/test_repo"), + ], + any_order=True, + ) + + +def test_fetch_github_issues_with_given_query_labels(mocker, generator, config_repository): + # Arrange + config_repository.query_labels = ["bug", "enhancement"] + repo = mocker.Mock() + repo.full_name = "test_org/test_repo" + issue1 = mocker.Mock() + issue2 = mocker.Mock() + + expected_issues = {"test_org/test_repo": [issue1, issue2]} + mock_get_repo = generator._LivingDocumentationGenerator__github_instance.get_repo + mock_get_repo.return_value = repo + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_repositories", return_value=[config_repository] + ) + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + mock_logger_debug = mocker.patch("living_documentation_generator.generator.logger.debug") + mock_get_issues = mocker.patch.object(repo, "get_issues", side_effect=[[issue1], [issue2]]) + + # Act + actual = generator._fetch_github_issues() + + # Assert + assert expected_issues == actual + assert 1 == len(actual) + mock_get_repo.assert_called_once_with("test_org/test_repo") + mock_get_issues.assert_any_call(state="all", labels=["bug"]) + mock_get_issues.assert_any_call(state="all", labels=["enhancement"]) + mock_logger_info.assert_has_calls( + [ + mocker.call("Fetching repository GitHub issues - from `%s`.", "test_org/test_repo"), + mocker.call( + "Fetching repository GitHub issues - fetched `%i` repository issues (%s).", 2, "test_org/test_repo" + ), + mocker.call("Fetching repository GitHub issues - loaded `%i` repository issues in total.", 2), + ], + any_order=True, + ) + mock_logger_debug.assert_has_calls( + [ + mocker.call("Labels to be fetched from: %s.", config_repository.query_labels), + mocker.call("Fetching issues with label `%s`.", "bug"), + mocker.call("Fetching issues with label `%s`.", "enhancement"), + ], + any_order=True, + ) + + +def test_fetch_github_issues_repository_none(mocker, generator, config_repository): + # Arrange + mock_get_repo = generator._LivingDocumentationGenerator__github_instance.get_repo + mock_get_repo.return_value = None + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_repositories", return_value=[config_repository] + ) + + # Act + actual = generator._fetch_github_issues() + + # Assert + assert {} == actual + mock_get_repo.assert_called_once_with("test_org/test_repo") + + +# _fetch_github_project_issues + + +def test_fetch_github_project_issues_correct_behaviour(mocker, generator): + # Arrange + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + mock_logger_debug = mocker.patch("living_documentation_generator.generator.logger.debug") + + repository_1 = mocker.Mock() + repository_1.organization_name = "OrgA" + repository_1.repository_name = "RepoA" + repository_1.projects_title_filter = "" + + repository_2 = mocker.Mock() + repository_2.organization_name = "OrgA" + repository_2.repository_name = "RepoB" + repository_2.projects_title_filter = "ProjectB" + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_repositories", + return_value=[repository_1, repository_2], + ) + + mock_github_projects_instance = mocker.patch.object( + generator, "_LivingDocumentationGenerator__github_projects_instance" + ) + + repo_a = mocker.Mock() + repo_a.full_name = "OrgA/RepoA" + repo_b = mocker.Mock() + repo_b.full_name = "OrgA/RepoB" + generator._LivingDocumentationGenerator__github_instance.get_repo.side_effect = [repo_a, repo_b] + + project_a = mocker.Mock(title="Project A") + project_b = mocker.Mock(title="Project B") + mock_github_projects_instance.get_repository_projects.side_effect = [[project_a], [project_b]] + + project_status_1 = mocker.Mock() + project_status_1.status = "In Progress" + + project_status_2 = mocker.Mock() + project_status_2.status = "Done" + + project_issue_1 = mocker.Mock(spec=ProjectIssue) + project_issue_1.organization_name = "OrgA" + project_issue_1.repository_name = "RepoA" + project_issue_1.number = 1 + project_issue_1.project_status = project_status_1 + + # By creating two same Project Issues (same unique issue key) that has different project statuses + # we test the situation where one issue is linked to more projects (need of keeping all project statuses) + project_issue_2 = mocker.Mock(spec=ProjectIssue) + project_issue_2.organization_name = "OrgA" + project_issue_2.repository_name = "RepoA" + project_issue_2.number = 1 + project_issue_2.project_status = project_status_2 + + mock_github_projects_instance.get_project_issues.side_effect = [[project_issue_1], [project_issue_2]] + + mock_make_issue_key = mocker.patch( + "living_documentation_generator.generator.make_issue_key", + side_effect=lambda org, repo, num: f"{org}/{repo}#{num}", + ) + + # Act + actual = generator._fetch_github_project_issues() + + # Assert + assert mock_make_issue_key.call_count == 2 + assert len(actual) == 1 + assert "OrgA/RepoA#1" in actual + assert actual["OrgA/RepoA#1"] == [project_issue_1, project_issue_2] + + generator._LivingDocumentationGenerator__github_instance.get_repo.assert_any_call("OrgA/RepoA") + generator._LivingDocumentationGenerator__github_instance.get_repo.assert_any_call("OrgA/RepoB") + mock_github_projects_instance.get_repository_projects.assert_any_call(repository=repo_a, projects_title_filter="") + mock_github_projects_instance.get_repository_projects.assert_any_call( + repository=repo_b, projects_title_filter="ProjectB" + ) + mock_github_projects_instance.get_project_issues.assert_any_call(project=project_a) + mock_github_projects_instance.get_project_issues.assert_any_call(project=project_b) + mock_logger_info.assert_has_calls( + [ + mocker.call("Fetching GitHub project data - for repository `%s` found `%i` project/s.", "OrgA/RepoA", 1), + mocker.call("Fetching GitHub project data - fetching project data from `%s`.", "Project A"), + mocker.call("Fetching GitHub project data - successfully fetched project data from `%s`.", "Project A"), + mocker.call("Fetching GitHub project data - for repository `%s` found `%i` project/s.", "OrgA/RepoB", 1), + mocker.call("Fetching GitHub project data - fetching project data from `%s`.", "Project B"), + mocker.call("Fetching GitHub project data - successfully fetched project data from `%s`.", "Project B"), + ], + any_order=True, + ) + mock_logger_debug.assert_has_calls( + [ + mocker.call("Project data mining allowed."), + mocker.call("Filtering projects: %s. If filter is empty, fetching all.", ""), + mocker.call("Filtering projects: %s. If filter is empty, fetching all.", "ProjectB"), + mocker.call("Fetching GitHub project data - looking for repository `%s` projects.", "OrgA/RepoA"), + mocker.call("Fetching GitHub project data - looking for repository `%s` projects.", "OrgA/RepoB"), + ], + any_order=True, + ) + + +def test_fetch_github_project_issues_project_mining_disabled(mocker, generator): + # Arrange + mock_get_project_mining_enabled = mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=False + ) + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + + # Act + actual = generator._fetch_github_project_issues() + + # Assert + assert {} == actual + mock_get_project_mining_enabled.assert_called_once() + mock_logger_info.assert_called_once_with("Fetching GitHub project data - project mining is not allowed.") + + +def test_fetch_github_project_issues_no_repositories(mocker, generator, config_repository): + # Arrange + mock_get_repo = generator._LivingDocumentationGenerator__github_instance.get_repo + mock_get_repo.return_value = None + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_repositories", return_value=[config_repository] + ) + + # Act + actual = generator._fetch_github_project_issues() + + # Assert + assert {} == actual + mock_get_repo.assert_called_once_with("test_org/test_repo") + + +def test_fetch_github_project_issues_with_no_projects(mocker, generator, config_repository): + # Arrange + mock_get_repo = generator._LivingDocumentationGenerator__github_instance.get_repo + repo_a = mocker.Mock() + repo_a.full_name = "test_org/test_repo" + mock_get_repo.return_value = repo_a + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_repositories", return_value=[config_repository] + ) + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + mock_get_repository_projects = mocker.patch.object( + generator._LivingDocumentationGenerator__github_projects_instance, "get_repository_projects", return_value=[] + ) + + # Act + actual = generator._fetch_github_project_issues() + + # Assert + assert {} == actual + mock_get_repo.assert_called_once_with("test_org/test_repo") + mock_get_repository_projects.assert_called_once_with(repository=repo_a, projects_title_filter=[]) + mock_logger_info.assert_called_once_with( + "Fetching GitHub project data - no project data found for repository `%s`.", "test_org/test_repo" + ) + + +# _consolidate_issues_data + + +def test_consolidate_issues_data_correct_behaviour(mocker, generator): + # Arrange + consolidated_issue_mock_1 = mocker.Mock(spec=ConsolidatedIssue) + consolidated_issue_mock_2 = mocker.Mock(spec=ConsolidatedIssue) + repository_issues = {"TestOrg/TestRepo": [mocker.Mock(spec=Issue, number=1), mocker.Mock(spec=Issue, number=2)]} + project_issues = { + "TestOrg/TestRepo#1": [mocker.Mock(spec=ProjectIssue, project_status="In Progress")], + "TestOrg/TestRepo#2": [mocker.Mock(spec=ProjectIssue, project_status="Done")], + } + + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + mock_logger_debug = mocker.patch("living_documentation_generator.generator.logger.debug") + mock_make_issue_key = mocker.patch( + "living_documentation_generator.generator.make_issue_key", + side_effect=lambda org, repo, num: f"{org}/{repo}#{num}", + ) + mock_consolidated_issue_class = mocker.patch( + "living_documentation_generator.generator.ConsolidatedIssue", + side_effect=[consolidated_issue_mock_1, consolidated_issue_mock_2], + ) + + # Act + actual = generator._consolidate_issues_data(repository_issues, project_issues) + + # Assert + assert 2 == len(actual) + assert 2 == mock_consolidated_issue_class.call_count + assert 2 == mock_make_issue_key.call_count + assert actual["TestOrg/TestRepo#1"] == consolidated_issue_mock_1 + assert actual["TestOrg/TestRepo#2"] == consolidated_issue_mock_2 + consolidated_issue_mock_1.update_with_project_data.assert_called_once_with("In Progress") + consolidated_issue_mock_2.update_with_project_data.assert_called_once_with("Done") + mock_logger_info.assert_called_once_with( + "Issue and project data consolidation - consolidated `%i` repository issues with extra project data.", 2 + ) + mock_logger_debug.assert_called_once_with("Updating consolidated issue structure with project data.") + + +# _generate_markdown_pages + + +def test_generate_markdown_pages_with_structured_output_and_topic_grouping_enabled( + mocker, generator, consolidated_issue, load_all_templates_setup +): + # Arrange + mock_load_all_templates = load_all_templates_setup + topics = {"documentationTopic"} + issues = {"issue_1": consolidated_issue, "issue_2": consolidated_issue} + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mock_generate_root_level_index_page = mocker.patch( + "living_documentation_generator.generator.generate_root_level_index_page" + ) + mock_generate_structured_index_pages = mocker.patch.object( + LivingDocumentationGenerator, "_generate_structured_index_pages" + ) + mock_generate_index_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_index_page") + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + mock_generate_md_issue_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_md_issue_page") + + # Act + generator._generate_markdown_pages(issues) + + # Assert + assert 2 == mock_generate_md_issue_page.call_count + mock_load_all_templates.assert_called_once() + mock_generate_md_issue_page.assert_any_call("Issue Page Template", consolidated_issue) + mock_generate_root_level_index_page.assert_called_once_with("Root Level Page Template", "/base/output") + mock_generate_structured_index_pages.assert_called_once_with( + "Data Level Template", "Repo Page Template", "Org Level Template", topics, issues + ) + mock_generate_index_page.assert_not_called() + mock_logger_info.assert_called_once_with("Markdown page generation - generated `%i` issue pages.", 2) + + +def test_generate_markdown_pages_with_structured_output_enabled_and_topic_grouping_disabled( + mocker, generator, consolidated_issue, load_all_templates_setup +): + # Arrange + topics = {"documentationTopic", "FETopic"} + consolidated_issue.topics = ["documentationTopic", "FETopic"] + issues = {"issue_1": consolidated_issue, "issue_2": consolidated_issue, "issue_3": consolidated_issue} + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=False + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mock_generate_md_issue_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_md_issue_page") + mock_generate_root_level_index_page = mocker.patch( + "living_documentation_generator.generator.generate_root_level_index_page" + ) + mock_generate_structured_index_pages = mocker.patch.object( + LivingDocumentationGenerator, "_generate_structured_index_pages" + ) + mock_generate_index_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_index_page") + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + + # Act + generator._generate_markdown_pages(issues) + + # Assert + assert 3 == mock_generate_md_issue_page.call_count + load_all_templates_setup.assert_called_once() + mock_generate_md_issue_page.assert_any_call("Issue Page Template", consolidated_issue) + mock_generate_root_level_index_page.assert_called_once_with("Root Level Page Template", "/base/output") + mock_generate_structured_index_pages.assert_called_once_with( + "Data Level Template", "Repo Page Template", "Org Level Template", topics, issues + ) + mock_logger_info.assert_called_once_with("Markdown page generation - generated `%i` issue pages.", 3) + + +def test_generate_markdown_pages_with_structured_output_and_topic_grouping_disabled( + mocker, generator, consolidated_issue, load_all_templates_setup +): + # Arrange + issues = {"issue_1": consolidated_issue} + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=False + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=False + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mock_generate_md_issue_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_md_issue_page") + mock_generate_root_level_index_page = mocker.patch( + "living_documentation_generator.generator.generate_root_level_index_page" + ) + mock_generate_structured_index_pages = mocker.patch.object( + LivingDocumentationGenerator, "_generate_structured_index_pages" + ) + mock_generate_index_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_index_page") + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + + # Act + generator._generate_markdown_pages(issues) + + # Assert + load_all_templates_setup.assert_called_once() + assert mock_generate_md_issue_page.call_count == 1 + mock_generate_md_issue_page.assert_any_call("Issue Page Template", consolidated_issue) + mock_generate_root_level_index_page.assert_not_called() + mock_generate_structured_index_pages.assert_not_called() + mock_generate_index_page.assert_called_once_with("Index Page Template", list(issues.values())) + mock_logger_info.assert_any_call("Markdown page generation - generated `%i` issue pages.", 1) + mock_logger_info.assert_any_call("Markdown page generation - generated `_index.md`") + + +def test_generate_markdown_pages_with_topic_grouping_enabled_and_structured_output_disabled( + mocker, generator, consolidated_issue, load_all_templates_setup +): + # Arrange + consolidated_issue.topics = ["documentationTopic", "FETopic"] + issues = {"issue_1": consolidated_issue, "issue_2": consolidated_issue} + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=False + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mock_generate_md_issue_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_md_issue_page") + mock_generate_root_level_index_page = mocker.patch( + "living_documentation_generator.generator.generate_root_level_index_page" + ) + mock_generate_structured_index_pages = mocker.patch.object( + LivingDocumentationGenerator, "_generate_structured_index_pages" + ) + mock_generate_index_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_index_page") + + # Act + generator._generate_markdown_pages(issues) + + # Assert + assert 2 == mock_generate_md_issue_page.call_count + load_all_templates_setup.assert_called_once() + mock_generate_md_issue_page.assert_any_call("Issue Page Template", consolidated_issue) + mock_generate_root_level_index_page.assert_called_once_with("Root Level Page Template", "/base/output") + mock_generate_structured_index_pages.assert_not_called() + mock_generate_index_page.assert_any_call( + "Data Level Template", list(issues.values()), grouping_topic="documentationTopic" + ) + mock_generate_index_page.assert_any_call("Data Level Template", list(issues.values()), grouping_topic="FETopic") + + +# _generate_md_issue_page + + +def test_generate_md_issue_page(mocker, generator, consolidated_issue): + # Arrange + issue_page_template = "Title: {title}\nDate: {date}\nSummary:\n{issue_summary_table}\nContent:\n{issue_content}" + consolidated_issue.generate_directory_path = mocker.Mock(return_value=["/base/output/org/repo/issues"]) + consolidated_issue.generate_page_filename = mocker.Mock(return_value="issue_42.md") + expected_date = datetime.now().strftime("%Y-%m-%d") + expected_content = f"Title: Sample Issue\nDate: {expected_date}\nSummary:\nGenerated Issue Summary Table\nContent:\nThis is the issue content." + + mock_generate_issue_summary_table = mocker.patch.object( + LivingDocumentationGenerator, "_generate_issue_summary_table", return_value="Generated Issue Summary Table" + ) + mock_makedirs = mocker.patch("os.makedirs") + mock_open = mocker.patch("builtins.open", mocker.mock_open()) + + # Act + generator._generate_md_issue_page(issue_page_template, consolidated_issue) + + # Assert + mock_generate_issue_summary_table.assert_called_once_with(consolidated_issue) + mock_makedirs.assert_called_once_with("/base/output/org/repo/issues", exist_ok=True) + mock_open.assert_called_once_with("/base/output/org/repo/issues/issue_42.md", "w", encoding="utf-8") + mock_open().write.assert_called_once_with(expected_content) + + +# _generate_structured_index_pages + + +def test_generate_structured_index_pages_with_topic_grouping_enabled(mocker, generator, consolidated_issue): + # Arrange + index_data_level_template = "Data Level Template" + index_repo_level_template = "Repo Level Template" + index_org_level_template = "Org Level Template" + topics = ["documentationTopic"] + consolidated_issues = {"issue_1": consolidated_issue, "issue_2": consolidated_issue} + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=True + ) + mock_generate_sub_level_index_page = mocker.patch.object( + LivingDocumentationGenerator, "_generate_sub_level_index_page" + ) + mock_generate_index_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_index_page") + mock_logger_info = mocker.patch("living_documentation_generator.generator.logger.info") + mock_logger_debug = mocker.patch("living_documentation_generator.generator.logger.debug") + + # Act + generator._generate_structured_index_pages( + index_data_level_template, + index_repo_level_template, + index_org_level_template, + topics, + consolidated_issues, + ) + + # Assert + mock_generate_sub_level_index_page.assert_any_call(index_org_level_template, "org", "TestOrg/TestRepo") + mock_generate_sub_level_index_page.assert_any_call(index_repo_level_template, "repo", "TestOrg/TestRepo") + mock_generate_index_page.assert_called_once_with( + index_data_level_template, [consolidated_issue, consolidated_issue], "TestOrg/TestRepo", "documentationTopic" + ) + mock_logger_info.assert_called_once_with( + "Markdown page generation - generated `_index.md` pages for %s.", "TestOrg/TestRepo" + ) + mock_logger_debug.assert_any_call("Generated organization level `_index.md` for %s.", "TestOrg") + mock_logger_debug.assert_any_call("Generated repository level _index.md` for repository: %s.", "TestRepo") + mock_logger_debug.assert_any_call( + "Generated data level `_index.md` with topic: %s for %s.", "documentationTopic", "TestOrg/TestRepo" + ) + + +def test_generate_structured_index_pages_with_topic_grouping_disabled(mocker, generator, consolidated_issue): + # Arrange + index_data_level_template = "Data Level Template" + index_repo_level_template = "Repo Level Template" + index_org_level_template = "Org Level Template" + topics = ["documentationTopic"] + consolidated_issue.repository_id = "TestOrg/TestRepo" + consolidated_issues = {"issue_1": consolidated_issue, "issue_2": consolidated_issue} + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=False + ) + mock_generate_sub_level_index_page = mocker.patch.object( + LivingDocumentationGenerator, "_generate_sub_level_index_page" + ) + mock_generate_index_page = mocker.patch.object(LivingDocumentationGenerator, "_generate_index_page") + mock_logger_debug = mocker.patch("living_documentation_generator.generator.logger.debug") + + # Act + generator._generate_structured_index_pages( + index_data_level_template, + index_repo_level_template, + index_org_level_template, + topics, + consolidated_issues, + ) + + # Assert + mock_generate_sub_level_index_page.assert_called_once_with(index_org_level_template, "org", "TestOrg/TestRepo") + mock_generate_index_page.assert_called_once_with( + index_data_level_template, [consolidated_issue, consolidated_issue], "TestOrg/TestRepo" + ) + mock_logger_debug.assert_any_call("Generated organization level `_index.md` for %s.", "TestOrg") + mock_logger_debug.assert_any_call("Generated data level `_index.md` for %s", "TestOrg/TestRepo") + + +# _generate_index_page +TABLE_HEADER_WITH_PROJECT_DATA = """ +| Organization name | Repository name | Issue 'Number - Title' |Linked to project | Project status | Issue URL | +|-------------------|-----------------|------------------------|------------------|----------------|-----------| +""" + + +def test_generate_index_page_with_all_features_enabled(mocker, generator, consolidated_issue, project_status): + # Arrange + issue_index_page_template = "Date: {date}\nIssues:\n{issue_overview_table}\nData Level: {data_level_name}" + consolidated_issue.linked_to_project = True + consolidated_issue.project_issue_statuses = [project_status] + consolidated_issues = [consolidated_issue, consolidated_issue] + + repository_id = "TestOrg/TestRepo" + grouping_topic = "documentationTopic" + + expected_date = datetime.now().strftime("%Y-%m-%d") + expected_issue_line = "| TestOrg | TestRepo | [#42 - Sample Issue](features#sample-issue) | 🟢 | In Progress |GitHub link |\n" + expected_issue_table = TABLE_HEADER_WITH_PROJECT_DATA + expected_issue_line + expected_issue_line + expected_data_level_name = "documentationTopic" + expected_index_page_content = ( + f"Date: {expected_date}\nIssues:\n{expected_issue_table}\nData Level: {expected_data_level_name}" + ) + expected_output_path = "/base/output/org/repo/topic/_index.md" + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=True + ) + mock_generate_index_directory_path = mocker.patch.object( + LivingDocumentationGenerator, "_generate_index_directory_path", return_value="/base/output/org/repo/topic" + ) + mock_open = mocker.patch("builtins.open", mocker.mock_open()) + mocker.patch("os.makedirs") + + # Act + generator._generate_index_page(issue_index_page_template, consolidated_issues, repository_id, grouping_topic) + + # Assert + mock_generate_index_directory_path.assert_called_once_with(repository_id, grouping_topic) + mock_open.assert_called_once_with(expected_output_path, "w", encoding="utf-8") + mock_open().write.assert_called_once_with(expected_index_page_content) + + +def test_generate_index_page_with_topic_grouping_disabled_structured_output_project_mining_enabled( + mocker, generator, consolidated_issue, project_status +): + # Arrange + issue_index_page_template = "Date: {date}\nIssues:\n{issue_overview_table}\nData Level: {data_level_name}" + consolidated_issue.linked_to_project = True + consolidated_issue.project_issue_statuses = [project_status] + consolidated_issues = [consolidated_issue, consolidated_issue] + + repository_id = "TestOrg/TestRepo" + grouping_topic = None + + expected_date = datetime.now().strftime("%Y-%m-%d") + expected_issue_line = "| TestOrg | TestRepo | [#42 - Sample Issue](features#sample-issue) | 🟢 | In Progress |GitHub link |\n" + expected_issue_table = TABLE_HEADER_WITH_PROJECT_DATA + expected_issue_line + expected_issue_line + expected_data_level_name = "TestRepo" + expected_index_page_content = ( + f"Date: {expected_date}\nIssues:\n{expected_issue_table}\nData Level: {expected_data_level_name}" + ) + expected_output_path = "/base/output/org/repo/_index.md" + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=False + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=True + ) + mock_generate_index_directory_path = mocker.patch.object( + LivingDocumentationGenerator, "_generate_index_directory_path", return_value="/base/output/org/repo" + ) + mock_open = mocker.patch("builtins.open", mocker.mock_open()) + mocker.patch("os.makedirs") + + # Act + generator._generate_index_page(issue_index_page_template, consolidated_issues, repository_id, grouping_topic) + + # Assert + mock_generate_index_directory_path.assert_called_once_with(repository_id, grouping_topic) + mock_open.assert_called_once_with(expected_output_path, "w", encoding="utf-8") + mock_open().write.assert_called_once_with(expected_index_page_content) + + +def test_generate_index_page_with_topic_grouping_and_structured_output_disabled_project_mining_enabled( + mocker, generator, consolidated_issue, project_status +): + # Arrange + issue_index_page_template = "Date: {date}\nIssues:\n{issue_overview_table}\n" + consolidated_issue.project_issue_statuses = [project_status] + consolidated_issues = [consolidated_issue, consolidated_issue] + + repository_id = None + grouping_topic = None + + expected_date = datetime.now().strftime("%Y-%m-%d") + expected_issue_line = "| TestOrg | TestRepo | [#42 - Sample Issue](features#sample-issue) | 🔴 | In Progress |GitHub link |\n" + expected_issue_table = TABLE_HEADER_WITH_PROJECT_DATA + expected_issue_line + expected_issue_line + expected_index_page_content = f"Date: {expected_date}\nIssues:\n{expected_issue_table}\n" + expected_output_path = "/base/output/_index.md" + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=False + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=False + ) + mock_generate_index_directory_path = mocker.patch.object( + LivingDocumentationGenerator, "_generate_index_directory_path", return_value="/base/output" + ) + mock_open = mocker.patch("builtins.open", mocker.mock_open()) + mocker.patch("os.makedirs") + + # Act + generator._generate_index_page(issue_index_page_template, consolidated_issues, repository_id, grouping_topic) + + # Assert + mock_generate_index_directory_path.assert_called_once_with(repository_id, grouping_topic) + mock_open.assert_called_once_with(expected_output_path, "w", encoding="utf-8") + mock_open().write.assert_called_once_with(expected_index_page_content) + + +# _generate_sub_level_index_page + + +def test_generate_sub_level_index_page_for_org_level(mocker, generator): + # Arrange + index_template = "Organization: {organization_name}, Date: {date}" + level = "org" + repository_id = "TestOrg/TestRepo" + expected_replacement_content = f"Organization: TestOrg, Date: {datetime.now().strftime('%Y-%m-%d')}" + expected_output_path = "/base/output/TestOrg/_index.md" + + mock_get_output_directory = mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mock_open = mocker.patch("builtins.open", mocker.mock_open()) + mocker.patch("os.makedirs") + + # Act + generator._generate_sub_level_index_page(index_template, level, repository_id) + + # Assert + mock_get_output_directory.assert_called_once() + mock_open.assert_called_once_with(expected_output_path, "w", encoding="utf-8") + mock_open().write.assert_called_once_with(expected_replacement_content) + + +def test_generate_sub_level_index_page_for_repo_level(mocker, generator): + # Arrange + index_template = "Repository: {repository_name}, Date: {date}" + level = "repo" + repository_id = "TestOrg/TestRepo" + expected_replacement_content = f"Repository: TestRepo, Date: {datetime.now().strftime('%Y-%m-%d')}" + expected_output_path = "/base/output/TestOrg/TestRepo/_index.md" + + mock_get_output_directory = mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mock_open = mocker.patch("builtins.open", mocker.mock_open()) + mocker.patch("os.makedirs") + + # Act + generator._generate_sub_level_index_page(index_template, level, repository_id) + + # Assert + mock_get_output_directory.assert_called_once() + mock_open.assert_called_once_with(expected_output_path, "w", encoding="utf-8") + mock_open().write.assert_called_once_with(expected_replacement_content) + + +# _generate_markdown_line + + +def test_generate_markdown_line_with_project_state_mining_enabled_linked_to_project_true_symbol( + mocker, consolidated_issue, project_status +): + # Arrange + consolidated_issue.linked_to_project = True + consolidated_issue.project_issue_statuses = [project_status, project_status] + expected_md_issue_line = ( + "| TestOrg | TestRepo | [#42 - Sample Issue](features#sample-issue) | 🟢 | In Progress, In Progress |" + "GitHub link |\n" + ) + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + + # Act + actual = LivingDocumentationGenerator._generate_markdown_line(consolidated_issue) + + # Assert + assert expected_md_issue_line == actual + + +def test_generate_markdown_line_with_project_state_mining_enabled_linked_to_project_false_symbol( + mocker, consolidated_issue, project_status +): + # Arrange + consolidated_issue.project_issue_statuses = [project_status] + expected_md_issue_line = ( + "| TestOrg | TestRepo | [#42 - Sample Issue](features#sample-issue) | 🔴 | In Progress |" + "GitHub link |\n" + ) + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + + # Act + actual = LivingDocumentationGenerator._generate_markdown_line(consolidated_issue) + + # Assert + assert expected_md_issue_line == actual + + +def test_generate_markdown_line_with_project_state_mining_disabled(mocker, consolidated_issue, project_status): + # Arrange + consolidated_issue.project_issue_statuses = [project_status] + expected_md_issue_line = ( + "| TestOrg | TestRepo | [#42 - Sample Issue](features#sample-issue) | OPEN |" + "GitHub link |\n" + ) + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=False + ) + + # Act + actual = LivingDocumentationGenerator._generate_markdown_line(consolidated_issue) + + # Assert + assert expected_md_issue_line == actual + + +# _generate_issue_summary_table + + +def test_generate_issue_summary_table_without_project_state_mining(mocker, generator, consolidated_issue): + # Arrange + expected_issue_info = ( + "| Attribute | Content |\n" + "|---|---|\n" + "| Organization name | TestOrg |\n" + "| Repository name | TestRepo |\n" + "| Issue number | 42 |\n" + "| State | open |\n" + "| Issue URL | GitHub link |\n" + "| Created at | 2024-01-01T00:00:00Z |\n" + "| Updated at | 2024-01-02T00:00:00Z |\n" + "| Closed at | None |\n" + "| Labels | bug, urgent |\n" + ) + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=False + ) + + actual = generator._generate_issue_summary_table(consolidated_issue) + + assert expected_issue_info == actual + + +def test_generate_issue_summary_table_with_project_state_mining_and_multiple_project_statuses( + mocker, consolidated_issue, project_status +): + # Arrange + consolidated_issue.linked_to_project = True + consolidated_issue.project_issue_statuses = [project_status, project_status] + expected_issue_info = ( + "| Attribute | Content |\n" + "|---|---|\n" + "| Organization name | TestOrg |\n" + "| Repository name | TestRepo |\n" + "| Issue number | 42 |\n" + "| State | open |\n" + "| Issue URL | GitHub link |\n" + "| Created at | 2024-01-01T00:00:00Z |\n" + "| Updated at | 2024-01-02T00:00:00Z |\n" + "| Closed at | None |\n" + "| Labels | bug, urgent |\n" + "| Project title | Test Project |\n" + "| Status | In Progress |\n" + "| Priority | High |\n" + "| Size | Large |\n" + "| MoSCoW | Must Have |\n" + "| Project title | Test Project |\n" + "| Status | In Progress |\n" + "| Priority | High |\n" + "| Size | Large |\n" + "| MoSCoW | Must Have |\n" + ) + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + + # Act + actual = LivingDocumentationGenerator._generate_issue_summary_table(consolidated_issue) + + # Assert + assert expected_issue_info == actual + + +def test_generate_issue_summary_table_with_project_state_mining_but_no_linked_project( + mocker, consolidated_issue, project_status +): + # Arrange + consolidated_issue.linked_to_project = False + expected_issue_info = ( + "| Attribute | Content |\n" + "|---|---|\n" + "| Organization name | TestOrg |\n" + "| Repository name | TestRepo |\n" + "| Issue number | 42 |\n" + "| State | open |\n" + "| Issue URL | GitHub link |\n" + "| Created at | 2024-01-01T00:00:00Z |\n" + "| Updated at | 2024-01-02T00:00:00Z |\n" + "| Closed at | None |\n" + "| Labels | bug, urgent |\n" + "| Linked to project | 🔴 |\n" + ) + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_project_state_mining_enabled", return_value=True + ) + + # Act + actual = LivingDocumentationGenerator._generate_issue_summary_table(consolidated_issue) + + assert expected_issue_info == actual + + +# _generate_index_directory_path + + +def test_generate_index_directory_path_with_structured_output_grouped_by_topics(mocker): + # Arrange + repository_id = "org123/repo456" + topic = "documentation" + expected_path = "/base/output/org123/repo456/documentation" + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=True + ) + mocker.patch("os.makedirs") + + # Act + actual = LivingDocumentationGenerator._generate_index_directory_path(repository_id, topic) + + # Assert + assert expected_path == actual + + +def test_generate_index_directory_path_with_structured_output_not_grouped_by_topics(mocker): + # Arrange + repository_id = "org123/repo456" + topic = None + expected_path = "/base/output/org123/repo456" + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=True + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=False + ) + mocker.patch("os.makedirs") + + # Act + actual = LivingDocumentationGenerator._generate_index_directory_path(repository_id, topic) + + # Assert + assert expected_path == actual + + +def test_generate_index_directory_path_with_only_grouping_by_topic_no_structured_output(mocker): + # Arrange + repository_id = "org123/repo456" + topic = "documentation" + expected_path = "/base/output/documentation" + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=False + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=True + ) + mocker.patch("os.makedirs") + + # Act + actual = LivingDocumentationGenerator._generate_index_directory_path(repository_id, topic) + + assert expected_path == actual + + +def test_generate_index_directory_path_with_no_structured_output_and_no_grouping_by_topics(mocker): + # Arrange + repository_id = None + topic = None + expected_path = "/base/output" + + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_output_directory", return_value="/base/output" + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_structured_output_enabled", return_value=False + ) + mocker.patch( + "living_documentation_generator.generator.ActionInputs.get_is_grouping_by_topics_enabled", return_value=False + ) + mocker.patch("os.makedirs") + + # Act + actual = LivingDocumentationGenerator._generate_index_directory_path(repository_id, topic) + + # Assert + assert expected_path == actual + + +# _load_all_templates + + +def test_load_all_templates_loads_correctly(mocker): + # Arrange + expected_templates = ( + "Issue Page Template Content", + "Index Page Template Content", + "Root Level Template Content", + "Organization Level Template Content", + "Repository Level Template Content", + "Data Level Template Content", + ) + + load_template_mock = mocker.patch("living_documentation_generator.generator.load_template") + load_template_mock.side_effect = [ + "Issue Page Template Content", + "Index Page Template Content", + "Root Level Template Content", + "Organization Level Template Content", + "Repository Level Template Content", + "Data Level Template Content", + ] + + # Act + actual = LivingDocumentationGenerator._load_all_templates() + + assert actual == expected_templates + assert load_template_mock.call_count == 6 + + +def test_load_all_templates_loads_just_some_templates(mocker): + # Arrange + expected_templates = ( + None, + None, + None, + None, + None, + "Data Level Template Content", + ) + + load_template_mock = mocker.patch("living_documentation_generator.generator.load_template") + load_template_mock.side_effect = [ + None, + None, + None, + None, + None, + "Data Level Template Content", + ] + + # Act + actual = LivingDocumentationGenerator._load_all_templates() + + # Assert + assert actual == expected_templates + assert load_template_mock.call_count == 6 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..a292ab5 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,46 @@ +# +# Copyright 2024 ABSA Group Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import os + +from living_documentation_generator.generator import LivingDocumentationGenerator +from main import run + + +# run + + +def test_run_correct_behaviour(mocker): + # Arrange + mock_log_info = mocker.patch("logging.getLogger").return_value.info + mock_get_action_input = mocker.patch("main.get_action_input") + mock_get_action_input.side_effect = lambda first_arg, **kwargs: ( + "./user/output/path" if first_arg == "OUTPUT_PATH" else None + ) + mocker.patch("main.ActionInputs.get_output_directory", return_value="./user/output/path") + mocker.patch.dict(os.environ, {"INPUT_GITHUB_TOKEN": "fake_token"}) + mocker.patch.object(LivingDocumentationGenerator, "generate") + + # Act + run() + + # Assert + mock_log_info.assert_has_calls( + [ + mocker.call("Starting Living Documentation generation."), + mocker.call("Living Documentation generation - output path set to `%s`.", "./user/output/path"), + mocker.call("Living Documentation generation completed."), + ] + )