Repository: relekang/python-semantic-release
Pull request #412 (2022-02-09 12:23:02)

feat(hvcs): add gitea support


Patch:
diff --git a/.gitignore b/.gitignore
index 47f9aef..27b561b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ __pycache__/
 
 # Distribution / packaging
 .Python
+.venv
 env/
 build/
 develop-eggs/
diff --git a/docs/commands.rst b/docs/commands.rst
index fa1d7e6..c582f0d 100644
--- a/docs/commands.rst
+++ b/docs/commands.rst
@@ -12,7 +12,8 @@ Print the changelog to stdout.
 
 If the option ``--post`` is used and there is an authentication token configured
 for your vcs provider (:ref:`env-gh_token` for GitHub, :ref:`env-gl_token` for
-GitLab), the changelog will be posted there too.
+GitLab, :ref:`env-gitea_token` for
+Gitea), the changelog will be posted there too.
 
 
 .. _cmd-version:
diff --git a/docs/envvars.rst b/docs/envvars.rst
index 2644ff5..58505a8 100644
--- a/docs/envvars.rst
+++ b/docs/envvars.rst
@@ -82,6 +82,13 @@ and click on *Personal access token*.
 A personal access token from GitLab. This is used for authenticating
 when pushing tags, publishing releases etc.
 
+.. _env-gitea_token:
+
+``GITEA_TOKEN``
+------------
+A personal access token from Gitea. This is used for authenticating
+when pushing tags, publishing releases etc.
+
 .. _env-repository:
 
 Artifact Repository Authentication
diff --git a/docs/index.rst b/docs/index.rst
index d67de41..3ff228f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -78,6 +78,7 @@ release notes and new versions to GitHub / GitLab:
 - :ref:`config-hvcs` - change this if you are using GitLab.
 - :ref:`env-gh_token` - GitHub personal access token.
 - :ref:`env-gl_token` - GitLab personal access token.
+- :ref:`env-gitea_token` - Gitea personal access token.
 
 Distributing release on PyPI or custom repository
 -----------------
diff --git a/semantic_release/defaults.cfg b/semantic_release/defaults.cfg
index 22c2713..a999a60 100644
--- a/semantic_release/defaults.cfg
+++ b/semantic_release/defaults.cfg
@@ -20,6 +20,8 @@ dist_path=dist
 fix_tag=:nut_and_bolt:
 github_token_var=GH_TOKEN
 gitlab_token_var=GL_TOKEN
+gitea_token_var=GITEA_TOKEN
+
 hvcs=github
 major_emoji=:boom:
 major_on_zero=true
diff --git a/semantic_release/hvcs.py b/semantic_release/hvcs.py
index 150f0a0..cf249de 100644
--- a/semantic_release/hvcs.py
+++ b/semantic_release/hvcs.py
@@ -351,6 +351,273 @@ class Github(Base):
 
         return not one_or_more_failed
 
