Repository: packit-service/packit
Pull request #875 (2020-06-23 13:54:17)

patch metadata
- Use patch metadata and load it from commit.

packit-as-a-service[bot]: Congratulations! One of the builds has completed. :champagne:

You can install the built RPMs by following these steps:

* `sudo yum install -y dnf-plugins-core` on RHEL 8
* `sudo dnf install -y dnf-plugins-core` on Fedora
* `dnf copr enable packit/packit-service-packit-875`
* And now you can install the packages.

Please note that the RPMs should be used only in a testing environment.
softwarefactory-project-zuul[bot]: Build succeeded.

- [packit-pre-commit ](https://softwarefactory-project.io/zuul/t/packit-service/build/af4af19652b24bf7a8ef249bd448874a) : SUCCESS in 3m 03s
- [packit-tests-rpm ](https://softwarefactory-project.io/zuul/t/packit-service/build/2603e583c0d04ff4b135bf2c5fdb6ca3) : SUCCESS in 7m 42s
- [packit-tests-pip-deps ](https://softwarefactory-project.io/zuul/t/packit-service/build/2c79521bae5744618ed5afdad4a32365) : SUCCESS in 7m 35s
- [packit-tests-git-master ](https://softwarefactory-project.io/zuul/t/packit-service/build/8aa1c5a8fc2e49e7bacef83a0766fc26) : SUCCESS in 7m 21s
- [packit-tests-rpm-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/59330c51709b47a6bf051b6e73561086) : SUCCESS in 2m 21s
- [packit-tests-pip-deps-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/f359b3661a1a4af78d3b08d644a1635c) : SUCCESS in 2m 45s
- [packit-tests-git-master-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/4c342b5662e84574888c2fa579fd2642) : SUCCESS in 3m 03s
- [reverse-dep-packit-service-tests ](https://softwarefactory-project.io/zuul/t/packit-service/build/1877864557e94a1784136fef39df7000) : SUCCESS in 3m 48s

dhodovsk: /packit build
softwarefactory-project-zuul[bot]: Build succeeded.

- [pre-commit ](https://softwarefactory-project.io/zuul/t/packit-service/build/5c5818a3735842c886e741213335f002) : SUCCESS in 2m 46s
- [packit-tests-rpm ](https://softwarefactory-project.io/zuul/t/packit-service/build/9c3722ebe45b4a7ebdede2df1964bb15) : SUCCESS in 6m 52s
- [packit-tests-pip-deps ](https://softwarefactory-project.io/zuul/t/packit-service/build/71e6fc3842db41c7b9842804608bf18b) : SUCCESS in 8m 22s
- [packit-tests-git-master ](https://softwarefactory-project.io/zuul/t/packit-service/build/f47f71b5e1434085bb6a1bb1c92bb006) : SUCCESS in 7m 33s
- [packit-tests-rpm-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/463c125cbbac44f29b8f55d8e0b85e8a) : SUCCESS in 2m 31s
- [packit-tests-pip-deps-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/8c586ac46c134500b07ba241cf69421d) : SUCCESS in 3m 29s
- [packit-tests-git-master-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/594e61c5690b42e98d35f32ceb145bad) : SUCCESS in 2m 41s
- [reverse-dep-packit-service-tests ](https://softwarefactory-project.io/zuul/t/packit-service/build/0d589c447e334d7aad87f22a6a3d7fa9) : SUCCESS in 4m 31s

TomasTomecek: What's missing here?
lachmanfrantisek: > What's missing here?

- [ ] more tests
- [ ] I'm working on the counter-part in generation script which comes with some changes here as well. (I'll try to create PR there and update this one ASAP.)
softwarefactory-project-zuul[bot]: Build succeeded.

- [pre-commit ](https://softwarefactory-project.io/zuul/t/packit-service/build/fa8ff8dc7a794d85982350c7039b2021) : SUCCESS in 2m 46s
- [packit-tests-rpm ](https://softwarefactory-project.io/zuul/t/packit-service/build/c9e90f09a3994cce91b50841efc671dc) : SUCCESS in 7m 30s
- [packit-tests-pip-deps ](https://softwarefactory-project.io/zuul/t/packit-service/build/9b4b775affcb4571843ea27df1b10828) : SUCCESS in 8m 10s
- [packit-tests-git-master ](https://softwarefactory-project.io/zuul/t/packit-service/build/446e828963d148b78e151adbe4a4b038) : SUCCESS in 8m 11s
- [packit-tests-rpm-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/1bfe6d461b0545a485323c21dab4ee53) : SUCCESS in 2m 17s
- [packit-tests-pip-deps-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/af93da2edcc34ada919bf92c2e35078b) : SUCCESS in 3m 08s
- [packit-tests-git-master-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/4064350f9d6e4f658b388f02e0a1c83a) : SUCCESS in 3m 21s
- [reverse-dep-packit-service-tests ](https://softwarefactory-project.io/zuul/t/packit-service/build/ce7c954883e34aa68bdfdf21ef1f7006) : SUCCESS in 3m 48s

softwarefactory-project-zuul[bot]: Build succeeded.

- [pre-commit ](https://softwarefactory-project.io/zuul/t/packit-service/build/213b65c6de6d4673b4da61e300e3b2c2) : SUCCESS in 3m 00s
- [packit-tests-rpm ](https://softwarefactory-project.io/zuul/t/packit-service/build/e36324bc2b33417bb139fad641b56258) : SUCCESS in 9m 31s
- [packit-tests-pip-deps ](https://softwarefactory-project.io/zuul/t/packit-service/build/3c8256fc2b384a1c853212769c7edf3d) : SUCCESS in 8m 10s
- [packit-tests-git-master ](https://softwarefactory-project.io/zuul/t/packit-service/build/b84e1eec31e54bd29cfa96b1fd43eb30) : SUCCESS in 7m 51s
- [packit-tests-rpm-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/1cedc67dfc0143f49ab54685447e83f2) : SUCCESS in 2m 14s
- [packit-tests-pip-deps-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/86bab3fcb8854c768d50f552cd45dcc8) : SUCCESS in 2m 52s
- [packit-tests-git-master-sess-rec ](https://softwarefactory-project.io/zuul/t/packit-service/build/eda51d0ca1f34353bebddfa604b4e934) : SUCCESS in 3m 08s
- [reverse-dep-packit-service-tests ](https://softwarefactory-project.io/zuul/t/packit-service/build/b1494526ff1f4da78a70cb59a9b3a218) : SUCCESS in 3m 58s

lachmanfrantisek: @TomasTomecek PTAL

Is the naming of the current metadata fields OK? Other enhancements can be done later but renaming will be hard(er).
softwarefactory-project-zuul[bot]: Build failed (gate pipeline).


- [pre-commit ](https://softwarefactory-project.io/zuul/t/packit-service/build/003a52520e9d41abbf6a06692400671d) : FAILURE in 2m 53s


Patch:
diff --git a/packit/base_git.py b/packit/base_git.py
index 6a75971e..6950c83c 100644
--- a/packit/base_git.py
+++ b/packit/base_git.py
@@ -23,7 +23,7 @@
 import shlex
 from logging import getLogger
 from pathlib import Path
-from typing import Optional, Callable, List, Tuple, Iterable, Dict
+from typing import Optional, Callable, List, Iterable, Dict
 
 import git
 from git import PushInfo
@@ -34,6 +34,7 @@ from packit.config import Config, RunCommandType
 from packit.config.common_package_config import CommonPackageConfig
 from packit.exceptions import PackitException
 from packit.local_project import LocalProject
+from packit.patches import PatchMetadata
 from packit.security import CommitVerifier
 from packit.specfile import Specfile
 from packit.utils import cwd
@@ -308,7 +309,7 @@ class PackitRepositoryBase:
         logger.debug(f"Action command output: {outputs}")
         return outputs
 
-    def specfile_add_patches(self, patch_list: List[Tuple[Path, str]]) -> None:
+    def specfile_add_patches(self, patch_list: List[PatchMetadata]) -> None:
         """
         Add the given list of (patch_name, msg) to the specfile.
 
diff --git a/packit/git_utils.py b/packit/git_utils.py
new file mode 100644
index 00000000..2d6a6cde
--- /dev/null
+++ b/packit/git_utils.py
@@ -0,0 +1,109 @@
+# MIT License
+#
+# Copyright (c) 2020 Red Hat, Inc.
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""
+Helpers and wrappers on top of git.
+"""
+import logging
+from typing import Optional
+
+import git
+import yaml
+
+from packit.exceptions import PackitException
+
+logger = logging.getLogger(__name__)
+
+
+def get_message_from_metadata(metadata: dict, header: Optional[str] = None) -> str:
+    if not isinstance(metadata, dict):
+        raise PackitException(
+            f"We can save only dictionaries to metadata. Not {metadata}"
+        )
+
+    content = yaml.dump(metadata, indent=4) if metadata else ""
+    if not header:
+        return content
+
+    return f"{header}\n\n{content}"
+
+
+def get_metadata_from_message(commit: git.Commit) -> Optional[dict]:
+    """
+    Tries to load yaml format from the git message.
+
+    We are skipping first line until
+    the rest of the content is yaml-loaded to dictionary (yaml object type).
+
+    If nothing found, we return None.
+
+    Reference:
+    https://gitpython.readthedocs.io/en/stable/reference.html
+    ?highlight=archive#module-git.objects.commit
+
+    e.g.:
+
+    I)
+    key: value
+    another: value
+    -> {"key": "value", "another": "value"}
+
+    II)
+    On sentence.
+
+    key: value
+    another: value
+    -> {"key": "value", "another": "value"}
+
+    III)
+    A lot of
+    text
+
+    before keys.
+
+    key: value
+    another: value
+    -> {"key": "value", "another": "value"}
+
+    IV)
+    Other values are supported as well:
+
+    key:
+    - first
+    - second
+    - third
+
+    :param commit: git.Commit object
+    :return: dict loaded from message if it satisfies the rules above
+    """
+    splitted_message = commit.message.split("\n")
+
+    for i in range(len(splitted_message)):
+        message_part = "\n".join(splitted_message[i:])
+        try:
+            loaded_part = yaml.safe_load(message_part)
+        except yaml.YAMLError:
+            continue
+
+        if isinstance(loaded_part, dict):
+            return loaded_part
+
+    return None
diff --git a/packit/patches.py b/packit/patches.py
index de0230ee..86cb126a 100644
--- a/packit/patches.py
+++ b/packit/patches.py
@@ -26,17 +26,96 @@ import datetime
 import logging
 from itertools import islice
 from pathlib import Path
-from typing import List, Tuple, Optional
+from typing import List, Optional
 
 import git
+import yaml
 
 from packit.constants import DATETIME_FORMAT
+from packit.git_utils import get_metadata_from_message
 from packit.local_project import LocalProject
 from packit.utils import is_a_git_ref, run_command
 
 logger = logging.getLogger(__name__)
 
 
+class PatchMetadata:
+    def __init__(
+        self,
+        name: Optional[str] = None,
+        path: Optional[Path] = None,
+        location_in_specfile: Optional[str] = None,
+        description: Optional[str] = None,
+        commit: Optional[git.Commit] = None,
+        present_in_specfile: bool = False,
+    ) -> None:
+        self.name = name
+        self.path = path
+        self.location_in_specfile = location_in_specfile
+        self.description = description
+        self.commit = commit
+        self.present_in_specfile = present_in_specfile
+
+    @property
+    def specfile_comment(self) -> str:
+        comment = (
+            f"{self.commit.summary}\n"
+            f"Author: {self.commit.author.name} <{self.commit.author.email}>"
+        )
+        if self.description:
+            comment += f"\n{self.description}"
+        return comment
+
+    @property
+    def commit_message(self) -> str:
+        msg = f"Apply {self.name}\n\n"
+
+        if self.name:
+            msg += f"patch_name: {self.name}\n"
+
+        if self.location_in_specfile:
+            msg += f"location_in_specfile: {self.location_in_specfile}\n"
+
+        if self.description:
+            msg += f"description: {self.description}\n"
+
+        if self.present_in_specfile:
+            msg += "present_in_specfile: true"
+
+        return msg
+
+    def rename(self, new_name: str):
+        new_path = self.path.parent / new_name
+        logger.debug(f"Renaming the patch: {self.name} -> {new_path}")
+        self.path.rename(new_path)
+        self.path = new_path
+        self.name = new_name
+
+    def update_metadata_from_commit(self):
+        metadata = get_metadata_from_message(self.commit)
+        if not metadata:
+            logger.debug(
+                f"Commit {self.commit.hexsha:.8} does not contain any metadata."
+            )
+            return
+
+        logger.debug(
+            f"Commit {self.commit.hexsha:.8} metadata:\n"
+            f"{yaml.dump(metadata, indent=4)}"
+        )
+
+        if "patch_name" in metadata:
+            self.rename(metadata["patch_name"])
+
+        self.description = metadata.get("description") or self.description
+        self.present_in_specfile = (
+            metadata.get("present_in_specfile") or self.present_in_specfile
+        )
+        self.location_in_specfile = (
+            metadata.get("location_in_specfile") or self.location_in_specfile
+        )
+
+
 class PatchGenerator:
     """
     Generate .patch files from git
@@ -137,7 +216,7 @@ class PatchGenerator:
         git_ref: str,
         destination: str,
         files_to_ignore: Optional[List[str]] = None,
-    ) -> List[Tuple[Path, str]]:
+    ) -> List[PatchMetadata]:
         """
         Create patches from git commits.
 
@@ -150,7 +229,7 @@ class PatchGenerator:
         if not contained:
             self.linearize_history(git_ref)
 
-        patch_list = []
+        patch_list: List[PatchMetadata] = []
 
         try:
             commits = self.get_commits_since_ref(
@@ -179,12 +258,13 @@ class PatchGenerator:
                         # `git format-patch` usually creates one patch for a merge commit,
                         # so some commits won't be covered by a dedicated patch file
                         if commit.hexsha in patch_content:
-                            logger.debug(f"[{patch_name}] {commit.summary}")
-                            msg = (
-                                f"{commit.summary}\n"
-                                f"Author: {commit.author.name} <{commit.author.email}>"
+                            path = Path(patch_name)
+                            patch_metadata = PatchMetadata(
+                                commit=commit, path=path, name=path.name,
                             )
-                            patch_list.append((Path(patch_name), msg))
+                            patch_metadata.update_metadata_from_commit()
+                            logger.debug(f"[{patch_metadata.name}] {commit.summary}")
+                            patch_list.append(patch_metadata)
                             break
             else:
                 logger.warning(f"No patches between {git_ref!r} and {self.lp.ref!r}")
diff --git a/packit/specfile.py b/packit/specfile.py
index c4673d7d..287ce9cf 100644
--- a/packit/specfile.py
+++ b/packit/specfile.py
@@ -8,6 +8,7 @@ from rebasehelper.helpers.macro_helper import MacroHelper
 from rebasehelper.specfile import SpecFile, RebaseHelperError, saves
 
 from packit.constants import SPEC_PACKAGE_SECTION
+from packit.patches import PatchMetadata
 
 try:
     from rebasehelper.plugins.plugin_manager import plugin_manager
@@ -128,25 +129,42 @@ class Specfile(SpecFile):
             self._process_patches(comment_out=indexes)
 
     @saves
-    def add_patches(self, patch_list: List[Tuple[Path, str]]) -> None:
+    def add_patches(self, patch_list: List[PatchMetadata]) -> None:
         """
         Add given patches to the specfile.
 
-        :param patch_list: [(patch_name, msg)]
+        :param patch_list: [PatchMetadata]
         """
         if not patch_list:
             return
 
         logger.debug(f"About to add patches {patch_list} to specfile.")
         if [t.name for t in self.tags.filter(name="Patch*")]:
-            raise PackitException(
-                "This specfile already contains patches, please remove them."
-            )
-
+            logger.debug("This specfile already contains patches.")
+
+        source_git_patches: List[PatchMetadata] = []
+        original_patches: List[PatchMetadata] = []
+        for patch in patch_list:
+            if patch.present_in_specfile:
+                original_patches.append(patch)
+            else:
+                source_git_patches.append(patch)
+
+        logger.debug(
+            f"Original patches ({len(original_patches)}) "
+            "has to be already in the spec-file. "
+            "Following patches will not be added to the spec-file:\n - "
+            + "\n - ".join(f"{patch.name} ({patch.path})" for patch in original_patches)
+            + "\n"
+        )
+
+        logger.debug(f"Adding source-git patches ({len(source_git_patches)})")
         new_content = "\n# PATCHES FROM SOURCE GIT:\n"
-        for i, (patch, msg) in enumerate(patch_list):
-            new_content += "\n# " + "\n# ".join(msg.split("\n"))
-            new_content += f"\nPatch{(i + 1):04d}: {patch.name}\n"
+        for i, patch_metadata in enumerate(source_git_patches):
+            new_content += "\n# " + "\n# ".join(
+                patch_metadata.specfile_comment.split("\n")
+            )
+            new_content += f"\nPatch{(i + 1):04d}: {patch_metadata.name}\n"
 
         # valid=None: take any SourceX even if it's disabled
         last_source_tag_line = [
diff --git a/packit/upstream.py b/packit/upstream.py
index 3ee678d8..5185415b 100644
--- a/packit/upstream.py
+++ b/packit/upstream.py
@@ -45,7 +45,7 @@ from packit.exceptions import (
     PackitRPMNotFoundException,
 )
 from packit.local_project import LocalProject
-from packit.patches import PatchGenerator
+from packit.patches import PatchGenerator, PatchMetadata
 from packit.specfile import Specfile
 from packit.utils import run_command, git_remote_url_to_https_url
 
@@ -197,7 +197,7 @@ class Upstream(PackitRepositoryBase):
 
     def create_patches(
         self, upstream: str = None, destination: str = None
-    ) -> List[Tuple[Path, str]]:
+    ) -> List[PatchMetadata]:
         """
         Create patches from downstream commits.
 


Test patch:
diff --git a/tests/integration/test_source_git.py b/tests/integration/test_source_git.py
index b4f42a39..3d91ffd9 100644
--- a/tests/integration/test_source_git.py
+++ b/tests/integration/test_source_git.py
@@ -241,6 +241,108 @@ new file mode 100644"""
     assert "--- a/ignored_file.txt\n" not in git_diff
 
 
+def test_basic_local_update_patch_content_with_metadata(
+    sourcegit_and_remote,
+    distgit_and_remote,
+    mock_remote_functionality_sourcegit,
+    api_instance_source_git,
+):
+    """ propose-update for sourcegit test: mock remote API, use local upstream and dist-git """
+
+    sourcegit, _ = sourcegit_and_remote
+    distgit, _ = distgit_and_remote
+    mock_spec_download_remote_s(distgit)
+
+    create_merge_commit_in_source_git(sourcegit)
+
+    source_file = sourcegit / "big-source-file.txt"
+    source_file.write_text("new changes")
+    git_add_and_commit(
+        directory=sourcegit,
+        message="source change\n"
+        "patch_name: testing.patch\n"
+        "description: Few words for info.",
+    )
+
+    source_file = sourcegit / "ignored_file.txt"
+    source_file.write_text(" And I am sad.")
+    git_add_and_commit(directory=sourcegit, message="make a file sad")
+
+    api_instance_source_git.sync_release("master", "0.1.0", upstream_ref="0.1.0")
+
+    git_diff = subprocess.check_output(
+        ["git", "diff", "HEAD~", "HEAD"], cwd=distgit
+    ).decode()
+
+    patches = """
++# PATCHES FROM SOURCE GIT:
++
++# switching to amarillo hops
++# Author: Packit Test Suite <test@example.com>
++Patch0001: 0001-switching-to-amarillo-hops.patch
++
++# actually, let's do citra
++# Author: Packit Test Suite <test@example.com>
++Patch0002: 0002-actually-let-s-do-citra.patch
++
++# source change
++# Author: Packit Test Suite <test@example.com>
++# Few words for info.
++Patch0003: testing.patch
++
++
+ %description
+"""
+    assert patches in git_diff
+
+
+def test_basic_local_update_patch_content_with_downstream_patch(
+    sourcegit_and_remote,
+    distgit_and_remote,
+    mock_remote_functionality_sourcegit,
+    api_instance_source_git,
+):
+    """ propose-update for sourcegit test: mock remote API, use local upstream and dist-git """
+
+    sourcegit, _ = sourcegit_and_remote
+    distgit, _ = distgit_and_remote
+    mock_spec_download_remote_s(distgit)
+
+    create_merge_commit_in_source_git(sourcegit)
+
+    source_file = sourcegit / "big-source-file.txt"
+    source_file.write_text("new changes")
+    git_add_and_commit(
+        directory=sourcegit, message="source change\n" "present_in_specfile: true",
+    )
+
+    source_file = sourcegit / "ignored_file.txt"
+    source_file.write_text(" And I am sad.")
+    git_add_and_commit(directory=sourcegit, message="make a file sad")
+
+    api_instance_source_git.sync_release("master", "0.1.0", upstream_ref="0.1.0")
+
+    git_diff = subprocess.check_output(
+        ["git", "diff", "HEAD~", "HEAD"], cwd=distgit
+    ).decode()
+
+    patches = """
++# PATCHES FROM SOURCE GIT:
++
++# switching to amarillo hops
++# Author: Packit Test Suite <test@example.com>
++Patch0001: 0001-switching-to-amarillo-hops.patch
++
++# actually, let's do citra
++# Author: Packit Test Suite <test@example.com>
++Patch0002: 0002-actually-let-s-do-citra.patch
++
++
+ %description
+"""
+    assert patches in git_diff
+
+
 def test_srpm(mock_remote_functionality_sourcegit, api_instance_source_git):
     sg_path = Path(api_instance_source_git.upstream_local_project.working_dir)
     mock_spec_download_remote_s(sg_path / "fedora")
diff --git a/tests/unit/test_git_utils.py b/tests/unit/test_git_utils.py
new file mode 100644
index 00000000..be88f56f
--- /dev/null
+++ b/tests/unit/test_git_utils.py
@@ -0,0 +1,108 @@
+# MIT License
+#
+# Copyright (c) 2020 Red Hat, Inc.
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import pytest
+from flexmock import flexmock
+
+from packit.git_utils import get_metadata_from_message, get_message_from_metadata
+
+
+@pytest.mark.parametrize(
+    "source, result",
+    [
+        pytest.param("", None, id="empty message"),
+        pytest.param("One sentence", None, id="one sentence"),
+        pytest.param("One sentence\n", None, id="one sentence with end-line"),
+        pytest.param(
+            "One sentence\n\n", None, id="one sentence with multiple end-lines"
+        ),
+        pytest.param("One sentence\nSecond sentence\n", None, id="two sentences"),
+        pytest.param("One sentence\nSecond sentence\n", None, id="two sentences"),
+        pytest.param("key: value", {"key": "value"}, id="one key-value",),
+        pytest.param(
+            "key: value\n", {"key": "value"}, id="one key-value with empty-line",
+        ),
+        pytest.param(
+            "key: value\nsecond_key: value",
+            {"key": "value", "second_key": "value"},
+            id="two key-values",
+        ),
+        pytest.param(
+            "One sentence\nkey: value\n",
+            {"key": "value"},
+            id="one sentence and one key-value with empty-line",
+        ),
+        pytest.param(
+            "Once sentence\nSecond sentence.\nkey: value",
+            {"key": "value"},
+            id="two sentences and one key-value",
+        ),
+        pytest.param(
+            "Once sentence\n\nSecond sentence.\n\n\nkey: value",
+            {"key": "value"},
+            id="two sentences and one key-value with few empty lines",
+        ),
+        pytest.param(
+            "Once sentence\nSecond sentence.\nkey: value\nother: key",
+            {"key": "value", "other": "key"},
+            id="two sentences and one key-value",
+        ),
+        pytest.param(
+            "key: value\nsentence at the end", None, id="sentence at the end",
+        ),
+        pytest.param("key: [a,b,c]", {"key": ["a", "b", "c"]}, id="list as a value",),
+        pytest.param(
+            "key:\n- a\n- b\n- c",
+            {"key": ["a", "b", "c"]},
+            id="list as a value in separate lines",
+        ),
+        pytest.param(
+            "Sentence: with colon\nanother sentence without colon",
+            None,
+            id="colon in the sentence",
+        ),
+        pytest.param(
+            "Sentence with colon: in the middle\nanother sentence without colon",
+            None,
+            id="colon in the sentence in the middle",
+        ),
+    ],
+)
+def test_get_metadata_from_message(source, result):
+    assert get_metadata_from_message(commit=flexmock(message=source)) == result
+
+
+@pytest.mark.parametrize(
+    "metadata, header, result",
+    [
+        pytest.param({}, None, "", id="empty dict"),
+        pytest.param({"key": "value"}, None, "key: value\n", id="single key-value"),
+        pytest.param(
+            {"key": "value", "other": "value"},
+            None,
+            "key: value\nother: value\n",
+            id="multiple key-values",
+        ),
+    ],
+)
+def test_get_message_from_metadata(metadata, header, result):
+    assert get_message_from_metadata(metadata, header) == result