From dba612427ead23c7e1366424158599d68893dda2 Mon Sep 17 00:00:00 2001
From: Dominik Wombacher <dominik@wombacher.cc>
Date: Nov 22 2024 15:56:51 +0000
Subject: [PATCH 1/5] build(requirements): add missing mock package


---

diff --git a/requirements.txt b/requirements.txt
index aa0bbba..bd5d8c2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -19,6 +19,7 @@ werkzeug <= 3.0.3
 flask-wtf <= 1.2.1
 kitchen == 1.2.6
 markdown <= 3.5.2
+mock <= 4.0.3
 munch <= 2.5.0
 Pillow <= 10.3.0
 psutil <= 5.9.8

From 3d132b7e57346299d38026ac06817d69a3a74c07 Mon Sep 17 00:00:00 2001
From: Dominik Wombacher <dominik@wombacher.cc>
Date: Nov 22 2024 15:56:51 +0000
Subject: [PATCH 2/5] fix(ci): Load plugins before API Blueprint registration


Load all configured pagure ci plugins once to initiate routes

Otherwise causes errors like:

werkzeug.routing.exceptions.BuildError: Could not build url for endpoint 'api_ns.jenkins_ci_notification' with values ['pagure_ci_token', 'repo']

---

diff --git a/pagure/flask_app.py b/pagure/flask_app.py
index 68578cb..b062ddf 100644
--- a/pagure/flask_app.py
+++ b/pagure/flask_app.py
@@ -11,6 +11,8 @@
 from __future__ import absolute_import, unicode_literals
 
 import datetime
+import importlib
+
 import gc
 import logging
 import os
@@ -135,6 +137,14 @@ def create_app(config=None):
 
     from pagure.api import API  # noqa: E402
 
+    # Load all configured pagure ci plugins once to initiate routes
+    # Required before Blueprint registration
+    ci_plugins = {}
+    for ci_type in pagure_config["PAGURE_CI_SERVICES"]:
+        ci_plugins[ci_type] = importlib.import_module(
+            "pagure.api.ci." + ci_type
+        )
+
     app.register_blueprint(API)
 
     from pagure.ui import UI_NS  # noqa: E402

From 29f88bfdc6eb5c3123cae5bd48e6fb428a8ade90 Mon Sep 17 00:00:00 2001
From: Dominik Wombacher <dominik@wombacher.cc>
Date: Nov 22 2024 15:56:51 +0000
Subject: [PATCH 3/5] feat(ci): Enable ci_username and ci_password fields


Optional fields were added to the code base a while back but never made visible in the UI.

Refs: https://pagure.io/pagure/c/9c6dee24463fdbfe88c21cfa4a6d7d3af87a5b7c

---

diff --git a/pagure/hooks/pagure_ci.py b/pagure/hooks/pagure_ci.py
index 819b2a1..ca7ecd7 100644
--- a/pagure/hooks/pagure_ci.py
+++ b/pagure/hooks/pagure_ci.py
@@ -174,7 +174,7 @@ class PagureCi(BaseHook):
     form = PagureCiForm
     db_object = PagureCITable
     backref = "ci_hook"
-    form_fields = ["ci_type", "ci_url", "ci_job", "active_commit", "active_pr"]
+    form_fields = ["ci_type", "ci_url", "ci_job", "ci_username", "ci_password", "active_commit", "active_pr"]
     runner = PagureCIRunner
 
     @classmethod

From 6312dcde58047cbc1f4c0e06ec31e6bbea8fc8d7 Mon Sep 17 00:00:00 2001
From: Dominik Wombacher <dominik@wombacher.cc>
Date: Nov 22 2024 15:56:51 +0000
Subject: [PATCH 4/5] fix(ci): Change 'ci_password' to wtforms.PasswordField


---

diff --git a/pagure/hooks/pagure_ci.py b/pagure/hooks/pagure_ci.py
index ca7ecd7..38702a7 100644
--- a/pagure/hooks/pagure_ci.py
+++ b/pagure/hooks/pagure_ci.py
@@ -121,7 +121,7 @@ class PagureCiForm(FlaskForm):
         [wtforms.validators.Optional(), wtforms.validators.Length(max=255)],
     )
 