+class Gitea(Base):
+    """Gitea helper class"""
+
+    DEFAULT_DOMAIN = "gitea.com"
+    DEFAULT_API_PATH = "/api/v1"
+
+    _fix_mime_types()
+
+    @staticmethod
+    def domain() -> str:
+        """Gitea domain property
+
+        :return: The Gitea domain
+        """
+        # ref: https://docs.gitea.com/en/actions/reference/environment-variables#default-environment-variables
+        hvcs_domain = config.get(
+            "hvcs_domain", os.getenv("GITEA_SERVER_URL", "").replace("https://", "")
+        )
+        domain = hvcs_domain if hvcs_domain else Gitea.DEFAULT_DOMAIN
+        return domain
+
+    @staticmethod
+    def api_url() -> str:
+        """Gitea api_url property
+
+        :return: The Gitea API URL
+        """
+
+        hvcs_domain = config.get(
+            "hvcs_domain", os.getenv("GITEA_SERVER_URL", "").replace("https://", "")
+        )
+        hostname = config.get(
+            "hvcs_api_domain", os.getenv("GITEA_API_URL", "").replace("https://", "")
+        )
+        
+        if hvcs_domain and not hostname:
+            hostname = hvcs_domain + Gitea.DEFAULT_API_PATH
+        elif not hostname:
+            hostname = Gitea.DEFAULT_DOMAIN + Gitea.DEFAULT_API_PATH
+
+        return f"https://{hostname}"
+
+    @staticmethod
+    def token() -> Optional[str]:
+        """Gitea token property
+
+        :return: The Gitea token environment variable (GITEA_TOKEN) value
+        """
+        return os.environ.get(config.get("gitea_token_var"))
+
+    @staticmethod
+    def auth() -> Optional[TokenAuth]:
+        """Gitea token property
+
+        :return: The Gitea token environment variable (GITEA_TOKEN) value
+        """
+        token = Gitea.token()
+        if not token:
+            return None
+        return TokenAuth(token)
+
+    @staticmethod
+    def session(
+        raise_for_status=True, retry: Union[Retry, bool, int] = True
+    ) -> Session:
+        session = build_requests_session(raise_for_status=raise_for_status, retry=retry)
+        session.auth = Gitea.auth()
+        return session
+
+    @staticmethod
+    @LoggedFunction(logger)
+    def check_build_status(owner: str, repo: str, ref: str) -> bool:
+        """Check build status
+
+        https://gitea.com/api/swagger#/repository/repoCreateStatus
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param ref: The sha1 hash of the commit ref
+
+        :return: Was the build status success?
+        """
+        url = "{domain}/repos/{owner}/{repo}/statuses/{ref}"
+        try:
+            response = Gitea.session().get(
+                url.format(domain=Gitea.api_url(), owner=owner, repo=repo, ref=ref)
+            )
+            return response.json().get("state") == "success"
+        except HTTPError as e:
+            logger.warning(f"Build status check on Gitea has failed: {e}")
+            return False
+
+    @classmethod
+    @LoggedFunction(logger)
+    def create_release(cls, owner: str, repo: str, tag: str, changelog: str) -> bool:
+        """Create a new release
+
+        https://gitea.com/api/swagger#/repository/repoCreateRelease
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param tag: Tag to create release for
+        :param changelog: The release notes for this version
+
+        :return: Whether the request succeeded
+        """
+        try:
+            Gitea.session().post(
+                f"{Gitea.api_url()}/repos/{owner}/{repo}/releases",
+                json={
+                    "tag_name": tag,
+                    "name": tag,
+                    "body": changelog,
+                    "draft": False,
+                    "prerelease": False,
+                },
+            )
+            return True
+        except HTTPError as e:
+            logger.warning(f"Release creation on Gitea has failed: {e}")
+            return False
+
+    @classmethod
+    @LoggedFunction(logger)
+    def get_release(cls, owner: str, repo: str, tag: str) -> Optional[int]:
+        """Get a release by its tag name
+
+        https://gitea.com/api/swagger#/repository/repoGetReleaseByTag
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param tag: Tag to get release for
+
+        :return: ID of found release
+        """
+        try:
+            response = Gitea.session().get(
+                f"{Gitea.api_url()}/repos/{owner}/{repo}/releases/tags/{tag}"
+            )
+            return response.json().get("id")
+        except HTTPError as e:
+            if e.response.status_code != 404:
+                logger.debug(f"Get release by tag on Gitea has failed: {e}")
+            return None
+
+    @classmethod
+    @LoggedFunction(logger)
+    def edit_release(cls, owner: str, repo: str, id: int, changelog: str) -> bool:
+        """Edit a release with updated change notes
+
+        https://gitea.com/api/swagger#/repository/repoEditRelease
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param id: ID of release to update
+        :param changelog: The release notes for this version
+
+        :return: Whether the request succeeded
+        """
+        try:
+            Gitea.session().post(
+                f"{Gitea.api_url()}/repos/{owner}/{repo}/releases/{id}",
+                json={"body": changelog},
+            )
+            return True
+        except HTTPError as e:
+            logger.warning(f"Edit release on Gitea has failed: {e}")
+            return False
+
+    @classmethod
+    @LoggedFunction(logger)
+    def post_release_changelog(
+        cls, owner: str, repo: str, version: str, changelog: str
+    ) -> bool:
+        """Post release changelog
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param version: The version number
+        :param changelog: The release notes for this version
+
+        :return: The status of the request
+        """
+        tag = get_formatted_tag(version)
+        logger.debug(f"Attempting to create release for {tag}")
+        success = Gitea.create_release(owner, repo, tag, changelog)
+
+        if not success:
+            logger.debug("Unsuccessful, looking for an existing release to update")
+            release_id = Gitea.get_release(owner, repo, tag)
+            if release_id:
+                logger.debug(f"Updating release {release_id}")
+                success = Gitea.edit_release(owner, repo, release_id, changelog)
+            else:
+                logger.debug(f"Existing release not found")
+
+        return success
+
+    @classmethod
+    @LoggedFunction(logger)
+    def upload_asset(
+        cls, owner: str, repo: str, release_id: int, file: str, label: str = None
+    ) -> bool:
+        """Upload an asset to an existing release
+
+        https://gitea.com/api/swagger#/repository/repoCreateReleaseAttachment
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param release_id: ID of the release to upload to
+        :param file: Path of the file to upload
+        :param label: Custom label for this file
+
+        :return: The status of the request
+        """
+        url = f"{Gitea.api_url()}/repos/{owner}/{repo}/releases/{release_id}/assets"
+
+        content_type = mimetypes.guess_type(file, strict=False)[0]
+        if not content_type:
+            content_type = "application/octet-stream"
+
+        try:
+            response = Gitea.session().post(
+                url,
+                params={"name": os.path.basename(file)},
+                headers={
+                    "Content-Type": content_type,
+                },
+                data=open(file, "rb").read(),
+            )
+
+            logger.debug(
+                f"Asset upload on Gitea completed, url: {response.url}, status code: {response.status_code}"
+            )
+
+            return True
+        except HTTPError as e:
+            logger.warning(f"Asset upload {file} on Gitea has failed: {e}")
+            return False
+
+    @classmethod
+    def upload_dists(cls, owner: str, repo: str, version: str, path: str) -> bool:
+        """Upload distributions to a release
+
+        :param owner: The owner namespace of the repository
+        :param repo: The repository name
+        :param version: Version to upload for
+        :param path: Path to the dist directory
+
+        :return: The status of the request
+        """
+
+        # Find the release corresponding to this version
+        release_id = Gitea.get_release(owner, repo, get_formatted_tag(version))
+        if not release_id:
+            logger.debug("No release found to upload assets to")
+            return False
+
+        # Upload assets
+        one_or_more_failed = False
+        for file in os.listdir(path):
+            file_path = os.path.join(path, file)
+
+            if not Gitea.upload_asset(owner, repo, release_id, file_path):
+                one_or_more_failed = True
+
+        return not one_or_more_failed
 
 class Gitlab(Base):
     """Gitlab helper class"""
