├── tests ├── __init__.py ├── test_git_core_commit.py ├── test_git_core_properties.py ├── test_file_utils.py ├── conftest.py ├── test_git_core_init.py ├── test_config.py └── test_git_config.py ├── behave_ext ├── __init__.py └── cucumber_json.py ├── pyadr ├── git │ ├── __init__.py │ ├── cli │ │ ├── __init__.py │ │ ├── application.py │ │ └── commands.py │ ├── const.py │ ├── exceptions.py │ ├── config.py │ └── utils.py ├── assets │ ├── __init__.py │ ├── 0000-record-architecture-decisions.md │ ├── 0001-use-markdown-architectural-decision-records.md │ └── madr-template.md ├── __version__.py ├── __init__.py ├── cli │ ├── __init__.py │ ├── config.py │ ├── application.py │ ├── io.py │ └── commands.py ├── file_utils.py ├── exceptions.py ├── config.py ├── content_utils.py └── const.py ├── invoke.yaml ├── docs ├── authors.rst ├── readme.rst ├── history.rst ├── contributing.rst ├── .gitignore ├── modules.rst ├── usage.rst ├── index.rst ├── installation.rst ├── Makefile └── conf.py ├── poetry.toml ├── features ├── steps │ ├── use_steplib_behave4cli.py │ ├── use_steplib_behave4git.py │ ├── git_steps.py │ └── adr_steps.py ├── git_adr │ ├── generate_toc.feature │ ├── config.feature │ ├── helper_project_repo.feature │ ├── helper_adr_only_repo.feature │ ├── base_step.feature │ ├── config_shared_with_pyadr.feature │ ├── new_adr.feature │ ├── commit.feature │ ├── init_adr_repo.feature │ ├── helper_any_repo.feature │ ├── accept_or_reject_proposed_adr.feature │ └── pre-merge-checks.feature ├── pyadr │ ├── verifications_before_processing_proposed_adr.feature │ ├── init_adr_repo_with_config.feature │ ├── new_adr.feature │ ├── config.feature │ ├── helper.feature │ ├── generate_toc.feature │ ├── init_adr_repo.feature │ ├── accept_or_reject_proposed_adr.feature │ └── check-adr-repo.feature └── environment.py ├── AUTHORS.rst ├── conftest.py ├── scripts ├── verify_git_tags_new_version_tag_non_existant.sh ├── verify_git_tags_one_version_tag_present.sh ├── semantic_release_exec_verify_release.sh ├── generate_version_file.py ├── install_poetry.sh ├── verify_version_not_on_pypi.py ├── install_pyenv.sh └── verify_pypi_env_variables.py ├── .flake8 ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── publish.yml │ └── test_and_make_release.yml ├── .editorconfig ├── .pre-commit-hooks.yaml ├── renovate.json ├── .pre-commit-config.yaml ├── LICENSE ├── tox.ini ├── .releaserc ├── pyproject.toml ├── CONTRIBUTING.rst ├── .gitignore └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /behave_ext/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyadr/git/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyadr/assets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /invoke.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | echo: True 3 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../CHANGELOG.md 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | _static 3 | _templates 4 | pyadr* 5 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | prefer-active-python = true 4 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | pyadr 2 | ===== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | pyadr 8 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use ADR Process Tool in a project:: 6 | 7 | import pyadr 8 | -------------------------------------------------------------------------------- /pyadr/__version__.py: -------------------------------------------------------------------------------- 1 | # This file is automatically generated during the release process 2 | # Do not edit manually 3 | """Version module for pyadr.""" 4 | 5 | __version__ = "0.20.0" 6 | -------------------------------------------------------------------------------- /features/steps/use_steplib_behave4cli.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | """ 3 | Use behave4cli step library. 4 | """ 5 | 6 | # -- REGISTER-STEPS FROM STEP-LIBRARY: 7 | import behave4cli.__all_steps__ 8 | -------------------------------------------------------------------------------- /features/steps/use_steplib_behave4git.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | """ 3 | Use behave4git step library. 4 | """ 5 | 6 | # -- REGISTER-STEPS FROM STEP-LIBRARY: 7 | import behave4git.__all_steps__ 8 | -------------------------------------------------------------------------------- /pyadr/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for ADR Process Tool.""" 2 | 3 | __author__ = """Emmanuel Sciara""" 4 | __email__ = "emmanuel.sciara@gmail.com" 5 | 6 | from .__version__ import __version__ # noqa 7 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Emmanuel Sciara 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /pyadr/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from pyadr.cli.application import App 4 | 5 | 6 | def main(args=None): 7 | return App().run() 8 | 9 | 10 | if __name__ == "__main__": 11 | sys.exit(main()) # pragma: no cover 12 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | # Emtpy conftest.py to make sure pytest finds the project's module 2 | # See https://stackoverflow.com/a/49033954/4374048 for quick explanation 3 | # and https://stackoverflow.com/a/34520971/4374048 for a more detailed explanation 4 | -------------------------------------------------------------------------------- /pyadr/git/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from pyadr.git.cli.application import App 4 | 5 | 6 | def main(args=None): 7 | return App().run() 8 | 9 | 10 | if __name__ == "__main__": 11 | sys.exit(main()) # pragma: no cover 12 | -------------------------------------------------------------------------------- /scripts/verify_git_tags_new_version_tag_non_existant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [[ $(git ls-remote --tags | grep "refs/tags/v${1}$") ]]; then 5 | echo "ERROR: Version '${1}' already exists on the remote repository." 6 | exit 1 7 | fi 8 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Setup of for black compatibility 3 | # (see https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8) 4 | max-line-length = 88 5 | extend-ignore = E203 6 | select = C,E,F,W,B,B950 7 | ignore = E501, B011, W503 8 | -------------------------------------------------------------------------------- /features/git_adr/generate_toc.feature: -------------------------------------------------------------------------------- 1 | Feature: Generate a table of content in markdown 2 | 3 | Scenario: Toc command available to `git adr` 4 | When I run "git adr" 5 | Then it should pass 6 | And the command output should contain "toc" 7 | -------------------------------------------------------------------------------- /scripts/verify_git_tags_one_version_tag_present.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | if [[ ! $(git tag -l | grep '^v') ]]; then 5 | echo "ERROR: No version tag found. Either initial version tag was not set (suggestion: v0.0.0) or did not fetch enough commits." 6 | exit 1 7 | fi 8 | -------------------------------------------------------------------------------- /scripts/semantic_release_exec_verify_release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | BASEDIR=$(dirname "$0") 6 | 7 | ${BASEDIR}/verify_git_tags_one_version_tag_present.sh $1 8 | 9 | #python ${BASEDIR}/verify_pypi_env_variables.py 10 | 11 | pip install toml 12 | python ${BASEDIR}/verify_version_not_on_pypi.py $1 13 | -------------------------------------------------------------------------------- /pyadr/git/const.py: -------------------------------------------------------------------------------- 1 | """Package constants""" 2 | 3 | GIT_ADR_DEFAULT_SETTINGS = {"adr-only-repo": "false"} 4 | 5 | PROPOSAL_REQUEST = "propose" 6 | DEPRECATION_REQUEST = "deprecate" 7 | SUPERSEDING_REQUEST = "supersede" 8 | VALID_REQUESTS = [ 9 | PROPOSAL_REQUEST, 10 | DEPRECATION_REQUEST, 11 | SUPERSEDING_REQUEST, 12 | ] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * ADR Process Tool version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to ADR Process Tool's documentation! 2 | ====================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | modules 12 | contributing 13 | authors 14 | history 15 | 16 | Indices and tables 17 | ================== 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /pyadr/assets/0000-record-architecture-decisions.md: -------------------------------------------------------------------------------- 1 | # Record architecture decisions 2 | 3 | * Status: proposed 4 | * Date: 2020-03-25 5 | 6 | ## Context 7 | 8 | We need to record the architectural decisions made on Opinionated Digital Center. 9 | 10 | ## Decision 11 | 12 | We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). 13 | 14 | ## Consequences 15 | 16 | See Michael Nygard's article, linked above. 17 | -------------------------------------------------------------------------------- /features/git_adr/config.feature: -------------------------------------------------------------------------------- 1 | Feature: Configure Git ADR cli - Git specific 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: Valid config settings 7 | When I run "git adr config --list" 8 | Then it should pass with 9 | """ 10 | adr-only-repo = false 11 | records-dir = docs/adr 12 | """ 13 | 14 | Scenario: Get config setting value 15 | When I run "git adr config adr-only-repo" 16 | Then it should pass 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 88 10 | tab_width = 4 11 | trim_trailing_whitespace = true 12 | 13 | [{*.bash,*.zsh,*.sh}] 14 | indent_size = 2 15 | tab_width = 2 16 | 17 | [{*.yml,*.yaml}] 18 | indent_size = 2 19 | 20 | [{*.md,*.rst}] 21 | indent_size = 2 22 | 23 | [{.releaserc,*.json}] 24 | indent_size = 2 25 | tab_width = 2 26 | 27 | [LICENSE] 28 | insert_final_newline = false 29 | 30 | [Makefile] 31 | indent_style = tab 32 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: generate-toc 2 | name: Generate ADR TOC 3 | description: Generates a table of contents based on architecture decision register items 4 | language: python 5 | entry: pyadr toc --no-interaction 6 | files: \d{4}-.*\.md$ 7 | pass_filenames: false 8 | - id: check-adr 9 | name: Check repository ADR 10 | description: Perform sanity checks typically required on ADR files before merging a Pull Request 11 | language: python 12 | entry: pyadr check-adr-repo --no-interaction 13 | files: \d{4}-.*\.md$ 14 | pass_filenames: false 15 | -------------------------------------------------------------------------------- /features/pyadr/verifications_before_processing_proposed_adr.feature: -------------------------------------------------------------------------------- 1 | Feature: Verify ADR repo state before accepting/rejecting proposed ADR 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: Fail when no existing numbered ADR in repository 7 | Given an empty file named "docs/adr/XXXX-an-adr.md" 8 | When I run "pyadr accept docs/adr/XXXX-an-adr.md" 9 | Then it should fail with 10 | """ 11 | There should be at least one initial accepted/rejected ADR (usually 'docs/adr/0000-record-architecture-decisions.md'). 12 | """ 13 | -------------------------------------------------------------------------------- /tests/test_git_core_commit.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, equal_to, not_ 2 | 3 | 4 | def test_adr_commit_msg_prefix_for_status_proposed(git_adr_core): 5 | # Given 6 | # When 7 | # Then 8 | assert_that( 9 | git_adr_core._commit_message_prefix_for_status("proposed"), 10 | equal_to("chore(adr):"), 11 | ) 12 | 13 | 14 | def test_adr_commit_msg_prefix_for_status_other_than_proposed(git_adr_core): 15 | # Given 16 | # When 17 | # Then 18 | assert_that( 19 | git_adr_core._commit_message_prefix_for_status(""), 20 | not_(equal_to("chore(adr):")), 21 | ) 22 | -------------------------------------------------------------------------------- /tests/test_git_core_properties.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, equal_to 2 | 3 | 4 | def test_commit_msg_default_prefix_for_adr_only_repo(git_adr_core): 5 | # Given 6 | git_adr_core.config["git"]["adr-only-repo"] = "true" 7 | 8 | # When 9 | # Then 10 | assert_that(git_adr_core.commit_message_default_prefix, equal_to("feat(adr):")) 11 | 12 | 13 | def test_commit_msg_default_prefix_for_project_repo(git_adr_core): 14 | # Given 15 | git_adr_core.config["git"]["adr-only-repo"] = "false" 16 | 17 | # When 18 | # Then 19 | assert_that(git_adr_core.commit_message_default_prefix, equal_to("docs(adr):")) 20 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install ADR Process Tool, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install pyadr 16 | 17 | This is the preferred method to install ADR Process Tool, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = poetry run python -m sphinx 7 | SPHINXPROJ = pyadr 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /pyadr/cli/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from cleo.config import ApplicationConfig 4 | from loguru import logger 5 | 6 | from pyadr.cli.io import LOGGING_VERY_VERBOSE, ClikitDebuggerHandler, ClikitHandler 7 | 8 | 9 | class LoggingAppConfig(ApplicationConfig): 10 | def create_io(self, *args, **kwargs): 11 | # remove loguru's default handler 12 | logger.remove() 13 | 14 | # add cli's own handler 15 | io = super().create_io(*args, **kwargs) 16 | logger.add(ClikitDebuggerHandler(io), colorize=True, level=logging.DEBUG) 17 | logger.add(ClikitHandler(io), format="{message}", level=LOGGING_VERY_VERBOSE) 18 | logger.debug("Logger initialized.") 19 | 20 | return io 21 | -------------------------------------------------------------------------------- /scripts/generate_version_file.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import toml 4 | 5 | root_dir = Path(__file__).resolve().parents[1] 6 | 7 | with (root_dir / "pyproject.toml").open() as f: 8 | pyproject = toml.loads(f.read()) 9 | 10 | packages = [ 11 | i["include"] 12 | for i in pyproject["tool"]["poetry"]["packages"] 13 | if "include" in i.keys() 14 | ] 15 | 16 | for package in packages: 17 | with (root_dir / package / "__version__.py").open("w") as v: 18 | content = f"""# This file is automatically generated during the release process 19 | # Do not edit manually 20 | \"\"\"Version module for {package}.\"\"\" 21 | 22 | __version__ = \"{pyproject["tool"]["poetry"]["version"]}\" 23 | """ 24 | v.write(content) 25 | -------------------------------------------------------------------------------- /features/steps/git_steps.py: -------------------------------------------------------------------------------- 1 | from behave import given 2 | from behave4cli.command_steps import step_i_successfully_run_command 3 | from behave4git.git_steps import step_a_starting_git_repo_with_initial_branch 4 | 5 | 6 | @given("an initialised git adr repo") 7 | def step_an_initialised_git_adr_repo(context): 8 | step_a_starting_git_repo_with_initial_branch(context, "main") 9 | step_i_successfully_run_command(context, "git adr init") 10 | context.repo.heads.main.checkout() 11 | context.repo.git.merge("adr-init-repo") 12 | 13 | 14 | @given("an initialised git adr only repo") 15 | def step_an_initialised_git_adr_only_repo(context): 16 | step_a_starting_git_repo_with_initial_branch(context, "main") 17 | step_i_successfully_run_command(context, "git adr init --adr-only-repo") 18 | context.repo.heads.main.checkout() 19 | context.repo.git.merge("adr-init-repo") 20 | -------------------------------------------------------------------------------- /pyadr/file_utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pyadr.content_utils import update_adr_content_title_and_status 4 | from pyadr.exceptions import PyadrNoNumberedAdrError 5 | 6 | 7 | def update_adr(file: Path, title: str = None, status: str = None) -> None: 8 | with file.open() as f: 9 | updated_content = update_adr_content_title_and_status( 10 | f.read(), title=title, status=status 11 | ) 12 | with file.open("w") as f: 13 | f.write(updated_content) 14 | 15 | 16 | def calculate_next_adr_id(adr_path: Path) -> str: 17 | numbered_adrs = sorted(adr_path.glob("[0-9][0-9][0-9][0-9]-*")) 18 | if not len(numbered_adrs): 19 | raise PyadrNoNumberedAdrError() 20 | most_recent_adr = numbered_adrs.pop() 21 | most_recent_id = most_recent_adr.stem.split("-")[0] 22 | next_id = f"{int(most_recent_id) + 1:04d}" 23 | return next_id 24 | -------------------------------------------------------------------------------- /features/git_adr/helper_project_repo.feature: -------------------------------------------------------------------------------- 1 | Feature: Helper for the various names and messages - Git included - project repo 2 | 3 | Background: 4 | Given a new working directory 5 | And an initialised git adr repo 6 | 7 | Scenario: Return commit message for proposed adr 8 | Given a proposed adr file named "XXXX-my-adr-title.md" 9 | When I run "git adr helper commit-message XXXX-my-adr-title.md" 10 | Then it should pass with 11 | """ 12 | chore(adr): [proposed] XXXX-my-adr-title 13 | """ 14 | 15 | Scenario: Return commit message for other than proposed adr 16 | Given an accepted adr file named "0002-my-adr-title.md" 17 | When I run "git adr helper commit-message 0002-my-adr-title.md" 18 | Then it should pass with 19 | """ 20 | docs(adr): [accepted] 0002-my-adr-title 21 | """ 22 | -------------------------------------------------------------------------------- /pyadr/cli/application.py: -------------------------------------------------------------------------------- 1 | import cleo 2 | 3 | from pyadr import __version__ 4 | from pyadr.cli.commands import ( 5 | AcceptCommand, 6 | CheckAdrRepoCommand, 7 | ConfigCommand, 8 | GenerateTocCommand, 9 | HelperCommand, 10 | InitCommand, 11 | NewCommand, 12 | ProposeCommand, 13 | RejectCommand, 14 | ) 15 | from pyadr.cli.config import LoggingAppConfig 16 | 17 | 18 | class App(cleo.Application): 19 | def __init__(self, config=None): 20 | super().__init__( 21 | config=config or LoggingAppConfig("ADR Process Tool", __version__) 22 | ) 23 | 24 | self.add(ConfigCommand()) 25 | self.add(InitCommand()) 26 | self.add(NewCommand()) 27 | self.add(ProposeCommand()) 28 | self.add(AcceptCommand()) 29 | self.add(RejectCommand()) 30 | self.add(GenerateTocCommand()) 31 | self.add(CheckAdrRepoCommand()) 32 | self.add(HelperCommand()) 33 | -------------------------------------------------------------------------------- /features/git_adr/helper_adr_only_repo.feature: -------------------------------------------------------------------------------- 1 | Feature: Helper for the various names and messages - Git included - adr only repo 2 | 3 | Background: 4 | Given a new working directory 5 | And an initialised git adr only repo 6 | 7 | Scenario: Commit message for ADR only repos starts with 'feat(adr)' 8 | Given a proposed adr file named "XXXX-my-adr-title.md" 9 | When I run "git adr helper commit-message XXXX-my-adr-title.md" 10 | Then it should pass with 11 | """ 12 | chore(adr): [proposed] XXXX-my-adr-title 13 | """ 14 | 15 | Scenario: Return commit message for other than proposed adr in an adr only repo 16 | Given an accepted adr file named "0002-my-adr-title.md" 17 | When I run "git adr helper commit-message 0002-my-adr-title.md" 18 | Then it should pass with 19 | """ 20 | feat(adr): [accepted] 0002-my-adr-title 21 | """ 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python package to Pypi 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | publish: 10 | name: Publish release 11 | runs-on: ubuntu-latest 12 | env: 13 | POETRY_HTTP_BASIC_PYPI_PASSWORD: ${{ secrets.POETRY_HTTP_BASIC_PYPI_PASSWORD }} 14 | POETRY_HTTP_BASIC_PYPI_USERNAME: ${{ secrets.POETRY_HTTP_BASIC_PYPI_USERNAME }} 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Install tools (Poetry) 18 | uses: Gr1N/setup-poetry@v8 19 | - name: Set up Python 3.10 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.10' 23 | cache: 'poetry' 24 | - name: Install dependencies 25 | run: poetry install 26 | - name: Install remaining CI/CD tools and dependencies 27 | run: poetry run inv cicd-setup.setup-test-stage 28 | - name: Publish to repository 29 | run: poetry run inv publish 30 | -------------------------------------------------------------------------------- /pyadr/git/cli/application.py: -------------------------------------------------------------------------------- 1 | import cleo 2 | 3 | from pyadr import __version__ 4 | from pyadr.cli.config import LoggingAppConfig 5 | from pyadr.git.cli.commands import ( 6 | GitAcceptCommand, 7 | GitCommitCommand, 8 | GitConfigCommand, 9 | GitGenerateTocCommand, 10 | GitHelperCommand, 11 | GitInitCommand, 12 | GitNewCommand, 13 | GitPreMergeChecksCommand, 14 | GitProposeCommand, 15 | GitRejectCommand, 16 | ) 17 | 18 | 19 | class App(cleo.Application): 20 | def __init__(self, config=None): 21 | super().__init__( 22 | config=config or LoggingAppConfig("ADR Process Tool for Git", __version__) 23 | ) 24 | 25 | self.add(GitConfigCommand()) 26 | self.add(GitInitCommand()) 27 | self.add(GitNewCommand()) 28 | self.add(GitProposeCommand()) 29 | self.add(GitAcceptCommand()) 30 | self.add(GitRejectCommand()) 31 | self.add(GitCommitCommand()) 32 | self.add(GitHelperCommand()) 33 | self.add(GitPreMergeChecksCommand()) 34 | self.add(GitGenerateTocCommand()) 35 | -------------------------------------------------------------------------------- /scripts/install_poetry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | which poetry && ([ $? -eq 0 ]) || curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python 4 | 5 | colorize() { 6 | if [ -t 1 ]; then printf "\e[%sm%s\e[m" "$1" "$2" 7 | else echo -n "$2" 8 | fi 9 | } 10 | 11 | set -e 12 | 13 | case "$SHELL" in 14 | /bin/bash ) 15 | profile="$HOME/.bashrc" 16 | ;; 17 | /bin/zsh ) 18 | profile="$HOME/.zshrc" 19 | ;; 20 | /bin/ksh ) 21 | profile="$HOME/.profile" 22 | ;; 23 | /bin/fish ) 24 | profile="$HOME/.config/fish/config.fish" 25 | ;; 26 | * ) 27 | profile="your profile" 28 | ;; 29 | esac 30 | 31 | # Add Poetry to path if necessary 32 | if grep -ql '^# POETRY is installed$' $profile ; then 33 | echo "POETRY is already set in ${profile}" 34 | else 35 | { colorize 1 "WARNING" 36 | echo ": seems you still have not added 'poetry' to the load path => adding the 'poetry' commands to ${profile}." 37 | echo "" 38 | echo "# POETRY is installed" >> ${profile} 39 | echo ". \$HOME/.poetry/env" >> ${profile} 40 | } >&2 41 | fi 42 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":dependencyDashboard", 6 | ":rebaseStalePrs" 7 | ], 8 | "gitAuthor": "Renovate Bot ", 9 | "branchPrefix": "feature/renovate/", 10 | "onboardingBranch": "feature/renovate/configure", 11 | "prHourlyLimit": 0, 12 | "packageRules": [ 13 | { 14 | "groupName": "Python Poetry Dev Dependencies", 15 | "matchDepTypes": ["dev"], 16 | "automerge": true, 17 | "platformAutomerge": true, 18 | "separateMultipleMajor": true 19 | }, 20 | { 21 | "groupName": "Python Poetry Major Dependencies", 22 | "matchDepTypes": ["dependencies"], 23 | "matchUpdateTypes": ["major"], 24 | "automerge": true, 25 | "platformAutomerge": true 26 | }, 27 | { 28 | "groupName": "Python Poetry Non-Major Dependencies", 29 | "matchDepTypes": ["dependencies"], 30 | "matchUpdateTypes": ["minor", "patch"], 31 | "automerge": true, 32 | "platformAutomerge": true 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: false 2 | repos: 3 | - repo: https://github.com/psf/black 4 | rev: 23.3.0 5 | hooks: 6 | - id: black 7 | args: [ --safe ] 8 | language_version: python3.10 9 | - repo: https://github.com/pre-commit/mirrors-isort 10 | rev: v5.10.1 11 | hooks: 12 | - id: isort 13 | args: [ --profile, "black" ] 14 | - repo: https://github.com/pycqa/flake8 15 | rev: 6.0.0 16 | hooks: 17 | - id: flake8 18 | additional_dependencies: 19 | - flake8-bugbear 20 | - flake8-comprehensions 21 | - flake8-simplify 22 | - repo: https://github.com/pre-commit/pre-commit-hooks 23 | rev: v4.4.0 24 | hooks: 25 | - id: check-docstring-first 26 | - id: check-json 27 | - id: check-yaml 28 | exclude: .gitlab-ci*.yml$ 29 | - id: end-of-file-fixer 30 | - id: debug-statements 31 | - id: name-tests-test 32 | args: ["--django"] 33 | - id: trailing-whitespace 34 | - repo: https://github.com/pre-commit/pygrep-hooks 35 | rev: v1.10.0 36 | hooks: 37 | - id: rst-backticks 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020, Emmanuel Sciara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /features/pyadr/init_adr_repo_with_config.feature: -------------------------------------------------------------------------------- 1 | Feature: Initialise an ADR repository 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: Fail when a repo already exists - records-dir config option set 7 | Given a file named ".adr" with 8 | """ 9 | [adr] 10 | records-dir = another_adr_dir 11 | """ 12 | And a directory named "another_adr_dir" 13 | When I run "pyadr init" 14 | Then it should fail 15 | 16 | Scenario: Create the repo directory - records-dir config option set 17 | Given a file named ".adr" with 18 | """ 19 | [adr] 20 | records-dir = another_adr_dir 21 | """ 22 | When I run "pyadr init" 23 | Then it should pass 24 | And the directory "another_adr_dir" exists 25 | And the file named "another_adr_dir/template.md" should exist 26 | And the file named "another_adr_dir/0000-record-architecture-decisions.md" should exist 27 | And the file named "another_adr_dir/0001-use-markdown-architectural-decision-records.md" should exist 28 | -------------------------------------------------------------------------------- /pyadr/git/exceptions.py: -------------------------------------------------------------------------------- 1 | from pyadr.exceptions import PyadrError 2 | 3 | 4 | class PyadrGitError(PyadrError): 5 | """Base exception for errors raised by pyadr""" 6 | 7 | 8 | class PyadrInvalidGitRepositoryError(PyadrGitError): 9 | """Could not find a valid repository""" 10 | 11 | 12 | class PyadrGitIndexNotEmptyError(PyadrGitError): 13 | """Files are staged in the index""" 14 | 15 | 16 | class PyadrGitBranchAlreadyExistsError(PyadrGitError): 17 | """Branch already exists""" 18 | 19 | 20 | class PyadrGitMainBranchDoesNotExistError(PyadrGitError): 21 | """Main branch (main or other specified) does not exist""" 22 | 23 | 24 | class PyadrGitPreMergeChecksFailedError(PyadrGitError): 25 | """Pre-merge checks have failed""" 26 | 27 | 28 | class PyadrGitAdrNotStagedOrCommittedError(PyadrGitError): 29 | """ADR file was expected to be staged or committed""" 30 | 31 | 32 | class PyadrGitAdrNotStagedError(PyadrGitError): 33 | """ADR file was expected to be staged""" 34 | 35 | 36 | class PyadrGitAdrBadFilenameFormatOrTitleError(PyadrGitError): 37 | """ADR filename formot incorrect or title portion different from title in file""" 38 | -------------------------------------------------------------------------------- /features/git_adr/base_step.feature: -------------------------------------------------------------------------------- 1 | Feature: Git steps reused throughout the features 2 | 3 | Scenario: Create a starting repo 4 | Given a starting git repo with "main" as initial branch 5 | Then a git repo should exist 6 | And the file named "initial_commit_file" should exist 7 | And the file "initial_commit_file" should contain 8 | """ 9 | foo bar 10 | """ 11 | And the head commit message should be 12 | """ 13 | chore: initial commit 14 | """ 15 | And 1 files should be committed in the last commit 16 | And the file "initial_commit_file" should be committed in the last commit 17 | 18 | Scenario: Create a starting repo 19 | Given an initialised git adr repo 20 | Then a git repo should exist 21 | And the directory "docs/adr" should exist 22 | And 3 files should be committed in the last commit 23 | And the file "docs/adr/template.md" should be committed in the last commit 24 | And the file "docs/adr/0000-record-architecture-decisions.md" should be committed in the last commit 25 | And the file "docs/adr/0001-use-markdown-architectural-decision-records.md" should be committed in the last commit 26 | -------------------------------------------------------------------------------- /pyadr/git/config.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from pyadr.config import AdrConfig 4 | from pyadr.const import ADR_DEFAULT_SETTINGS, DEFAULT_CONFIG_FILE_PATH 5 | from pyadr.git.const import GIT_ADR_DEFAULT_SETTINGS 6 | 7 | 8 | class GitAdrConfig(AdrConfig): 9 | config_file_path = DEFAULT_CONFIG_FILE_PATH 10 | 11 | def __init__(self): 12 | defaults = {**ADR_DEFAULT_SETTINGS, **GIT_ADR_DEFAULT_SETTINGS} 13 | sorted_defaults = {} 14 | for key in sorted(defaults.keys()): 15 | sorted_defaults[key] = defaults[key] 16 | super().__init__(defaults=sorted_defaults) 17 | 18 | self.section_defaults = { 19 | "adr": ADR_DEFAULT_SETTINGS, 20 | "git": GIT_ADR_DEFAULT_SETTINGS, 21 | } 22 | 23 | self.add_section("adr") 24 | self.add_section("git") 25 | 26 | if self.config_file_path.exists(): 27 | self.read(self.config_file_path) 28 | 29 | self.check_filled_settings_supported() 30 | 31 | def persist(self) -> None: 32 | defaults = deepcopy(self.defaults()) 33 | self[self.default_section] = {} # type: ignore 34 | 35 | with self.config_file_path.open("w") as f: 36 | self.write(f) 37 | 38 | self[self.default_section] = defaults # type: ignore 39 | -------------------------------------------------------------------------------- /features/git_adr/config_shared_with_pyadr.feature: -------------------------------------------------------------------------------- 1 | Feature: Configure Git ADR cli 2 | Shared features with pyadr. Only a minimum is retested, as much code base is the same. 3 | 4 | Background: 5 | Given a new working directory 6 | 7 | Scenario: List config settings 8 | When I run "git adr config --list" 9 | Then it should pass with 10 | """ 11 | records-dir = docs/adr 12 | """ 13 | 14 | Scenario: Get config setting value 15 | When I run "git adr config records-dir" 16 | Then it should pass 17 | 18 | Scenario: Read config file 19 | Given a file named ".adr" with 20 | """ 21 | [adr] 22 | records-dir = another_dir 23 | """ 24 | When I run "git adr config records-dir" 25 | Then it should pass with 26 | """ 27 | records-dir = another_dir 28 | """ 29 | 30 | Scenario: Set config setting: ADR directory 31 | When I run "git adr config records-dir another_dir" 32 | Then it should pass 33 | And a file named ".adr" should exist 34 | 35 | Scenario: Unset config settings 36 | Given a file named ".adr" with 37 | """ 38 | [adr] 39 | records-dir = another_dir 40 | """ 41 | When I run "git adr config records-dir --unset" 42 | Then it should pass 43 | And the file ".adr" should not contain 44 | """ 45 | records-dir = another_dir 46 | """ 47 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3{11,10,9,8}, 3 | bdd-py3{11,10,9,8} 4 | skip_missing_interpreters = True 5 | minversion = 4.0 6 | isolated_build = true 7 | skipsdist = true 8 | ignore_basepython_conflict = false 9 | 10 | [testenv] 11 | allowlist_externals = poetry 12 | passenv = 13 | HTTP_PROXY 14 | HTTPS_PROXY 15 | NO_PROXY 16 | commands = 17 | poetry install 18 | inv test 19 | 20 | # fix to avoid using tox-pyenv 21 | # See https://warchantua.hashnode.dev/how-to-use-tox-4-with-pyenv-and-poetry 22 | [testenv:py311] 23 | base_python = python3.11 24 | 25 | [testenv:py310] 26 | base_python = python3.10 27 | 28 | [testenv:py39] 29 | base_python = python3.9 30 | 31 | [testenv:py38] 32 | base_python = python3.8 33 | 34 | [testenv:bdd] 35 | allowlist_externals = 36 | {[testenv]allowlist_externals} 37 | bash 38 | passenv = {[testenv]passenv} 39 | commands = 40 | poetry install 41 | inv bdd 42 | 43 | [testenv:bdd-py311] 44 | allowlist_externals = {[testenv:bdd]allowlist_externals} 45 | passenv = {[testenv:bdd]passenv} 46 | commands = {[testenv:bdd]commands} 47 | 48 | [testenv:bdd-py310] 49 | allowlist_externals = {[testenv:bdd]allowlist_externals} 50 | passenv = {[testenv:bdd]passenv} 51 | commands = {[testenv:bdd]commands} 52 | 53 | [testenv:bdd-py39] 54 | allowlist_externals = {[testenv:bdd]allowlist_externals} 55 | passenv = {[testenv:bdd]passenv} 56 | commands = {[testenv:bdd]commands} 57 | 58 | [testenv:bdd-py38] 59 | allowlist_externals = {[testenv:bdd]allowlist_externals} 60 | passenv = {[testenv:bdd]passenv} 61 | commands = {[testenv:bdd]commands} 62 | -------------------------------------------------------------------------------- /tests/test_file_utils.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, calling, equal_to, raises 2 | 3 | from pyadr.core import AdrCore 4 | from pyadr.exceptions import PyadrNoNumberedAdrError 5 | from pyadr.file_utils import calculate_next_adr_id 6 | 7 | 8 | def test_add_id_and_update_title_on_proposed_adr_file_name(adr_tmp_path): 9 | # Given 10 | adr_file = adr_tmp_path / "XXXX-adr-title.md" 11 | with adr_file.open("w") as f: 12 | f.write( 13 | """# My ADR Updated Title 14 | 15 | * Status: any_status 16 | * Date: any_date 17 | 18 | ## Context and Problem Statement 19 | 20 | [..] 21 | """ 22 | ) 23 | 24 | # When 25 | result_file = AdrCore()._sync_adr_filename(adr_file, "0002") 26 | 27 | # Then 28 | expected_file = adr_tmp_path / "0002-my-adr-updated-title.md" 29 | assert_that(result_file, equal_to(expected_file)) 30 | 31 | 32 | def test_determine_next_id(adr_tmp_path): 33 | # Given 34 | (adr_tmp_path / "0001-an-accepted-or-rejected-adr.md").touch() 35 | (adr_tmp_path / "0002-an-accepted-or-rejected-adr.md").touch() 36 | (adr_tmp_path / "XXXX-a-proposed-adr.md").touch() 37 | 38 | # When 39 | next_id = calculate_next_adr_id(adr_tmp_path) 40 | 41 | # Then 42 | assert_that(next_id, equal_to("0003")) 43 | 44 | 45 | def test_determine_next_id_fail_when_no_previous_adr(adr_tmp_path): 46 | # Given 47 | (adr_tmp_path / "XXXX-a-proposed-adr.md").touch() 48 | 49 | # When 50 | 51 | # Then 52 | assert_that( 53 | calling(calculate_next_adr_id).with_args(adr_tmp_path), 54 | raises(PyadrNoNumberedAdrError), 55 | ) 56 | -------------------------------------------------------------------------------- /features/steps/adr_steps.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from behave import given 4 | from behave4cli.command_steps import step_a_file_named_filename_with 5 | from hamcrest import assert_that, matches_regexp 6 | 7 | from pyadr.const import VALID_ADR_FILENAME_WITH_ID_REGEX 8 | 9 | 10 | @given('a proposed adr file named "{filename}"') 11 | def step_an_proposed_adr_file_named_filename(context, filename): 12 | path = Path(filename) 13 | assert_that(path.name, matches_regexp(r"^.*-[a-z0-9-]*\.md")) 14 | 15 | title_slug = path.stem.split("-", 1)[1] 16 | title = " ".join([word.capitalize() for word in title_slug.split("-")]) 17 | 18 | context.surrogate_text = f""" 19 | # {title} 20 | 21 | * Status: proposed 22 | * Date: 2020-03-26 23 | 24 | ## Context and Problem Statement 25 | 26 | Context and problem statement. 27 | 28 | ## Decision Outcome 29 | 30 | Decision outcome. 31 | """ 32 | step_a_file_named_filename_with(context, filename) 33 | 34 | 35 | @given('an accepted adr file named "{filename}"') 36 | def step_an_accepted_adr_file_named_filename(context, filename): 37 | path = Path(filename) 38 | assert_that(path.name, matches_regexp(VALID_ADR_FILENAME_WITH_ID_REGEX)) 39 | 40 | title_slug = path.stem.split("-", 1)[1] 41 | title = " ".join([word.capitalize() for word in title_slug.split("-")]) 42 | 43 | context.surrogate_text = f""" 44 | # {title} 45 | 46 | * Status: accepted 47 | * Date: 2020-03-26 48 | 49 | ## Context and Problem Statement 50 | 51 | Context and problem statement. 52 | 53 | ## Decision Outcome 54 | 55 | Decision outcome. 56 | """ 57 | step_a_file_named_filename_with(context, filename) 58 | -------------------------------------------------------------------------------- /features/pyadr/new_adr.feature: -------------------------------------------------------------------------------- 1 | Feature: Create a new ADR 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: Fail when no title is given 7 | When I run "pyadr new" 8 | Then it should fail with: 9 | """ 10 | Not enough arguments (missing: "words"). 11 | """ 12 | 13 | Scenario: Fail when no ADR repo directory 14 | When I run "pyadr new My ADR Title" 15 | Then it should fail with: 16 | """ 17 | Directory 'docs/adr/' does not exist. Initialise your ADR repo first. 18 | """ 19 | 20 | Scenario: Create a new ADR and succeed with a success message (when using verbose) 21 | Given a directory named "docs/adr/" 22 | When I run "pyadr new My ADR Title -v" 23 | Then it should pass with: 24 | """ 25 | Creating ADR 'docs/adr/XXXX-my-adr-title.md'... 26 | ... done. 27 | """ 28 | And the file named "docs/adr/XXXX-my-adr-title.md" should exist 29 | And the file "docs/adr/XXXX-my-adr-title.md" should contain: 30 | """ 31 | # My ADR Title 32 | 33 | * Status: proposed 34 | """ 35 | And the file "docs/adr/XXXX-my-adr-title.md" should contain: 36 | """ 37 | * Date: {__TODAY_YYYY_MM_DD__} 38 | 39 | """ 40 | 41 | Scenario: Propose a new ADR (same as create, different command name) 42 | Given a directory named "docs/adr/" 43 | When I run "pyadr propose My ADR Title" 44 | Then it should pass 45 | And the file named "docs/adr/XXXX-my-adr-title.md" should exist 46 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from git import Repo 5 | from loguru import logger 6 | 7 | import pyadr 8 | from pyadr.const import DEFAULT_ADR_PATH, DEFAULT_CONFIG_FILE_NAME 9 | from pyadr.core import AdrCore 10 | from pyadr.git.core import GitAdrCore 11 | 12 | LOGGING_VERBOSE = 18 13 | LOGGING_VERY_VERBOSE = 16 14 | 15 | logger.level("VERBOSE", LOGGING_VERBOSE, color="", icon="🔈️") 16 | logger.level("VERY_VERBOSE", LOGGING_VERY_VERBOSE, color="", icon="🔊") 17 | 18 | 19 | @pytest.fixture() 20 | def adr_tmp_path(tmp_path): 21 | path = tmp_path / DEFAULT_ADR_PATH 22 | path.mkdir(parents=True) 23 | yield path 24 | 25 | 26 | @pytest.fixture() 27 | def tmp_repo(tmp_path): 28 | repo = Repo.init(tmp_path, initial_branch="main") 29 | 30 | file = Path(tmp_path / "foo") 31 | file.touch() 32 | 33 | repo.index.add([str(file)]) 34 | repo.index.commit("initial commit") 35 | 36 | yield repo 37 | 38 | 39 | @pytest.fixture(autouse=True) 40 | def initialise_config(monkeypatch, tmp_path): 41 | monkeypatch.setattr( 42 | pyadr.config.AdrConfig, "config_file_path", tmp_path / DEFAULT_CONFIG_FILE_NAME 43 | ) 44 | assert ( 45 | pyadr.config.AdrConfig.config_file_path == tmp_path / DEFAULT_CONFIG_FILE_NAME 46 | ) 47 | monkeypatch.setattr( 48 | pyadr.git.config.GitAdrConfig, 49 | "config_file_path", 50 | tmp_path / DEFAULT_CONFIG_FILE_NAME, 51 | ) 52 | assert ( 53 | pyadr.git.config.GitAdrConfig.config_file_path 54 | == tmp_path / DEFAULT_CONFIG_FILE_NAME 55 | ) 56 | 57 | 58 | @pytest.fixture() 59 | def adr_core(): 60 | yield AdrCore() 61 | 62 | 63 | @pytest.fixture() 64 | def git_adr_core(): 65 | yield GitAdrCore() 66 | -------------------------------------------------------------------------------- /pyadr/cli/io.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from clikit.api.io import flags as verbosity 4 | from loguru import logger 5 | 6 | LOGGING_VERBOSE = 18 7 | LOGGING_VERY_VERBOSE = 16 8 | 9 | logger.level("VERBOSE", LOGGING_VERBOSE, color="", icon="🔈️") 10 | logger.level("VERY_VERBOSE", LOGGING_VERY_VERBOSE, color="", icon="🔊") 11 | 12 | _levels = { 13 | logger.level("CRITICAL").no: verbosity.NORMAL, 14 | logger.level("ERROR").no: verbosity.NORMAL, 15 | logger.level("WARNING").no: verbosity.NORMAL, 16 | logger.level("SUCCESS").no: verbosity.NORMAL, 17 | logger.level("INFO").no: verbosity.NORMAL, 18 | logger.level("VERBOSE").no: verbosity.VERBOSE, 19 | logger.level("VERY_VERBOSE").no: verbosity.VERY_VERBOSE, 20 | logger.level("DEBUG").no: verbosity.DEBUG, 21 | } 22 | 23 | 24 | class ClikitHandler(logging.Handler): 25 | """Logging handler that redirects all messages to clikit io object.""" 26 | 27 | def __init__(self, io, level=logging.NOTSET): 28 | super().__init__(level=level) 29 | self.io = io 30 | 31 | def emit(self, record: logging.LogRecord): 32 | level = _levels[record.levelno] 33 | if self.io.verbosity <= verbosity.VERY_VERBOSE: 34 | self.write_line(level, record) 35 | 36 | def write_line(self, level: int, record: logging.LogRecord): 37 | if record.levelno >= logging.WARNING: 38 | text = record.getMessage() 39 | self.io.error_line(text, flags=level) 40 | elif self.io.verbosity >= level: 41 | text = record.getMessage() 42 | self.io.write_line(text) 43 | 44 | 45 | class ClikitDebuggerHandler(ClikitHandler): 46 | """Logging handler that redirects and format debug messages to clikit io object.""" 47 | 48 | def emit(self, record: logging.LogRecord): 49 | level = _levels[record.levelno] 50 | if self.io.verbosity > verbosity.VERY_VERBOSE: 51 | self.write_line(level, record) 52 | -------------------------------------------------------------------------------- /features/environment.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import sys 3 | 4 | import six 5 | from behave.tag_matcher import ActiveTagMatcher, setup_active_tag_values 6 | 7 | # -- MATCHES ANY TAGS: @use.with_{category}={value} 8 | # NOTE: active_tag_value_provider provides category values for active tags. 9 | python_version = "%s.%s" % sys.version_info[:2] 10 | active_tag_value_provider = { 11 | "python2": str(six.PY2).lower(), 12 | "python3": str(six.PY3).lower(), 13 | "python.version": python_version, 14 | # -- python.implementation: cpython, pypy, jython, ironpython 15 | "python.implementation": platform.python_implementation().lower(), 16 | "pypy": str("__pypy__" in sys.modules).lower(), 17 | "os": sys.platform, 18 | } 19 | active_tag_matcher = ActiveTagMatcher(active_tag_value_provider) 20 | 21 | 22 | # ----------------------------------------------------------------------------- 23 | # HOOKS: 24 | # ----------------------------------------------------------------------------- 25 | def before_all(context): 26 | # -- SETUP ACTIVE-TAG MATCHER (with userdata): 27 | # USE: behave -D browser=safari ... 28 | setup_active_tag_values(active_tag_value_provider, context.config.userdata) 29 | setup_python_path() 30 | # -- SETUP Logging: 31 | context.config.setup_logging() 32 | 33 | 34 | def before_feature(context, feature): 35 | if active_tag_matcher.should_exclude_with(feature.tags): 36 | feature.skip(reason=active_tag_matcher.exclude_reason) 37 | 38 | 39 | def before_scenario(context, scenario): 40 | if active_tag_matcher.should_exclude_with(scenario.effective_tags): 41 | scenario.skip(reason=active_tag_matcher.exclude_reason) 42 | 43 | 44 | # ----------------------------------------------------------------------------- 45 | # SPECIFIC FUNCTIONALITY: 46 | # ----------------------------------------------------------------------------- 47 | def setup_python_path(): 48 | # -- NEEDED-FOR: formatter.user_defined.feature 49 | import os 50 | 51 | PYTHONPATH = os.environ.get("PYTHONPATH", "") 52 | os.environ["PYTHONPATH"] = "." + os.pathsep + PYTHONPATH 53 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | [ 6 | "@semantic-release/release-notes-generator", 7 | { 8 | "writerOpts": { 9 | 10 | "mainTemplate": "{{> header}}\n\n{{#each commitGroups}}\n\n{{#if title}}\n### {{title}}\n\n{{/if}}\n{{#each commits}}\n{{> commit root=@root}}\n{{/each}}\n\n{{/each}}\n{{> footer}}\n\n", 11 | "headerPartial": "## {{#if @root.linkCompare~}}\n [{{version}}](\n {{~#if @root.repository~}}\n {{~#if @root.host}}\n {{~@root.host}}/\n {{~/if}}\n {{~#if @root.owner}}\n {{~@root.owner}}/\n {{~/if}}\n {{~@root.repository}}\n {{~else}}\n {{~@root.repoUrl}}\n {{~/if~}}\n /compare/{{previousTag}}...{{currentTag}})\n{{~else}}\n {{~version}}\n{{~/if}}\n{{~#if title}} \"{{title}}\"\n{{~/if}}\n{{~#if date}} ({{date}})\n{{/if}}", 12 | "footerPartial": "{{#if noteGroups}}\n{{#each noteGroups}}\nblah\n### {{title}}\nblah\n{{#each notes}}\n* {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{text}}\n{{/each}}\n{{/each}}\n\n{{/if}}" 13 | 14 | } 15 | } 16 | ], 17 | [ 18 | "@semantic-release/changelog", 19 | { 20 | "changelogTitle": "# Changelog\n\nAll notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines." 21 | } 22 | ], 23 | [ 24 | "@semantic-release/exec", 25 | { 26 | "verifyConditionsCmd": "./scripts/verify_git_tags_one_version_tag_present.sh", 27 | "verifyReleaseCmd": "./scripts/verify_git_tags_new_version_tag_non_existant.sh ${nextRelease.version}", 28 | "prepareCmd": "make bump NEW_VERSION=${nextRelease.version}" 29 | } 30 | ], 31 | [ 32 | "@semantic-release/git", 33 | { 34 | "assets": [ 35 | "CHANGELOG.md", 36 | "pyproject.toml", 37 | "pyadr/__version__.py" 38 | ], 39 | "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" 40 | } 41 | ], 42 | "@semantic-release/github" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /features/pyadr/config.feature: -------------------------------------------------------------------------------- 1 | Feature: Configure ADR cli 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: List config settings 7 | When I run "pyadr config --list" 8 | Then it should pass with 9 | """ 10 | records-dir = docs/adr 11 | """ 12 | 13 | Scenario: Get config setting value 14 | When I run "pyadr config records-dir" 15 | Then it should pass with 16 | """ 17 | records-dir = docs/adr 18 | """ 19 | 20 | Scenario: Read config file 21 | Given a file named ".adr" with 22 | """ 23 | [adr] 24 | records-dir = another_dir 25 | """ 26 | When I run "pyadr config records-dir" 27 | Then it should pass with 28 | """ 29 | records-dir = another_dir 30 | """ 31 | 32 | Scenario: Write to config file when setting config setting 33 | Given the file named ".adr" does not exist 34 | When I run "pyadr config records-dir another_dir" 35 | Then it should pass 36 | And a file named ".adr" should exist 37 | And the file ".adr" should contain 38 | """ 39 | [adr] 40 | records-dir = another_dir 41 | """ 42 | 43 | Scenario: Set config setting: ADR directory 44 | When I run "pyadr config records-dir another_dir" 45 | Then it should pass with 46 | """ 47 | Configured 'records-dir' to 'another_dir' 48 | """ 49 | And a file named ".adr" should exist 50 | And the file ".adr" should contain 51 | """ 52 | [adr] 53 | records-dir = another_dir 54 | """ 55 | 56 | Scenario: Unset config settings 57 | Given a file named ".adr" with 58 | """ 59 | [adr] 60 | records-dir = another_dir 61 | """ 62 | When I run "pyadr config records-dir --unset" 63 | Then it should pass with 64 | """ 65 | Config setting 'records-dir' unset. 66 | """ 67 | And the file ".adr" should not contain 68 | """ 69 | records-dir = another_dir 70 | """ 71 | -------------------------------------------------------------------------------- /scripts/verify_version_not_on_pypi.py: -------------------------------------------------------------------------------- 1 | # Uses the trick described in following link: 2 | # https://stackoverflow.com/questions/4888027/python-and-pip-list-all-versions-of-a-package-thats-available/26664162#26664162 # noqa 3 | import os 4 | import subprocess 5 | import sys 6 | from pathlib import Path 7 | from urllib.parse import urlparse 8 | 9 | import toml 10 | 11 | if len(sys.argv) < 2: 12 | print(f"ERROR: expected a version passed as argument.\n") 13 | sys.exit(1) 14 | 15 | if len(sys.argv) > 2: 16 | print(f"ERROR: expected only one argument.\n") 17 | sys.exit(1) 18 | 19 | root_dir = Path(__file__).resolve().parents[1] 20 | 21 | with (root_dir / "pyproject.toml").open() as f: 22 | pyproject = toml.loads(f.read()) 23 | 24 | pip_cmd = "pip install" 25 | 26 | try: 27 | url = os.environ["PYPI_REPOSITORY_URL"] 28 | except KeyError: 29 | pass 30 | else: 31 | if url: 32 | pip_cmd = " ".join([pip_cmd, "-i", url]) 33 | print(pip_cmd) 34 | parsed_url = urlparse(url) 35 | if parsed_url.scheme == "http": 36 | pip_cmd = " ".join([pip_cmd, "--trusted-host", parsed_url.hostname]) 37 | 38 | pip_cmd = " ".join([pip_cmd, f'{pyproject["tool"]["poetry"]["name"]}==']) 39 | print(pip_cmd) 40 | 41 | try: 42 | cmd_response = subprocess.run( 43 | pip_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE 44 | ) 45 | except subprocess.CalledProcessError as e: 46 | print(f'ERROR: The pip command did not succeed: {e.stderr.decode("utf-8")}') 47 | sys.exit(1) 48 | 49 | cmd_error_string = cmd_response.stderr.decode("utf-8").strip() 50 | 51 | if "NewConnectionError" in cmd_error_string: 52 | print( 53 | "ERROR: pip indicated that it has connection problems. " 54 | "Please check your network." 55 | ) 56 | sys.exit(1) 57 | 58 | from_versions_string = "(from versions: " 59 | versions_start = cmd_error_string.find(from_versions_string) + len(from_versions_string) 60 | versions_end = cmd_error_string.find(")", versions_start) 61 | versions = cmd_error_string[versions_start:versions_end].split(", ") 62 | 63 | if sys.argv[1] in versions: 64 | print( 65 | f"ERROR: The version you are trying to publish ({sys.argv[1]}) already exists " 66 | "in the pypi repository." 67 | ) 68 | sys.exit(1) 69 | -------------------------------------------------------------------------------- /pyadr/assets/0001-use-markdown-architectural-decision-records.md: -------------------------------------------------------------------------------- 1 | # Use Markdown Architectural Decision Records 2 | 3 | Adapted from 4 | [MADR's similar decision record](https://github.com/adr/madr/blob/2.1.2/docs/adr/0000-use-markdown-architectural-decision-records.md). 5 | 6 | * Status: proposed 7 | * Date: 2020-03-25 8 | 9 | ## Context and Problem Statement 10 | 11 | We want to record architectural decisions made in this project. 12 | Which format and structure should these records follow? 13 | 14 | ## Considered Options 15 | 16 | * [MADR](https://adr.github.io/madr/) 2.1.2 - The Markdown Architectural Decision Records 17 | * [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) - The first incarnation of the term "ADR" 18 | * [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) - The Y-Statements 19 | * Other templates listed at 20 | * Formless - No conventions for file format and structure 21 | 22 | ## Decision Outcome 23 | 24 | Chosen option: "MADR 2.1.2", because 25 | 26 | * Implicit assumptions should be made explicit. 27 | Design documentation is important to enable people understanding the decisions later on. 28 | See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). 29 | * The MADR format is lean and fits our development style. 30 | * The MADR structure is comprehensible and facilitates usage & maintenance. 31 | * The MADR project is vivid. 32 | * Version 2.1.2 is the latest one available when starting to document ADRs. 33 | 34 | ### Positive Consequences 35 | 36 | The ADR are more structured. See especially: 37 | * [MADR-0002 - Do not use numbers in headings](https://github.com/adr/madr/blob/2.1.2/docs/adr/0002-do-not-use-numbers-in-headings.md). 38 | * [MADR-0005 - Use (unique number and) dashes in filenames](https://github.com/adr/madr/blob/2.1.2/docs/adr/0005-use-dashes-in-filenames.md). 39 | * [MADR-0010 - Support categories (in form of subfolders with local ids)](https://github.com/adr/madr/blob/2.1.2/docs/adr/0010-support-categories.md). 40 | * See [full set of MADR ADRs](https://github.com/adr/madr/blob/2.1.2/docs/adr). 41 | 42 | ### Negative Consequences 43 | 44 | * Learning curve will be slightly longer. 45 | -------------------------------------------------------------------------------- /pyadr/assets/madr-template.md: -------------------------------------------------------------------------------- 1 | # [short title of solved problem and solution] 2 | 3 | * Status: [proposed | rejected | accepted | deprecated | ... | superseded by [ADR-0005](0005-example.md)] 4 | * Deciders: [list everyone involved in the decision] 5 | * Date: [YYYY-MM-DD when the decision was last updated] 6 | 7 | Technical Story: [description | ticket/issue URL] 8 | 9 | ## Context and Problem Statement 10 | 11 | [Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.] 12 | 13 | ## Decision Drivers 14 | 15 | * [driver 1, e.g., a force, facing concern, ...] 16 | * [driver 2, e.g., a force, facing concern, ...] 17 | * ... 18 | 19 | ## Considered Options 20 | 21 | * [option 1] 22 | * [option 2] 23 | * [option 3] 24 | * ... 25 | 26 | ## Decision Outcome 27 | 28 | Chosen option: "[option 1]", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | ... | comes out best (see below)]. 29 | 30 | ### Positive Consequences 31 | 32 | * [e.g., improvement of quality attribute satisfaction, follow-up decisions required, ...] 33 | * ... 34 | 35 | ### Negative Consequences 36 | 37 | * [e.g., compromising quality attribute, follow-up decisions required, ...] 38 | * ... 39 | 40 | ## Pros and Cons of the Options 41 | 42 | ### [option 1] 43 | 44 | [example | description | pointer to more information | ...] 45 | 46 | * Good, because [argument a] 47 | * Good, because [argument b] 48 | * Bad, because [argument c] 49 | * ... 50 | 51 | ### [option 2] 52 | 53 | [example | description | pointer to more information | ...] 54 | 55 | * Good, because [argument a] 56 | * Good, because [argument b] 57 | * Bad, because [argument c] 58 | * ... 59 | 60 | ### [option 3] 61 | 62 | [example | description | pointer to more information | ...] 63 | 64 | * Good, because [argument a] 65 | * Good, because [argument b] 66 | * Bad, because [argument c] 67 | * ... 68 | 69 | ## Links 70 | 71 | * [Link type] [Link to ADR] 72 | * ... 73 | -------------------------------------------------------------------------------- /scripts/install_pyenv.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -z "$PYENV_ROOT" ]; then 4 | PYENV_ROOT="${HOME}/.pyenv" 5 | fi 6 | 7 | colorize() { 8 | if [ -t 1 ]; then printf "\e[%sm%s\e[m" "$1" "$2" 9 | else echo -n "$2" 10 | fi 11 | } 12 | 13 | set -e 14 | 15 | case "$SHELL" in 16 | /bin/bash ) 17 | profile="$HOME/.bashrc" 18 | ;; 19 | /bin/zsh ) 20 | profile="$HOME/.zshrc" 21 | ;; 22 | /bin/ksh ) 23 | profile="$HOME/.profile" 24 | ;; 25 | /bin/fish ) 26 | profile="$HOME/.config/fish/config.fish" 27 | ;; 28 | * ) 29 | profile="your profile" 30 | ;; 31 | esac 32 | 33 | if [[ ! -d $PYENV_ROOT ]]; then 34 | curl -s https://pyenv.run | bash 35 | fi 36 | 37 | # Add Pyenv to path if necessary 38 | if grep -ql '^# PYENV is installed$' $profile ; then 39 | echo "PYENV is already set in ${profile}" 40 | else 41 | { echo "" 42 | colorize 1 "WARNING" 43 | echo ": seems you still have not added 'pyenv' to the load path => adding the 'pyenv' commands to ${profile}." 44 | echo "" 45 | case "$SHELL" in 46 | /bin/fish ) 47 | echo "# PYENV is installed" >> ${profile} 48 | echo "set -x PATH \"${PYENV_ROOT}/bin\" \$PATH" >> ${profile} 49 | echo 'status --is-interactive; and . (pyenv init -|psub)' >> ${profile} 50 | echo 'status --is-interactive; and . (pyenv virtualenv-init -|psub)' >> ${profile} 51 | ;; 52 | * ) 53 | echo "# PYENV is installed" >> ${profile} 54 | echo "export PATH=\"${PYENV_ROOT}/bin:\$PATH\"" >> ${profile} 55 | echo "eval \"\$(pyenv init -)\"" >> ${profile} 56 | echo "eval \"\$(pyenv virtualenv-init -)\"" >> ${profile} 57 | ;; 58 | esac 59 | } >&2 60 | fi 61 | 62 | source $HOME/.bashrc 63 | 64 | echo "Python environments installations. If there are missing dependencies, check https://github.com/pyenv/pyenv/wiki/common-build-problems" 65 | LATEST_AVAILABLE_PYTHON_VERSION_38=$(pyenv install --list | grep -v - | grep -v b | grep 3.8 | tail -1) 66 | LATEST_AVAILABLE_PYTHON_VERSION_37=$(pyenv install --list | grep -v - | grep -v b | grep 3.7 | tail -1) 67 | LATEST_AVAILABLE_PYTHON_VERSION_36=$(pyenv install --list | grep -v - | grep -v b | grep 3.6 | tail -1) 68 | pyenv install -s $LATEST_AVAILABLE_PYTHON_VERSION_38 69 | pyenv install -s $LATEST_AVAILABLE_PYTHON_VERSION_37 70 | pyenv install -s $LATEST_AVAILABLE_PYTHON_VERSION_36 71 | echo "py37 is main version we will be working with, then py38 and py36" 72 | pyenv global $LATEST_AVAILABLE_PYTHON_VERSION_37 $LATEST_AVAILABLE_PYTHON_VERSION_38 $LATEST_AVAILABLE_PYTHON_VERSION_36 73 | -------------------------------------------------------------------------------- /tests/test_git_core_init.py: -------------------------------------------------------------------------------- 1 | from git import Repo 2 | from hamcrest import assert_that, calling, not_, raises 3 | 4 | from pyadr.git.exceptions import ( 5 | PyadrGitBranchAlreadyExistsError, 6 | PyadrGitMainBranchDoesNotExistError, 7 | ) 8 | from pyadr.git.utils import verify_branch_does_not_exist, verify_main_branch_exists 9 | 10 | 11 | def test_verify_branch_does_not_exist_empty_repo(tmp_path): 12 | # Given 13 | repo = Repo.init(tmp_path) 14 | # When 15 | # Then 16 | assert_that( 17 | calling(verify_branch_does_not_exist).with_args(repo, "main"), 18 | not_(raises(PyadrGitBranchAlreadyExistsError)), 19 | ) 20 | assert_that( 21 | calling(verify_branch_does_not_exist).with_args(repo, "my-branch"), 22 | not_(raises(PyadrGitBranchAlreadyExistsError)), 23 | ) 24 | 25 | 26 | def test_verify_branch_does_not_exist_repo_fails_with_main(tmp_repo): 27 | # Given 28 | # When 29 | # Then 30 | assert_that( 31 | calling(verify_branch_does_not_exist).with_args(tmp_repo, "main"), 32 | raises(PyadrGitBranchAlreadyExistsError), 33 | ) 34 | assert_that( 35 | calling(verify_branch_does_not_exist).with_args(tmp_repo, "my-branch"), 36 | not_(raises(PyadrGitBranchAlreadyExistsError)), 37 | ) 38 | 39 | 40 | def test_verify_branch_does_not_exist_empty_fails(tmp_repo): 41 | # Given 42 | tmp_repo.create_head("my-branch") 43 | # tmp_repo.heads["my-branch"].checkout() 44 | 45 | # When 46 | # Then 47 | assert_that( 48 | calling(verify_branch_does_not_exist).with_args(tmp_repo, "my-branch"), 49 | raises(PyadrGitBranchAlreadyExistsError), 50 | ) 51 | 52 | 53 | def test_verify_main_branch_exists_empty_repo(tmp_path): 54 | # Given 55 | repo = Repo.init(tmp_path) 56 | # When 57 | # Then 58 | assert_that( 59 | calling(verify_main_branch_exists).with_args(repo, "main"), 60 | raises(PyadrGitMainBranchDoesNotExistError), 61 | ) 62 | 63 | 64 | def test_verify_main_branch_exists_other_main_than_main_fail(tmp_repo): 65 | # Given 66 | # When 67 | # Then 68 | assert_that( 69 | calling(verify_main_branch_exists).with_args(tmp_repo, "main"), 70 | not_(raises(PyadrGitBranchAlreadyExistsError)), 71 | ) 72 | assert_that( 73 | calling(verify_main_branch_exists).with_args(tmp_repo, "non-existing-branch"), 74 | raises(PyadrGitMainBranchDoesNotExistError), 75 | ) 76 | 77 | 78 | def test_verify_main_branch_exists_other_main_than_main_pass(tmp_repo): 79 | # Given 80 | tmp_repo.create_head("other-main-branch") 81 | 82 | # When 83 | # Then 84 | assert_that( 85 | calling(verify_main_branch_exists).with_args(tmp_repo, "other-main-branch"), 86 | not_(raises(PyadrGitMainBranchDoesNotExistError)), 87 | ) 88 | -------------------------------------------------------------------------------- /features/pyadr/helper.feature: -------------------------------------------------------------------------------- 1 | Feature: Helper for the various names and messages 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: Fail when adr format incorrect 7 | # We won't test all formatting errors here as they are already tested in 8 | # unit tests. 9 | Given an empty file named "foo-bar-file" 10 | When I run "pyadr helper slug foo-bar-file" 11 | Then it should fail with: 12 | """ 13 | PyadrAdrTitleNotFoundError 14 | """ 15 | 16 | Scenario: Return title slug 17 | Given an accepted adr file named "0001-my-adr-title.md" 18 | When I run "pyadr helper slug 0001-my-adr-title.md" 19 | Then it should pass with 20 | """ 21 | my-adr-title 22 | """ 23 | 24 | Scenario: Return title in lowercase 25 | Given an accepted adr file named "0001-my-adr-title.md" 26 | When I run "pyadr helper lowercase 0001-my-adr-title.md" 27 | Then it should pass with 28 | """ 29 | my adr title 30 | """ 31 | 32 | Scenario: Sync ADR filename with its title 33 | Given a file named "0001-my-adr-title.md" with: 34 | """ 35 | # My ADR Updated Title 36 | 37 | * Status: accepted 38 | * Date: 2020-03-26 39 | 40 | ## Context and Problem Statement 41 | 42 | Context and problem statement. 43 | 44 | ## Decision Outcome 45 | 46 | Decision outcome. 47 | """ 48 | When I run "pyadr helper sync-filename 0001-my-adr-title.md" 49 | Then it should pass with 50 | """ 51 | File renamed to '0001-my-adr-updated-title.md'. 52 | """ 53 | And the file named "0001-my-adr-title.md" should not exist 54 | And the file named "0001-my-adr-updated-title.md" should exist 55 | 56 | Scenario: Fail before sync filename if initial filename format not suitable 57 | Given a file named "001-my-adr-title.md" with: 58 | """ 59 | # My ADR Title 60 | 61 | * Status: accepted 62 | * Date: 2020-03-26 63 | 64 | ## Context and Problem Statement 65 | [..] 66 | """ 67 | When I run "pyadr helper sync-filename 001-my-adr-title.md" 68 | Then it should fail with 69 | """ 70 | PyadrAdrFilenameFormatError 71 | 001-my-adr-title.md 72 | """ 73 | And the command output should contain 74 | """ 75 | (status to verify against: 'accepted') 76 | ADR(s)'s filename follow the format '[0-9][0-9][0-9][0-9]-*.md'. 77 | """ 78 | 79 | Scenario: No sync if filename already correct 80 | Given an accepted adr file named "0001-my-adr-title.md" 81 | When I run "pyadr helper sync-filename 0001-my-adr-title.md" 82 | Then it should pass with 83 | """ 84 | File name already up-to-date. 85 | """ 86 | -------------------------------------------------------------------------------- /pyadr/git/utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | from git import Commit, InvalidGitRepositoryError, Repo # type: ignore[attr-defined] 5 | from gitdb.exc import BadName 6 | from loguru import logger 7 | 8 | from pyadr.git.exceptions import ( 9 | PyadrGitBranchAlreadyExistsError, 10 | PyadrGitIndexNotEmptyError, 11 | PyadrGitMainBranchDoesNotExistError, 12 | PyadrInvalidGitRepositoryError, 13 | ) 14 | 15 | 16 | def verify_index_empty(repo: Repo) -> None: 17 | logger.info("Verifying Git index is empty...") 18 | try: 19 | count_staged_files = len(repo.index.diff("HEAD")) 20 | except BadName: 21 | # HEAD does not exist => the repo is empty, so must verify index is too 22 | count_staged_files = len(list(repo.index.iter_blobs())) 23 | 24 | if count_staged_files > 0: 25 | logger.error("... files staged in Git index. Clean before running command.") 26 | raise PyadrGitIndexNotEmptyError() 27 | 28 | logger.log("VERBOSE", "... done.") 29 | 30 | 31 | def verify_branch_does_not_exist(repo: Repo, branch: str) -> None: 32 | logger.info(f"Verifying branch '{branch}' does not exist...") 33 | if "main" not in repo.heads or branch not in repo.heads: 34 | logger.log("VERBOSE", "... done.") 35 | else: 36 | logger.error( 37 | f"... branch '{branch}' already exists. Clean before running command." 38 | ) 39 | raise PyadrGitBranchAlreadyExistsError(branch) 40 | 41 | 42 | def verify_main_branch_exists(repo: Repo, branch: str = "main") -> None: 43 | logger.info(f"Verifying branch '{branch}' exists... ") 44 | if branch in repo.heads: 45 | logger.log("VERBOSE", "... done.") 46 | else: 47 | message = ( 48 | "... branch '{branch}' does not exist. {supplement}" 49 | "Correct before running command." 50 | ) 51 | if branch == "main": 52 | supplement = "" 53 | else: 54 | supplement = "Your repo is empty or it was deleted. " 55 | logger.error(message.format(branch=branch, supplement=supplement)) 56 | raise PyadrGitMainBranchDoesNotExistError(branch) 57 | 58 | 59 | def get_verified_repo_client(repo_workdir: Path) -> Repo: 60 | try: 61 | repo = Repo(repo_workdir) 62 | except InvalidGitRepositoryError as e: 63 | logger.error( 64 | f"No Git repository found in directory '{repo_workdir}/'. " 65 | f"Please initialise a Git repository before running command." 66 | ) 67 | raise PyadrInvalidGitRepositoryError(e) 68 | return repo 69 | 70 | 71 | def create_feature_branch_and_checkout(repo: Repo, branch_name: str) -> None: 72 | logger.info("Switching to 'main'...") 73 | repo.heads.main.checkout() 74 | logger.log("VERBOSE", "... done.") 75 | 76 | logger.info(f"Creating branch '{branch_name}' and switching to it...") 77 | repo.create_head(branch_name) 78 | repo.heads[branch_name].checkout() 79 | logger.log("VERBOSE", "... done.") 80 | 81 | 82 | def files_committed_in_commit(commit: Commit) -> List: 83 | return list(commit.stats.files.keys()) 84 | -------------------------------------------------------------------------------- /features/git_adr/new_adr.feature: -------------------------------------------------------------------------------- 1 | Feature: Create a new ADR - Git included 2 | Since 'git adr' makes calls to 'pyadr', some features will be already fully 3 | tested in the bdd tests for 'pyadr'. 4 | 5 | Background: 6 | Given a new working directory 7 | 8 | Scenario: Create a new ADR in an standard repo 9 | Given an initialised git adr repo 10 | When I run "git adr new My ADR Title" 11 | Then it should pass 12 | And the file named "docs/adr/XXXX-my-adr-title.md" should exist 13 | 14 | Scenario: Propose a new ADR (same as create, different command name) 15 | Given an initialised git adr repo 16 | When I run "git adr propose My ADR Title" 17 | Then it should pass 18 | And the file named "docs/adr/XXXX-my-adr-title.md" should exist 19 | 20 | Scenario: Create feature branch and stage new ADR file (no commit) 21 | Given an initialised git adr repo 22 | When I run "git adr new My ADR Title -v" 23 | Then it should pass with 24 | """ 25 | Staging 'docs/adr/XXXX-my-adr-title.md'... 26 | ... done. 27 | """ 28 | And the branch "adr-propose-my-adr-title" should exist 29 | And the head should be at branch "adr-propose-my-adr-title" 30 | And the branch "adr-propose-my-adr-title" should be at the same level as branch "main" 31 | And the file "docs/adr/XXXX-my-adr-title.md" should be staged 32 | 33 | Scenario: Fail when no title is given 34 | When I run "git adr new" 35 | Then it should fail with: 36 | """ 37 | Not enough arguments (missing: "words"). 38 | """ 39 | 40 | Scenario: Fail when no ADR repo directory 41 | Given a directory named "docs/adr/" 42 | When I run "git adr new My ADR Title" 43 | Then it should fail with: 44 | """ 45 | No Git repository found in directory '{__WORKDIR__}/'. Please initialise a Git repository before running command. 46 | """ 47 | 48 | Scenario: Fail when no main branch 49 | Given an empty git repo with "main" as initial branch 50 | And a directory named "docs/adr/" 51 | When I run "git adr new My ADR Title" 52 | Then it should fail with: 53 | """ 54 | Verifying branch 'main' exists... 55 | ... branch 'main' does not exist. Correct before running command. 56 | """ 57 | 58 | Scenario: Fail when index dirty 59 | Given an initialised git adr repo 60 | And a file named "foo" with 61 | """ 62 | bar 63 | """ 64 | And I add the file "foo" to the git index 65 | When I run "git adr new My ADR Title" 66 | Then it should fail with 67 | """ 68 | Verifying Git index is empty... 69 | ... files staged in Git index. Clean before running command. 70 | """ 71 | 72 | Scenario: Fail when feature branch already exists 73 | Given an initialised git adr repo 74 | And I create the branch "adr-propose-my-adr-title" 75 | When I run "git adr new My ADR Title" 76 | Then it should fail with 77 | """ 78 | Verifying branch 'adr-propose-my-adr-title' does not exist... 79 | ... branch 'adr-propose-my-adr-title' already exists. Clean before running command. 80 | """ 81 | -------------------------------------------------------------------------------- /pyadr/exceptions.py: -------------------------------------------------------------------------------- 1 | """All package specific exceptions""" 2 | from pyadr.const import VALID_ADR_CONTENT_FORMAT 3 | 4 | 5 | class PyadrError(Exception): 6 | """Base exception for errors raised by pyadr""" 7 | 8 | 9 | class PyadrNoNumberedAdrError(PyadrError): 10 | """No numbered ADR was found""" 11 | 12 | 13 | class PyadrNoLineWithSuffixError(PyadrError): 14 | """The searched line with suffix could not be found""" 15 | 16 | 17 | class PyadrAdrDirectoryAlreadyExistsError(PyadrError): 18 | """ADR directory already exists""" 19 | 20 | 21 | class PyadrAdrDirectoryDoesNotExistsError(PyadrError): 22 | """ADR directory does not exist""" 23 | 24 | 25 | class PyadrNoProposedAdrError(PyadrError): 26 | """Could not find a proposed ADR""" 27 | 28 | 29 | class PyadrTooManyProposedAdrError(PyadrError): 30 | """Too many proposed ADR found""" 31 | 32 | 33 | class PyadrConfigSettingNotSupported(PyadrError): 34 | """Config setting not supported""" 35 | 36 | 37 | class PyadrConfigFileSettingsNotSupported(PyadrError): 38 | """Config settings in the config file not supported""" 39 | 40 | 41 | class PyadrAdrFormatError(PyadrError): 42 | """ADR file format error""" 43 | 44 | def __init__(self, item_name: str, source: str): 45 | adr_format_message = ( 46 | "{item} not found in ADR '{source}', " 47 | + "where as it should be of format:\n" 48 | + VALID_ADR_CONTENT_FORMAT 49 | ) 50 | super(PyadrAdrFormatError, self).__init__( 51 | adr_format_message.format(item=item_name, source=source) 52 | ) 53 | 54 | 55 | class PyadrAdrTitleNotFoundError(PyadrAdrFormatError): 56 | """ADR title was not found in ADR file""" 57 | 58 | def __init__(self, source: str): 59 | super(PyadrAdrTitleNotFoundError, self).__init__( 60 | item_name="Title", source=source 61 | ) 62 | 63 | 64 | class PyadrAdrStatusNotFoundError(PyadrAdrFormatError): 65 | """ADR status was not found in ADR file""" 66 | 67 | def __init__(self, source: str): 68 | super(PyadrAdrStatusNotFoundError, self).__init__( 69 | item_name="Status", source=source 70 | ) 71 | 72 | 73 | class PyadrAdrDateNotFoundError(PyadrAdrFormatError): 74 | """ADR date was not found in ADR file""" 75 | 76 | def __init__(self, source: str): 77 | super(PyadrAdrDateNotFoundError, self).__init__(item_name="Date", source=source) 78 | 79 | 80 | class PyadrSomeAdrFilenamesIncorrectError(PyadrError): 81 | """Check on the name of each ADR files has failed""" 82 | 83 | 84 | class PyadrAdrFilenameIncorrectError(PyadrError): 85 | """ADR filename is badly formatted and/or not synched with the ADR content""" 86 | 87 | 88 | class PyadrSomeAdrStatusesAreProposedError(PyadrError): 89 | """Check on the fact that no ADR status is 'proposed' has failed""" 90 | 91 | 92 | class PyadrSomeAdrIdsNotUniqueError(PyadrError): 93 | """Some ADRs have the same number""" 94 | 95 | 96 | class PyadrAdrRepoChecksFailedError(PyadrError): 97 | """ADR repository checks have failed""" 98 | 99 | 100 | class PyadrAdrFilenameFormatError(PyadrError): 101 | """ADR filename format incorrect""" 102 | 103 | 104 | class PyadrStatusIncompatibleWithReviewRequestError(PyadrError): 105 | """Cannot create a review request branch with status of the given ADR""" 106 | -------------------------------------------------------------------------------- /features/git_adr/commit.feature: -------------------------------------------------------------------------------- 1 | Feature: Commit adrs 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: Commit proposed ADR in standard repo 7 | Given an initialised git adr repo 8 | And a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 9 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 10 | When I run "git adr commit docs/adr/XXXX-my-adr-title.md" 11 | Then it should pass 12 | And the file "docs/adr/XXXX-my-adr-title.md" should be committed in the last commit 13 | And the head commit message should be 14 | """ 15 | chore(adr): [proposed] XXXX-my-adr-title 16 | """ 17 | 18 | Scenario: Commit proposed ADR in adr only repo 19 | Given an initialised git adr only repo 20 | And a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 21 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 22 | When I run "git adr commit docs/adr/XXXX-my-adr-title.md" 23 | Then it should pass 24 | And the file "docs/adr/XXXX-my-adr-title.md" should be committed in the last commit 25 | And the head commit message should be 26 | """ 27 | chore(adr): [proposed] XXXX-my-adr-title 28 | """ 29 | 30 | Scenario: Commit accepted ADR in standard repo 31 | Given an initialised git adr repo 32 | And an accepted adr file named "docs/adr/0002-my-adr-title.md" 33 | And I stage the file "docs/adr/0002-my-adr-title.md" 34 | When I run "git adr commit docs/adr/0002-my-adr-title.md" 35 | Then it should pass 36 | And the file "docs/adr/0002-my-adr-title.md" should be committed in the last commit 37 | And the head commit message should be 38 | """ 39 | docs(adr): [accepted] 0002-my-adr-title 40 | """ 41 | 42 | Scenario: Commit accepted ADR in adr only repo 43 | Given an initialised git adr only repo 44 | And an accepted adr file named "docs/adr/0002-my-adr-title.md" 45 | And I stage the file "docs/adr/0002-my-adr-title.md" 46 | When I run "git adr commit docs/adr/0002-my-adr-title.md" 47 | Then it should pass 48 | And the file "docs/adr/0002-my-adr-title.md" should be committed in the last commit 49 | And the head commit message should be 50 | """ 51 | feat(adr): [accepted] 0002-my-adr-title 52 | """ 53 | 54 | Scenario: Commit ADR command output 55 | Given an initialised git adr repo 56 | And a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 57 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 58 | When I run "git adr commit docs/adr/XXXX-my-adr-title.md" 59 | Then the command output should contain 60 | """ 61 | Committing ADR 'docs/adr/XXXX-my-adr-title.md'... 62 | Committed ADR 'docs/adr/XXXX-my-adr-title.md' with message 'chore(adr): [proposed] XXXX-my-adr-title'. 63 | """ 64 | 65 | Scenario: Commit proposed ADR: Fail if not previously staged 66 | Given an initialised git adr repo 67 | And a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 68 | When I run "git adr commit docs/adr/XXXX-my-adr-title.md" 69 | Then it should fail with 70 | """ 71 | ADR 'docs/adr/XXXX-my-adr-title.md' should be staged first. 72 | """ 73 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Largely inspired from (pointing to precise version) 2 | # https://github.com/thejohnfreeman/project-template-python/tree/6d04c7b3b00460bb7473246096c52cc22d403226 3 | 4 | # black support portion inspired from (pointing to precise version) 5 | # https://github.com/python/black/blob/4a953b7241ce5f8bcac985fa33fdf3af4f42c0de/pyproject.toml 6 | 7 | [tool.poetry] 8 | name = "pyadr" 9 | version = "0.20.0" 10 | description = "CLI to help with an ADR process lifecycle (proposal/approval/rejection/deprecation/superseeding), which used git." 11 | license = "MIT" 12 | homepage = "https://github.com/opinionated-digital-center/pyadr" 13 | authors = ["Emmanuel Sciara "] 14 | packages = [ 15 | { include = "pyadr" }, 16 | ] 17 | keywords = ["pyadr", "adr"] 18 | classifiers = [ 19 | "Development Status :: 2 - Pre-Alpha", 20 | "Intended Audience :: Developers", 21 | "License :: OSI Approved :: MIT License", 22 | "Natural Language :: English", 23 | "Operating System :: POSIX", 24 | "Operating System :: MacOS :: MacOS X", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.8", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Topic :: Software Development :: Libraries", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | ] 34 | 35 | [tool.poetry.dependencies] 36 | python = "^3.8.1, <3.12" 37 | cleo = "^0.8.1" 38 | ############################# 39 | # Add you dependencies here # 40 | ############################# 41 | python-slugify = "^8.0.1" 42 | loguru = "^0" 43 | gitpython = "^3.1" 44 | 45 | [tool.poetry.group.dev.dependencies] 46 | tox = "^4.0.0" 47 | rope = "^1.0.0" 48 | pytest = "^8.0.0" 49 | pytest-cov = "^4.0" 50 | pytest-mock = "^3.10" 51 | pytest-html = "^4.0.0" 52 | pytest-asyncio = "^0" 53 | behave4git = "^0" 54 | PyHamcrest = "^2.0" 55 | flake8 = "^7.0.0" 56 | flake8-bugbear = "^23.3.12" 57 | pydocstyle = "^6.3" 58 | pylint = "^3.0.0" 59 | yapf = "^0" 60 | mypy = "^1.2" 61 | types-python-slugify = "^8" 62 | black = "^23.3.0" 63 | isort = "^5.12" 64 | sphinx = "^7.0.0" 65 | sphinx-autodoc-typehints = "^2.0.0" 66 | sphinx-autobuild = "^2021" 67 | sphinx_rtd_theme = "^2.0.0" 68 | m2r = "^0" 69 | bpython = "^0" 70 | invoke = "^2.0.0" 71 | 72 | [tool.poetry.scripts] 73 | pyadr = "pyadr.cli:main" 74 | git-adr = "pyadr.git.cli:main" 75 | 76 | [tool.pytest.ini_options] 77 | minversion = "6.0" 78 | addopts = "--cov=pyadr --cov-report html:reports/python/htmlcov --cov-report xml:reports/python/coverage.xml --cov-report=term --junitxml=reports/python/xunit.xml" 79 | testpaths = [ 80 | "tests", 81 | ] 82 | 83 | # following black's doc for compatibility 84 | # See https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#pylint 85 | [tool.pylint.messages_control] 86 | disable = "C0114, C0116, wrong-import-order, missing-class-docstring, W1203, W0511" 87 | 88 | [tool.pylint.redefined-outer-name] 89 | ignore = "tests" 90 | 91 | [tool.pylint.format] 92 | max-line-length = "88" 93 | 94 | [tool.isort] 95 | # following black's doc for compatibility 96 | # See https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#isort 97 | profile = "black" 98 | 99 | [tool.black] 100 | target_version = ["py39"] 101 | 102 | [tool.mypy] 103 | python_version = "3.10" 104 | disallow_untyped_defs = true 105 | warn_return_any = true 106 | warn_unused_configs = true 107 | 108 | [build-system] 109 | requires = ["poetry-core>=1.4.0"] 110 | build-backend = "poetry.core.masonry.api" 111 | -------------------------------------------------------------------------------- /scripts/verify_pypi_env_variables.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | PYPI_REPOSITORY_NAME_ENV_VAR = "PYPI_REPOSITORY_NAME" 5 | PYPI_REPOSITORY_URL_ENV_VAR_TEMPLATE = "POETRY_REPOSITORIES_{}_URL" 6 | PYPI_REPOSITORY_USERNAME_ENV_VAR_TEMPLATE = "POETRY_HTTP_BASIC_{}_USERNAME" 7 | PYPI_REPOSITORY_PASSWORD_ENV_VAR_TEMPLATE = "POETRY_HTTP_BASIC_{}_PASSWORD" 8 | PYPI_REPOSITORY_API_TOKEN_ENV_VAR_TEMPLATE = "POETRY_PYPI_TOKEN_{}" 9 | 10 | pypi_name = "pypi" 11 | pypi_url = pypi_username = pypi_password = pypi_api_token = None 12 | 13 | exit_code = 0 14 | 15 | try: 16 | pypi_name = os.environ[PYPI_REPOSITORY_NAME_ENV_VAR] 17 | except KeyError: 18 | pass 19 | 20 | print(f"Target pypi repository: '{pypi_name}'") 21 | 22 | 23 | def to_env_var_name(name): 24 | return name.replace(".", "_").replace("-", "_").upper() 25 | 26 | 27 | pypi_repository_url_env_var = PYPI_REPOSITORY_URL_ENV_VAR_TEMPLATE.format( 28 | to_env_var_name(pypi_name) 29 | ) 30 | pypi_repository_username_env_var = PYPI_REPOSITORY_USERNAME_ENV_VAR_TEMPLATE.format( 31 | to_env_var_name(pypi_name) 32 | ) 33 | pypi_repository_password_env_var = PYPI_REPOSITORY_PASSWORD_ENV_VAR_TEMPLATE.format( 34 | to_env_var_name(pypi_name) 35 | ) 36 | pypi_repository_api_token_env_var = PYPI_REPOSITORY_API_TOKEN_ENV_VAR_TEMPLATE.format( 37 | to_env_var_name(pypi_name) 38 | ) 39 | 40 | # This line is only to avoid confusion in the code formatting error message by having 41 | # all variables in lower case 42 | pypi_repository_name_env_var = PYPI_REPOSITORY_NAME_ENV_VAR 43 | 44 | try: 45 | pypi_url = os.environ[pypi_repository_url_env_var] 46 | except KeyError: 47 | pass 48 | try: 49 | pypi_username = os.environ[pypi_repository_username_env_var] 50 | except KeyError: 51 | pass 52 | try: 53 | pypi_password = os.environ[pypi_repository_password_env_var] 54 | except KeyError: 55 | pass 56 | try: 57 | pypi_api_token = os.environ[pypi_repository_api_token_env_var] 58 | except KeyError: 59 | pass 60 | 61 | print(pypi_url) 62 | print(pypi_username) 63 | print(pypi_password) 64 | print(pypi_api_token) 65 | 66 | if pypi_name == "pypi": 67 | if pypi_url: 68 | print( 69 | f"ERROR: ({pypi_repository_name_env_var} = '{pypi_name}', " 70 | f"{pypi_repository_url_env_var} = '{pypi_url}') " 71 | f"When the environment variable {PYPI_REPOSITORY_NAME_ENV_VAR} is 'pypi', " 72 | f"empty or not defined, you must NOT define the environment variable " 73 | f"{pypi_repository_url_env_var}." 74 | ) 75 | exit_code = 1 76 | else: 77 | if not pypi_url: 78 | print( 79 | f"ERROR: ({pypi_repository_name_env_var} = '{pypi_name}', " 80 | f"{pypi_repository_url_env_var} = '{pypi_url}') " 81 | f"When the environment variable {PYPI_REPOSITORY_NAME_ENV_VAR} " 82 | f"is NOT 'pypi', you must define the environment variable " 83 | f"{pypi_repository_url_env_var}." 84 | ) 85 | exit_code = 1 86 | if pypi_username is None and pypi_api_token is None: 87 | print( 88 | f"ERROR: Either {pypi_repository_username_env_var} or " 89 | f"{pypi_repository_api_token_env_var} environment variables " 90 | f"must be set." 91 | ) 92 | exit_code = 1 93 | if pypi_username and pypi_password is None: 94 | print( 95 | f"ERROR: No {pypi_repository_api_token_env_var} environment variable set " 96 | f"for {pypi_repository_username_env_var} = '{pypi_username}'." 97 | ) 98 | exit_code = 1 99 | 100 | if exit_code == 0: 101 | print("Pypi repository environment variables correctly configured for CI/CD.") 102 | 103 | sys.exit(exit_code) 104 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/opinionated-digital-center/pyadr/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | ADR Process Tool could always use more documentation, whether as part of the 42 | official ADR Process Tool docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at 49 | https://github.com/opinionated-digital-center/pyadr/issues. 50 | 51 | If you are proposing a feature: 52 | 53 | * Explain in detail how it would work. 54 | * Keep the scope as narrow as possible, to make it easier to implement. 55 | * Remember that this is a volunteer-driven project, and that contributions 56 | are welcome :) 57 | 58 | Get Started! 59 | ------------ 60 | 61 | Ready to contribute? Here's how to set up ``pyadr`` for local development on a Mac. 62 | 63 | Prerequisite 64 | ~~~~~~~~~~~~ 65 | 66 | * python 3.6, 3.7 and 3.8 with `pyenv `_ 67 | 68 | * `poetry `_ 69 | 70 | * `pre-commit `_ (optional but useful) 71 | 72 | Setup (for Mac) 73 | ~~~~~~~~~~~~~~~ 74 | 75 | 1. Fork the ``pyadr`` repo on GitHub. 76 | 2. Clone your fork locally:: 77 | 78 | $ git clone https://github.com/your-namespace/pyadr.git 79 | 80 | 3. Assuming you have the prerequisites installed, this is how you set up your fork for local development:: 81 | 82 | $ cd pyadr/ 83 | $ make setup-dev-env-full 84 | 85 | 4. Create a branch for local development:: 86 | 87 | $ git checkout -b name-of-your-bugfix-or-feature 88 | 89 | Now you can make your changes locally. 90 | 91 | 5. When you're done making changes, check that your changes pass typing, linting, formatting, unit tests 92 | (for all versions of Python) and functional (bdd) tests:: 93 | 94 | $ make tox 95 | 96 | .. note:: 97 | 98 | See the Makefile help for all available targets. 99 | 100 | 6. Commit your changes and push your branch to GitHub:: 101 | 102 | $ git add . 103 | $ git commit -m "[feat|fix|chore|...]: your detailed description of your changes" 104 | $ git push --set-upstream origin name-of-your-bugfix-or-feature 105 | 106 | 7. Submit a pull request through the GitHub website. 107 | 108 | Pull Request Guidelines 109 | ----------------------- 110 | 111 | Before you submit a pull request, check that it meets these guidelines: 112 | 113 | 1. The pull request should include tests. 114 | 2. If the pull request adds functionality, the docs should be updated. Put 115 | your new functionality into a function with a docstring, and add the 116 | feature to the list in README.rst. 117 | 3. The pull request should pass on all tests and checks of the pipeline. 118 | -------------------------------------------------------------------------------- /features/git_adr/init_adr_repo.feature: -------------------------------------------------------------------------------- 1 | Feature: Initialise a git ADR repository 2 | Since 'git adr' makes calls to 'pyadr', some features will be already fully 3 | tested in the bdd tests for 'pyadr'. 4 | 5 | Background: 6 | Given a new working directory 7 | 8 | Scenario: Fail when an ADR repo directory already exists 9 | Given a directory named "docs/adr" 10 | When I run "git adr init" 11 | Then it should fail 12 | 13 | Scenario: Fail when a git repo has not been initialised 14 | When I run "git adr init" 15 | Then it should fail with: 16 | """ 17 | No Git repository found in directory '{__WORKDIR__}/'. Please initialise a Git repository before running command. 18 | """ 19 | 20 | Scenario: Create and commit initial ADR files on empty git repo 21 | Given an empty git repo with "main" as initial branch 22 | When I run "git adr init" 23 | Then it should pass with 24 | """ 25 | Files committed to branch 'main' with commit message 26 | """ 27 | And the command output should contain 28 | """ 29 | Git repo empty. Will commit files to 'main'. 30 | """ 31 | And there should be 1 commit in "main" 32 | And the head commit message should be 33 | """ 34 | docs(adr): initialise adr repository 35 | """ 36 | And 3 files should be committed in the last commit 37 | And the file "docs/adr/template.md" should be committed in the last commit 38 | And the file "docs/adr/0000-record-architecture-decisions.md" should be committed in the last commit 39 | And the file "docs/adr/0001-use-markdown-architectural-decision-records.md" should be committed in the last commit 40 | 41 | Scenario: Initialise for ADR only repo 42 | Given an empty git repo with "main" as initial branch 43 | When I run "git adr init --adr-only-repo" 44 | Then it should pass 45 | And the head commit message should be 46 | """ 47 | feat(adr): initialise adr repository 48 | """ 49 | 50 | Scenario: Create and commit initial ADR files on a non-empty git repo 51 | Given a starting git repo with "main" as initial branch 52 | When I run "git adr init" 53 | Then it should pass with 54 | """ 55 | Files committed to branch 'adr-init-repo' with commit message 56 | """ 57 | And the branch "adr-init-repo" should exist 58 | And the head should be at branch "adr-init-repo" 59 | And there should be 1 commit between head and the branch "main" 60 | 61 | Scenario: Fail when index dirty 62 | Given a starting git repo with "main" as initial branch 63 | And a file named "foo" with 64 | """ 65 | bar 66 | """ 67 | And I add the file "foo" to the git index 68 | When I run "git adr init" 69 | Then it should fail with 70 | """ 71 | Verifying Git index is empty... 72 | ... files staged in Git index. Clean before running command. 73 | """ 74 | 75 | Scenario: Fail when init branch exists 76 | Given an empty git repo with "main" as initial branch 77 | And a file named "foo" with 78 | """ 79 | bar 80 | """ 81 | And I add the file "foo" to the git index 82 | And I commit the git index with message "foo bar" 83 | And I create the branch "adr-init-repo" 84 | When I run "git adr init" 85 | Then it should fail with 86 | """ 87 | Verifying branch 'adr-init-repo' does not exist... 88 | ... branch 'adr-init-repo' already exists. Clean before running command. 89 | """ 90 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, calling, contains_string, equal_to, not_, raises 2 | 3 | from pyadr.config import AdrConfig 4 | from pyadr.const import DEFAULT_ADR_PATH 5 | from pyadr.core import AdrCore 6 | from pyadr.exceptions import ( 7 | PyadrConfigFileSettingsNotSupported, 8 | PyadrConfigSettingNotSupported, 9 | ) 10 | 11 | 12 | def test_config_set(adr_core, tmp_path): 13 | # Given 14 | adr_ini_path = tmp_path / ".adr" 15 | 16 | # When 17 | adr_core.config["adr"]["records-dir"] = "doc/adr" 18 | 19 | # Then 20 | with adr_ini_path.open() as f: 21 | content = f.read() 22 | 23 | expected = """[adr] 24 | records-dir = doc/adr 25 | 26 | """ 27 | assert_that(content, equal_to(expected)) 28 | 29 | 30 | def test_config_defaults(adr_core): 31 | # Given 32 | # When 33 | # Then 34 | assert_that(adr_core.config["adr"]["records-dir"], equal_to(str(DEFAULT_ADR_PATH))) 35 | 36 | 37 | def test_config_configure(adr_core): 38 | # Given 39 | # When 40 | adr_core.configure("records-dir", "another") 41 | # Then 42 | assert_that(adr_core.config["adr"]["records-dir"], equal_to("another")) 43 | 44 | 45 | def test_config_unset(adr_core, tmp_path): 46 | # Given 47 | adr_core.config["adr"]["records-dir"] = "another" 48 | 49 | # When 50 | adr_core.unset_config_setting("records-dir") 51 | # Then 52 | assert_that(adr_core.config["adr"]["records-dir"], equal_to(str(DEFAULT_ADR_PATH))) 53 | 54 | adr_ini_path = tmp_path / ".adr" 55 | assert_that(adr_ini_path.exists(), equal_to(True)) 56 | with adr_ini_path.open() as f: 57 | assert_that(f.read(), not_(contains_string("records-dir = "))) 58 | 59 | 60 | def test_config_does_not_touch_other_config_file_sections(adr_core, tmp_path): 61 | # Given 62 | adr_ini_path = tmp_path / ".adr" 63 | content = """[adr] 64 | unsupported_option = value 65 | 66 | [other_section] 67 | option = value 68 | 69 | """ 70 | with adr_ini_path.open("w") as f: 71 | f.write(content) 72 | 73 | # When 74 | adr_core.config.persist() 75 | # Then 76 | with adr_ini_path.open() as f: 77 | result = f.read() 78 | 79 | expected = """[adr] 80 | 81 | [other_section] 82 | option = value 83 | 84 | """ 85 | assert_that(result, equal_to(expected)) 86 | 87 | 88 | def test_config_works_with_other_sections_on_init(tmp_path): 89 | # Given 90 | adr_ini_path = tmp_path / ".adr" 91 | content = """[adr] 92 | 93 | [other_section] 94 | option = value 95 | 96 | """ 97 | with adr_ini_path.open("w") as f: 98 | f.write(content) 99 | 100 | # When 101 | adr_core = AdrCore() 102 | adr_core.config.persist() 103 | # Then 104 | with adr_ini_path.open() as f: 105 | result = f.read() 106 | 107 | expected = """[adr] 108 | 109 | [other_section] 110 | option = value 111 | 112 | """ 113 | assert_that(result, equal_to(expected)) 114 | 115 | 116 | def test_config_unset_fail_on_unknown_setting(adr_core, tmp_path): 117 | # Given 118 | # config["unsupported_option"] = "value" 119 | 120 | # Then 121 | assert_that( 122 | calling(adr_core.config["adr"].__delitem__).with_args("unsupported_option"), 123 | raises(PyadrConfigSettingNotSupported), 124 | ) 125 | 126 | 127 | def test_config_fail_on_unknown_setting(adr_core): 128 | # Given 129 | # When 130 | # Then 131 | assert_that( 132 | calling(adr_core.config["adr"].__setitem__).with_args( 133 | "unsupported_option", "value" 134 | ), 135 | raises(PyadrConfigSettingNotSupported), 136 | ) 137 | 138 | 139 | def test_config_fail_on_unknown_setting_in_config_file(tmp_path): 140 | # Given 141 | adr_ini_path = tmp_path / ".adr" 142 | with adr_ini_path.open("w") as f: 143 | f.write("[adr]\nunsupported_option = value\n\n") 144 | # When 145 | # Then 146 | assert_that(calling(AdrConfig), raises(PyadrConfigFileSettingsNotSupported)) 147 | -------------------------------------------------------------------------------- /pyadr/cli/commands.py: -------------------------------------------------------------------------------- 1 | """Console script for pyadr.""" 2 | from typing import List 3 | 4 | import cleo 5 | 6 | from pyadr.const import STATUS_ACCEPTED, STATUS_REJECTED 7 | from pyadr.core import AdrCore 8 | from pyadr.exceptions import PyadrAdrRepoChecksFailedError 9 | 10 | 11 | class BaseCommand(cleo.Command): 12 | def __init__(self): 13 | super().__init__() 14 | self.adr_core = AdrCore() 15 | 16 | 17 | class ConfigCommand(BaseCommand): 18 | """ 19 | Configure an ADR repository 20 | 21 | config 22 | {setting? : Configuration setting.} 23 | {value? : Configuration value.} 24 | {--l|list : List configuration settings.} 25 | {--u|unset : Unset configuration setting.} 26 | """ 27 | 28 | def handle(self): 29 | if self.option("list"): 30 | self.adr_core.list_config() 31 | elif self.option("unset"): 32 | if not self.argument("setting"): 33 | self.line_error('Not enough arguments (missing: "words").') 34 | self.adr_core.unset_config_setting(self.argument("setting")) 35 | elif self.argument("setting"): 36 | if self.argument("value"): 37 | self.adr_core.configure( 38 | self.argument("setting"), self.argument("value") 39 | ) 40 | else: 41 | self.adr_core.print_config_setting(self.argument("setting")) 42 | 43 | 44 | class InitCommand(BaseCommand): 45 | """ 46 | Initialise an ADR repository 47 | 48 | init 49 | {--f|force : If set, will erase existing repository.} 50 | """ 51 | 52 | def handle(self): 53 | self.adr_core.init_adr_repo(force=self.option("force")) 54 | 55 | 56 | class NewCommand(BaseCommand): 57 | """ 58 | Create an new ADR 59 | 60 | new 61 | {words* : Words in the title.} 62 | """ 63 | 64 | def handle(self): 65 | self.adr_core.new_adr(title=" ".join(self.argument("words"))) 66 | 67 | 68 | class ProposeCommand(NewCommand): 69 | """ 70 | Propose a new ADR (same as 'new' command) 71 | 72 | propose 73 | {words* : Words in the title} 74 | """ 75 | 76 | 77 | class AcceptCommand(BaseCommand): 78 | """ 79 | Accept a proposed ADR by assigning an ID, updating filename, status and date 80 | 81 | accept 82 | {file : ADR file.} 83 | {--t|toc : If set, generates also the table of content.} 84 | """ 85 | 86 | def handle(self): 87 | self.adr_core.accept_or_reject( 88 | self.argument("file"), STATUS_ACCEPTED, self.option("toc") 89 | ) 90 | 91 | 92 | class RejectCommand(BaseCommand): 93 | """ 94 | Reject a proposed ADR by assigning an ID, updating filename, status and date 95 | 96 | reject 97 | {file : ADR file.} 98 | {--t|toc : If set, generates also the table of content.} 99 | """ 100 | 101 | def handle(self): 102 | self.adr_core.accept_or_reject( 103 | self.argument("file"), STATUS_REJECTED, self.option("toc") 104 | ) 105 | 106 | 107 | class GenerateTocCommand(BaseCommand): 108 | """ 109 | Generate a table of content of the ADRs 110 | 111 | toc 112 | """ 113 | 114 | def handle(self): 115 | self.adr_core.generate_toc() 116 | 117 | 118 | class CheckAdrRepoCommand(BaseCommand): 119 | """ 120 | Perform sanity checks typically required on ADR files before merging a Pull Request 121 | 122 | check-adr-repo 123 | {--p|no-proposed : If set, will also check that there are no proposed ADR.} 124 | """ 125 | 126 | def handle(self): 127 | try: 128 | self.adr_core.check_adr_repo(self.option("no-proposed")) 129 | except PyadrAdrRepoChecksFailedError: 130 | return 1 131 | 132 | 133 | class HelperSlugCommand(BaseCommand): 134 | """ 135 | Return the ADR's title in slug format 136 | 137 | slug 138 | {file : ADR file.} 139 | """ 140 | 141 | def handle(self): 142 | self.adr_core.print_title_slug(self.argument("file")) 143 | 144 | 145 | class HelperLowercaseCommand(BaseCommand): 146 | """ 147 | Return the ADR's title in lowercase 148 | 149 | lowercase 150 | {file : ADR file.} 151 | """ 152 | 153 | def handle(self): 154 | self.adr_core.print_title_lowercase(self.argument("file")) 155 | 156 | 157 | class HelperSyncFilenameCommand(BaseCommand): 158 | """ 159 | Sync the ADR's filename with its actual title 160 | 161 | sync-filename 162 | {file : ADR file.} 163 | """ 164 | 165 | def handle(self): 166 | self.adr_core.sync_filename(self.argument("file")) 167 | 168 | 169 | class HelperCommand(BaseCommand): 170 | """ 171 | Helper command generating and syncing various useful things 172 | 173 | helper 174 | """ 175 | 176 | commands: List[BaseCommand] = [] 177 | 178 | def __init__(self): 179 | self.commands.extend( 180 | [ 181 | HelperSlugCommand(), 182 | HelperLowercaseCommand(), 183 | HelperSyncFilenameCommand(), 184 | ] 185 | ) 186 | super().__init__() 187 | 188 | def handle(self): 189 | return self.call("help", self.config.name) 190 | -------------------------------------------------------------------------------- /.github/workflows/test_and_make_release.yml: -------------------------------------------------------------------------------- 1 | name: Test and make release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'docs/**' 9 | - '*.rst' 10 | - '*.md' 11 | pull_request: 12 | paths-ignore: 13 | - 'docs/**' 14 | - '*.rst' 15 | - '*.md' 16 | 17 | env: 18 | POETRY_VIRTUALENVS_CREATE: true 19 | POETRY_VIRTUALENVS_IN_PROJECT: true 20 | 21 | jobs: 22 | test: 23 | runs-on: ubuntu-latest 24 | strategy: 25 | matrix: 26 | python-version: ['3.11', '3.10', '3.9', '3.8'] 27 | steps: 28 | - uses: actions/checkout@v3 29 | - name: Install tools (Poetry) 30 | uses: Gr1N/setup-poetry@v8 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | cache: 'poetry' 36 | - name: Verify python version 37 | run: poetry run python -V 38 | - name: Install dependencies 39 | run: poetry install 40 | - name: Complete CI/CD setup 41 | run: poetry run inv cicd-setup.setup-test-stage 42 | - name: Run unit tests 43 | run: poetry run inv test 44 | 45 | bdd: 46 | runs-on: ubuntu-latest 47 | strategy: 48 | matrix: 49 | python-version: ['3.11', '3.10', '3.9', '3.8'] 50 | steps: 51 | - uses: actions/checkout@v3 52 | - name: Install tools (Poetry) 53 | uses: Gr1N/setup-poetry@v8 54 | - name: Set up Python ${{ matrix.python-version }} 55 | uses: actions/setup-python@v4 56 | with: 57 | python-version: ${{ matrix.python-version }} 58 | cache: 'poetry' 59 | - name: Verify python version 60 | run: poetry run python -V 61 | - name: Install dependencies 62 | run: poetry install 63 | - name: Complete CI/CD setup 64 | run: poetry run inv cicd-setup.setup-test-stage 65 | - name: Run bdd tests 66 | run: poetry run inv bdd 67 | 68 | format: 69 | runs-on: ubuntu-latest 70 | steps: 71 | - uses: actions/checkout@v3 72 | - name: Install tools (Poetry) 73 | uses: Gr1N/setup-poetry@v8 74 | - name: Set up Python 3.10 75 | uses: actions/setup-python@v4 76 | with: 77 | python-version: '3.10' 78 | cache: 'poetry' 79 | - name: Verify python version 80 | run: poetry run python -V 81 | - name: Install dependencies 82 | run: poetry install 83 | - name: Complete CI/CD setup 84 | run: poetry run inv cicd-setup.setup-test-stage 85 | - name: Run format checks 86 | run: poetry run inv format-check 87 | 88 | lint: 89 | runs-on: ubuntu-latest 90 | steps: 91 | - uses: actions/checkout@v3 92 | - name: Install tools (Poetry) 93 | uses: Gr1N/setup-poetry@v8 94 | - name: Set up Python 3.10 95 | uses: actions/setup-python@v4 96 | with: 97 | python-version: '3.10' 98 | cache: 'poetry' 99 | - name: Verify python version 100 | run: poetry run python -V 101 | - name: Install dependencies 102 | run: poetry install 103 | - name: Complete CI/CD setup 104 | run: poetry run inv cicd-setup.setup-test-stage 105 | - name: Run lint checks 106 | run: poetry run inv lint 107 | 108 | # type: 109 | # runs-on: ubuntu-latest 110 | # steps: 111 | # - uses: actions/checkout@v3 112 | # - name: Install tools (Poetry) 113 | # uses: Gr1N/setup-poetry@v8 114 | # - name: Set up Python 3.10 115 | # uses: actions/setup-python@v4 116 | # with: 117 | # python-version: '3.10' 118 | # cache: 'poetry' 119 | # - name: Verify python version 120 | # run: poetry run python -V 121 | # - name: Install dependencies 122 | # run: poetry install 123 | # - name: Complete CI/CD setup 124 | # run: poetry run inv cicd-setup.setup-test-stage 125 | # - name: Run type checks 126 | # run: poetry run inv type 127 | 128 | semantic_release: 129 | needs: 130 | - test 131 | - bdd 132 | - format 133 | - lint 134 | # - type 135 | runs-on: ubuntu-latest 136 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 137 | steps: 138 | - uses: actions/checkout@v3 139 | - name: Use Node.js 12.x 140 | uses: actions/setup-node@v3 141 | with: 142 | node-version: 12.x 143 | - name: Install tools (Poetry) 144 | uses: Gr1N/setup-poetry@v8 145 | - name: Set up Python 3.10 146 | uses: actions/setup-python@v4 147 | with: 148 | python-version: '3.10' 149 | cache: 'poetry' 150 | - name: Verify python version 151 | run: poetry run python -V 152 | - name: Complete CI/CD setup 153 | run: make setup-cicd-release-stage 154 | - name: Semantic Release 155 | uses: cycjimmy/semantic-release-action@v3.4.2 156 | with: 157 | semantic_version: 17.0.4 158 | extra_plugins: | 159 | @semantic-release/changelog@"^5.0.1" 160 | @semantic-release/exec@"^5.0.0" 161 | @semantic-release/git@"^9.0.0" 162 | @semantic-release/github@"^7.0.5" 163 | env: 164 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 165 | GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }} 166 | GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }} 167 | GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }} 168 | GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }} 169 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # pyadr documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another 16 | # directory, add these directories to sys.path here. If the directory is 17 | # relative to the documentation root, use os.path.abspath to make it 18 | # absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | 23 | import pyadr 24 | 25 | sys.path.insert(0, os.path.abspath("..")) 26 | 27 | 28 | # -- General configuration --------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # 32 | # needs_sphinx = "1.0" 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named "sphinx.ext.*") or your custom ones. 36 | extensions = [ 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.viewcode", 39 | "m2r", 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ["_templates"] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # 48 | # source_suffix = ".rst" 49 | source_suffix = [ 50 | ".rst", 51 | ".md", 52 | ] 53 | 54 | # The master toctree document. 55 | master_doc = "index" 56 | 57 | # General information about the project. 58 | project = "ADR Process Tool" 59 | copyright = "2020, Emmanuel Sciara" 60 | author = "Emmanuel Sciara" 61 | 62 | # The version info for the project you're documenting, acts as replacement 63 | # for |version| and |release|, also used in various other places throughout 64 | # the built documents. 65 | # 66 | # The short X.Y version. 67 | version = pyadr.__version__ 68 | # The full version, including alpha/beta/rc tags. 69 | release = pyadr.__version__ 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | # 74 | # This is also used if you do content translation via gettext catalogs. 75 | # Usually you set "language" from the command line for these cases. 76 | language = None 77 | 78 | # List of patterns, relative to source directory, that match files and 79 | # directories to ignore when looking for source files. 80 | # This patterns also effect to html_static_path and html_extra_path 81 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = "sphinx" 85 | 86 | # If true, `todo` and `todoList` produce output, else they produce nothing. 87 | todo_include_todos = False 88 | 89 | 90 | # -- Options for HTML output ------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | # 95 | # html_theme = "alabaster" 96 | html_theme = "sphinx_rtd_theme" 97 | 98 | # Theme options are theme-specific and customize the look and feel of a 99 | # theme further. For a list of options available for each theme, see the 100 | # documentation. 101 | # 102 | # html_theme_options = {} 103 | 104 | # Add any paths that contain custom static files (such as style sheets) here, 105 | # relative to this directory. They are copied after the builtin static files, 106 | # so a file named "default.css" will overwrite the builtin "default.css". 107 | html_static_path = ["_static"] 108 | 109 | 110 | # -- Options for HTMLHelp output --------------------------------------- 111 | 112 | # Output file base name for HTML help builder. 113 | htmlhelp_basename = "pyadrdoc" 114 | 115 | 116 | # -- Options for LaTeX output ------------------------------------------ 117 | 118 | latex_elements = { 119 | # The paper size ("letterpaper" or "a4paper"). 120 | # "papersize": "letterpaper", 121 | # 122 | # The font size ("10pt", "11pt" or "12pt"). 123 | # "pointsize": "10pt", 124 | # 125 | # Additional stuff for the LaTeX preamble. 126 | # "preamble": "", 127 | # 128 | # Latex figure (float) alignment 129 | # "figure_align": "htbp", 130 | } 131 | 132 | # Grouping the document tree into LaTeX files. List of tuples 133 | # (source start file, target name, title, author, documentclass 134 | # [howto, manual, or own class]). 135 | latex_documents = [ 136 | ( 137 | master_doc, 138 | "pyadr.tex", 139 | "ADR Process Tool Documentation", 140 | "Emmanuel Sciara", 141 | "manual", 142 | ), 143 | ] 144 | 145 | 146 | # -- Options for manual page output ------------------------------------ 147 | 148 | # One entry per manual page. List of tuples 149 | # (source start file, name, description, authors, manual section). 150 | man_pages = [(master_doc, "pyadr", "ADR Process Tool Documentation", [author], 1)] 151 | 152 | 153 | # -- Options for Texinfo output ---------------------------------------- 154 | 155 | # Grouping the document tree into Texinfo files. List of tuples 156 | # (source start file, target name, title, author, 157 | # dir menu entry, description, category) 158 | texinfo_documents = [ 159 | ( 160 | master_doc, 161 | "pyadr", 162 | "ADR Process Tool Documentation", 163 | author, 164 | "pyadr", 165 | "One line description of project.", 166 | "Miscellaneous", 167 | ), 168 | ] 169 | -------------------------------------------------------------------------------- /features/pyadr/generate_toc.feature: -------------------------------------------------------------------------------- 1 | Feature: Generate a table of content in markdown 2 | 3 | Scenario: Generate a table of content in markdown - all statuses have adr 4 | Given a new working directory 5 | And a file named "docs/adr/0001-an-accepted-adr.md" with: 6 | """ 7 | # An accepted ADR 8 | 9 | * Status: accepted 10 | * Date: 2020-03-26 11 | 12 | ## Context and Problem Statement 13 | 14 | [..] 15 | """ 16 | And a file named "docs/adr/0002-a-rejected-adr.md" with: 17 | """ 18 | # A rejected ADR 19 | 20 | * Status: rejected 21 | * Date: 2020-03-26 22 | 23 | ## Context and Problem Statement 24 | 25 | [..] 26 | """ 27 | And a file named "docs/adr/0003-a-superseded-adr.md" with: 28 | """ 29 | # A superseded ADR 30 | 31 | * Status: superseded by [ADR-0001](0001-an-accepted-adr.md) 32 | * Date: 2020-03-26 33 | 34 | ## Context and Problem Statement 35 | 36 | [..] 37 | """ 38 | And a file named "docs/adr/0004-a-deprecated-adr.md" with: 39 | """ 40 | # A deprecated ADR 41 | 42 | * Status: deprecated 43 | * Date: 2020-03-26 44 | 45 | ## Context and Problem Statement 46 | 47 | [..] 48 | """ 49 | And a file named "docs/adr/0005-an-adr-with-a-non-standard-status.md" with: 50 | """ 51 | # An ADR with a non-standard status 52 | 53 | * Status: foo 54 | * Date: 2020-03-26 55 | 56 | ## Context and Problem Statement 57 | 58 | [..] 59 | """ 60 | When I run "pyadr toc" 61 | Then it should pass with: 62 | """ 63 | Markdown table of content generated in 'docs/adr/index.md' 64 | """ 65 | And the file "docs/adr/index.md" should contain: 66 | """ 67 | # Architecture Decision Records 68 | 69 | ## Accepted Records 70 | 71 | * [0001 - An accepted ADR](0001-an-accepted-adr.md) 72 | 73 | ## Rejected Records 74 | 75 | * [0002 - A rejected ADR](0002-a-rejected-adr.md) 76 | 77 | ## Superseded Records 78 | 79 | * [0003 - A superseded ADR](0003-a-superseded-adr.md): superseded by [ADR-0001](0001-an-accepted-adr.md) 80 | 81 | ## Deprecated Records 82 | 83 | * [0004 - A deprecated ADR](0004-a-deprecated-adr.md) 84 | 85 | ## Records with non-standard statuses 86 | 87 | ### Status `foo` 88 | 89 | * [0005 - An ADR with a non-standard status](0005-an-adr-with-a-non-standard-status.md) 90 | """ 91 | 92 | Scenario: Generate a table of content in markdown - some statuses don't have adr 93 | Given a new working directory 94 | And a file named "docs/adr/0001-an-adr.md" with: 95 | """ 96 | # An ADR 97 | 98 | * Status: accepted 99 | * Date: 2020-03-26 100 | 101 | ## Context and Problem Statement 102 | 103 | [..] 104 | """ 105 | And a file named "docs/adr/0002-another-adr.md" with: 106 | """ 107 | # Another ADR 108 | 109 | * Status: accepted 110 | * Date: 2020-03-26 111 | 112 | ## Context and Problem Statement 113 | 114 | [..] 115 | """ 116 | When I run "pyadr toc" 117 | Then it should pass 118 | And the file named "docs/adr/index.md" should exist 119 | And the file "docs/adr/index.md" should contain: 120 | """ 121 | # Architecture Decision Records 122 | 123 | ## Accepted Records 124 | 125 | * [0001 - An ADR](0001-an-adr.md) 126 | * [0002 - Another ADR](0002-another-adr.md) 127 | 128 | ## Rejected Records 129 | 130 | * None 131 | 132 | ## Superseded Records 133 | 134 | * None 135 | 136 | ## Deprecated Records 137 | 138 | * None 139 | 140 | ## Records with non-standard statuses 141 | 142 | * None 143 | """ 144 | 145 | Scenario: Do not include proposed ADRs in table of content 146 | Given a new working directory 147 | And a file named "docs/adr/0001-an-adr.md" with: 148 | """ 149 | # An ADR 150 | 151 | * Status: accepted 152 | * Date: 2020-03-26 153 | 154 | ## Context and Problem Statement 155 | 156 | [..] 157 | """ 158 | And a file named "docs/adr/XXXX-another-adr.md" with: 159 | """ 160 | # Another ADR 161 | 162 | * Status: proposed 163 | * Date: 2020-03-26 164 | 165 | ## Context and Problem Statement 166 | 167 | [..] 168 | """ 169 | When I run "pyadr toc" 170 | Then it should pass 171 | And the file named "docs/adr/index.md" should exist 172 | And the file "docs/adr/index.md" should contain: 173 | """ 174 | # Architecture Decision Records 175 | 176 | ## Accepted Records 177 | 178 | * [0001 - An ADR](0001-an-adr.md) 179 | 180 | ## Rejected Records 181 | 182 | * None 183 | 184 | ## Superseded Records 185 | 186 | * None 187 | 188 | ## Deprecated Records 189 | 190 | * None 191 | 192 | ## Records with non-standard statuses 193 | 194 | * None 195 | """ 196 | -------------------------------------------------------------------------------- /tests/test_git_config.py: -------------------------------------------------------------------------------- 1 | from hamcrest import assert_that, calling, contains_string, equal_to, not_, raises 2 | 3 | from pyadr.const import DEFAULT_ADR_PATH 4 | from pyadr.exceptions import ( 5 | PyadrConfigFileSettingsNotSupported, 6 | PyadrConfigSettingNotSupported, 7 | ) 8 | from pyadr.git.config import GitAdrConfig 9 | 10 | 11 | def test_git_config_set_adr_setting(git_adr_core, tmp_path): 12 | # Given 13 | adr_ini_path = tmp_path / ".adr" 14 | 15 | # When 16 | git_adr_core.config["adr"]["records-dir"] = "doc/adr" 17 | 18 | # Then 19 | with adr_ini_path.open() as f: 20 | content = f.read() 21 | 22 | expected = """[adr] 23 | records-dir = doc/adr 24 | 25 | [git] 26 | 27 | """ 28 | assert_that(content, equal_to(expected)) 29 | 30 | 31 | def test_git_config_set_git_setting(git_adr_core, tmp_path): 32 | # Given 33 | adr_ini_path = tmp_path / ".adr" 34 | 35 | # When 36 | git_adr_core.config["git"]["adr-only-repo"] = "true" 37 | 38 | # Then 39 | with adr_ini_path.open() as f: 40 | content = f.read() 41 | 42 | expected = """[adr] 43 | 44 | [git] 45 | adr-only-repo = true 46 | 47 | """ 48 | assert_that(content, equal_to(expected)) 49 | 50 | 51 | def test_git_config_git_setting_not_erased_when_update_adr_setting_with_pyadr( 52 | adr_core, git_adr_core, tmp_path 53 | ): 54 | # Given 55 | adr_ini_path = tmp_path / ".adr" 56 | git_adr_core.config["git"]["adr-only-repo"] = "true" 57 | 58 | # When 59 | git_adr_core.config["adr"]["records-dir"] = "doc/adr" 60 | 61 | # Then 62 | with adr_ini_path.open() as f: 63 | content = f.read() 64 | 65 | expected = """[adr] 66 | records-dir = doc/adr 67 | 68 | [git] 69 | adr-only-repo = true 70 | 71 | """ 72 | assert_that(content, equal_to(expected)) 73 | 74 | 75 | def test_git_config_adr_setting_defaults(git_adr_core): 76 | # Given 77 | # When 78 | # Then 79 | assert_that( 80 | git_adr_core.config["adr"]["records-dir"], equal_to(str(DEFAULT_ADR_PATH)) 81 | ) 82 | 83 | 84 | def test_git_config_git_setting_defaults(git_adr_core): 85 | # Given 86 | # When 87 | # Then 88 | assert_that(git_adr_core.config["git"]["adr-only-repo"], equal_to("false")) 89 | 90 | 91 | def test_git_config_configure_adr_setting(git_adr_core): 92 | # Given 93 | # When 94 | git_adr_core.configure("records-dir", "another") 95 | # Then 96 | assert_that(git_adr_core.config["adr"]["records-dir"], equal_to("another")) 97 | 98 | 99 | def test_git_config_configure_git_setting(git_adr_core): 100 | # Given 101 | # When 102 | git_adr_core.configure("adr-only-repo", "true") 103 | # Then 104 | assert_that(git_adr_core.config["git"]["adr-only-repo"], equal_to("true")) 105 | 106 | 107 | def test_git_config_unset_adr_setting(git_adr_core, tmp_path): 108 | # Given 109 | git_adr_core.config["adr"]["records-dir"] = "another" 110 | 111 | # When 112 | git_adr_core.unset_config_setting("records-dir") 113 | # Then 114 | assert_that( 115 | git_adr_core.config["adr"]["records-dir"], equal_to(str(DEFAULT_ADR_PATH)) 116 | ) 117 | 118 | adr_ini_path = tmp_path / ".adr" 119 | assert_that(adr_ini_path.exists(), equal_to(True)) 120 | with adr_ini_path.open() as f: 121 | assert_that(f.read(), not_(contains_string("records-dir = "))) 122 | 123 | 124 | def test_git_config_unset_git_setting(git_adr_core, tmp_path): 125 | # Given 126 | git_adr_core.config["git"]["adr-only-repo"] = "true" 127 | 128 | # When 129 | git_adr_core.unset_config_setting("adr-only-repo") 130 | # Then 131 | assert_that(git_adr_core.config["git"]["adr-only-repo"], equal_to("false")) 132 | 133 | adr_ini_path = tmp_path / ".adr" 134 | assert_that(adr_ini_path.exists(), equal_to(True)) 135 | with adr_ini_path.open() as f: 136 | assert_that(f.read(), not_(contains_string("adr-only-repo = "))) 137 | 138 | 139 | def test_git_config_unset_fail_on_unknown_setting(git_adr_core, tmp_path): 140 | # Given 141 | # config["unsupported_option"] = "value" 142 | 143 | # Then 144 | assert_that( 145 | calling(git_adr_core.config["adr"].__delitem__).with_args("unsupported_option"), 146 | raises(PyadrConfigSettingNotSupported), 147 | ) 148 | assert_that( 149 | calling(git_adr_core.config["git"].__delitem__).with_args("unsupported_option"), 150 | raises(PyadrConfigSettingNotSupported), 151 | ) 152 | 153 | 154 | def test_git_config_fail_on_unknown_setting(git_adr_core): 155 | # Given 156 | # When 157 | # Then 158 | assert_that( 159 | calling(git_adr_core.config["adr"].__setitem__).with_args( 160 | "unsupported_option", "value" 161 | ), 162 | raises(PyadrConfigSettingNotSupported), 163 | ) 164 | assert_that( 165 | calling(git_adr_core.config["git"].__setitem__).with_args( 166 | "unsupported_option", "value" 167 | ), 168 | raises(PyadrConfigSettingNotSupported), 169 | ) 170 | 171 | 172 | def test_git_config_fail_on_unknown_adr_setting_in_config_file(tmp_path): 173 | # Given 174 | adr_ini_path = tmp_path / ".adr" 175 | with adr_ini_path.open("w") as f: 176 | f.write("[adr]\nunsupported_option = value\n\n") 177 | # When 178 | # Then 179 | assert_that(calling(GitAdrConfig), raises(PyadrConfigFileSettingsNotSupported)) 180 | 181 | 182 | def test_git_config_fail_on_unknown_git_setting_in_config_file(tmp_path): 183 | # Given 184 | adr_ini_path = tmp_path / ".adr" 185 | with adr_ini_path.open("w") as f: 186 | f.write("[git]\nunsupported_option = value\n\n") 187 | # When 188 | # Then 189 | assert_that(calling(GitAdrConfig), raises(PyadrConfigFileSettingsNotSupported)) 190 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | reports 2 | 3 | # IDE settings 4 | .vscode/ 5 | .idea/ 6 | 7 | # Created by .ignore support plugin (hsz.mobi) 8 | ### Python template 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # C extensions 15 | *.so 16 | 17 | # Distribution / packaging 18 | .Python 19 | env/ 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | pip-wheel-metadata/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Behave / coverage reports 63 | cucumber-report.json 64 | 65 | # behave4cli 66 | __WORKDIR__/ 67 | 68 | # Translations 69 | *.mo 70 | *.pot 71 | 72 | # Django stuff: 73 | *.log 74 | local_settings.py 75 | db.sqlite3 76 | 77 | # Flask stuff: 78 | instance/ 79 | .webassets-cache 80 | 81 | # Scrapy stuff: 82 | .scrapy 83 | 84 | # Sphinx documentation 85 | docs/_build/ 86 | 87 | # PyBuilder 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | 93 | # IPython 94 | profile_default/ 95 | ipython_config.py 96 | 97 | # pyenv 98 | .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # celery beat schedule file 108 | celerybeat-schedule 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # dotenv 114 | .env 115 | 116 | # virtualenv 117 | .venv 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | ### VirtualEnv template 142 | # Virtualenv 143 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 144 | .Python 145 | #[Bb]in # Changed to allow use of project's source scripts 146 | [Ii]nclude 147 | [Ll]ib 148 | [Ll]ib64 149 | [Ll]ocal 150 | #[Ss]cripts # Changed to allow use of project's source scripts 151 | pyvenv.cfg 152 | .venv 153 | pip-selfcheck.json 154 | 155 | ### macOS template 156 | # General 157 | .DS_Store 158 | .AppleDouble 159 | .LSOverride 160 | 161 | # Icon must end with two \r 162 | Icon 163 | 164 | # Thumbnails 165 | ._* 166 | 167 | # Files that might appear in the root of a volume 168 | .DocumentRevisions-V100 169 | .fseventsd 170 | .Spotlight-V100 171 | .TemporaryItems 172 | .Trashes 173 | .VolumeIcon.icns 174 | .com.apple.timemachine.donotpresent 175 | 176 | # Directories potentially created on remote AFP share 177 | .AppleDB 178 | .AppleDesktop 179 | Network Trash Folder 180 | Temporary Items 181 | .apdisk 182 | 183 | 184 | ### Node template 185 | # Logs 186 | logs 187 | *.log 188 | npm-debug.log* 189 | yarn-debug.log* 190 | yarn-error.log* 191 | lerna-debug.log* 192 | 193 | # Diagnostic reports (https://nodejs.org/api/report.html) 194 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 195 | 196 | # Runtime data 197 | pids 198 | *.pid 199 | *.seed 200 | *.pid.lock 201 | 202 | # Directory for instrumented libs generated by jscoverage/JSCover 203 | lib-cov 204 | 205 | # Coverage directory used by tools like istanbul 206 | coverage 207 | *.lcov 208 | 209 | # nyc test coverage 210 | .nyc_output 211 | 212 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 213 | .grunt 214 | 215 | # Bower dependency directory (https://bower.io/) 216 | bower_components 217 | 218 | # node-waf configuration 219 | .lock-wscript 220 | 221 | # Compiled binary addons (https://nodejs.org/api/addons.html) 222 | build/Release 223 | 224 | # Dependency directories 225 | node_modules/ 226 | jspm_packages/ 227 | 228 | # Snowpack dependency directory (https://snowpack.dev/) 229 | web_modules/ 230 | 231 | # TypeScript cache 232 | *.tsbuildinfo 233 | 234 | # Optional npm cache directory 235 | .npm 236 | 237 | # Optional eslint cache 238 | .eslintcache 239 | 240 | # Microbundle cache 241 | .rpt2_cache/ 242 | .rts2_cache_cjs/ 243 | .rts2_cache_es/ 244 | .rts2_cache_umd/ 245 | 246 | # Optional REPL history 247 | .node_repl_history 248 | 249 | # Output of 'npm pack' 250 | *.tgz 251 | 252 | # Yarn Integrity file 253 | .yarn-integrity 254 | 255 | # dotenv environment variables file 256 | #.env 257 | .env.test 258 | 259 | # parcel-bundler cache (https://parceljs.org/) 260 | .cache 261 | 262 | # Next.js build output 263 | .next 264 | 265 | # Nuxt.js build / generate output 266 | .nuxt 267 | dist 268 | 269 | # Gatsby files 270 | .cache/ 271 | # Comment in the public line in if your project uses Gatsby and not Next.js 272 | # https://nextjs.org/blog/next-9-1#public-directory-support 273 | # public 274 | 275 | # vuepress build output 276 | .vuepress/dist 277 | 278 | # Serverless directories 279 | .serverless/ 280 | 281 | # FuseBox cache 282 | .fusebox/ 283 | 284 | # DynamoDB Local files 285 | .dynamodb/ 286 | 287 | # TernJS port file 288 | .tern-port 289 | 290 | # Stores VSCode versions used for testing VSCode extensions 291 | .vscode-test 292 | -------------------------------------------------------------------------------- /pyadr/config.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from copy import deepcopy 3 | from typing import Dict 4 | 5 | from pyadr.const import ADR_DEFAULT_SETTINGS, DEFAULT_CONFIG_FILE_PATH 6 | from pyadr.exceptions import ( 7 | PyadrConfigFileSettingsNotSupported, 8 | PyadrConfigSettingNotSupported, 9 | ) 10 | 11 | 12 | class AdrConfig(ConfigParser): 13 | config_file_path = DEFAULT_CONFIG_FILE_PATH 14 | 15 | def __init__(self, defaults=None): 16 | if defaults: 17 | super().__init__(defaults=defaults) 18 | else: 19 | sorted_default_settings = {} 20 | for key in sorted(ADR_DEFAULT_SETTINGS.keys()): 21 | sorted_default_settings[key] = ADR_DEFAULT_SETTINGS[key] 22 | super().__init__(defaults=sorted_default_settings) 23 | 24 | self.section_defaults = {"adr": ADR_DEFAULT_SETTINGS} 25 | 26 | self.add_section("adr") 27 | 28 | if self.config_file_path.exists(): 29 | self.read(self.config_file_path) 30 | 31 | self.check_filled_settings_supported() 32 | 33 | def set(self, section: str, setting: str, value: str = None) -> None: 34 | if section != self.default_section: # type: ignore 35 | self.check_section_supports_setting(section, setting) 36 | super().set(section, setting, value) 37 | if section != self.default_section: # type: ignore 38 | self.persist() 39 | 40 | def has_option(self, section: str, setting: str) -> bool: 41 | self.check_section_supports_setting(section, setting) 42 | return super().has_option(section, setting) 43 | 44 | def remove_option(self, section: str, setting: str) -> bool: 45 | self.check_section_supports_setting(section, setting) 46 | existed = super().remove_option(section, setting) 47 | if existed and section != self.default_section: # type: ignore 48 | self.persist() 49 | return existed 50 | 51 | def configure(self, setting: str, value: str) -> None: 52 | setting_supported = False 53 | for section in self._sections.keys(): # type: ignore 54 | if self.section_supports_setting(section, setting): 55 | self[section][setting] = value 56 | setting_supported = True 57 | if not setting_supported: 58 | raise PyadrConfigSettingNotSupported( 59 | f"'{self.optionxform(setting)}' not in {list(self.defaults().keys())}" 60 | ) 61 | 62 | def unset(self, setting: str) -> None: 63 | setting_supported = False 64 | for section in self._sections.keys(): # type: ignore 65 | if self.section_supports_setting(section, setting): 66 | del self[section][setting] 67 | setting_supported = True 68 | if not setting_supported: 69 | raise PyadrConfigSettingNotSupported( 70 | f"'{self.optionxform(setting)}' not in {list(self.defaults().keys())}" 71 | ) 72 | 73 | def raw(self) -> Dict[str, str]: 74 | raw_config = {} 75 | for setting in self.defaults().keys(): 76 | for section in self._sections.keys(): # type: ignore 77 | if self.section_supports_setting(section, setting): 78 | raw_config[setting] = self[section][setting] 79 | return raw_config 80 | 81 | # def print_config_setting(self, setting: str) -> None: 82 | # logger.info(f"{setting} = {self.config['adr'][setting]}") 83 | 84 | def persist(self) -> None: 85 | defaults = deepcopy(self.defaults()) 86 | self[self.default_section] = {} # type: ignore 87 | 88 | if self.config_file_path.exists(): 89 | tmp_config = ConfigParser() 90 | tmp_config.read(self.config_file_path) 91 | tmp_config["adr"] = self["adr"] 92 | with self.config_file_path.open("w") as f: 93 | tmp_config.write(f) 94 | else: 95 | with self.config_file_path.open("w") as f: 96 | self.write(f) 97 | 98 | self[self.default_section] = defaults # type: ignore 99 | 100 | def check_filled_settings_supported(self) -> None: 101 | messages = [] 102 | for section in self._sections.keys(): # type: ignore 103 | try: 104 | self.check_section_supports_filled_settings(section) 105 | except PyadrConfigFileSettingsNotSupported as e: 106 | messages.append(str(e)) 107 | if messages: 108 | raise PyadrConfigFileSettingsNotSupported(", ".join(messages)) 109 | 110 | def check_section_supports_filled_settings(self, section: str) -> None: 111 | unsupported_settings = [ 112 | self.optionxform(setting) 113 | for setting in self._sections[section].keys() # type: ignore 114 | if not self.section_supports_setting(section, setting) 115 | ] 116 | if unsupported_settings: 117 | raise PyadrConfigFileSettingsNotSupported( 118 | f"{unsupported_settings} not in " 119 | f"{list(self.section_defaults[section].keys())}" 120 | ) 121 | 122 | def check_section_supports_setting(self, section: str, setting: str) -> None: 123 | if not self.section_supports_setting(section, setting): 124 | raise PyadrConfigSettingNotSupported( 125 | f"'{self.optionxform(setting)}' not in " 126 | f"{list(self.section_defaults[section].keys())}" 127 | ) 128 | 129 | def section_supports_setting(self, section: str, setting: str) -> bool: 130 | if section in self.section_defaults.keys(): 131 | return self.optionxform(setting) in self.section_defaults[section].keys() 132 | else: 133 | return True 134 | -------------------------------------------------------------------------------- /features/pyadr/init_adr_repo.feature: -------------------------------------------------------------------------------- 1 | Feature: Initialise an ADR repository 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: Fail when a repo already exists 7 | Given a directory named "docs/adr" 8 | When I run "pyadr init" 9 | Then it should fail with: 10 | """ 11 | Directory '{__WORKDIR__}/docs/adr/' already exists. You can use '--force' option to erase. 12 | """ 13 | 14 | Scenario: Force init the repo and succeed with a warning message 15 | Given a directory named "docs/adr" 16 | And an empty file named "docs/adr/to-be-erased" 17 | And an empty file named "docs/to-be-kept" 18 | And a directory named "docs/dir-to-be-kept" 19 | When I run "pyadr init -f" 20 | Then it should pass with: 21 | """ 22 | Directory '{__WORKDIR__}/docs/adr/' already exists. Used '--force' option => Erasing... 23 | ... erased. 24 | """ 25 | And the file named "docs/adr/to-be-erased" should not exist 26 | And the file named "docs/to-be-kept" should exist 27 | And the directory "docs/dir-to-be-kept" should exist 28 | 29 | Scenario: Succeed with a success message 30 | When I run "pyadr init" 31 | Then it should pass with: 32 | """ 33 | ADR repository successfully created at '{__WORKDIR__}/docs/adr/'. 34 | """ 35 | 36 | Scenario: Create the repo directory 37 | When I run "pyadr init" 38 | Then it should pass 39 | And the directory "docs/adr" exists 40 | 41 | Scenario: Copy the MADR template to the repo and succeed with a success message (when using verbose) 42 | When I run "pyadr init -v" 43 | Then it should pass with: 44 | """ 45 | Copying MADR template to 'docs/adr/template.md'... 46 | ... done. 47 | """ 48 | And the file named "docs/adr/template.md" should exist 49 | And the file "docs/adr/template.md" should contain: 50 | """ 51 | # [short title of solved problem and solution] 52 | 53 | * Status: [proposed | rejected | accepted | deprecated | ... | superseded by [ADR-0005](0005-example.md)] 54 | """ 55 | 56 | Scenario: Create the ADR to record architecture decisions and succeed with a success message (when using verbose) 57 | When I run "pyadr init -v" 58 | Then it should pass with: 59 | """ 60 | Creating ADR 'docs/adr/0000-record-architecture-decisions.md'... 61 | ... done. 62 | """ 63 | And the file named "docs/adr/0000-record-architecture-decisions.md" should exist 64 | And the file "docs/adr/0000-record-architecture-decisions.md" should contain: 65 | """ 66 | # Record architecture decisions 67 | 68 | * Status: accepted 69 | * Date: {__TODAY_YYYY_MM_DD__} 70 | 71 | ## Context 72 | 73 | We need to record the architectural decisions made on Opinionated Digital Center. 74 | 75 | ## Decision 76 | 77 | We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). 78 | 79 | ## Consequences 80 | 81 | See Michael Nygard's article, linked above. 82 | """ 83 | 84 | Scenario: Create the ADR to use MADR and succeed with a success message (when using verbose) 85 | When I run "pyadr init -v" 86 | Then it should pass with: 87 | """ 88 | Creating ADR 'docs/adr/0001-use-markdown-architectural-decision-records.md'... 89 | ... done. 90 | """ 91 | And the file named "docs/adr/0001-use-markdown-architectural-decision-records.md" should exist 92 | And the file "docs/adr/0001-use-markdown-architectural-decision-records.md" should contain: 93 | """ 94 | # Use Markdown Architectural Decision Records 95 | 96 | Adapted from 97 | [MADR's similar decision record](https://github.com/adr/madr/blob/2.1.2/docs/adr/0000-use-markdown-architectural-decision-records.md). 98 | 99 | * Status: accepted 100 | * Date: {__TODAY_YYYY_MM_DD__} 101 | 102 | ## Context and Problem Statement 103 | 104 | We want to record architectural decisions made in this project. 105 | Which format and structure should these records follow? 106 | 107 | ## Considered Options 108 | 109 | * [MADR](https://adr.github.io/madr/) 2.1.2 - The Markdown Architectural Decision Records 110 | * [Michael Nygard's template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) - The first incarnation of the term "ADR" 111 | * [Sustainable Architectural Decisions](https://www.infoq.com/articles/sustainable-architectural-design-decisions) - The Y-Statements 112 | * Other templates listed at 113 | * Formless - No conventions for file format and structure 114 | 115 | ## Decision Outcome 116 | 117 | Chosen option: "MADR 2.1.2", because 118 | 119 | * Implicit assumptions should be made explicit. 120 | Design documentation is important to enable people understanding the decisions later on. 121 | See also [A rational design process: How and why to fake it](https://doi.org/10.1109/TSE.1986.6312940). 122 | * The MADR format is lean and fits our development style. 123 | * The MADR structure is comprehensible and facilitates usage & maintenance. 124 | * The MADR project is vivid. 125 | * Version 2.1.2 is the latest one available when starting to document ADRs. 126 | 127 | ### Positive Consequences 128 | 129 | The ADR are more structured. See especially: 130 | * [MADR-0002 - Do not use numbers in headings](https://github.com/adr/madr/blob/2.1.2/docs/adr/0002-do-not-use-numbers-in-headings.md). 131 | * [MADR-0005 - Use (unique number and) dashes in filenames](https://github.com/adr/madr/blob/2.1.2/docs/adr/0005-use-dashes-in-filenames.md). 132 | * [MADR-0010 - Support categories (in form of subfolders with local ids)](https://github.com/adr/madr/blob/2.1.2/docs/adr/0010-support-categories.md). 133 | * See [full set of MADR ADRs](https://github.com/adr/madr/blob/2.1.2/docs/adr). 134 | 135 | ### Negative Consequences 136 | 137 | * Learning curve will be slightly longer. 138 | """ 139 | -------------------------------------------------------------------------------- /features/git_adr/helper_any_repo.feature: -------------------------------------------------------------------------------- 1 | Feature: Helper for the various names and messages - Git included - Any repo type 2 | Since 'git adr' makes calls to 'pyadr', some features will be already fully 3 | tested in the bdd tests for 'pyadr'. 4 | 5 | Background: 6 | Given a new working directory 7 | And an initialised git adr repo 8 | 9 | Scenario: Sync ADR filename with its title - untracked files 10 | Given a file named "0002-my-adr-title.md" with: 11 | """ 12 | # My ADR Updated Title 13 | 14 | * Status: accepted 15 | * Date: 2020-03-26 16 | 17 | ## Context and Problem Statement 18 | 19 | Context and problem statement. 20 | 21 | ## Decision Outcome 22 | 23 | Decision outcome. 24 | """ 25 | When I run "git adr helper sync-filename 0002-my-adr-title.md" 26 | Then it should pass 27 | And the file named "0002-my-adr-title.md" should not exist 28 | And the file named "0002-my-adr-updated-title.md" should exist 29 | And the file "0002-my-adr-updated-title.md" should be staged 30 | 31 | Scenario: Sync ADR filename with its title - staged file 32 | Given a file named "0002-my-adr-title.md" with: 33 | """ 34 | # My ADR Updated Title 35 | 36 | * Status: accepted 37 | * Date: 2020-03-26 38 | 39 | ## Context and Problem Statement 40 | 41 | Context and problem statement. 42 | 43 | ## Decision Outcome 44 | 45 | Decision outcome. 46 | """ 47 | And I stage the file "0002-my-adr-title.md" 48 | When I run "git adr helper sync-filename 0002-my-adr-title.md" 49 | Then it should pass 50 | And the file named "0002-my-adr-title.md" should not exist 51 | And the file named "0002-my-adr-updated-title.md" should exist 52 | And the file "0002-my-adr-updated-title.md" should be staged 53 | 54 | Scenario: Sync ADR filename with its title - committed file 55 | Given a file named "0002-my-adr-title.md" with: 56 | """ 57 | # My ADR Updated Title 58 | 59 | * Status: accepted 60 | * Date: 2020-03-26 61 | 62 | ## Context and Problem Statement 63 | 64 | Context and problem statement. 65 | 66 | ## Decision Outcome 67 | 68 | Decision outcome. 69 | """ 70 | And I stage the file "0002-my-adr-title.md" 71 | And I commit the staged files with message "foo bar" 72 | When I run "git adr helper sync-filename 0002-my-adr-title.md" 73 | Then it should pass 74 | And the file named "0002-my-adr-title.md" should not exist 75 | And the file named "0002-my-adr-updated-title.md" should exist 76 | And the file "0002-my-adr-updated-title.md" should be staged 77 | 78 | Scenario: No sync if filename already correct 79 | Given an accepted adr file named "0002-my-adr-title.md" 80 | When I run "git adr helper sync-filename 0002-my-adr-title.md" 81 | Then it should pass 82 | And the file "0002-my-adr-title.md" should not be staged 83 | 84 | Scenario: Return commit message fail on wrong filename format 85 | Given a proposed adr file named "XXXXX-my-adr-title.md" 86 | When I run "git adr helper commit-message XXXXX-my-adr-title.md" 87 | Then it should fail with 88 | """ 89 | PyadrAdrFilenameIncorrectError 90 | XXXXX-my-adr-title.md 91 | """ 92 | And the command output should contain 93 | """ 94 | (status to verify against: 'proposed') 95 | ADR(s)'s filename follow the format 'XXXX-.md', but: 96 | => 'XXXXX-my-adr-title.md' does not start with 'XXXX-'. 97 | """ 98 | 99 | Scenario: Return commit message fail on unsynched filename title 100 | Given a file named "XXXX-my-adr-title.md" with: 101 | """ 102 | # My ADR Updated Title 103 | 104 | * Status: proposed 105 | * Date: 2020-03-26 106 | 107 | ## Context and Problem Statement 108 | [..] 109 | """ 110 | When I run "git adr helper commit-message XXXX-my-adr-title.md" 111 | Then it should fail with 112 | """ 113 | PyadrAdrFilenameIncorrectError 114 | XXXX-my-adr-title.md 115 | """ 116 | And the command output should contain 117 | """ 118 | (status to verify against: 'proposed') 119 | ADR(s)'s filename follow the format 'XXXX-.md', but: 120 | => 'XXXX-my-adr-title.md' does not have the correct title slug ('my-adr-updated-title'). 121 | """ 122 | 123 | Scenario: Return branch title 124 | Given a proposed adr file named "XXXX-my-adr-title.md" 125 | When I run "git adr helper branch-title XXXX-my-adr-title.md" 126 | Then it should pass with 127 | """ 128 | propose-my-adr-title 129 | """ 130 | 131 | Scenario: Return branch title fail on unsynched filename title 132 | Given a file named "XXXX-my-adr-title.md" with: 133 | """ 134 | # My ADR Updated Title 135 | 136 | * Status: proposed 137 | * Date: 2020-03-26 138 | 139 | ## Context and Problem Statement 140 | [..] 141 | """ 142 | When I run "git adr helper branch-title XXXX-my-adr-title.md" 143 | Then it should fail with 144 | """ 145 | PyadrAdrFilenameIncorrectError 146 | """ 147 | Scenario: Fail on branch option if ADR status not valid (propose, deprecate, supersede) 148 | Given an accepted adr file named "0001-my-adr-title.md" 149 | When I run "git adr helper branch-title 0001-my-adr-title.md" 150 | Then it should fail with 151 | """ 152 | PyadrStatusIncompatibleWithReviewRequestError 153 | ADR: '0001-my-adr-title.md'; status: 'accepted'. 154 | """ 155 | And the command output should contain 156 | """ 157 | Can only create review request branches for ADR statuses: ['proposed', 'deprecated', 'superseding']. 158 | """ 159 | 160 | 161 | # TODO 162 | # Scenario: Fail on commit message option for superseding if no superseded file given 163 | # 164 | # Scenario: Fail on commit message option if ADR status not valid 165 | # 166 | -------------------------------------------------------------------------------- /features/pyadr/accept_or_reject_proposed_adr.feature: -------------------------------------------------------------------------------- 1 | Feature: Accept or reject proposed ADR 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: Increment ID for first accepted (same code for rejected, no need to duplicate test) ADR 7 | Given an empty file named "docs/adr/0000-record-architecture-decisions.md" 8 | Given a file named "docs/adr/XXXX-my-adr-title.md" with: 9 | """ 10 | # My ADR Title 11 | 12 | * Status: proposed 13 | * Date: 2020-03-26 14 | 15 | ## Context and Problem Statement 16 | 17 | Context and problem statement. 18 | 19 | ## Decision Outcome 20 | 21 | Decision outcome. 22 | """ 23 | When I run "pyadr accept docs/adr/XXXX-my-adr-title.md" 24 | Then it should pass 25 | And the file named "docs/adr/XXXX-my-adr-title.md" should not exist 26 | And the file named "docs/adr/0001-my-adr-title.md" should exist 27 | 28 | Scenario: Increment ID for subsequent accepted (same code for rejected, no need to duplicate test) ADR 29 | Given an empty file named "docs/adr/0001-a-first-adr.md" 30 | And a file named "docs/adr/XXXX-my-adr-title.md" with: 31 | """ 32 | # My ADR Title 33 | 34 | * Status: proposed 35 | * Date: 2020-03-26 36 | 37 | ## Context and Problem Statement 38 | 39 | Context and problem statement. 40 | 41 | ## Decision Outcome 42 | 43 | Decision outcome. 44 | """ 45 | When I run "pyadr accept docs/adr/XXXX-my-adr-title.md" 46 | Then it should pass 47 | And the file named "docs/adr/XXXX-my-adr-title.md" should not exist 48 | And the file named "docs/adr/0002-my-adr-title.md" should exist 49 | 50 | Scenario: Ensure filename corresponds to title of accepted (same code for rejected, no need to duplicate test) ADR 51 | Given an empty file named "docs/adr/0001-my-first-adr.md" 52 | And a file named "docs/adr/XXXX-my-adr-title.md" with: 53 | """ 54 | # My ADR Updated Title 55 | 56 | * Status: proposed 57 | * Date: 2020-03-26 58 | 59 | ## Context and Problem Statement 60 | 61 | Context and problem statement. 62 | 63 | ## Decision Outcome 64 | 65 | Decision outcome. 66 | """ 67 | When I run "pyadr accept docs/adr/XXXX-my-adr-title.md" 68 | Then it should pass 69 | And the file named "docs/adr/XXXX-my-adr-title.md" should not exist 70 | And the file named "docs/adr/0002-my-adr-updated-title.md" should exist 71 | 72 | Scenario: Update Status and Date for accepted ADR 73 | Given an empty file named "docs/adr/0000-record-architecture-decisions.md" 74 | And a file named "docs/adr/XXXX-my-adr-title.md" with: 75 | """ 76 | # My ADR Title 77 | 78 | * Status: proposed 79 | * Date: 2020-03-26 80 | 81 | ## Context and Problem Statement 82 | 83 | Context and problem statement. 84 | 85 | ## Decision Outcome 86 | 87 | Decision outcome. 88 | """ 89 | When I run "pyadr accept docs/adr/XXXX-my-adr-title.md" 90 | Then it should pass 91 | And the file "docs/adr/0001-my-adr-title.md" should contain: 92 | """ 93 | # My ADR Title 94 | 95 | * Status: accepted 96 | * Date: {__TODAY_YYYY_MM_DD__} 97 | 98 | ## Context and Problem Statement 99 | 100 | Context and problem statement. 101 | 102 | ## Decision Outcome 103 | 104 | Decision outcome. 105 | """ 106 | 107 | Scenario: Update Status and Date for rejected ADR 108 | Given an empty file named "docs/adr/0000-record-architecture-decisions.md" 109 | Given a file named "docs/adr/XXXX-my-adr-title.md" with: 110 | """ 111 | # My ADR Title 112 | 113 | * Status: proposed 114 | * Date: 2020-03-26 115 | 116 | ## Context and Problem Statement 117 | 118 | Context and problem statement. 119 | 120 | ## Decision Outcome 121 | 122 | Decision outcome. 123 | """ 124 | When I run "pyadr reject docs/adr/XXXX-my-adr-title.md" 125 | Then it should pass 126 | And the file named "docs/adr/XXXX-my-adr-title.md" should not exist 127 | And the file named "docs/adr/0001-my-adr-title.md" should exist 128 | And the file "docs/adr/0001-my-adr-title.md" should contain: 129 | """ 130 | # My ADR Title 131 | 132 | * Status: rejected 133 | * Date: {__TODAY_YYYY_MM_DD__} 134 | 135 | ## Context and Problem Statement 136 | 137 | Context and problem statement. 138 | 139 | ## Decision Outcome 140 | 141 | Decision outcome. 142 | """ 143 | 144 | Scenario: Generate index when approving if requested 145 | Given an accepted adr file named "docs/adr/0000-record-architecture-decisions.md" 146 | Given a file named "docs/adr/XXXX-my-adr-title.md" with: 147 | """ 148 | # My ADR Title 149 | 150 | * Status: proposed 151 | * Date: 2020-03-26 152 | 153 | ## Context and Problem Statement 154 | 155 | Context and problem statement. 156 | 157 | ## Decision Outcome 158 | 159 | Decision outcome. 160 | """ 161 | When I run "pyadr accept docs/adr/XXXX-my-adr-title.md --toc" 162 | Then it should pass with: 163 | """ 164 | Markdown table of content generated in 'docs/adr/index.md' 165 | """ 166 | And the file named "docs/adr/index.md" should exist 167 | 168 | Scenario: Generate index whe rejecting if requested 169 | Given an accepted adr file named "docs/adr/0000-record-architecture-decisions.md" 170 | Given a file named "docs/adr/XXXX-my-adr-title.md" with: 171 | """ 172 | # My ADR Title 173 | 174 | * Status: proposed 175 | * Date: 2020-03-26 176 | 177 | ## Context and Problem Statement 178 | 179 | Context and problem statement. 180 | 181 | ## Decision Outcome 182 | 183 | Decision outcome. 184 | """ 185 | When I run "pyadr reject docs/adr/XXXX-my-adr-title.md --toc" 186 | Then it should pass with: 187 | """ 188 | Markdown table of content generated in 'docs/adr/index.md' 189 | """ 190 | And the file named "docs/adr/index.md" should exist 191 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | ADR Process Tool 3 | ================ 4 | 5 | .. image:: https://github.com/opinionated-digital-center/pyadr/workflows/Test%20and%20make%20release/badge.svg 6 | :target: https://github.com/opinionated-digital-center/pyadr/actions 7 | 8 | .. image:: https://github.com/opinionated-digital-center/pyadr/workflows/Publish%20Python%20package%20to%20Pypi/badge.svg 9 | :target: https://github.com/opinionated-digital-center/pyadr/actions 10 | 11 | .. image:: https://img.shields.io/pypi/v/pyadr.svg 12 | :target: https://pypi.org/project/pyadr/ 13 | 14 | .. image:: https://img.shields.io/pypi/status/pyadr.svg 15 | :target: https://pypi.org/project/pyadr/ 16 | 17 | .. image:: https://img.shields.io/pypi/pyversions/pyadr.svg 18 | :target: https://pypi.org/project/pyadr/ 19 | 20 | .. image:: https://img.shields.io/pypi/l/pyadr.svg 21 | :target: https://pypi.org/project/pyadr/ 22 | 23 | 24 | CLI to help with an ADR process lifecycle (``proposal``/``acceptance``/``rejection``/ 25 | ``deprecation``/``superseding``) based on ``markdown`` files and ``git``. 26 | 27 | *This tools is in pre-alpha state. Sphinx doc to be updated.* 28 | 29 | Features 30 | -------- 31 | 32 | ``pyadr`` 33 | +++++++++ 34 | 35 | * ``pyadr init``: initialise an ADR repository 36 | (`corresponding BDD tests `_). 37 | * ``pyadr new|propose Title of your ADR``: propose a new ADR 38 | (`corresponding BDD tests `_). 39 | * ``pyadr accept []``: accept a proposed ADR 40 | (`corresponding BDD tests `_). 41 | * ``pyadr reject []``: reject a proposed ADR (see ``accept`` above for BDD 42 | tests). 43 | * ``pyadr deprecate ``: (not yet implemented) deprecate an ADR. 44 | * ``pyadr supersede ``: (not yet implemented) supersede an ADR with another ADR. 45 | * ``pyadr toc``: generate a table of content (in format ``index.md``) 46 | (`corresponding BDD tests `_). 47 | * ``pyadr helper``: generate and syncs various useful things 48 | (`corresponding BDD tests `_): 49 | 50 | * print title slug. 51 | * print title in lowercase. 52 | * synch filename with ADR title. 53 | 54 | * ``pyadr check-adr-repo``: performs sanity checks on the ADR repo 55 | (`corresponding BDD tests `_). 56 | * ``pyadr config [] []``: configure a setting 57 | (`corresponding BDD tests `_). 58 | 59 | Help for all commands is available through ``pyadr help``. 60 | 61 | Help for individual commands is available through ``pyadr help ``. 62 | 63 | ``git adr`` 64 | +++++++++++ 65 | 66 | The ``git`` extension to ``pyadr`` does the following additional actions: 67 | 68 | * ``git adr init`` 69 | (`corresponding BDD tests `_): 70 | 71 | * initialise a git repository for the ADRs. 72 | 73 | * ``git adr new|propose Title of your ADR`` 74 | (`corresponding BDD tests `_): 75 | 76 | * create a new branch from ``main``. 77 | * stage the new ADR in that branch. 78 | 79 | * ``git adr accept []`` 80 | (`corresponding BDD tests `_): 81 | 82 | * stage ADR to current branch. 83 | * optionally commit ADR. 84 | * optionally squash commits. (not yet implemented) 85 | 86 | * ``git adr reject []``: 87 | (`corresponding BDD tests `_): 88 | 89 | * stage ADR to current branch. 90 | * optionally commit ADR. 91 | * optionally squash commits. (not yet implemented) 92 | 93 | * ``git adr deprecate ``: (not yet implemented) 94 | 95 | * create a new branch from ``main``. 96 | * stage the deprecated ADR in that branch. 97 | * optionally commit. 98 | * optionally squash commits. 99 | 100 | * ``git adr supersede ``: (not yet implemented) 101 | 102 | * create a new branch from ``main``. 103 | * stage the superseded and superseding ADRs in that branch. 104 | * optionally commit both ADRs. 105 | * optionally squash commits. 106 | 107 | * ``git adr commit []``: (not yet implemented) 108 | 109 | * optionally stage ADR(s) to current branch. 110 | * commit ADR(s). 111 | * optionally squash commits. 112 | 113 | * ``git adr helper``: generate and syncs various useful things 114 | (`corresponding BDD tests `_): 115 | 116 | * print title slug. 117 | * print title in lowercase. 118 | * synch filename with ADR title and staged renamed file. 119 | * print expected commit message for ADR. 120 | * print expected review request branch for ADR. 121 | 122 | * ``git adr pre-merge-checks`` 123 | (`corresponding BDD tests `_): 124 | 125 | * Performs sanity checks typically required on ADR files before merging a 126 | Pull Request. 127 | 128 | * ``git adr config [] []`` 129 | (`corresponding BDD tests one `_ and 130 | `two `_): 131 | 132 | * configure also settings specific to ``git adr``. 133 | 134 | Help for all commands is available through ``git adr help``. 135 | 136 | Help for individual commands is available through ``git adr help ``. 137 | 138 | Process Details 139 | --------------- 140 | 141 | (Needs rewriting) 142 | 143 | Once a proposed ADR placed in the ``docs/adr`` directory has been reviewed by peers, you can either action the decision 144 | to accept it (``pyadr accept``) or to reject it (``pyadr reject``), which will: 145 | 146 | * Update the ADR content by: 147 | 148 | * Changing the ADR status (``accepted`` or ``rejected``) 149 | * Changing the ADR date to current date 150 | 151 | * Change the ADR file name from ``XXXX-`` to 152 | ``-`` (follows 153 | `MADR-0005-format `_) 154 | 155 | Various safety checks are performed before these actions take place. See BDD tests 156 | in the ``features`` directory. 157 | 158 | Installation 159 | ------------ 160 | 161 | To install ADR Process Tool, run: 162 | 163 | .. code-block:: console 164 | 165 | $ pip install pyadr 166 | 167 | Credits 168 | ------- 169 | 170 | This package was created with Cookiecutter_ and the 171 | `opinionated-digital-center/python-library-project-generator`_ project template. 172 | 173 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 174 | .. _`opinionated-digital-center/python-library-project-generator`: https://github.com/opinionated-digital-center/python-library-project-generator 175 | -------------------------------------------------------------------------------- /pyadr/content_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | from pathlib import Path 4 | from typing import Any, Dict, List, Optional, TextIO, Tuple, Union 5 | 6 | from slugify import slugify 7 | 8 | from pyadr.exceptions import ( 9 | PyadrAdrDateNotFoundError, 10 | PyadrAdrStatusNotFoundError, 11 | PyadrAdrTitleNotFoundError, 12 | PyadrNoLineWithSuffixError, 13 | ) 14 | 15 | 16 | def update_adr_content_title(content: str, title: str) -> str: 17 | return update_adr_content_title_and_status(content, title=title, status=None) 18 | 19 | 20 | def update_adr_content_status(content: str, status: str) -> str: 21 | return update_adr_content_title_and_status(content, title=None, status=status) 22 | 23 | 24 | def update_adr_content_title_and_status( 25 | content: str, title: Union[str, None], status: Union[str, None] 26 | ) -> str: 27 | if not title and not status: 28 | raise TypeError("argument 'title' of 'status' has to be given") 29 | 30 | today = datetime.today().strftime("%Y-%m-%d") 31 | updated_content = content 32 | if title: 33 | updated_content = re.sub( 34 | r"^# .*$", f"# {title}", updated_content, 1, re.MULTILINE 35 | ) 36 | if status: 37 | updated_content = re.sub( 38 | r"^\* Status: .*$", f"* Status: {status}", updated_content, 1, re.MULTILINE 39 | ) 40 | updated_content = re.sub( 41 | r"^\* Date: .*$", f"* Date: {today}", updated_content, 1, re.MULTILINE 42 | ) 43 | return updated_content 44 | 45 | 46 | def adr_title_slug_from_file(path: Path) -> str: 47 | title, _, _ = adr_title_status_and_date_from_file(path) 48 | return slugify(title) 49 | 50 | 51 | def adr_title_lowercase_from_file(path: Path) -> str: 52 | title, _, _ = adr_title_status_and_date_from_file(path) 53 | return title.lower() 54 | 55 | 56 | def adr_status_from_file(path: Path) -> str: 57 | _, (status, _), _ = adr_title_status_and_date_from_file(path) 58 | return status 59 | 60 | 61 | def adr_title_status_and_date_from_file( 62 | path: Path, 63 | ) -> Tuple[str, Tuple[str, Optional[str]], str]: 64 | with path.open() as f: 65 | try: 66 | title = extract_next_line_with_suffix_from_content_stream( 67 | f, "# ", stream_source=str(path) 68 | ) 69 | except PyadrNoLineWithSuffixError: 70 | raise PyadrAdrTitleNotFoundError(source=str(path)) 71 | 72 | try: 73 | full_status = extract_next_line_with_suffix_from_content_stream( 74 | f, "* Status:", stream_source=str(path) 75 | ) 76 | except PyadrNoLineWithSuffixError: 77 | raise PyadrAdrStatusNotFoundError(source=str(path)) 78 | 79 | status_phrase: Optional[str] 80 | try: 81 | status, status_phrase = full_status.split(" ", 1) 82 | except ValueError: 83 | status = full_status 84 | status_phrase = None 85 | else: 86 | status_phrase = status_phrase.strip() 87 | 88 | try: 89 | date = extract_next_line_with_suffix_from_content_stream( 90 | f, "* Date:", stream_source=str(path) 91 | ) 92 | except PyadrNoLineWithSuffixError: 93 | raise PyadrAdrDateNotFoundError(source=str(path)) 94 | 95 | return title, (status, status_phrase), date 96 | 97 | 98 | def extract_next_line_with_suffix_from_content_stream( 99 | stream: TextIO, suffix: str, stream_source: str = "Not provided" 100 | ) -> str: 101 | line = "bootstrap string" 102 | while not line.startswith(suffix) and len(line) != 0: 103 | line = stream.readline() 104 | if len(line) == 0: 105 | raise PyadrNoLineWithSuffixError( 106 | f"No line found with suffix '{suffix}' " 107 | f"in content from source '{stream_source}'" 108 | ) 109 | title = line[len(suffix) :].strip() 110 | return title 111 | 112 | 113 | def build_toc_content_from_adrs_by_status( 114 | adrs_by_status: Dict[str, Dict[str, Any]] 115 | ) -> List[str]: 116 | toc_content = [ 117 | "\n", 119 | "# Architecture Decision Records\n", 120 | ] 121 | for status in ["accepted", "rejected", "superseded", "deprecated"]: 122 | toc_content.append("\n") 123 | toc_content.append(f"## {adrs_by_status[status]['status-title']}\n") 124 | toc_content.append("\n") 125 | if adrs_by_status[status]["adrs"]: 126 | toc_content.extend(adrs_by_status[status]["adrs"]) 127 | else: 128 | toc_content.append("* None\n") 129 | 130 | status = "non-standard" 131 | toc_content.append("\n") 132 | toc_content.append(f"## {adrs_by_status[status]['status-title']}\n") 133 | if adrs_by_status[status]["adrs-by-status"]: 134 | for value in adrs_by_status[status]["adrs-by-status"].values(): 135 | toc_content.append("\n") 136 | toc_content.append(f"### {value['status-title']}\n") 137 | toc_content.append("\n") 138 | toc_content.extend(value["adrs"]) 139 | else: 140 | toc_content.append("\n") 141 | toc_content.append("* None\n") 142 | return toc_content 143 | 144 | 145 | def extract_adrs_by_status( 146 | records_path: Path, adr_paths: List[Path] 147 | ) -> Dict[str, Dict[str, Any]]: 148 | adrs_by_status: Dict[str, Dict[str, Any]] = { 149 | "accepted": {"status-title": "Accepted Records", "adrs": []}, 150 | "rejected": {"status-title": "Rejected Records", "adrs": []}, 151 | "superseded": {"status-title": "Superseded Records", "adrs": []}, 152 | "deprecated": {"status-title": "Deprecated Records", "adrs": []}, 153 | "non-standard": { 154 | "status-title": "Records with non-standard statuses", 155 | "adrs-by-status": {}, 156 | }, 157 | } 158 | 159 | for adr in adr_paths: 160 | ( 161 | title, 162 | (status, status_phrase), 163 | date, 164 | ) = adr_title_status_and_date_from_file(adr) 165 | id = adr.stem.split("-")[0] 166 | 167 | if status_phrase: 168 | status_supplement = f": {status} {status_phrase}" 169 | else: 170 | status_supplement = "" 171 | 172 | adr_statement = ( 173 | f"* [{id} - {title}]({adr.relative_to(records_path)}){status_supplement}\n" 174 | ) 175 | try: 176 | adrs_by_status[status]["adrs"].append(adr_statement) 177 | except KeyError: 178 | if status not in adrs_by_status["non-standard"]["adrs-by-status"].keys(): 179 | adrs_by_status["non-standard"]["adrs-by-status"][status] = { 180 | "status-title": f"Status `{status}`", 181 | "adrs": [], 182 | } 183 | 184 | adrs_by_status["non-standard"]["adrs-by-status"][status]["adrs"].append( 185 | adr_statement 186 | ) 187 | return adrs_by_status 188 | -------------------------------------------------------------------------------- /pyadr/git/cli/commands.py: -------------------------------------------------------------------------------- 1 | """Console script for git adr.""" 2 | # flake8: noqa: B950 3 | from typing import Callable, List 4 | 5 | import cleo 6 | 7 | from pyadr.const import STATUS_ACCEPTED, STATUS_REJECTED 8 | from pyadr.git.core import GitAdrCore 9 | from pyadr.git.exceptions import PyadrGitError, PyadrGitPreMergeChecksFailedError 10 | 11 | 12 | class BaseGitCommand(cleo.Command): 13 | def __init__(self): 14 | super().__init__() 15 | self.git_adr_core = GitAdrCore() 16 | 17 | 18 | class GitConfigCommand(BaseGitCommand): 19 | """ 20 | Configure an ADR repository 21 | 22 | config 23 | {setting? : Configuration setting.} 24 | {value? : Configuration value.} 25 | {--l|list : List configuration settings.} 26 | {--u|unset : Unset configuration setting.} 27 | """ 28 | 29 | def handle(self): 30 | if self.option("list"): 31 | self.git_adr_core.list_config() 32 | elif self.option("unset"): 33 | if not self.argument("setting"): 34 | self.line_error('Not enough arguments (missing: "words").') 35 | self.git_adr_core.unset_config_setting(self.argument("setting")) 36 | elif self.argument("setting"): 37 | if self.argument("value"): 38 | self.git_adr_core.configure( 39 | self.argument("setting"), self.argument("value") 40 | ) 41 | else: 42 | self.git_adr_core.print_config_setting(self.argument("setting")) 43 | 44 | 45 | class GitInitCommand(BaseGitCommand): 46 | """ 47 | Initialise a Git ADR repository 48 | 49 | init 50 | {--f|force : If set, will erase existing ADR directory.} 51 | {--a|adr-only-repo : ADR only repo. This will affect the prefixes of 52 | commit messages.} 53 | """ 54 | 55 | def handle(self): 56 | if self.option("adr-only-repo"): 57 | self.git_adr_core.config["git"]["adr-only-repo"] = "true" 58 | 59 | try: 60 | self.git_adr_core.git_init_adr_repo(force=self.option("force")) 61 | except PyadrGitError: 62 | return 1 63 | 64 | 65 | class GitNewCommand(BaseGitCommand): 66 | """ 67 | Create an new ADR, create a feature branch and stage new ADR in feature branch 68 | 69 | new 70 | {words* : Words in the title} 71 | """ 72 | 73 | def handle(self): 74 | try: 75 | self.git_adr_core.git_new_adr(title=" ".join(self.argument("words"))) 76 | except PyadrGitError: 77 | return 1 78 | 79 | 80 | class GitProposeCommand(GitNewCommand): 81 | """ 82 | Propose a new ADR (same as 'new' command) 83 | 84 | propose 85 | {words* : Words in the title} 86 | """ 87 | 88 | 89 | class GitAcceptCommand(BaseGitCommand): 90 | """ 91 | Accept a proposed ADR by assigning an ID, updating filename, status and date, and stage to the current branch 92 | 93 | accept 94 | {file : ADR file.} 95 | {--t|toc : If set, generates and stages the table of content after the ADR's 96 | update.} 97 | {--c|commit : If set, commits the updated ADR.} 98 | """ 99 | 100 | def handle(self): 101 | self.git_adr_core.git_accept_or_reject( 102 | self.argument("file"), 103 | STATUS_ACCEPTED, 104 | self.option("toc"), 105 | self.option("commit"), 106 | ) 107 | 108 | 109 | class GitRejectCommand(BaseGitCommand): # noqa 110 | """ 111 | Reject a proposed ADR by assigning an ID, updating filename, status and date, and stage to the current branch 112 | 113 | reject 114 | {file : ADR file.} 115 | {--t|toc : If set, generates and stages the table of content after the ADR's 116 | update.} 117 | {--c|commit : If set, commits the updated ADR.} 118 | """ 119 | 120 | def handle(self): 121 | self.git_adr_core.git_accept_or_reject( 122 | self.argument("file"), 123 | STATUS_REJECTED, 124 | self.option("toc"), 125 | self.option("commit"), 126 | ) 127 | 128 | 129 | class GitCommitCommand(BaseGitCommand): 130 | """ 131 | Commit an ADR 132 | 133 | commit 134 | {file : ADR file.} 135 | """ 136 | 137 | def handle(self): 138 | self.git_adr_core.commit_adr(self.argument("file")) 139 | 140 | 141 | class GitPreMergeChecksCommand(BaseGitCommand): 142 | """ 143 | Perform sanity checks typically required on ADR files before merging a Pull Request 144 | 145 | pre-merge-checks 146 | """ 147 | 148 | def handle(self): 149 | try: 150 | self.git_adr_core.git_pre_merge_checks() 151 | except PyadrGitPreMergeChecksFailedError: 152 | return 1 153 | 154 | 155 | class GitHelperSlugCommand(BaseGitCommand): 156 | """ 157 | Print the ADR's title in slug format 158 | 159 | slug 160 | {file : ADR file.} 161 | """ 162 | 163 | def handle(self): 164 | self.git_adr_core.print_title_slug(self.argument("file")) 165 | 166 | 167 | class GitHelperLowercaseCommand(BaseGitCommand): 168 | """ 169 | Print the ADR's title in lowercase 170 | 171 | lowercase 172 | {file : ADR file.} 173 | """ 174 | 175 | def handle(self): 176 | self.git_adr_core.print_title_lowercase(self.argument("file")) 177 | 178 | 179 | class GitHelperSyncFilenameCommand(BaseGitCommand): 180 | """ 181 | Sync the ADR's filename with its actual title 182 | 183 | sync-filename 184 | {file : ADR file.} 185 | """ 186 | 187 | def handle(self): 188 | self.git_adr_core.sync_filename(self.argument("file")) 189 | 190 | 191 | class GitHelperCommitMessageCommand(BaseGitCommand): 192 | """ 193 | Print the commit message related to the ADR 194 | 195 | commit-message 196 | {file : ADR file.} 197 | """ 198 | 199 | def handle(self): 200 | self.git_adr_core.print_commit_message(self.argument("file")) 201 | 202 | 203 | class GitHelperBranchTitleCommand(BaseGitCommand): 204 | """ 205 | Print the branch title related to the ADR's review request 206 | 207 | branch-title 208 | {file : ADR file.} 209 | """ 210 | 211 | def handle(self): 212 | self.git_adr_core.print_branch_title(self.argument("file")) 213 | 214 | 215 | class GitHelperCommand(BaseGitCommand): 216 | """ 217 | Helper command generating and syncing various useful things 218 | 219 | helper 220 | """ 221 | 222 | commands: List[BaseGitCommand] = [] 223 | 224 | def __init__(self): 225 | self.commands.extend( 226 | [ 227 | GitHelperSlugCommand(), 228 | GitHelperLowercaseCommand(), 229 | GitHelperSyncFilenameCommand(), 230 | GitHelperCommitMessageCommand(), 231 | GitHelperBranchTitleCommand(), 232 | ] 233 | ) 234 | super().__init__() 235 | 236 | def handle(self): 237 | return self.call("help", self.config.name) 238 | 239 | 240 | class GitGenerateTocCommand(BaseGitCommand): 241 | """ 242 | Generate a table of content of the ADRs 243 | 244 | toc 245 | """ 246 | 247 | def handle(self): 248 | self.git_adr_core.generate_toc() 249 | -------------------------------------------------------------------------------- /features/git_adr/accept_or_reject_proposed_adr.feature: -------------------------------------------------------------------------------- 1 | Feature: Accept or reject proposed ADR - Git included 2 | 3 | Background: 4 | Given a new working directory 5 | And an initialised git adr repo 6 | 7 | Scenario: The proposed ADR should be already staged or committed 8 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 9 | When I run "git adr accept docs/adr/XXXX-my-adr-title.md" 10 | Then it should fail 11 | And the command output should contain 12 | """ 13 | PyadrGitAdrNotStagedOrCommittedError 14 | docs/adr/XXXX-my-adr-title.md 15 | """ 16 | And the command output should contain 17 | """ 18 | ADR 'docs/adr/XXXX-my-adr-title.md' should be staged or committed first. 19 | """ 20 | 21 | Scenario: Accepting should pass when the proposed ADR is staged (code shared with rejected => no need to duplicate test) 22 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 23 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 24 | When I run "git adr accept docs/adr/XXXX-my-adr-title.md" 25 | Then it should pass 26 | 27 | Scenario: Accepting should pass when the proposed ADR is committed (code shared with rejected => no need to duplicate test) 28 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 29 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 30 | And I commit the staged files with message "foo bar" 31 | When I run "git adr accept docs/adr/XXXX-my-adr-title.md" 32 | Then it should pass 33 | 34 | Scenario: An incremented ID number should be assigned to the accepted ADR (code shared with rejected => no need to duplicate test) 35 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 36 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 37 | When I run "git adr accept docs/adr/XXXX-my-adr-title.md" 38 | Then it should pass 39 | And the file named "docs/adr/XXXX-my-adr-title.md" should not exist 40 | And the file named "docs/adr/0002-my-adr-title.md" should exist 41 | 42 | Scenario: The accepted ADR's filename should correspond to title of the ADR (code shared with rejected => no need to duplicate test) 43 | Given a file named "docs/adr/XXXX-my-adr-title.md" with: 44 | """ 45 | # My Adr Updated Title 46 | 47 | * Status: proposed 48 | * Date: 2020-03-26 49 | 50 | ## Context and Problem Statement 51 | 52 | Context and problem statement. 53 | 54 | ## Decision Outcome 55 | 56 | Decision outcome. 57 | """ 58 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 59 | When I run "git adr accept docs/adr/XXXX-my-adr-title.md" 60 | Then it should pass 61 | And the file named "docs/adr/XXXX-my-adr-title.md" should not exist 62 | And the file named "docs/adr/0002-my-adr-updated-title.md" should exist 63 | 64 | Scenario: The renaming of the ADR file should be traced by git (code shared with rejected => no need to duplicate test) 65 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 66 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 67 | And I commit the staged files with message "dummy message" 68 | When I run "git adr accept docs/adr/XXXX-my-adr-title.md" 69 | Then it should pass 70 | And the file "docs/adr/0002-my-adr-title.md" should be staged as renamed 71 | 72 | Scenario: Accepted ADR's Status and Date should be updated 73 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 74 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 75 | When I run "git adr accept docs/adr/XXXX-my-adr-title.md" 76 | Then it should pass 77 | And the file "docs/adr/0002-my-adr-title.md" should contain: 78 | """ 79 | # My Adr Title 80 | 81 | * Status: accepted 82 | * Date: {__TODAY_YYYY_MM_DD__} 83 | 84 | ## Context and Problem Statement 85 | 86 | Context and problem statement. 87 | 88 | ## Decision Outcome 89 | 90 | Decision outcome. 91 | """ 92 | 93 | Scenario: Rejected ADR's Status and Date should be updated 94 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 95 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 96 | When I run "git adr reject docs/adr/XXXX-my-adr-title.md" 97 | Then it should pass 98 | And the file named "docs/adr/XXXX-my-adr-title.md" should not exist 99 | And the file named "docs/adr/0002-my-adr-title.md" should exist 100 | And the file "docs/adr/0002-my-adr-title.md" should contain: 101 | """ 102 | # My Adr Title 103 | 104 | * Status: rejected 105 | * Date: {__TODAY_YYYY_MM_DD__} 106 | 107 | ## Context and Problem Statement 108 | 109 | Context and problem statement. 110 | 111 | ## Decision Outcome 112 | 113 | Decision outcome. 114 | """ 115 | 116 | # Scenario: All changes to accepted ADR should be staged (code shared with rejected => no need to duplicate test) 117 | # Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 118 | # And I stage the file "docs/adr/XXXX-my-adr-title.md" 119 | # When I run "git adr accept docs/adr/XXXX-my-adr-title.md" 120 | # Then it should pass 121 | # And the file "docs/adr/0002-my-adr-title.md" should be staged 122 | # And the file "docs/adr/0002-my-adr-title.md" should NOT be marked in the git working tree as modified 123 | 124 | Scenario: Optionnaly, one should be able to re-generate the index upon acceptance 125 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 126 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 127 | When I run "git adr accept docs/adr/XXXX-my-adr-title.md --toc" 128 | Then it should pass with: 129 | """ 130 | Markdown table of content generated in 'docs/adr/index.md' 131 | """ 132 | And the file named "docs/adr/index.md" should exist 133 | And the file "docs/adr/index.md" should be staged 134 | 135 | Scenario: Optionnaly, one should be able to re-generate the index upon rejection 136 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 137 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 138 | When I run "git adr reject docs/adr/XXXX-my-adr-title.md --toc" 139 | Then it should pass with: 140 | """ 141 | Markdown table of content generated in 'docs/adr/index.md' 142 | """ 143 | And the file named "docs/adr/index.md" should exist 144 | And the file "docs/adr/index.md" should be staged 145 | 146 | Scenario: Optionnaly, one should be able to commit the ADR upon acceptance 147 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 148 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 149 | When I run "git adr accept docs/adr/XXXX-my-adr-title.md --commit" 150 | Then it should pass 151 | And the file "docs/adr/0002-my-adr-title.md" should be committed in the last commit 152 | And the head commit message should be 153 | """ 154 | docs(adr): [accepted] 0002-my-adr-title 155 | """ 156 | 157 | Scenario: Optionnaly, one should be able to commit the ADR upon rejection 158 | Given a proposed adr file named "docs/adr/XXXX-my-adr-title.md" 159 | And I stage the file "docs/adr/XXXX-my-adr-title.md" 160 | When I run "git adr reject docs/adr/XXXX-my-adr-title.md --commit" 161 | Then it should pass 162 | And the file "docs/adr/0002-my-adr-title.md" should be committed in the last commit 163 | And the head commit message should be 164 | """ 165 | docs(adr): [rejected] 0002-my-adr-title 166 | """ 167 | -------------------------------------------------------------------------------- /pyadr/const.py: -------------------------------------------------------------------------------- 1 | """Package constants""" 2 | from pathlib import Path 3 | 4 | ############################### 5 | # STATUSES 6 | ############################### 7 | 8 | STATUS_ANY = "" 9 | STATUS_ANY_WITHOUT_ID = "" 10 | STATUS_ANY_WITH_ID = "" 11 | 12 | STATUS_PROPOSED = "proposed" 13 | STATUS_ACCEPTED = "accepted" 14 | STATUS_REJECTED = "rejected" 15 | STATUS_DEPRECATED = "deprecated" 16 | STATUS_SUPERSEDING = "superseding" 17 | VALID_STATUSES = [ 18 | STATUS_PROPOSED, 19 | STATUS_ACCEPTED, 20 | STATUS_REJECTED, 21 | STATUS_DEPRECATED, 22 | STATUS_SUPERSEDING, 23 | ] 24 | STATUSES_WITH_ID = [ 25 | STATUS_ANY_WITH_ID, 26 | STATUS_ACCEPTED, 27 | STATUS_REJECTED, 28 | STATUS_DEPRECATED, 29 | STATUS_SUPERSEDING, 30 | ] 31 | STATUSES_WITHOUT_ID = [ 32 | STATUS_ANY_WITHOUT_ID, 33 | STATUS_PROPOSED, 34 | ] 35 | 36 | ############################### 37 | # REVIEW REQUESTS 38 | ############################### 39 | 40 | REVIEW_REQUESTS = { 41 | STATUS_PROPOSED: "propose", 42 | STATUS_DEPRECATED: "deprecate", 43 | STATUS_SUPERSEDING: "supersede", 44 | } 45 | ############################### 46 | # CONFIG AND SETTINGS 47 | ############################### 48 | 49 | DEFAULT_CONFIG_FILE_NAME = ".adr" 50 | DEFAULT_CONFIG_FILE_PATH = Path(DEFAULT_CONFIG_FILE_NAME) 51 | DEFAULT_ADR_PATH = Path("docs", "adr") 52 | 53 | ADR_DEFAULT_SETTINGS = {"records-dir": str(DEFAULT_ADR_PATH)} 54 | 55 | ############################### 56 | # CONTENT FORMAT 57 | ############################### 58 | 59 | VALID_ADR_CONTENT_FORMAT = """>>>>> 60 | # Title 61 | 62 | * Status: a_status 63 | [..] 64 | * Date: YYYY-MM-DD 65 | [..] 66 | <<<<< 67 | """ 68 | 69 | ############################### 70 | # REGEX 71 | ############################### 72 | 73 | ADR_ID_NOT_SET_REGEX = r"XXXX" 74 | ADR_ID_REGEX = r"[0-9][0-9][0-9][0-9]" 75 | ADR_TITLE_SLUG_REGEX = r"[a-z0-9-]*" 76 | 77 | ADR_ID_NOT_SET_REGEX_WITH_SEPARATOR = ADR_ID_NOT_SET_REGEX + "-" 78 | ADR_ID_REGEX_WITH_SEPARATOR = ADR_ID_REGEX + "-" 79 | 80 | VALID_ADR_FILENAME_REGEX = ( 81 | r"^(" 82 | + ADR_ID_NOT_SET_REGEX 83 | + r"|" 84 | + ADR_ID_REGEX 85 | + r")-" 86 | + ADR_TITLE_SLUG_REGEX 87 | + r"\.md" 88 | ) 89 | VALID_ADR_FILENAME_WITHOUT_ID_REGEX = ( 90 | r"^" + ADR_ID_NOT_SET_REGEX + r"-" + ADR_TITLE_SLUG_REGEX + r"\.md" 91 | ) 92 | VALID_ADR_FILENAME_WITH_ID_REGEX = ( 93 | r"^" + ADR_ID_REGEX + r"-" + ADR_TITLE_SLUG_REGEX + r"\.md" 94 | ) 95 | 96 | VALID_ADR_FILENAME_SKIP_TITLE_REGEX = ( 97 | r"^(" + ADR_ID_NOT_SET_REGEX + r"|" + ADR_ID_REGEX + r")-.*\.md" 98 | ) 99 | VALID_ADR_FILENAME_WITHOUT_ID_SKIP_TITLE_REGEX = ( 100 | r"^" + ADR_ID_NOT_SET_REGEX + r"-.*\.md" 101 | ) 102 | VALID_ADR_FILENAME_WITH_ID_SKIP_TITLE_REGEX = r"^" + ADR_ID_REGEX + r"-.*\.md" 103 | 104 | FILENAME_REGEXES = { 105 | STATUS_ANY: { 106 | "full": VALID_ADR_FILENAME_REGEX, 107 | "skip_title": VALID_ADR_FILENAME_SKIP_TITLE_REGEX, 108 | }, 109 | STATUS_ANY_WITHOUT_ID: { 110 | "full": VALID_ADR_FILENAME_WITHOUT_ID_REGEX, 111 | "skip_title": VALID_ADR_FILENAME_WITHOUT_ID_SKIP_TITLE_REGEX, 112 | }, 113 | STATUS_ANY_WITH_ID: { 114 | "full": VALID_ADR_FILENAME_WITH_ID_REGEX, 115 | "skip_title": VALID_ADR_FILENAME_WITH_ID_SKIP_TITLE_REGEX, 116 | }, 117 | STATUS_PROPOSED: { 118 | "full": VALID_ADR_FILENAME_WITHOUT_ID_REGEX, 119 | "skip_title": VALID_ADR_FILENAME_WITHOUT_ID_SKIP_TITLE_REGEX, 120 | }, 121 | STATUS_ACCEPTED: { 122 | "full": VALID_ADR_FILENAME_WITH_ID_REGEX, 123 | "skip_title": VALID_ADR_FILENAME_WITH_ID_SKIP_TITLE_REGEX, 124 | }, 125 | STATUS_REJECTED: { 126 | "full": VALID_ADR_FILENAME_WITH_ID_REGEX, 127 | "skip_title": VALID_ADR_FILENAME_WITH_ID_SKIP_TITLE_REGEX, 128 | }, 129 | STATUS_DEPRECATED: { 130 | "full": VALID_ADR_FILENAME_WITH_ID_REGEX, 131 | "skip_title": VALID_ADR_FILENAME_WITH_ID_SKIP_TITLE_REGEX, 132 | }, 133 | STATUS_SUPERSEDING: { 134 | "full": VALID_ADR_FILENAME_WITH_ID_REGEX, 135 | "skip_title": VALID_ADR_FILENAME_WITH_ID_SKIP_TITLE_REGEX, 136 | }, 137 | } 138 | 139 | ############################### 140 | # REGEX ERROR MESSAGES 141 | ############################### 142 | 143 | REGEX_ERROR_MESSAGE_PREFIX = ( 144 | "(status to verify against: '{status}')\nADR(s)'s filename follow the format '" 145 | ) 146 | 147 | REGEX_ERROR_MESSAGE_ADR_FILENAME = "".join( 148 | [ 149 | REGEX_ERROR_MESSAGE_PREFIX, 150 | ADR_ID_NOT_SET_REGEX, 151 | "-.md' or '", 152 | ADR_ID_REGEX, 153 | "-.md'", 154 | ] 155 | ) 156 | REGEX_ERROR_MESSAGE_ADR_FILENAME_WITHOUT_ID = "".join( 157 | [ 158 | REGEX_ERROR_MESSAGE_PREFIX, 159 | ADR_ID_NOT_SET_REGEX, 160 | "-.md'", 161 | ] 162 | ) 163 | REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID = "".join( 164 | [REGEX_ERROR_MESSAGE_PREFIX, ADR_ID_REGEX, "-.md'"] 165 | ) 166 | 167 | REGEX_ERROR_MESSAGE_ADR_FILENAME_SKIP_TITLE = "".join( 168 | [ 169 | REGEX_ERROR_MESSAGE_PREFIX, 170 | ADR_ID_NOT_SET_REGEX, 171 | "-*.md' or '", 172 | ADR_ID_REGEX, 173 | "-*.md'", 174 | ] 175 | ) 176 | REGEX_ERROR_MESSAGE_ADR_FILENAME_WITHOUT_ID_SKIP_TITLE = "".join( 177 | [REGEX_ERROR_MESSAGE_PREFIX, ADR_ID_NOT_SET_REGEX, "-*.md'"] 178 | ) 179 | REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID_SKIP_TITLE = "".join( 180 | [REGEX_ERROR_MESSAGE_PREFIX, ADR_ID_REGEX, "-*.md'"] 181 | ) 182 | 183 | REGEX_ERROR_MESSAGES = { 184 | STATUS_ANY: { 185 | "full": REGEX_ERROR_MESSAGE_ADR_FILENAME.format(status=STATUS_ANY), 186 | "skip_title": REGEX_ERROR_MESSAGE_ADR_FILENAME_SKIP_TITLE.format( 187 | status=STATUS_ANY 188 | ), 189 | "id_prefix": "' or '".join( 190 | [ADR_ID_REGEX_WITH_SEPARATOR, ADR_ID_NOT_SET_REGEX_WITH_SEPARATOR] 191 | ), 192 | }, 193 | STATUS_ANY_WITHOUT_ID: { 194 | "full": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITHOUT_ID.format( 195 | status=STATUS_ANY_WITHOUT_ID 196 | ), 197 | "skip_title": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITHOUT_ID_SKIP_TITLE.format( 198 | status=STATUS_ANY_WITHOUT_ID 199 | ), 200 | "id_prefix": ADR_ID_NOT_SET_REGEX_WITH_SEPARATOR, 201 | }, 202 | STATUS_ANY_WITH_ID: { 203 | "full": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID.format( 204 | status=STATUS_ANY_WITH_ID 205 | ), 206 | "skip_title": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID_SKIP_TITLE.format( 207 | status=STATUS_ANY_WITH_ID 208 | ), 209 | "id_prefix": ADR_ID_REGEX_WITH_SEPARATOR, 210 | }, 211 | STATUS_PROPOSED: { 212 | "full": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITHOUT_ID.format( 213 | status=STATUS_PROPOSED 214 | ), 215 | "skip_title": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITHOUT_ID_SKIP_TITLE.format( 216 | status=STATUS_PROPOSED 217 | ), 218 | "id_prefix": ADR_ID_NOT_SET_REGEX_WITH_SEPARATOR, 219 | }, 220 | STATUS_ACCEPTED: { 221 | "full": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID.format(status=STATUS_ACCEPTED), 222 | "skip_title": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID_SKIP_TITLE.format( 223 | status=STATUS_ACCEPTED 224 | ), 225 | "id_prefix": ADR_ID_REGEX, 226 | }, 227 | STATUS_REJECTED: { 228 | "full": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID.format(status=STATUS_REJECTED), 229 | "skip_title": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID_SKIP_TITLE.format( 230 | status=STATUS_REJECTED 231 | ), 232 | "id_prefix": ADR_ID_REGEX_WITH_SEPARATOR, 233 | }, 234 | STATUS_DEPRECATED: { 235 | "full": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID.format( 236 | status=STATUS_DEPRECATED 237 | ), 238 | "skip_title": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID_SKIP_TITLE.format( 239 | status=STATUS_DEPRECATED 240 | ), 241 | "id_prefix": ADR_ID_REGEX_WITH_SEPARATOR, 242 | }, 243 | STATUS_SUPERSEDING: { 244 | "full": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID.format( 245 | status=STATUS_SUPERSEDING 246 | ), 247 | "skip_title": REGEX_ERROR_MESSAGE_ADR_FILENAME_WITH_ID_SKIP_TITLE.format( 248 | status=STATUS_SUPERSEDING 249 | ), 250 | "id_prefix": ADR_ID_NOT_SET_REGEX_WITH_SEPARATOR, 251 | }, 252 | } 253 | -------------------------------------------------------------------------------- /features/git_adr/pre-merge-checks.feature: -------------------------------------------------------------------------------- 1 | Feature: Git ADR - Check ADRs well formed before allowing to merge 2 | 3 | Background: 4 | Given a new working directory 5 | And an initialised git adr repo 6 | 7 | Scenario: Check all ADR file names are slugs of the ADR title 8 | Given a file named "docs/adr/0002-an-adr.md" with: 9 | """ 10 | # A different ADR Title 11 | 12 | * Status: accepted 13 | * Date: 2020-03-26 14 | 15 | ## Context and Problem Statement 16 | 17 | [..] 18 | """ 19 | And a file named "docs/adr/0003-another-adr.md" with: 20 | """ 21 | # Yet another different ADR title 22 | 23 | * Status: accepted 24 | * Date: 2020-03-26 25 | 26 | ## Context and Problem Statement 27 | 28 | [..] 29 | """ 30 | And a file named "docs/adr/0004-a-last-adr.md" with: 31 | """ 32 | # A last ADR 33 | 34 | * Status: accepted 35 | * Date: 2020-03-26 36 | 37 | ## Context and Problem Statement 38 | 39 | [..] 40 | """ 41 | When I run "git adr pre-merge-checks" 42 | Then it should fail with: 43 | """ 44 | (status to verify against: '') 45 | ADR(s)'s filename follow the format '[0-9][0-9][0-9][0-9]-.md', but: 46 | => 'docs/adr/0002-an-adr.md' does not have the correct title slug ('a-different-adr-title'). 47 | => 'docs/adr/0003-another-adr.md' does not have the correct title slug ('yet-another-different-adr-title'). 48 | """ 49 | 50 | Scenario: Check all ADR file names have '[0-9][0-9][0-9][0-9]' followed by '-' 51 | Given a file named "docs/adr/XXXX-an-adr.md" with: 52 | """ 53 | # An ADR 54 | 55 | * Status: accepted 56 | * Date: 2020-03-26 57 | 58 | ## Context and Problem Statement 59 | 60 | [..] 61 | """ 62 | And a file named "docs/adr/000X-another-adr.md" with: 63 | """ 64 | # Another ADR 65 | 66 | * Status: accepted 67 | * Date: 2020-03-26 68 | 69 | ## Context and Problem Statement 70 | 71 | [..] 72 | """ 73 | And a file named "docs/adr/000-yet-another-adr.md" with: 74 | """ 75 | # Yet another ADR 76 | 77 | * Status: accepted 78 | * Date: 2020-03-26 79 | 80 | ## Context and Problem Statement 81 | 82 | [..] 83 | """ 84 | And a file named "docs/adr/00023-a-last-adr.md" with: 85 | """ 86 | # A last ADR 87 | 88 | * Status: accepted 89 | * Date: 2020-03-26 90 | 91 | ## Context and Problem Statement 92 | 93 | [..] 94 | """ 95 | When I run "git adr pre-merge-checks" 96 | Then it should fail with: 97 | """ 98 | (status to verify against: '') 99 | ADR(s)'s filename follow the format '[0-9][0-9][0-9][0-9]-.md', but: 100 | => 'docs/adr/000-yet-another-adr.md' does not start with '[0-9][0-9][0-9][0-9]-'. 101 | => 'docs/adr/00023-a-last-adr.md' does not start with '[0-9][0-9][0-9][0-9]-'. 102 | => 'docs/adr/000X-another-adr.md' does not start with '[0-9][0-9][0-9][0-9]-'. 103 | => 'docs/adr/XXXX-an-adr.md' does not start with '[0-9][0-9][0-9][0-9]-'. 104 | """ 105 | 106 | Scenario: Check all ADR files have a status other than 'proposed' 107 | Given a file named "docs/adr/0002-an-adr.md" with: 108 | """ 109 | # An ADR 110 | 111 | * Status: accepted 112 | * Date: 2020-03-26 113 | 114 | ## Context and Problem Statement 115 | 116 | [..] 117 | """ 118 | And a file named "docs/adr/0003-another-adr.md" with: 119 | """ 120 | # Another ADR 121 | 122 | * Status: proposed 123 | * Date: 2020-03-26 124 | 125 | ## Context and Problem Statement 126 | 127 | [..] 128 | """ 129 | When I run "git adr pre-merge-checks" 130 | Then it should fail with: 131 | """ 132 | ADR(s) must not have their status set to 'proposed', but: 133 | => 'docs/adr/0003-another-adr.md' has status 'proposed'. 134 | """ 135 | 136 | Scenario: Check all ADR files have a unique number 137 | Given a file named "docs/adr/0002-an-adr.md" with: 138 | """ 139 | # An ADR 140 | 141 | * Status: accepted 142 | * Date: 2020-03-26 143 | 144 | ## Context and Problem Statement 145 | 146 | [..] 147 | """ 148 | And a file named "docs/adr/0002-another-adr.md" with: 149 | """ 150 | # Another ADR 151 | 152 | * Status: accepted 153 | * Date: 2020-03-26 154 | 155 | ## Context and Problem Statement 156 | 157 | [..] 158 | """ 159 | And a file named "docs/adr/0002-a-last-adr.md" with: 160 | """ 161 | # A last ADR 162 | 163 | * Status: accepted 164 | * Date: 2020-03-26 165 | 166 | ## Context and Problem Statement 167 | 168 | [..] 169 | """ 170 | And a file named "docs/adr/0003-more-adr.md" with: 171 | """ 172 | # More ADR 173 | 174 | * Status: accepted 175 | * Date: 2020-03-26 176 | 177 | ## Context and Problem Statement 178 | 179 | [..] 180 | """ 181 | And a file named "docs/adr/0003-yet-more-adr.md" with: 182 | """ 183 | # Yet more ADR 184 | 185 | * Status: accepted 186 | * Date: 2020-03-26 187 | 188 | ## Context and Problem Statement 189 | 190 | [..] 191 | """ 192 | When I run "git adr pre-merge-checks" 193 | Then it should fail with: 194 | """ 195 | ADRs must have a unique number, but the following files have the same number: 196 | => ['docs/adr/0002-a-last-adr.md', 'docs/adr/0002-an-adr.md', 'docs/adr/0002-another-adr.md']. 197 | => ['docs/adr/0003-more-adr.md', 'docs/adr/0003-yet-more-adr.md']. 198 | """ 199 | 200 | Scenario: Check all ADR files have a title followed by a status and a date 201 | Given a file named "docs/adr/0002-an-adr.md" with: 202 | """ 203 | * Status: accepted 204 | * Date: 2020-03-26 205 | 206 | ## Context and Problem Statement 207 | 208 | [..] 209 | """ 210 | When I run "git adr pre-merge-checks" 211 | Then it should fail with 212 | """ 213 | ADR must be of format: 214 | >>>>> 215 | # Title 216 | 217 | * Status: a_status 218 | [..] 219 | * Date: YYYY-MM-DD 220 | [..] 221 | <<<<< 222 | but the following files where not: 223 | => 'docs/adr/0002-an-adr.md'. 224 | """ 225 | 226 | Scenario: Pass checks when all conditions are filled 227 | Given a file named "docs/adr/0002-an-adr.md" with: 228 | """ 229 | # An ADR 230 | 231 | * Status: accepted 232 | * Date: 2020-03-26 233 | 234 | ## Context and Problem Statement 235 | 236 | [..] 237 | """ 238 | And a file named "docs/adr/0003-another-adr.md" with: 239 | """ 240 | # Another ADR 241 | 242 | * Status: accepted 243 | * Date: 2020-03-26 244 | 245 | ## Context and Problem Statement 246 | 247 | [..] 248 | """ 249 | And a file named "docs/adr/0004-a-last-adr.md" with: 250 | """ 251 | # A last ADR 252 | 253 | * Status: accepted 254 | * Date: 2020-03-26 255 | 256 | ## Context and Problem Statement 257 | 258 | [..] 259 | """ 260 | When I run "git adr pre-merge-checks" 261 | Then it should pass with 262 | """ 263 | All checks passed. 264 | """ 265 | -------------------------------------------------------------------------------- /features/pyadr/check-adr-repo.feature: -------------------------------------------------------------------------------- 1 | Feature: Check ADRs well formed 2 | 3 | Background: 4 | Given a new working directory 5 | 6 | Scenario: Check all ADR file names are slugs of the ADR title 7 | Given a file named "docs/adr/0002-an-adr.md" with: 8 | """ 9 | # A different ADR Title 10 | 11 | * Status: accepted 12 | * Date: 2020-03-26 13 | 14 | ## Context and Problem Statement 15 | 16 | [..] 17 | """ 18 | And a file named "docs/adr/0003-another-adr.md" with: 19 | """ 20 | # Yet another different ADR title 21 | 22 | * Status: accepted 23 | * Date: 2020-03-26 24 | 25 | ## Context and Problem Statement 26 | 27 | [..] 28 | """ 29 | And a file named "docs/adr/0004-a-last-adr.md" with: 30 | """ 31 | # A last ADR 32 | 33 | * Status: accepted 34 | * Date: 2020-03-26 35 | 36 | ## Context and Problem Statement 37 | 38 | [..] 39 | """ 40 | When I run "pyadr check-adr-repo" 41 | Then it should fail with: 42 | """ 43 | (status to verify against: '') 44 | ADR(s)'s filename follow the format '[0-9][0-9][0-9][0-9]-.md', but: 45 | => 'docs/adr/0002-an-adr.md' does not have the correct title slug ('a-different-adr-title'). 46 | => 'docs/adr/0003-another-adr.md' does not have the correct title slug ('yet-another-different-adr-title'). 47 | """ 48 | 49 | Scenario: Check all ADR file names have '[0-9][0-9][0-9][0-9]' followed by '-' 50 | Given a file named "docs/adr/XXXX-an-adr.md" with: 51 | """ 52 | # An ADR 53 | 54 | * Status: accepted 55 | * Date: 2020-03-26 56 | 57 | ## Context and Problem Statement 58 | 59 | [..] 60 | """ 61 | And a file named "docs/adr/000X-another-adr.md" with: 62 | """ 63 | # Another ADR 64 | 65 | * Status: accepted 66 | * Date: 2020-03-26 67 | 68 | ## Context and Problem Statement 69 | 70 | [..] 71 | """ 72 | And a file named "docs/adr/000-yet-another-adr.md" with: 73 | """ 74 | # Yet another ADR 75 | 76 | * Status: accepted 77 | * Date: 2020-03-26 78 | 79 | ## Context and Problem Statement 80 | 81 | [..] 82 | """ 83 | And a file named "docs/adr/00023-a-last-adr.md" with: 84 | """ 85 | # A last ADR 86 | 87 | * Status: accepted 88 | * Date: 2020-03-26 89 | 90 | ## Context and Problem Statement 91 | 92 | [..] 93 | """ 94 | When I run "pyadr check-adr-repo" 95 | Then it should fail with: 96 | """ 97 | (status to verify against: '') 98 | ADR(s)'s filename follow the format '[0-9][0-9][0-9][0-9]-.md', but: 99 | => 'docs/adr/000-yet-another-adr.md' does not start with '[0-9][0-9][0-9][0-9]-'. 100 | => 'docs/adr/00023-a-last-adr.md' does not start with '[0-9][0-9][0-9][0-9]-'. 101 | => 'docs/adr/000X-another-adr.md' does not start with '[0-9][0-9][0-9][0-9]-'. 102 | => 'docs/adr/XXXX-an-adr.md' does not start with '[0-9][0-9][0-9][0-9]-'. 103 | """ 104 | 105 | Scenario: Check all ADR files have a unique number 106 | Given a file named "docs/adr/0002-an-adr.md" with: 107 | """ 108 | # An ADR 109 | 110 | * Status: accepted 111 | * Date: 2020-03-26 112 | 113 | ## Context and Problem Statement 114 | 115 | [..] 116 | """ 117 | And a file named "docs/adr/0002-another-adr.md" with: 118 | """ 119 | # Another ADR 120 | 121 | * Status: accepted 122 | * Date: 2020-03-26 123 | 124 | ## Context and Problem Statement 125 | 126 | [..] 127 | """ 128 | And a file named "docs/adr/0002-a-last-adr.md" with: 129 | """ 130 | # A last ADR 131 | 132 | * Status: accepted 133 | * Date: 2020-03-26 134 | 135 | ## Context and Problem Statement 136 | 137 | [..] 138 | """ 139 | And a file named "docs/adr/0003-more-adr.md" with: 140 | """ 141 | # More ADR 142 | 143 | * Status: accepted 144 | * Date: 2020-03-26 145 | 146 | ## Context and Problem Statement 147 | 148 | [..] 149 | """ 150 | And a file named "docs/adr/0003-yet-more-adr.md" with: 151 | """ 152 | # Yet more ADR 153 | 154 | * Status: accepted 155 | * Date: 2020-03-26 156 | 157 | ## Context and Problem Statement 158 | 159 | [..] 160 | """ 161 | When I run "pyadr check-adr-repo" 162 | Then it should fail with: 163 | """ 164 | ADRs must have a unique number, but the following files have the same number: 165 | => ['docs/adr/0002-a-last-adr.md', 'docs/adr/0002-an-adr.md', 'docs/adr/0002-another-adr.md']. 166 | => ['docs/adr/0003-more-adr.md', 'docs/adr/0003-yet-more-adr.md']. 167 | """ 168 | 169 | Scenario: Check all ADR files have a title followed by a status and a date 170 | Given a file named "docs/adr/0002-an-adr.md" with: 171 | """ 172 | * Status: accepted 173 | * Date: 2020-03-26 174 | 175 | ## Context and Problem Statement 176 | 177 | [..] 178 | """ 179 | When I run "pyadr check-adr-repo" 180 | Then it should fail with 181 | """ 182 | ADR must be of format: 183 | >>>>> 184 | # Title 185 | 186 | * Status: a_status 187 | [..] 188 | * Date: YYYY-MM-DD 189 | [..] 190 | <<<<< 191 | but the following files where not: 192 | => 'docs/adr/0002-an-adr.md'. 193 | """ 194 | 195 | Scenario: Pass checks when all conditions are filled 196 | Given a file named "docs/adr/0002-an-adr.md" with: 197 | """ 198 | # An ADR 199 | 200 | * Status: accepted 201 | * Date: 2020-03-26 202 | 203 | ## Context and Problem Statement 204 | 205 | [..] 206 | """ 207 | And a file named "docs/adr/0003-another-adr.md" with: 208 | """ 209 | # Another ADR 210 | 211 | * Status: accepted 212 | * Date: 2020-03-26 213 | 214 | ## Context and Problem Statement 215 | 216 | [..] 217 | """ 218 | And a file named "docs/adr/0004-yet-another-adr.md" with: 219 | """ 220 | # Yet Another ADR 221 | 222 | * Status: accepted 223 | * Date: 2020-03-26 224 | 225 | ## Context and Problem Statement 226 | 227 | [..] 228 | """ 229 | And a file named "docs/adr/0005-a-last-adr.md" with: 230 | """ 231 | # A last ADR 232 | 233 | * Status: proposed 234 | * Date: 2020-03-26 235 | 236 | ## Context and Problem Statement 237 | 238 | [..] 239 | """ 240 | When I run "pyadr check-adr-repo" 241 | Then it should pass with 242 | """ 243 | All checks passed. 244 | """ 245 | 246 | Scenario: `no-proposed` option - Check all ADR files have a status other than 'proposed' 247 | Given a file named "docs/adr/0002-an-adr.md" with: 248 | """ 249 | # An ADR 250 | 251 | * Status: accepted 252 | * Date: 2020-03-26 253 | 254 | ## Context and Problem Statement 255 | 256 | [..] 257 | """ 258 | And a file named "docs/adr/0003-another-adr.md" with: 259 | """ 260 | # Another ADR 261 | 262 | * Status: proposed 263 | * Date: 2020-03-26 264 | 265 | ## Context and Problem Statement 266 | 267 | [..] 268 | """ 269 | When I run "pyadr check-adr-repo --no-proposed" 270 | Then it should fail with: 271 | """ 272 | ADR(s) must not have their status set to 'proposed', but: 273 | => 'docs/adr/0003-another-adr.md' has status 'proposed'. 274 | """ 275 | -------------------------------------------------------------------------------- /behave_ext/cucumber_json.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # from __future__ import absolute_import 4 | import base64 5 | import copy 6 | 7 | import six 8 | from behave.formatter.base import Formatter 9 | from behave.model_core import Status 10 | 11 | try: 12 | import json 13 | except ImportError: 14 | import simplejson as json 15 | 16 | 17 | # ----------------------------------------------------------------------------- 18 | # CLASS: JSONFormatter 19 | # ----------------------------------------------------------------------------- 20 | class CucumberJSONFormatter(Formatter): 21 | name = "json" 22 | description = "JSON dump of test run" 23 | dumps_kwargs = {} 24 | 25 | json_number_types = six.integer_types + (float,) 26 | json_scalar_types = json_number_types + (six.text_type, bool, type(None)) 27 | 28 | def __init__(self, stream_opener, config): 29 | super(CucumberJSONFormatter, self).__init__(stream_opener, config) 30 | # -- ENSURE: Output stream is open. 31 | self.stream = self.open() 32 | self.feature_count = 0 33 | self.current_feature = None 34 | self.current_feature_data = None 35 | self._step_index = 0 36 | self.current_background = None 37 | self.current_background_data = None 38 | 39 | def reset(self): 40 | self.current_feature = None 41 | self.current_feature_data = None 42 | self._step_index = 0 43 | self.current_background = None 44 | 45 | # -- FORMATTER API: 46 | def uri(self, uri): 47 | pass 48 | 49 | def feature(self, feature): 50 | self.reset() 51 | self.current_feature = feature 52 | self.current_feature_data = { 53 | "id": self.generate_id(feature), 54 | "uri": feature.location.filename, 55 | "line": feature.location.line, 56 | "description": "", 57 | "keyword": feature.keyword, 58 | "name": feature.name, 59 | "tags": self.write_tags(feature.tags), 60 | "status": feature.status.name, 61 | } 62 | element = self.current_feature_data 63 | if feature.description: 64 | element["description"] = self.format_description(feature.description) 65 | 66 | def background(self, background): 67 | element = { 68 | "type": "background", 69 | "keyword": background.keyword, 70 | "name": background.name, 71 | "location": six.text_type(background.location), 72 | "steps": [], 73 | } 74 | self._step_index = 0 75 | self.current_background = element 76 | 77 | def scenario(self, scenario): 78 | if self.current_background is not None: 79 | self.add_feature_element(copy.deepcopy(self.current_background)) 80 | element = self.add_feature_element( 81 | { 82 | "type": "scenario", 83 | "id": self.generate_id(self.current_feature, scenario), 84 | "line": scenario.location.line, 85 | "description": "", 86 | "keyword": scenario.keyword, 87 | "name": scenario.name, 88 | "tags": self.write_tags(scenario.tags), 89 | "location": six.text_type(scenario.location), 90 | "steps": [], 91 | } 92 | ) 93 | if scenario.description: 94 | element["description"] = self.format_description(scenario.description) 95 | self._step_index = 0 96 | 97 | @classmethod 98 | def make_table(cls, table): 99 | table_data = { 100 | "headings": table.headings, 101 | "rows": [list(row) for row in table.rows], 102 | } 103 | return table_data 104 | 105 | def step(self, step): 106 | s = { 107 | "keyword": step.keyword, 108 | "step_type": step.step_type, 109 | "name": step.name, 110 | "line": step.location.line, 111 | "result": {"status": "skipped", "duration": 0}, 112 | } 113 | 114 | if step.text: 115 | s["doc_string"] = {"value": step.text, "line": step.text.line} 116 | if step.table: 117 | s["rows"] = [{"cells": list(step.table.headings)}] 118 | s["rows"] += [{"cells": list(row.cells)} for row in step.table] 119 | 120 | if self.current_feature.background is not None: 121 | element = self.current_feature_data["elements"][-2] 122 | if len(element["steps"]) >= len(self.current_feature.background.steps): 123 | element = self.current_feature_element 124 | else: 125 | element = self.current_feature_element 126 | element["steps"].append(s) 127 | 128 | def match(self, match): 129 | if match.location: 130 | # -- NOTE: match.location=None occurs for undefined steps. 131 | match_data = { 132 | "location": six.text_type(match.location) or "", 133 | } 134 | self.current_step["match"] = match_data 135 | 136 | def result(self, result): 137 | self.current_step["result"] = { 138 | "status": result.status.name, 139 | "duration": int(round(result.duration * 1000.0 * 1000.0 * 1000.0)), 140 | } 141 | if result.error_message and result.status == Status.failed: 142 | # -- OPTIONAL: Provided for failed steps. 143 | error_message = result.error_message 144 | result_element = self.current_step["result"] 145 | result_element["error_message"] = error_message 146 | self._step_index += 1 147 | 148 | def embedding(self, mime_type, data): 149 | step = self.current_feature_element["steps"][-1] 150 | step["embeddings"].append( 151 | { 152 | "mime_type": mime_type, 153 | "data": base64.b64encode(data).replace("\n", ""), 154 | } 155 | ) 156 | 157 | def eof(self): 158 | """ 159 | End of feature 160 | """ 161 | if not self.current_feature_data: 162 | return 163 | 164 | # -- NORMAL CASE: Write collected data of current feature. 165 | self.update_status_data() 166 | 167 | if self.feature_count == 0: 168 | # -- FIRST FEATURE: 169 | self.write_json_header() 170 | else: 171 | # -- NEXT FEATURE: 172 | self.write_json_feature_separator() 173 | 174 | self.write_json_feature(self.current_feature_data) 175 | self.current_feature_data = None 176 | self.feature_count += 1 177 | 178 | def close(self): 179 | self.write_json_footer() 180 | self.close_stream() 181 | 182 | # -- JSON-DATA COLLECTION: 183 | def add_feature_element(self, element): 184 | assert self.current_feature_data is not None 185 | if "elements" not in self.current_feature_data: 186 | self.current_feature_data["elements"] = [] 187 | self.current_feature_data["elements"].append(element) 188 | return element 189 | 190 | @property 191 | def current_feature_element(self): 192 | assert self.current_feature_data is not None 193 | return self.current_feature_data["elements"][-1] 194 | 195 | @property 196 | def current_step(self): 197 | step_index = self._step_index 198 | if self.current_feature.background is not None: 199 | element = self.current_feature_data["elements"][-2] 200 | if step_index >= len(self.current_feature.background.steps): 201 | step_index -= len(self.current_feature.background.steps) 202 | element = self.current_feature_element 203 | else: 204 | element = self.current_feature_element 205 | 206 | return element["steps"][step_index] 207 | 208 | def update_status_data(self): 209 | assert self.current_feature 210 | assert self.current_feature_data 211 | self.current_feature_data["status"] = self.current_feature.status.name 212 | 213 | def write_tags(self, tags): 214 | return [ 215 | {"name": tag, "line": tag.line if hasattr(tag, "line") else 1} 216 | for tag in tags 217 | ] 218 | 219 | def generate_id(self, feature, scenario=None): 220 | def convert(name): 221 | return name.lower().replace(" ", "-") 222 | 223 | id = convert(feature.name) 224 | if scenario is not None: 225 | id += ";" 226 | id += convert(scenario.name) 227 | return id 228 | 229 | def format_description(self, lines): 230 | description = "\n".join(lines) 231 | description = "
%s
" % description 232 | return description 233 | 234 | # -- JSON-WRITER: 235 | def write_json_header(self): 236 | self.stream.write("[\n") 237 | 238 | def write_json_footer(self): 239 | self.stream.write("\n]\n") 240 | 241 | def write_json_feature(self, feature_data): 242 | self.stream.write(json.dumps(feature_data, **self.dumps_kwargs)) 243 | self.stream.flush() 244 | 245 | def write_json_feature_separator(self): 246 | self.stream.write(",\n\n") 247 | 248 | 249 | # ----------------------------------------------------------------------------- 250 | # CLASS: PrettyJSONFormatter 251 | # ----------------------------------------------------------------------------- 252 | class PrettyCucumberJSONFormatter(CucumberJSONFormatter): 253 | """ 254 | Provides readable/comparable textual JSON output. 255 | """ 256 | 257 | name = "json.pretty" 258 | description = "JSON dump of test run (human readable)" 259 | dumps_kwargs = {"indent": 2, "sort_keys": True} 260 | --------------------------------------------------------------------------------