-    ci_password = wtforms.StringField(
+    ci_password = wtforms.PasswordField(
         "Password to authenticate with if needed",
         [wtforms.validators.Optional(), wtforms.validators.Length(max=255)],
     )

From c7489b8de890744d80ce1db93def365bc3a8f4c1 Mon Sep 17 00:00:00 2001
From: Dominik Wombacher <dominik@wombacher.cc>
Date: Nov 22 2024 15:56:51 +0000
Subject: [PATCH 5/5] feat(ci): generic plugin, work in progress, 2/3 done


Completed:
- Outgoing webhook json generation
- Incoming generic_ci_notification
- Trigger build (mandatory value validation)

Pending:
- Sending the webhook as part of Trigger build
- SIGv4, Basic and Token authentication for outgoing requests

Full test coverage because of TDD approach

---

diff --git a/pagure/api/ci/generic.py b/pagure/api/ci/generic.py
new file mode 100644
index 0000000..c631285
--- /dev/null
+++ b/pagure/api/ci/generic.py
@@ -0,0 +1,245 @@
+# -*- coding: utf-8 -*-
+
+# SPDX-FileCopyrightText: 2024 Dominik Wombacher <dominik@wombacher.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+from flask import request, g
+from cryptography.hazmat.primitives import constant_time
+from kitchen.text.converters import to_bytes
+import logging
+import json
+
+import pagure.lib.query
+from pagure.config import config as pagure_config
+from pagure.exceptions import PagureException, APIError
+from pagure.lib import model, plugins
+from pagure.lib.query import get_authorized_project
+from pagure.api import API, APIERROR, api_method
+from pagure.api.ci import BUILD_STATS
+
+_log = logging.getLogger(__name__)
+
+
+@API.route(
+    "/ci/generic/<repo>", methods=["POST"]
+)
+@API.route(
+    "/ci/generic/<namespace>/<repo>", methods=["POST"],
+)
+@API.route(
+    "/ci/generic/forks/<username>/<repo>", methods=["POST"],
+)
+@API.route(
+    "/ci/generic/forks/<username>/<namespace>/<repo>", methods=["POST"],
+)
+@api_method
+def generic_ci_notification(repo, username=None, namespace=None):
+    """
+        Example incoming Json payload:
+            {
+                "ci_token": <required>,
+                "pull_request_uid": <required>,
+                "build_status": <required>,
+                "comment_display_name": <optional>,
+                "build_url": <optional>,
+                "build_id_external": <optional>,
+            }
+
+        Expected values:
+            ci_token = Has to match the token in the Project Pagure CI settings
+            pull_request_uid = The same UID that was part of the outgoing webhook to identify the Pull Request
+            build_status = The current status of the update
+            comment_display_name = Name that shows up in the Pull Request comment, defaults to 'pagure ci - generic'
+            build_url = URL where build logs, status, history or dashboard can be accessed, linked from the Pull Request comment
+            build_id_external = Unique ID of the build, shows up as additional information in the Pull Request comment
+
+        Value restrictions:
+            build_status = SUCCESS | FAILURE | ABORTED | BUILDING
+    """
+    if request.is_json is False:
+        _log.debug("Bad Request: No JSON payload received.")
+        raise APIError(400, error_code=APIERROR.EINVALIDREQ)
+
+    data = request.json
+    ci_token = data.get("ci_token")
+    pull_request_uid = data.get("pull_request_uid")
+    build_status = data.get("build_status")
+    comment_display_name = data.get("comment_display_name")
+    build_url = data.get("build_url")
+    build_id_external = data.get("build_id_external")
+
+    if ci_token is None or ci_token == "":
+        _log.debug("Bad Request: 'ci_token' not provided.")
+        raise APIError(400, error_code=APIERROR.EINVALIDREQ)
+
+    if pull_request_uid is None or pull_request_uid == "":
+        _log.debug("Bad Request: 'pull_request_uid' not provided.")
+        raise APIError(400, error_code=APIERROR.EINVALIDREQ)
+
+    if build_status is None or build_status == "":
+        _log.debug("Bad Request: 'build_status' not provided.")
+        raise APIError(400, error_code=APIERROR.EINVALIDREQ)
+
+    project = get_authorized_project(
+        g.session, repo, user=username, namespace=namespace
+    )
+    if not project:
+        _log.debug(f"Not Found: Project / Repo %s doesn't exist." % repo)
+        raise APIError(404, error_code=APIERROR.ENOPROJECT)
+
+    ci_hook = plugins.get_plugin("Pagure CI")
+    ci_hook.db_object()
+    if not constant_time.bytes_eq(
+        to_bytes(ci_token), to_bytes(project.ci_hook.pagure_ci_token)
+    ):
+        _log.debug(f"Access Denied: The provided ci token is invalid for project %s." % project.fullname)
+        raise APIError(401, error_code=APIERROR.EINVALIDTOK)
+
+    if build_status not in BUILD_STATS:
+        _log.debug(f"Bad Request: Invalid build status retrieved: %s" % build_status)
+        raise APIError(400, error_code=APIERROR.EINVALIDREQ)
+
+    pull_request = pagure.lib.query.get_request_by_uid(
+        g.session, pull_request_uid
+    )
+    if not pull_request:
+        _log.debug(f"Not Found: Pull Request with UID %s doesn't exist." % pull_request_uid)
+        raise APIError(404, error_code=APIERROR.ENOREQ)
+
+    # Usage in https://pagure.io/pagure/blob/master/f/pagure/lib/query.py#_1451
+    # makes 'pull_request.commit_stop' mandatory, otherwise 'pagure.lib.query.add_pull_request_flag'
+    # fails down the road with:
+    #   "sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) NOT NULL constraint failed: commit_flags.commit_hash"
+    if not pull_request.commit_stop:
+        _log.debug(f"Internal Server Error: Value for 'commit_stop' mandatory but not found in Pull Request with UID %s." % pull_request_uid)
+        # Nothing a user can do to recover, treat it as fatal error
+        raise APIError(500, error_code=APIERROR.ENOCOMMIT)
+
+    # build_url is mandatory: https://pagure.io/pagure/blob/master/f/pagure/lib/model.py#_2503
+    # Fallback to link to the PR if not provided to allow use-cases were no url is available
+    if build_url is None or build_url == "":
+        build_url = pull_request.full_url
+
+    comment, state, percent = BUILD_STATS[build_status]
+
+    # build_id_external is optional, if not provided fallback to 'N/A'
+    if build_id_external is None or build_id_external == "":
+        build_id_external = ("N/A"
+                             "")
+    comment = comment % build_id_external
+
+    if comment_display_name is None or comment_display_name == "":
+        comment_display_name = "pagure ci - %s" % project.ci_hook.ci_type
+
+    if pull_request.commit_stop:
+        comment += " (commit: %s)" % (pull_request.commit_stop[:8])
+
+    flag_uid = None
+    for flag in pull_request.flags:
+        if (
+            flag.status == pagure_config["FLAG_PENDING"]
+            and flag.username == comment_display_name
+        ):
+            flag_uid = flag.uid
+            break
+
+    _log.debug("Flag's UID: %s", flag_uid)
+
+    pagure.lib.query.add_pull_request_flag(
+        g.session,
+        request=pull_request,
+        username=comment_display_name,
+        percent=percent,
+        comment=comment,
+        url=build_url,
+        status=state,
+        uid=flag_uid,
+        user=project.user.username,
+        token=None,
+    )
+    g.session.commit()
+
+    ret = {
+        "pull_request_uid": pull_request.uid,
+        "build_status": build_status,
+        "result": "Status update successful."
+    }
+
+    return ret, 200
+
+
+def trigger_build(
+        project: model.Project,
+        ci_url: str,
+        ci_job: str,
+        ci_token: str,
+        branch: str,
+        branch_to: str,
+        is_pull_request: bool = False,
+        is_commit: bool = False,
+        commit_hash: str = None,
+        pull_request: model.PullRequest = None,
+        ci_username = None,
+        ci_password = None,
+) -> None:
+
+    _log.info("pagure ci - generic - trigger_build")
+
+    if is_commit and is_pull_request:
+        raise PagureException("pagure ci - generic - Invalid: Event Type can't be commit AND pull request")
+
+    if is_commit and commit_hash is None or commit_hash == "":
+        raise PagureException("pagure ci - generic - Invalid: Commit needs to contain a commit hash")
+
+    if is_pull_request and pull_request is None:
+        raise PagureException("pagure ci - generic - Invalid: Pull Request needs to contain a model.PullRequest object")
+
+
+def _outgoing_payload(
+        project: model.Project,
+        branch: str,
+        branch_to: str,
+        is_pull_request: bool = False,
+        is_commit: bool = False,
+        commit_hash: str = None,
+        pull_request: model.PullRequest = None
+) -> json:
+
+    if project.is_fork:
+        fork_name = project.name
+        fork_url = project.full_url
+        repo_name = project.parent.name
+        repo_url = project.parent.full_url
+    else:
+        repo_name = project.name
+        repo_url = project.full_url
+        fork_name = None
+        fork_url = None
+
+    if pull_request:
+        pull_request_url = pull_request.full_url
+        pull_request_id = pull_request.id
+        pull_request_uid = pull_request.uid
+    else:
+        pull_request_url = None
+        pull_request_id = None
+        pull_request_uid = None
+
+    payload = {
+            "is_pull_request": is_pull_request,
+            "is_commit": is_commit,
+            "pull_request_id": pull_request_id,
+            "pull_request_uid": pull_request_uid,
+            "pull_request_url": pull_request_url,
+            "commit_hash": commit_hash,
+            "repo_name": repo_name,
+            "repo_url": repo_url,
+            "fork_name": fork_name,
+            "fork_url": fork_url,
+            "branch": branch,
+            "branch_to": branch_to,
+            "response_url": f"%s/api/0/ci/generic/%s" % (pagure_config["APP_URL"][:-1], project.url_path),
+        }
+
+    return json.dumps(payload)
\ No newline at end of file
diff --git a/pagure/lib/tasks_services.py b/pagure/lib/tasks_services.py
index 070dab1..da042c5 100644
--- a/pagure/lib/tasks_services.py
+++ b/pagure/lib/tasks_services.py
@@ -430,6 +430,16 @@ def trigger_ci_build(
         else:
             project_name = pr.project_from.fullname
 
+        is_pull_request = True
+        pull_request = pr
+        is_commit = False
+        commit_hash = None
+    else:
+        is_commit = True
+        commit_hash = cause
+        is_pull_request = False
+        pull_request = None
+
     user, namespace, project_name = split_project_fullname(project_name)
 
     _log.info("Pagure-CI: Looking for project: %s", project_name)
@@ -478,16 +488,20 @@ def trigger_ci_build(
             ci_project = project.parent
         ci = importlib.import_module(f"pagure.api.ci.{ci_type}")
         ci.trigger_build(
-            project_path=project.path,
-            url=ci_project.ci_hook.ci_url,
-            job=ci_project.ci_hook.ci_job,
-            token=ci_project.ci_hook.pagure_ci_token,
+            project=project,
+            ci_url=ci_project.ci_hook.ci_url,
+            ci_job=ci_project.ci_hook.ci_job,
+            ci_token=ci_project.ci_hook.pagure_ci_token,
             branch=branch,
             branch_to=branch_to,
-            cause=cause,
+            is_pull_request=is_pull_request,
+            is_commit=is_commit,
+            commit_hash=commit_hash,
+            pull_request=pull_request,
             ci_username=ci_project.ci_hook.ci_username,
             ci_password=ci_project.ci_hook.ci_password,
         )
+
     except Exception as e:
         _log.error(
             f"Pagure-CI: Un-supported CI type {ci_type}. "
diff --git a/tests/test_pagure_api_ci_generic.py b/tests/test_pagure_api_ci_generic.py
new file mode 100644
index 0000000..18b435c
--- /dev/null
+++ b/tests/test_pagure_api_ci_generic.py
@@ -0,0 +1,750 @@
+# -*- coding: utf-8 -*-
+
+# SPDX-FileCopyrightText: 2024 Dominik Wombacher <dominik@wombacher.cc>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+import json
+import os
+
+import sys
+from mock import patch, Mock, ANY
+
+sys.path.insert(
+    0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
+)
+
+from pagure.config import config as pagure_config
+from pagure.lib import model
+import pagure.api.ci.generic
+import pagure.lib.tasks_services
+import pagure.lib.query
+import tests
+from pagure.api.ci import BUILD_STATS
+
+from pagure.lib.query import get_authorized_project, get_request_by_uid
+_unpatched_get_authorized_project = get_authorized_project
+_unpatched_get_request_by_uid = get_request_by_uid
+
+
+class PagureApiCiGenerictests(tests.Modeltests):
+    """Tests for pagure.lib.task_services"""
+
+    maxDiff = None
+
+
+    def setUp(self):
+        """Set up the environnment, ran before every tests."""
+        super(PagureApiCiGenerictests, self).setUp()
+
+        pagure.config.config["REQUESTS_FOLDER"] = None
+        self.sshkeydir = os.path.join(self.path, "sshkeys")
+        pagure.config.config["MIRROR_SSHKEYS_FOLDER"] = self.sshkeydir
+
+        tests.create_projects(self.session)
+
+        # Use of '_unpatched_get_authorized_project' because
+        # 'pagure.lib.query.get_authorized_project' is mocked for some tests
+        project = _unpatched_get_authorized_project(self.session, "test")
+
+        # Install the plugin at the DB level
+        plugin = pagure.lib.plugins.get_plugin("Pagure CI")
+        dbobj = plugin.db_object()
+        dbobj.ci_url = "https://ci.example.com/"
+        dbobj.ci_job = "pagure"
+        dbobj.pagure_ci_token = "random_token"
+        dbobj.project_id = project.id
+        dbobj.ci_type = "generic"
+        self.session.add(dbobj)
+        self.session.commit()
+
+        # Create a fork of test for foo
+        item = pagure.lib.model.Project(
+            user_id=2,  # foo
+            name="test",
+            is_fork=True,
+            parent_id=1,
+            description="test project #1",
+            hook_token="aaabbbccc_foo",
+        )
+        item.close_status = [
+            "Invalid",
+            "Insufficient data",
+            "Fixed",
+            "Duplicate",
+        ]
+        self.session.add(item)
+        self.session.commit()
+
+        # Create Pull Request in test project
+        pr_test = pagure.lib.model.PullRequest(
+            id=1,
+            uid="720a0568c1274e74966e54b433b2003e",
+            title="pr1",
+            project_id=item.id,
+            project_id_from=project.id,
+            branch="main",
+            branch_from="feature",
+            user_id=project.user_id,
+            commit_start="96df1145c3466fb33edbbde327c6f8705627d2eb",
+            commit_stop="96df1145c3466fb33edbbde327c6f8705627d2eb",
+        )
+        self.session.add(pr_test)
+        self.session.commit()
+
+
+    def _response_url(self, project: model.Project) -> str:
+        return f"%s/api/0/ci/generic/%s" % (pagure_config["APP_URL"][:-1], project.url_path)
+
+
+    def _values_for_required_parameters(self):
+        # Use of '_unpatched_get_authorized_project' because
+        # 'pagure.lib.query.get_authorized_project' is mocked for some tests
+        project = _unpatched_get_authorized_project(self.session, "test")
+        ci_url = "https://ci.example.com/"
+        ci_job = "pagure"
+        ci_token = "random_token"
+        branch = "feature"
+        branch_to = "main"
+
+        return project, ci_url, ci_job, ci_token, branch, branch_to
+
+
+    def _values_for_required_parameters_fork(self, user: str):
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+
+        # Use of '_unpatched_get_authorized_project' because
+        # 'pagure.lib.query.get_authorized_project' is mocked for some tests
+        project = _unpatched_get_authorized_project(self.session, "test", user=user)
+
+        return project, ci_url, ci_job, ci_token, branch, branch_to
+
+
+    def test_trigger_ci_build_without_required_args(self,):
+        """
+            If the mandatory args are not passed, there should be a TypeError exception
+            when 'pagure.lib.task_services.trigger_ci_build' eventually calls
+            'pagure.api.ci.generic.trigger_build'
+        """
+        self.assertRaises(
+            TypeError,
+            pagure.lib.tasks_services.trigger_ci_build
+        )
+
+
+    def test_trigger_ci_build_pull_request(self,):
+        """
+            Test that pagure.lib.tasks_services.trigger_ci_build calls
+            pagure.api.ci.generic.trigger_build without throwing an Exception.
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        cause = "1"
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+
+        output = pagure.lib.tasks_services.trigger_ci_build(
+            cause=cause,
+            branch=branch,
+            branch_to=branch_to,
+            ci_type="generic",
+            project_name = project.name,
+            pr_uid = pr_uid,
+        )
+
+        self.assertIsNone(output)
+
+
+    def test_trigger_ci_build_commit(self):
+        """
+            Test that pagure.lib.tasks_services.trigger_ci_build calls
+            pagure.api.ci.generic.trigger_build without throwing an Exception.
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        cause = "9c0110e671ffb1e76b629fd23d6a08414ffb44ab"
+
+        output = pagure.lib.tasks_services.trigger_ci_build(
+            cause=cause,
+            branch=branch,
+            branch_to=None,
+            ci_type="generic",
+            project_name = project.name,
+            pr_uid = None,
+        )
+
+        self.assertIsNone(output)
+
+
+    @patch("pagure.lib.query.get_request_by_uid")
+    @patch("pagure.lib.query.get_authorized_project")
+    @patch("pagure.api.ci.generic.trigger_build")
+    def test_trigger_ci_build_pull_request_called_once(self, trigger_build, get_authorized_project, get_request_by_uid):
+        """
+            Test that pagure.lib.tasks_services.trigger_ci_build calls
+            pagure.api.ci.generic.trigger_build once.
+            No further evaluation of failure or success.
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        cause = "1"
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        pull_request = _unpatched_get_request_by_uid(self.session, pr_uid)
+
+        # Ensure that the same project obj is used across the test
+        get_authorized_project.return_value = project
+
+        # Ensure that the same pull_request obj is used across the test
+        get_request_by_uid.return_value = pull_request
+
+        output = pagure.lib.tasks_services.trigger_ci_build(
+            cause=cause,
+            branch=branch,
+            branch_to=branch_to,
+            ci_type="generic",
+            project_name = project.name,
+            pr_uid = pr_uid,
+        )
+
+        self.assertIsNone(output)
+
+        trigger_build.assert_called_once_with(
+            project=project,
+            ci_url=project.ci_hook.ci_url,
+            ci_job=project.ci_hook.ci_job,
+            ci_token=project.ci_hook.pagure_ci_token,
+            branch=branch,
+            branch_to=branch_to,
+            is_pull_request=True,
+            is_commit=False,
+            commit_hash=None,
+            pull_request=pull_request,
+            ci_username=None,
+            ci_password=None
+        )
+
+
+    @patch("pagure.lib.query.get_authorized_project")
+    @patch("pagure.api.ci.generic.trigger_build")
+    def test_trigger_ci_build_commit_called_once(self, trigger_build, get_authorized_project):
+        """
+            Test that pagure.lib.tasks_services.trigger_ci_build calls
+            pagure.api.ci.generic.trigger_build once.
+            No further evaluation of failure or success.
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        cause = "9c0110e671ffb1e76b629fd23d6a08414ffb44ab"
+
+        # Ensure that the same project obj is used across the test
+        get_authorized_project.return_value = project
+
+        output = pagure.lib.tasks_services.trigger_ci_build(
+            cause=cause,
+            branch=branch,
+            branch_to=None,
+            ci_type="generic",
+            project_name = project.name,
+            pr_uid = None,
+        )
+
+        self.assertIsNone(output)
+
+        trigger_build.assert_called_once_with(
+            project=project,
+            ci_url=project.ci_hook.ci_url,
+            ci_job=project.ci_hook.ci_job,
+            ci_token=project.ci_hook.pagure_ci_token,
+            branch=branch,
+            branch_to=None,
+            is_pull_request=False,
+            is_commit=True,
+            commit_hash=cause,
+            pull_request=None,
+            ci_username=None,
+            ci_password=None
+        )
+
+
+    def test_outgoing_payload_pull_request(self):
+        """
+            Validated generated payload for a pull request
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        is_pull_request = True
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        pull_request = _unpatched_get_request_by_uid(self.session, pr_uid)
+
+        want = {
+            "is_pull_request": is_pull_request,
+            "is_commit": False,
+            "pull_request_id": pull_request.id,
+            "pull_request_uid": pull_request.uid,
+            "pull_request_url": pull_request.full_url,
+            "commit_hash": None,
+            "repo_name": project.name,
+            "repo_url": project.full_url,
+            "fork_name": None,
+            "fork_url": None,
+            "branch": branch,
+            "branch_to": branch_to,
+            "response_url": self._response_url(project),
+        }
+
+        got = pagure.api.ci.generic._outgoing_payload(
+            project=project,
+            branch=branch,
+            branch_to=branch_to,
+            is_pull_request=is_pull_request,
+            pull_request=pull_request,
+        )
+
+        self.assertJSONEqual(got, json.dumps(want))
+
+
+    def test_outgoing_payload_pull_request_fork(self):
+        """
+            Validated generated payload for a pull request from a fork
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters_fork(user="foo")
+        is_pull_request = True
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        pull_request = _unpatched_get_request_by_uid(self.session, pr_uid)
+
+        want = {
+            "is_pull_request": is_pull_request,
+            "is_commit": False,
+            "pull_request_id": pull_request.id,
+            "pull_request_uid": pull_request.uid,
+            "pull_request_url": pull_request.full_url,
+            "commit_hash": None,
+            "repo_name": project.parent.name,
+            "repo_url": project.parent.full_url,
+            "fork_name": project.name,
+            "fork_url": project.full_url,
+            "branch": branch,
+            "branch_to": branch_to,
+            "response_url": self._response_url(project),
+        }
+
+        got = pagure.api.ci.generic._outgoing_payload(
+            project=project,
+            branch=branch,
+            branch_to=branch_to,
+            is_pull_request=is_pull_request,
+            pull_request=pull_request,
+        )
+
+        self.assertJSONEqual(got, json.dumps(want))
+
+
+    def test_outgoing_payload_commit(self):
+        """
+            Validated generated payload for a commit
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        is_commit = True
+        commit_hash = "96df1145c3466fb33edbbde327c6f8705627d2eb"
+
+        want = {
+            "is_pull_request": False,
+            "is_commit": is_commit,
+            "pull_request_id": None,
+            "pull_request_uid": None,
+            "pull_request_url": None,
+            "commit_hash": commit_hash,
+            "repo_name": project.name,
+            "repo_url": project.full_url,
+            "fork_name": None,
+            "fork_url": None,
+            "branch": branch,
+            "branch_to": branch_to,
+            "response_url": self._response_url(project),
+        }
+
+        got = pagure.api.ci.generic._outgoing_payload(
+            project=project,
+            branch=branch,
+            branch_to=branch_to,
+            is_commit=is_commit,
+            commit_hash=commit_hash
+        )
+
+        self.assertJSONEqual(got, json.dumps(want))
+
+
+    def test_outgoing_payload_commit_fork(self):
+        """
+            Validated generated payload for a commit from a fork
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters_fork(user="foo")
+        is_commit = True
+        commit_hash = "96df1145c3466fb33edbbde327c6f8705627d2eb"
+
+        want = {
+            "is_pull_request": False,
+            "is_commit": is_commit,
+            "pull_request_id": None,
+            "pull_request_uid": None,
+            "pull_request_url": None,
+            "commit_hash": commit_hash,
+            "repo_name": project.parent.name,
+            "repo_url": project.parent.full_url,
+            "fork_name": project.name,
+            "fork_url": project.full_url,
+            "branch": branch,
+            "branch_to": branch_to,
+            "response_url": self._response_url(project),
+        }
+
+        got = pagure.api.ci.generic._outgoing_payload(
+            project=project,
+            branch=branch,
+            branch_to=branch_to,
+            is_commit=is_commit,
+            commit_hash=commit_hash
+        )
+
+        self.assertJSONEqual(got, json.dumps(want))
+
+
+    def test_trigger_build_commit_and_pull_request(self):
+        """
+            Failing with PagureException expected when 'is_commit' and 'is_pull_request' are True
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        is_commit = True
+        commit_hash = "96df1145c3466fb33edbbde327c6f8705627d2eb"
+        is_pull_request = True
+
+        self.assertRaises(
+            pagure.exceptions.PagureException,
+            pagure.api.ci.generic.trigger_build,
+            project=project,
+            ci_url=ci_url,
+            ci_job=ci_job,
+            ci_token=ci_token,
+            branch=branch,
+            branch_to=branch_to,
+            is_commit=is_commit,
+            commit_hash=commit_hash,
+            is_pull_request=is_pull_request
+        )
+
+
+    def test_trigger_build_commit_without_hash(self):
+        """
+            Failing with PagureException expected when 'is_commit' but no 'commit_hash' was provided
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        is_commit = True
+
+        self.assertRaises(
+            pagure.exceptions.PagureException,
+            pagure.api.ci.generic.trigger_build,
+            project=project,
+            ci_url=ci_url,
+            ci_job=ci_job,
+            ci_token=ci_token,
+            branch=branch,
+            branch_to=branch_to,
+            is_commit=is_commit,
+        )
+
+
+    def test_trigger_build_pull_request_without_pull_request_object(self):
+        """
+            Failing with PagureException expected when 'is_pull_request' but no 'pull_request' object was provided
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        is_pull_request = True
+
+        self.assertRaises(
+            pagure.exceptions.PagureException,
+            pagure.api.ci.generic.trigger_build,
+            project=project,
+            ci_url=ci_url,
+            ci_job=ci_job,
+            ci_token=ci_token,
+            branch=branch,
+            branch_to=branch_to,
+            is_pull_request=is_pull_request,
+        )
+
+
+    def test_generic_ci_notification_post_no_json(self):
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        want = 400
+
+        got = self.app.post(
+            f"/api/0/ci/generic/%s" % project.url_path
+        )
+
+        self.assertEqual(got.status_code, want)
+
+
+    def test_generic_ci_notification_post_json_missing_arg_ci_token(self):
+        """
+            Expecting a bad request response when mandatory arg 'ci_token' is missing.
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        build_status = "SUCCESS"
+
+        want = 400
+
+        got = self.app.post(
+            f"/api/0/ci/generic/%s" % project.url_path,
+            json={
+                "pull_request_uid": pr_uid,
+                "build_status": build_status,
+                "comment_display_name": None,
+                "build_url": None,
+                "build_id_external": None,
+            }
+        )
+
+        self.assertEqual(got.status_code, want)
+
+
+    def test_generic_ci_notification_post_json_missing_arg_pull_request_uid(self):
+        """
+            Expecting a bad request response when mandatory arg 'pull_request_uid' is missing.
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        ci_token = "random_token"
+        build_status = "SUCCESS"
+
+        want = 400
+
+        got = self.app.post(
+            f"/api/0/ci/generic/%s" % project.url_path,
+            json={
+                "ci_token": ci_token,
+                "build_status": build_status,
+                "comment_display_name": None,
+                "build_url": None,
+                "build_id_external": None,
+            }
+        )
+
+        self.assertEqual(got.status_code, want)
+
+
+    def test_generic_ci_notification_post_json_missing_arg_build_status(self):
+        """
+            Expecting a bad request response when mandatory arg 'build_status' is missing.
+        """
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        ci_token = "random_token"
+
+        want = 400
+
+        got = self.app.post(
+            f"/api/0/ci/generic/%s" % project.url_path,
+            json={
+                "pull_request_uid": pr_uid,
+                "ci_token": ci_token,
+                "comment_display_name": None,
+                "build_url": None,
+                "build_id_external": None,
+            }
+        )
+
+        self.assertEqual(got.status_code, want)
+
+
+    def test_generic_ci_notification_post_no_project_found(self):
+        ci_token = "random_token"
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        build_status = "SUCCESS"
+
+        want = 404
+
+        got = self.app.post(
+            f"/api/0/ci/generic/invalid_repo_name",
+            json={
+                "ci_token": ci_token,
+                "pull_request_uid": pr_uid,
+                "build_status": build_status,
+                "comment_display_name": None,
+                "build_url": None,
+                "build_id_external": None,
+            }
+        )
+
+        self.assertEqual(got.status_code, want)
+
+
+    def test_generic_ci_notification_post_invalid_ci_token(self):
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        ci_token = "InvalidToken"
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        build_status = "SUCCESS"
+
+        want = 401
+
+        got = self.app.post(
+            f"/api/0/ci/generic/%s" % project.url_path,
+            json={
+                "ci_token": ci_token,
+                "pull_request_uid": pr_uid,
+                "build_status": build_status,
+                "comment_display_name": None,
+                "build_url": None,
+                "build_id_external": None,
+            }
+        )
+
+        self.assertEqual(got.status_code, want)
+
+
+    def test_generic_ci_notification_post_no_pull_request_found(self):
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        ci_token = "random_token"
+        pr_uid = "InvalidPullRequestUID"
+        build_status = "SUCCESS"
+
+        want = 404
+
+        got = self.app.post(
+            f"/api/0/ci/generic/%s" % project.url_path,
+            json={
+                "ci_token": ci_token,
+                "pull_request_uid": pr_uid,
+                "build_status": build_status,
+                "comment_display_name": None,
+                "build_url": None,
+                "build_id_external": None,
+            }
+        )
+
+        self.assertEqual(got.status_code, want)
+
+
+    def test_generic_ci_notification_post_invalid_build_status(self):
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        ci_token = "random_token"
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        build_status = "InvalidBuildStatus"
+
+        want = 400
+
+        got = self.app.post(
+            f"/api/0/ci/generic/%s" % project.url_path,
+            json={
+                "ci_token": ci_token,
+                "pull_request_uid": pr_uid,
+                "build_status": build_status,
+                "comment_display_name": None,
+                "build_url": None,
+                "build_id_external": None,
+            }
+        )
+
+        self.assertEqual(got.status_code, want)
+
+
+    @patch("pagure.lib.query.get_authorized_project")
+    @patch("pagure.lib.query.get_request_by_uid")
+    @patch("pagure.lib.query.add_pull_request_flag")
+    def test_generic_ci_add_pull_request_flag_called_once(self, add_pull_request_flag, get_request_by_uid, get_authorized_project):
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        ci_token = "random_token"
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        build_status = "SUCCESS"
+        build_id_external = "1234"
+        comment_display_name = "pagure ci - generic"
+        build_url = "https://ci.example.com/1234"
+
+        pull_request = _unpatched_get_request_by_uid(self.session, pr_uid)
+
+        # Needs some improvement, quite some duplicate code and logic from the plugin
+        comment, build_state, build_percent = BUILD_STATS[build_status]
+        comment = comment % build_id_external
+        if pull_request.commit_stop:
+            comment += " (commit: %s)" % (pull_request.commit_stop[:8])
+
+        flag_uid = None
+        for flag in pull_request.flags:
+            if (
+                    flag.status == pagure_config["FLAG_PENDING"]
+                    and flag.username == comment_display_name
+            ):
+                flag_uid = flag.uid
+                break
+
+        # Ensure that the same project obj is used across the test
+        get_authorized_project.return_value = project
+
+        # Ensure that the same pull_request obj is used across the test
+        get_request_by_uid.return_value = pull_request
+
+        want = 200
+
+        got = self.app.post(
+            f"/api/0/ci/generic/%s" % project.url_path,
+            json={
+                "ci_token": ci_token,
+                "pull_request_uid": pr_uid,
+                "build_status": build_status,
+                "comment_display_name": comment_display_name,
+                "build_url": build_url,
+                "build_id_external": build_id_external,
+            }
+        )
+
+        self.assertEqual(got.status_code, want)
+
+        add_pull_request_flag.assert_called_once_with(
+            ANY,
+            request=pull_request,
+            username=comment_display_name,
+            percent=build_percent,
+            comment=comment,
+            url=build_url,
+            status=build_state,
+            uid=flag_uid,
+            user=project.user.username,
+            token=None
+        )
+
+
+    @patch("pagure.lib.query.get_authorized_project")
+    @patch("pagure.lib.query.get_request_by_uid")
+    def test_generic_ci_notification_post_response(self, get_request_by_uid, get_authorized_project):
+        project, ci_url, ci_job, ci_token, branch, branch_to = self._values_for_required_parameters()
+        ci_token = "random_token"
+        pr_uid = "720a0568c1274e74966e54b433b2003e"
+        build_status = "SUCCESS"
+
+        pull_request = _unpatched_get_request_by_uid(self.session, pr_uid)
+
+        # Ensure that the same project obj is used across the test
+        get_authorized_project.return_value = project
+
+        # Ensure that the same pull_request obj is used across the test
+        get_request_by_uid.return_value = pull_request
+
+        want = {
+            "status_code": 200,
+            "body": {
+                "pull_request_uid": pr_uid,
+                "build_status": build_status,
+                "result": "Status update successful."
+            }
+        }
+
+        got = self.app.post(
+            f"/api/0/ci/generic/%s" % project.url_path,
+            json={
+                "ci_token": ci_token,
+                "pull_request_uid": pr_uid,
+                "build_status": build_status,
+                "comment_display_name": None,
+                "build_url": None,
+            }
+        )
+
+        self.assertEqual(got.status_code, want["status_code"])
+
+        self.assertJSONEqual(
+            got.get_data(as_text=True),
+            json.dumps(want["body"])
+        )
\ No newline at end of file