@@ -460,7 +727,7 @@ def get_hvcs() -> Base:
     try:
         return globals()[hvcs.capitalize()]
     except KeyError:
-        raise ImproperConfigurationError('"{0}" is not a valid option for hvcs.')
+        raise ImproperConfigurationError('"{0}" is not a valid option for hvcs. Please use "github"|"gitlab"|"gitea"'.format(hvcs))
 
 
 def check_build_status(owner: str, repository: str, ref: str) -> bool:


Test patch:
diff --git a/tests/test_hvcs.py b/tests/test_hvcs.py
index b95c35a..9f68286 100644
--- a/tests/test_hvcs.py
+++ b/tests/test_hvcs.py
@@ -12,6 +12,7 @@ from semantic_release.errors import ImproperConfigurationError
 from semantic_release.hvcs import (
     Github,
     Gitlab,
+    Gitea,
     check_build_status,
     check_token,
     get_hvcs,
@@ -37,6 +38,9 @@ def test_get_hvcs_should_return_github():
 def test_get_hvcs_should_return_gitlab():
     assert get_hvcs() == Gitlab
 
+@mock.patch("semantic_release.hvcs.config.get", wrapped_config_get(hvcs="gitea"))
+def test_get_hvcs_should_return_gitea():
+    assert get_hvcs() == Gitea
 
 @mock.patch("semantic_release.hvcs.config.get", lambda *x: "doesnotexist")
 def test_get_hvcs_should_raise_improper_config():
@@ -79,6 +83,22 @@ def test_check_custom_github_token_var_should_return_true():
 def test_check_custom_github_token_should_return_false():
     assert check_token() is False
 
+@mock.patch("os.environ", {"GITEA_TOKEN": "token"})
+@mock.patch(
+    "semantic_release.hvcs.config.get",
+    wrapped_config_get(gitea_token_var="CUSTOM_TOKEN"),
+)
+def test_check_custom_gitea_token_should_return_false():
+    assert check_token() is False
+
+@mock.patch("os.environ", {"GITEA_CUSTOM_TOKEN": "token"})
+@mock.patch(
+    "semantic_release.hvcs.config.get",
+    wrapped_config_get(gitea_token_var="GITEA_CUSTOM_TOKEN"),
+)
+def test_check_custom_gitea_token_should_return_false():
+    assert check_token() is False
+
 
 @mock.patch("os.environ", {"CUSTOM_GL_TOKEN": "token"})
 @mock.patch(
@@ -99,10 +119,19 @@ def test_check_custom_gitlab_token_should_return_false():
 
 
 @pytest.mark.parametrize(
-    "hvcs,hvcs_domain,expected_domain,api_url,ci_server_host,hvcs_api_domain",
+    "hvcs,hvcs_domain,expected_domain,expected_api_url,ci_server_host,hvcs_api_domain",
     [
         ("github", None, "github.com", "https://api.github.com", None, None),
         ("gitlab", None, "gitlab.com", "https://gitlab.com", None, None),
+        ("gitea", "git.grassrootseconomics.net", "git.grassrootseconomics.net", "https://git.grassrootseconomics.net/api/v1", None, None),
+        (
+            "github",
+            "github.example.com",
+            "github.example.com",
+            "https://api.github.com",
+            None,
+            None,
+        ),
         (
             "github",
             "github.example.com",
@@ -139,7 +168,7 @@ def test_check_custom_gitlab_token_should_return_false():
 )
 @mock.patch("os.environ", {"GL_TOKEN": "token"})
 def test_get_domain_should_have_expected_domain(
-    hvcs, hvcs_domain, expected_domain, api_url, ci_server_host, hvcs_api_domain
+    hvcs, hvcs_domain, expected_domain, expected_api_url, ci_server_host, hvcs_api_domain
 ):
 
     with mock.patch(
@@ -153,12 +182,13 @@ def test_get_domain_should_have_expected_domain(
             {
                 "GL_TOKEN": "token",
                 "GH_TOKEN": "token",
+                "GITEA_TOKEN": "token",
                 "CI_SERVER_HOST": ci_server_host,
             },
         ):
 
             assert get_hvcs().domain() == expected_domain
-            assert get_hvcs().api_url() == api_url
+            assert get_hvcs().api_url() == expected_api_url
 
 
 @mock.patch("semantic_release.hvcs.config.get", wrapped_config_get(hvcs="github"))
@@ -238,6 +268,57 @@ class GithubCheckBuildStatusTests(TestCase):
             )
         )
 
+class GiteaCheckBuildStatusTests(TestCase):
+    url = "https://gitea.com/api/v1/repos/gitea/tea/statuses/268aa061795d53a1914b734c479b7c1f62cfd7af"
+
+    def get_response(self, status):
+        return json.dumps(
+            {
+                "state": status,
+            }
+        )
+
+    @responses.activate
+    def test_should_return_false_if_pending(self):
+        responses.add(
+            responses.GET,
+            self.url,
+            body=self.get_response("pending"),
+            content_type="application/json",
+        )
+        self.assertFalse(
+            Gitea.check_build_status(
+                "gitea", "tea", "268aa061795d53a1914b734c479b7c1f62cfd7af"
+            )
+        )
+
+    @responses.activate
+    def test_should_return_false_if_failure(self):
+        responses.add(
+            responses.GET,
+            self.url,
+            body=self.get_response("failure"),
+            content_type="application/json",
+        )
+        self.assertFalse(
+            Gitea.check_build_status(
+                "gitea", "tea", "268aa061795d53a1914b734c479b7c1f62cfd7af"
+            )
+        )
+
+    @responses.activate
+    def test_should_return_true_if_success(self):
+        responses.add(
+            responses.GET,
+            self.url,
+            body=self.get_response("success"),
+            content_type="application/json",
+        )
+        self.assertTrue(
+            Gitea.check_build_status(
+                "gitea", "tea", "268aa061795d53a1914b734c479b7c1f62cfd7af"
+            )
+        )
 
 class GitlabCheckBuildStatusTests(TestCase):
     @mock_gitlab("pending")
@@ -472,6 +553,221 @@ class GithubReleaseTests(TestCase):
         # Remove test file
         os.remove(dummy_file_path)
 
+class GiteaReleaseTests(TestCase):
+    
+    url = "https://gitea.com/api/v1/repos/gitea/tea/releases"
+    edit_url = "https://gitea.com/api/v1/repos/gitea/tea/releases/1"
+    get_url = "https://gitea.com/api/v1/repos/gitea/tea/releases/tags/v1.0.0"
+    asset_url = "https://gitea.com/api/v1/repos/gitea/tea/releases/1/assets"
+    asset_url_params = (
+        "https://gitea.com/api/v1/repos/gitea/tea/releases/1/assets"
+        "?name=testupload.md"
+    )
+    asset_no_extension_url_params = (
+        "https://gitea.com/api/v1/repos/gitea/tea/releases/1/assets"
+        "?name=testupload-no-extension"
+    )
+    dist_asset_url_params = (
+        "https://gitea.com/api/v1/repos/gitea/tea/releases/1/assets"
+        "?name=testupload.md"
+    )
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value="super-token")
+    def test_should_post_changelog_using_gitea_token(self, mock_token):
+        with NamedTemporaryFile("w") as netrc_file:
+            netrc_file.write("machine gitea.com\n")
+            netrc_file.write("login username\n")
+            netrc_file.write("password password\n")
+
+            netrc_file.flush()
+
+            def request_callback(request):
+                payload = json.loads(request.body)
+                self.assertEqual(payload["tag_name"], "v1.0.0")
+                self.assertEqual(payload["body"], "text")
+                self.assertEqual(payload["draft"], False)
+                self.assertEqual(payload["prerelease"], False)
+                self.assertEqual(
+                    "token super-token", request.headers.get("Authorization")
+                )
+
+                return 201, {}, json.dumps({})
+
+            responses.add_callback(
+                responses.POST,
+                self.url,
+                callback=request_callback,
+                content_type="application/json",
+            )
+
+            with mock.patch.dict(os.environ, {"NETRC": netrc_file.name}):
+                status = Gitea.post_release_changelog(
+                    "gitea", "tea", "1.0.0", "text"
+                )
+                self.assertTrue(status)
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value=None)
+    def test_should_post_changelog_using_netrc(self, mock_token):
+        with NamedTemporaryFile("w") as netrc_file:
+            netrc_file.write("machine gitea.com\n")
+            netrc_file.write("login username\n")
+            netrc_file.write("password password\n")
+
+            netrc_file.flush()
+
+            def request_callback(request):
+                payload = json.loads(request.body)
+                self.assertEqual(payload["tag_name"], "v1.0.0")
+                self.assertEqual(payload["body"], "text")
+                self.assertEqual(payload["draft"], False)
+                self.assertEqual(payload["prerelease"], False)
+                self.assertEqual(
+                    "Basic "
+                    + base64.encodebytes(b"username:password").decode("ascii").strip(),
+                    request.headers.get("Authorization"),
+                )
+
+                return 201, {}, json.dumps({})
+
+            responses.add_callback(
+                responses.POST,
+                self.url,
+                callback=request_callback,
+                content_type="application/json",
+            )
+
+            with mock.patch.dict(os.environ, {"NETRC": netrc_file.name}):
+                status = Gitea.post_release_changelog(
+                    "gitea", "tea", "1.0.0", "text"
+                )
+                self.assertTrue(status)
+
+    @responses.activate
+    def test_should_return_false_status_if_it_failed(self):
+        responses.add(
+            responses.POST,
+            self.url,
+            status=400,
+            body="{}",
+            content_type="application/json",
+        )
+        responses.add(
+            responses.GET,
+            self.get_url,
+            status=200,
+            body='{"id": 1}',
+            content_type="application/json",
+        )
+        responses.add(
+            responses.POST,
+            self.edit_url,
+            status=400,
+            body="{}",
+            content_type="application/json",
+        )
+        self.assertFalse(
+            Gitea.post_release_changelog("gitea", "tea", "1.0.0", "text")
+        )
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value="super-token")
+    def test_should_upload_asset(self, mock_token):
+        # Create temporary file to upload
+        dummy_file_path = os.path.join(temp_dir, "testupload.md")
+        os.makedirs(os.path.dirname(dummy_file_path), exist_ok=True)
+        dummy_content = "# Test File\n\n*Dummy asset for testing uploads.*"
+        with open(dummy_file_path, "w") as dummy_file:
+            dummy_file.write(dummy_content)
+
+        def request_callback(request):
+            self.assertEqual(request.body.decode().replace("\r\n", "\n"), dummy_content)
+            self.assertEqual(request.url, self.asset_url_params)
+            self.assertEqual(request.headers["Content-Type"], "text/markdown")
+            self.assertEqual("token super-token", request.headers.get("Authorization"))
+
+            return 201, {}, json.dumps({})
+
+        responses.add_callback(
+            responses.POST, self.asset_url, callback=request_callback
+        )
+        status = Gitea.upload_asset(
+            "gitea", "tea", 1, dummy_file_path, "Dummy file"
+        )
+        self.assertTrue(status)
+
+        # Remove test file
+        os.remove(dummy_file_path)
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value="super-token")
+    def test_should_upload_asset_with_no_extension(self, mock_token):
+        # Create temporary file to upload
+        dummy_file_path = os.path.join(temp_dir, "testupload-no-extension")
+        os.makedirs(os.path.dirname(dummy_file_path), exist_ok=True)
+        dummy_content = (
+            "# Test File with no extension\n\n*Dummy asset for testing uploads.*"
+        )
+        with open(dummy_file_path, "w") as dummy_file:
+            dummy_file.write(dummy_content)
+
+        def request_callback(request):
+            self.assertEqual(request.body.decode().replace("\r\n", "\n"), dummy_content)
+            self.assertEqual(request.url, self.asset_no_extension_url_params)
+            self.assertEqual(
+                request.headers["Content-Type"], "application/octet-stream"
+            )
+            self.assertEqual("token super-token", request.headers["Authorization"])
+
+            return 201, {}, json.dumps({})
+
+        responses.add_callback(
+            responses.POST, self.asset_url, callback=request_callback
+        )
+        status = Gitea.upload_asset(
+            "gitea", "tea", 1, dummy_file_path, "Dummy file no extension"
+        )
+        self.assertTrue(status)
+
+        # Remove test file
+        os.remove(dummy_file_path)
+
+    @responses.activate
+    @mock.patch("semantic_release.hvcs.Gitea.token", return_value="super-token")
+    def test_should_upload_dists(self, mock_token):
+        # Create temporary file to upload
+        dummy_file_path = os.path.join(temp_dir, "dist", "testupload.md")
+        os.makedirs(os.path.dirname(dummy_file_path), exist_ok=True)
+        dummy_content = "# Test File\n\n*Dummy asset for testing uploads.*"
+        with open(dummy_file_path, "w") as dummy_file:
+            dummy_file.write(dummy_content)
+
+        def request_callback(request):
+            self.assertEqual(request.body.decode().replace("\r\n", "\n"), dummy_content)
+            self.assertEqual(request.url, self.dist_asset_url_params)
+            self.assertEqual(request.headers["Content-Type"], "text/markdown")
+            self.assertEqual("token super-token", request.headers.get("Authorization"))
+
+            return 201, {}, json.dumps({})
+
+        responses.add(
+            responses.GET,
+            self.get_url,
+            status=200,
+            body='{"id": 1}',
+            content_type="application/json",
+        )
+        responses.add_callback(
+            responses.POST, self.asset_url, callback=request_callback
+        )
+        status = Gitea.upload_dists(
+            "gitea", "tea", "1.0.0", os.path.dirname(dummy_file_path)
+        )
+        self.assertTrue(status)
+
+        # Remove test file
+        os.remove(dummy_file_path)
 
 class GitlabReleaseTests(TestCase):
     @mock_gitlab()
@@ -486,6 +782,9 @@ class GitlabReleaseTests(TestCase):
     def test_should_return_true_for_locked_tags(self, mock_auth, mock_project):
         self.assertTrue(post_changelog("owner", "repo", "my_locked_tag", "changelog"))
 
+def test_gitea_token():
+    with mock.patch("os.environ", {"GITEA_TOKEN": "token"}):
+        assert Gitea.token() == "token"
 
 def test_github_token():
     with mock.patch("os.environ", {"GH_TOKEN": "token"}):
diff --git a/tests/test_settings.py b/tests/test_settings.py
index bdc7b1c..4ef43b8 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -40,6 +40,7 @@ class ConfigTests(TestCase):
         self.assertEqual(config.get("hvcs"), "github")
         self.assertEqual(config.get("upload_to_repository"), True)
         self.assertEqual(config.get("github_token_var"), "GH_TOKEN")
+        self.assertEqual(config.get("gitea_token_var"), "GITEA_TOKEN")
         self.assertEqual(config.get("gitlab_token_var"), "GL_TOKEN")
         self.assertEqual(config.get("pypi_pass_var"), "PYPI_PASSWORD")
         self.assertEqual(config.get("pypi_token_var"), "PYPI_TOKEN")