This is an automated email from the ASF dual-hosted git repository.

weilee pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 9200ae2c814 Add Support for GitHub App Installation Authentication in 
`GithubHook` (#54812)
9200ae2c814 is described below

commit 9200ae2c814f6693690bc9ffcbd2badbf4260ed5
Author: LU WEI HAO <[email protected]>
AuthorDate: Sat Nov 22 11:41:55 2025 +0800

    Add Support for GitHub App Installation Authentication in `GithubHook` 
(#54812)
---
 providers/github/docs/connections/github.rst       | 33 +++++++-
 .../src/airflow/providers/github/hooks/github.py   | 37 +++++---
 .../github/tests/unit/github/hooks/test_github.py  | 99 ++++++++++++++++++++--
 .../tests/unit/github/operators/test_github.py     | 34 +++++++-
 .../tests/unit/github/sensors/test_github.py       | 32 ++++++-
 5 files changed, 210 insertions(+), 25 deletions(-)

diff --git a/providers/github/docs/connections/github.rst 
b/providers/github/docs/connections/github.rst
index 8934207875b..20a5cbe19d3 100644
--- a/providers/github/docs/connections/github.rst
+++ b/providers/github/docs/connections/github.rst
@@ -20,11 +20,16 @@
 
 GitHub Connection
 ====================
-The GitHub connection type provides connection to a GitHub or GitHub 
Enterprise.
+The GitHub connection provides two authentication mechanisms:
+  - Token-based authentication
+  - GitHub App authentication
+
+For Token-based authentication, you must provide an access token.
+For GitHub App authentication, you must configure the connection's Extras 
field with the required GitHub App parameters.
 
 Configuring the Connection
 --------------------------
-Access Token (required)
+Access Token (optional)
     Personal Access token with required permissions.
         - GitHub - Create token - 
https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token/
         - GitHub Enterprise - Create token - 
https://docs.github.com/en/enterprise-cloud@latest/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token/
@@ -40,3 +45,27 @@ Host (optional)
     .. code-block::
 
         https://{hostname}/api/v3
+
+GitHub App authentication
+--------------------------------
+
+You can authenticate using a GitHub App installation by setting the extra 
field of your connection, instead of using a token.
+
+- ``key_path``: Path to the private key file used for GitHub App 
authentication.
+- ``app_id``: The application ID.
+- ``installation_id``: The ID of the app installation.
+- ``token_permissions``: A dictionary of permissions. - Properties of 
permissions - 
https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app
+
+Example "extras" field:
+
+.. code-block:: json
+
+    {
+      "key_path": "FAKE_KEY.pem",
+      "app_id": "123456s",
+      "installation_id": 123456789,
+      "token_permissions": {
+        "issues":"write",
+        "contents":"read"
+      }
+    }
diff --git a/providers/github/src/airflow/providers/github/hooks/github.py 
b/providers/github/src/airflow/providers/github/hooks/github.py
index d0b60d3908c..2e93e5e1c63 100644
--- a/providers/github/src/airflow/providers/github/hooks/github.py
+++ b/providers/github/src/airflow/providers/github/hooks/github.py
@@ -21,9 +21,8 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
-from github import Github as GithubClient
+from github import Auth, Github as GithubClient
 
-from airflow.exceptions import AirflowException
 from airflow.providers.common.compat.sdk import BaseHook
 
 
@@ -55,17 +54,35 @@ class GithubHook(BaseHook):
         conn = self.get_connection(self.github_conn_id)
         access_token = conn.password
         host = conn.host
-
-        # Currently the only method of authenticating to GitHub in Airflow is 
via a token. This is not the
-        # only means available, but raising an exception to enforce this 
method for now.
-        # TODO: When/If other auth methods are implemented this exception 
should be removed/modified.
-        if not access_token:
-            raise AirflowException("An access token is required to 
authenticate to GitHub.")
+        extras = conn.extra_dejson or {}
+
+        if access_token:
+            auth: Auth.Auth = Auth.Token(access_token)
+        elif extras:
+            if key_path := extras.get("key_path"):
+                if not key_path.endswith(".pem"):
+                    raise ValueError("Unrecognised key file: expected a .pem 
private key")
+                with open(key_path) as key_file:
+                    private_key = key_file.read()
+            else:
+                raise ValueError("No key_path provided for GitHub App 
authentication.")
+
+            app_id = extras.get("app_id")
+            installation_id = extras.get("installation_id")
+            if not isinstance(installation_id, int):
+                raise ValueError("The provided installation_id should be 
integer.")
+            if not isinstance(app_id, (str | int)):
+                raise ValueError("The provided app_id should be integer or 
string.")
+            token_permissions = extras.get("token_permissions", None)
+
+            auth = Auth.AppAuth(app_id, 
private_key).get_installation_auth(installation_id, token_permissions)
+        else:
+            raise ValueError("No access token or authentication method 
provided.")
 
         if not host:
-            self.client = GithubClient(login_or_token=access_token)
+            self.client = GithubClient(auth=auth)
         else:
-            self.client = GithubClient(login_or_token=access_token, 
base_url=host)
+            self.client = GithubClient(auth=auth, base_url=host)
 
         return self.client
 
diff --git a/providers/github/tests/unit/github/hooks/test_github.py 
b/providers/github/tests/unit/github/hooks/test_github.py
index f7e294f9c91..6982637a364 100644
--- a/providers/github/tests/unit/github/hooks/test_github.py
+++ b/providers/github/tests/unit/github/hooks/test_github.py
@@ -17,7 +17,7 @@
 # under the License.
 from __future__ import annotations
 
-from unittest.mock import Mock, patch
+from unittest.mock import Mock, mock_open, patch
 
 import pytest
 from github import BadCredentialsException, Github, NamedUser
@@ -26,6 +26,7 @@ from airflow.models import Connection
 from airflow.providers.github.hooks.github import GithubHook
 
 github_client_mock = Mock(name="github_client_for_test")
+github_app_client_mock = Mock(name="github_app_client_for_test")
 
 
 class TestGithubHook:
@@ -40,6 +41,19 @@ class TestGithubHook:
                 host="https://mygithub.com/api/v3";,
             )
         )
+        create_connection_without_db(
+            Connection(
+                conn_id="github_app_conn",
+                conn_type="github",
+                host="https://mygithub.com/api/v3";,
+                extra={
+                    "app_id": "123456",
+                    "installation_id": 654321,
+                    "key_path": "FAKE_PRIVATE_KEY.pem",
+                    "token_permissions": {"issues": "write", "pull_requests": 
"read"},
+                },
+            )
+        )
 
     @patch(
         "airflow.providers.github.hooks.github.GithubClient", autospec=True, 
return_value=github_client_mock
@@ -51,8 +65,14 @@ class TestGithubHook:
         assert isinstance(github_hook.client, Mock)
         assert github_hook.client.name == github_mock.return_value.name
 
-    def test_connection_success(self):
-        hook = GithubHook()
+    @pytest.mark.parametrize("conn_id", ["github_default", "github_app_conn"])
+    @patch(
+        "airflow.providers.github.hooks.github.open",
+        new_callable=mock_open,
+        read_data="FAKE_PRIVATE_KEY_CONTENT",
+    )
+    def test_connection_success(self, mock_file, conn_id):
+        hook = GithubHook(github_conn_id=conn_id)
         hook.client = Mock(spec=Github)
         hook.client.get_user.return_value = NamedUser.NamedUser
 
@@ -61,8 +81,14 @@ class TestGithubHook:
         assert status is True
         assert msg == "Successfully connected to GitHub."
 
-    def test_connection_failure(self):
-        hook = GithubHook()
+    @pytest.mark.parametrize("conn_id", ["github_default", "github_app_conn"])
+    @patch(
+        "airflow.providers.github.hooks.github.open",
+        new_callable=mock_open,
+        read_data="FAKE_PRIVATE_KEY_CONTENT",
+    )
+    def test_connection_failure(self, mock_file, conn_id):
+        hook = GithubHook(github_conn_id=conn_id)
         hook.client.get_user = Mock(
             side_effect=BadCredentialsException(
                 status=401,
@@ -74,3 +100,66 @@ class TestGithubHook:
 
         assert status is False
         assert msg == '401 {"message": "Bad credentials"}'
+
+    @pytest.mark.parametrize(
+        (
+            "conn_id",
+            "extra",
+            "expected_error_message",
+        ),
+        [
+            # Wrong key file extension
+            (
+                "invalid_key_path",
+                {"app_id": "1", "installation_id": 1, "key_path": 
"wrong_ext.txt"},
+                "Unrecognised key file: expected a .pem private key",
+            ),
+            # Missing key_path
+            (
+                "missing_key_path",
+                {"app_id": "1", "installation_id": 1},
+                "No key_path provided for GitHub App authentication.",
+            ),
+            # installation_id is not integer
+            (
+                "invalid_install_id",
+                {"app_id": "1", "installation_id": "654321_string", 
"key_path": "key.pem"},
+                "The provided installation_id should be integer.",
+            ),
+            # app_id is not integer or string
+            (
+                "invalid_app_id",
+                {"app_id": ["123456_list"], "installation_id": 1, "key_path": 
"key.pem"},
+                "The provided app_id should be integer or string.",
+            ),
+            # No access token or authentication method provided
+            (
+                "no_auth_conn",
+                {},
+                "No access token or authentication method provided.",
+            ),
+        ],
+    )
+    @patch("airflow.providers.github.hooks.github.GithubHook.get_connection")
+    @patch(
+        "airflow.providers.github.hooks.github.open",
+        new_callable=mock_open,
+        read_data="FAKE_PRIVATE_KEY_CONTENT",
+    )
+    def test_get_conn_value_error_cases(
+        self,
+        mock_file,
+        get_connection_mock,
+        conn_id,
+        extra,
+        expected_error_message,
+    ):
+        mock_conn = Connection(
+            conn_id=conn_id,
+            conn_type="github",
+            extra=extra,
+        )
+        get_connection_mock.return_value = mock_conn
+
+        with pytest.raises(ValueError, match=expected_error_message):
+            GithubHook(github_conn_id=conn_id)
diff --git a/providers/github/tests/unit/github/operators/test_github.py 
b/providers/github/tests/unit/github/operators/test_github.py
index b6c6cf681fb..25176e222a7 100644
--- a/providers/github/tests/unit/github/operators/test_github.py
+++ b/providers/github/tests/unit/github/operators/test_github.py
@@ -17,14 +17,18 @@
 # under the License.
 from __future__ import annotations
 
-from unittest.mock import Mock, patch
+from unittest.mock import Mock, mock_open, patch
 
 import pytest
 
 from airflow.models import Connection
 from airflow.models.dag import DAG
 from airflow.providers.github.operators.github import GithubOperator
-from airflow.utils import timezone
+
+try:
+    from airflow.sdk import timezone
+except ImportError:
+    from airflow.utils import timezone  # type: ignore[attr-defined,no-redef]
 
 DEFAULT_DATE = timezone.datetime(2017, 1, 1)
 github_client_mock = Mock(name="github_client_for_test")
@@ -42,26 +46,47 @@ class TestGithubOperator:
                 host="https://mygithub.com/api/v3";,
             )
         )
+        create_connection_without_db(
+            Connection(
+                conn_id="github_app_conn",
+                conn_type="github",
+                host="https://mygithub.com/api/v3";,
+                extra={
+                    "app_id": "123456",
+                    "installation_id": 654321,
+                    "key_path": "FAKE_PRIVATE_KEY.pem",
+                    "token_permissions": {"issues": "write", "pull_requests": 
"read"},
+                },
+            )
+        )
 
     def setup_class(self):
         args = {"owner": "airflow", "start_date": DEFAULT_DATE}
         dag = DAG("test_dag_id", schedule=None, default_args=args)
         self.dag = dag
 
-    def test_operator_init_with_optional_args(self):
+    @pytest.mark.parametrize("conn_id", ["github_default", "github_app_conn"])
+    def test_operator_init_with_optional_args(self, conn_id):
         github_operator = GithubOperator(
             task_id="github_list_repos",
             github_method="get_user",
+            github_conn_id=conn_id,
         )
 
         assert github_operator.github_method_args == {}
         assert github_operator.result_processor is None
 
+    @pytest.mark.parametrize("conn_id", ["github_default", "github_app_conn"])
     @pytest.mark.db_test
     @patch(
         "airflow.providers.github.hooks.github.GithubClient", autospec=True, 
return_value=github_client_mock
     )
-    def test_find_repos(self, github_mock, dag_maker):
+    @patch(
+        "airflow.providers.github.hooks.github.open",
+        new_callable=mock_open,
+        read_data="FAKE_PRIVATE_KEY_CONTENT",
+    )
+    def test_find_repos(self, mock_file, github_mock, dag_maker, conn_id):
         class MockRepository:
             pass
 
@@ -74,6 +99,7 @@ class TestGithubOperator:
                 task_id="github-test",
                 github_method="get_repo",
                 github_method_args={"full_name_or_id": "apache/airflow"},
+                github_conn_id=conn_id,
                 result_processor=lambda r: r.full_name,
             )
         dr = dag_maker.create_dagrun()
diff --git a/providers/github/tests/unit/github/sensors/test_github.py 
b/providers/github/tests/unit/github/sensors/test_github.py
index b885be43dde..a9a414bae37 100644
--- a/providers/github/tests/unit/github/sensors/test_github.py
+++ b/providers/github/tests/unit/github/sensors/test_github.py
@@ -17,14 +17,18 @@
 # under the License.
 from __future__ import annotations
 
-from unittest.mock import Mock, patch
+from unittest.mock import Mock, mock_open, patch
 
 import pytest
 
 from airflow.models import Connection
 from airflow.models.dag import DAG
 from airflow.providers.github.sensors.github import GithubTagSensor
-from airflow.utils import timezone
+
+try:
+    from airflow.sdk import timezone
+except ImportError:
+    from airflow.utils import timezone  # type: ignore[attr-defined,no-redef]
 
 DEFAULT_DATE = timezone.datetime(2017, 1, 1)
 github_client_mock = Mock(name="github_client_for_test")
@@ -42,18 +46,37 @@ class TestGithubSensor:
                 host="https://mygithub.com/api/v3";,
             )
         )
+        create_connection_without_db(
+            Connection(
+                conn_id="github_app_conn",
+                conn_type="github",
+                host="https://mygithub.com/api/v3";,
+                extra={
+                    "app_id": "123456",
+                    "installation_id": 654321,
+                    "key_path": "FAKE_PRIVATE_KEY.pem",
+                    "token_permissions": {"issues": "write", "pull_requests": 
"read"},
+                },
+            )
+        )
 
     def setup_class(self):
         args = {"owner": "airflow", "start_date": DEFAULT_DATE}
         dag = DAG("test_dag_id", schedule=None, default_args=args)
         self.dag = dag
 
+    @pytest.mark.parametrize("conn_id", ["github_default", "github_app_conn"])
     @patch(
         "airflow.providers.github.hooks.github.GithubClient",
         autospec=True,
         return_value=github_client_mock,
     )
-    def test_github_tag_created(self, github_mock):
+    @patch(
+        "airflow.providers.github.hooks.github.open",
+        new_callable=mock_open,
+        read_data="FAKE_PRIVATE_KEY_CONTENT",
+    )
+    def test_github_tag_created(self, mock_file, github_mock, conn_id):
         class MockTag:
             pass
 
@@ -63,12 +86,13 @@ class TestGithubSensor:
         github_mock.return_value.get_repo.return_value.get_tags.return_value = 
[tag]
 
         github_tag_sensor = GithubTagSensor(
-            task_id="search-ticket-test",
+            task_id=f"search-ticket-test-{conn_id}",
             tag_name="v1.0",
             repository_name="pateash/jetbrains_settings",
             timeout=60,
             poke_interval=10,
             dag=self.dag,
+            github_conn_id=conn_id,
         )
 
         github_tag_sensor.execute({})

Reply via email to