kaxil commented on code in PR #63260: URL: https://github.com/apache/airflow/pull/63260#discussion_r2914982409
########## providers/common/ai/src/airflow/providers/common/ai/hooks/adk.py: ########## @@ -0,0 +1,198 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import asyncio +import os +from typing import Any + +from google.adk.agents import Agent as ADKAgent +from google.adk.runners import Runner as ADKRunner +from google.adk.sessions import InMemorySessionService +from google.genai import types as genai_types + +from airflow.providers.common.compat.sdk import BaseHook + + +class AdkHook(BaseHook): + """ + Hook for LLM access via Google Agent Development Kit (ADK). + + Manages connection credentials and agent creation/execution. Uses + Google's ADK to orchestrate multi-turn agent interactions with Gemini + models. + + Connection fields: + - **password**: Google API key (for Gemini API access) + - **host**: Custom endpoint URL (optional — for custom/proxy endpoints) + + Cloud authentication via Application Default Credentials (ADC) is used + when no API key is provided. Configure ``GOOGLE_APPLICATION_CREDENTIALS`` + or run ``gcloud auth application-default login``. + + :param llm_conn_id: Airflow connection ID for the LLM provider. + :param model_id: Model identifier (e.g. ``"gemini-2.5-flash"``). + Overrides the model stored in the connection's extra field. + """ + + conn_name_attr = "llm_conn_id" + default_conn_name = "adk_default" + conn_type = "adk" + hook_name = "Google ADK" + + def __init__( + self, + llm_conn_id: str = default_conn_name, + model_id: str | None = None, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.llm_conn_id = llm_conn_id + self.model_id = model_id + self._configured: bool = False + + @staticmethod + def get_ui_field_behaviour() -> dict[str, Any]: + """Return custom field behaviour for the Airflow connection form.""" + return { + "hidden_fields": ["schema", "port", "login"], + "relabeling": {"password": "API Key"}, + "placeholders": { + "host": "(optional — for custom/proxy endpoints)", + }, + } + + def get_conn(self) -> str: + """ + Configure credentials and return the model identifier. + + Reads API key from connection password and model from (in priority + order): + + 1. ``model_id`` parameter on the hook + 2. ``extra["model"]`` on the connection + + When an API key is found in the connection, it is set as the + ``GOOGLE_API_KEY`` environment variable so the google-genai SDK + picks it up automatically. + + The result is cached for the lifetime of this hook instance. + + :return: The resolved model identifier string. + """ + if self._configured: + return self.model_id + + if self.llm_conn_id: + try: + conn = self.get_connection(self.llm_conn_id) + api_key = conn.password + if api_key: + os.environ.setdefault("GOOGLE_API_KEY", api_key) + model_name = self.model_id or conn.extra_dejson.get("model", "") + if model_name: + self.model_id = model_name + except Exception: Review Comment: This silently swallows all connection errors — typos in `conn_id`, permission errors, network failures. The user gets a confusing "No model specified" message instead of the actual error. `PydanticAIHook` lets `get_connection` raise directly. At minimum, log a warning here; better to not catch at all and let connection errors surface. ########## providers/common/ai/src/airflow/providers/common/ai/hooks/adk.py: ########## @@ -0,0 +1,198 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import asyncio +import os +from typing import Any + +from google.adk.agents import Agent as ADKAgent +from google.adk.runners import Runner as ADKRunner +from google.adk.sessions import InMemorySessionService +from google.genai import types as genai_types + +from airflow.providers.common.compat.sdk import BaseHook + + +class AdkHook(BaseHook): + """ + Hook for LLM access via Google Agent Development Kit (ADK). + + Manages connection credentials and agent creation/execution. Uses + Google's ADK to orchestrate multi-turn agent interactions with Gemini + models. + + Connection fields: + - **password**: Google API key (for Gemini API access) + - **host**: Custom endpoint URL (optional — for custom/proxy endpoints) + + Cloud authentication via Application Default Credentials (ADC) is used + when no API key is provided. Configure ``GOOGLE_APPLICATION_CREDENTIALS`` + or run ``gcloud auth application-default login``. + + :param llm_conn_id: Airflow connection ID for the LLM provider. + :param model_id: Model identifier (e.g. ``"gemini-2.5-flash"``). + Overrides the model stored in the connection's extra field. + """ + + conn_name_attr = "llm_conn_id" + default_conn_name = "adk_default" + conn_type = "adk" + hook_name = "Google ADK" + + def __init__( + self, + llm_conn_id: str = default_conn_name, + model_id: str | None = None, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.llm_conn_id = llm_conn_id + self.model_id = model_id + self._configured: bool = False + + @staticmethod + def get_ui_field_behaviour() -> dict[str, Any]: + """Return custom field behaviour for the Airflow connection form.""" + return { + "hidden_fields": ["schema", "port", "login"], + "relabeling": {"password": "API Key"}, + "placeholders": { + "host": "(optional — for custom/proxy endpoints)", + }, + } + + def get_conn(self) -> str: + """ + Configure credentials and return the model identifier. + + Reads API key from connection password and model from (in priority + order): + + 1. ``model_id`` parameter on the hook + 2. ``extra["model"]`` on the connection + + When an API key is found in the connection, it is set as the + ``GOOGLE_API_KEY`` environment variable so the google-genai SDK + picks it up automatically. + + The result is cached for the lifetime of this hook instance. + + :return: The resolved model identifier string. + """ + if self._configured: + return self.model_id + + if self.llm_conn_id: + try: + conn = self.get_connection(self.llm_conn_id) + api_key = conn.password + if api_key: + os.environ.setdefault("GOOGLE_API_KEY", api_key) Review Comment: This leaks the API key into the process-wide environment. In a multi-tenant worker, the first task's key persists for all later tasks in the same process. `PydanticAIHook` avoids this by passing credentials to the provider constructor rather than setting env vars. The google-genai SDK accepts `api_key` in its client constructor — that's the safer path. ########## providers/common/ai/src/airflow/providers/common/ai/operators/adk_agent.py: ########## @@ -0,0 +1,111 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Operator for running Google ADK agents with tools and multi-turn reasoning.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from functools import cached_property +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel + +from airflow.providers.common.compat.sdk import BaseOperator + +if TYPE_CHECKING: + from airflow.providers.common.ai.hooks.adk import AdkHook + from airflow.sdk import Context + + +class AdkAgentOperator(BaseOperator): + """ + Run a Google ADK Agent with tools and multi-turn reasoning. + + Uses Google's Agent Development Kit (``google-adk``) to orchestrate + multi-turn agent interactions with Gemini models. Tools are supplied as + plain Python callables whose docstrings are exposed to the LLM. + + :param prompt: The prompt to send to the agent. + :param model_id: Model identifier (e.g. ``"gemini-2.5-flash"``). + :param llm_conn_id: Airflow connection ID for the LLM provider. + When provided, API key and model can be read from the connection. + :param system_prompt: System-level instructions for the agent. + :param output_type: Expected output type. Default ``str``. Set to a Pydantic + ``BaseModel`` subclass for structured output. + :param tools: List of plain callables exposed as tools to the agent. + Each callable's docstring is sent to the LLM so it knows when and + how to call it. + :param agent_params: Additional keyword arguments passed to the ADK + ``Agent`` constructor. + """ + + template_fields: Sequence[str] = ( + "prompt", + "llm_conn_id", + "model_id", + "system_prompt", + "agent_params", + ) + + def __init__( + self, + *, + prompt: str, + model_id: str, + llm_conn_id: str = "", + system_prompt: str = "", + output_type: type = str, Review Comment: `output_type` is accepted as a parameter and stored, but never passed to the hook or agent. The ADK agent is never told to produce structured output. Either wire it through or remove the parameter — as-is it silently does nothing. ########## providers/common/ai/src/airflow/providers/common/ai/hooks/adk.py: ########## @@ -0,0 +1,198 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import asyncio +import os +from typing import Any + +from google.adk.agents import Agent as ADKAgent +from google.adk.runners import Runner as ADKRunner +from google.adk.sessions import InMemorySessionService +from google.genai import types as genai_types + +from airflow.providers.common.compat.sdk import BaseHook + + +class AdkHook(BaseHook): + """ + Hook for LLM access via Google Agent Development Kit (ADK). + + Manages connection credentials and agent creation/execution. Uses + Google's ADK to orchestrate multi-turn agent interactions with Gemini + models. + + Connection fields: + - **password**: Google API key (for Gemini API access) + - **host**: Custom endpoint URL (optional — for custom/proxy endpoints) + + Cloud authentication via Application Default Credentials (ADC) is used + when no API key is provided. Configure ``GOOGLE_APPLICATION_CREDENTIALS`` + or run ``gcloud auth application-default login``. + + :param llm_conn_id: Airflow connection ID for the LLM provider. + :param model_id: Model identifier (e.g. ``"gemini-2.5-flash"``). + Overrides the model stored in the connection's extra field. + """ + + conn_name_attr = "llm_conn_id" + default_conn_name = "adk_default" + conn_type = "adk" + hook_name = "Google ADK" + + def __init__( + self, + llm_conn_id: str = default_conn_name, + model_id: str | None = None, + **kwargs, + ) -> None: + super().__init__(**kwargs) + self.llm_conn_id = llm_conn_id + self.model_id = model_id + self._configured: bool = False + + @staticmethod + def get_ui_field_behaviour() -> dict[str, Any]: + """Return custom field behaviour for the Airflow connection form.""" + return { + "hidden_fields": ["schema", "port", "login"], + "relabeling": {"password": "API Key"}, + "placeholders": { + "host": "(optional — for custom/proxy endpoints)", + }, + } + + def get_conn(self) -> str: + """ + Configure credentials and return the model identifier. + + Reads API key from connection password and model from (in priority + order): + + 1. ``model_id`` parameter on the hook + 2. ``extra["model"]`` on the connection + + When an API key is found in the connection, it is set as the + ``GOOGLE_API_KEY`` environment variable so the google-genai SDK + picks it up automatically. + + The result is cached for the lifetime of this hook instance. + + :return: The resolved model identifier string. + """ + if self._configured: + return self.model_id + + if self.llm_conn_id: + try: + conn = self.get_connection(self.llm_conn_id) + api_key = conn.password + if api_key: + os.environ.setdefault("GOOGLE_API_KEY", api_key) + model_name = self.model_id or conn.extra_dejson.get("model", "") + if model_name: + self.model_id = model_name + except Exception: + # Connection not found — fall back to env-based auth + pass + + if not self.model_id: + raise ValueError( + "No model specified. Set model_id on the hook or the Model field on the connection." + ) + + self._configured = True + return self.model_id + + def create_agent( + self, + *, + name: str = "airflow_agent", + instruction: str = "", + tools: list | None = None, + **agent_kwargs: Any, + ) -> ADKAgent: + """ + Create an ADK Agent configured with this hook's model. + + :param name: Agent name (default: ``"airflow_agent"``). + :param instruction: System-level instructions for the agent. + :param tools: List of callables exposed as tools to the LLM. + :param agent_kwargs: Additional keyword arguments passed to the + ADK ``Agent`` constructor. + :return: A configured ``google.adk.agents.Agent`` instance. + """ + self.get_conn() + return ADKAgent( + name=name, + model=self.model_id, + instruction=instruction, + tools=tools or [], + **agent_kwargs, + ) + + def run_agent_sync(self, *, agent: ADKAgent, prompt: str) -> str: + """ + Run an ADK agent synchronously and return the final response text. + + Creates an in-memory session, submits the prompt, iterates through + the async event stream, and collects the final response. + + :param agent: A configured ADK Agent instance. + :param prompt: The user prompt to send to the agent. + :return: The concatenated text of the agent's final response. + """ + session_service = InMemorySessionService() + runner = ADKRunner( + agent=agent, + app_name="airflow", + session_service=session_service, + ) + + async def _run() -> str: + session = await session_service.create_session(app_name="airflow", user_id="airflow") + content = genai_types.Content( + role="user", + parts=[genai_types.Part(text=prompt)], + ) + final_text = "" + async for event in runner.run_async( + user_id="airflow", + session_id=session.id, + new_message=content, + ): + if event.is_final_response() and event.content and event.content.parts: + for part in event.content.parts: + if part.text: + final_text += part.text + return final_text + + return asyncio.run(_run()) Review Comment: `asyncio.run()` raises `RuntimeError` if there's already a running event loop (triggerer, Jupyter, some executors). Needs a fallback — check for a running loop and use `loop.run_until_complete()` or `asyncio.get_event_loop().run_until_complete()` instead. ########## providers/common/ai/src/airflow/providers/common/ai/operators/adk_agent.py: ########## @@ -0,0 +1,111 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Operator for running Google ADK agents with tools and multi-turn reasoning.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from functools import cached_property +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel + +from airflow.providers.common.compat.sdk import BaseOperator + +if TYPE_CHECKING: + from airflow.providers.common.ai.hooks.adk import AdkHook + from airflow.sdk import Context + + +class AdkAgentOperator(BaseOperator): + """ + Run a Google ADK Agent with tools and multi-turn reasoning. + + Uses Google's Agent Development Kit (``google-adk``) to orchestrate + multi-turn agent interactions with Gemini models. Tools are supplied as + plain Python callables whose docstrings are exposed to the LLM. + + :param prompt: The prompt to send to the agent. + :param model_id: Model identifier (e.g. ``"gemini-2.5-flash"``). + :param llm_conn_id: Airflow connection ID for the LLM provider. + When provided, API key and model can be read from the connection. + :param system_prompt: System-level instructions for the agent. + :param output_type: Expected output type. Default ``str``. Set to a Pydantic + ``BaseModel`` subclass for structured output. + :param tools: List of plain callables exposed as tools to the agent. + Each callable's docstring is sent to the LLM so it knows when and + how to call it. + :param agent_params: Additional keyword arguments passed to the ADK + ``Agent`` constructor. + """ + + template_fields: Sequence[str] = ( + "prompt", + "llm_conn_id", + "model_id", + "system_prompt", + "agent_params", + ) + + def __init__( + self, + *, + prompt: str, + model_id: str, + llm_conn_id: str = "", + system_prompt: str = "", + output_type: type = str, + tools: list[Callable] | None = None, + agent_params: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + + self.prompt = prompt + self.model_id = model_id + self.llm_conn_id = llm_conn_id + self.system_prompt = system_prompt + self.output_type = output_type + self.tools = tools + self.agent_params = agent_params or {} + + @cached_property + def hook(self) -> AdkHook: + """Return AdkHook for the configured connection.""" + from airflow.providers.common.ai.hooks.adk import AdkHook + + return AdkHook(llm_conn_id=self.llm_conn_id, model_id=self.model_id) + + def execute(self, context: Context) -> Any: + extra_kwargs = dict(self.agent_params) + adk_tools = list(self.tools or []) + if adk_tools: + extra_kwargs["tools"] = adk_tools + + agent = self.hook.create_agent( + instruction=self.system_prompt, + description=extra_kwargs.pop("description", "Airflow AdkAgentOperator agent"), Review Comment: `description` is silently popped from `agent_params` with a hardcoded default. If it's a meaningful parameter, expose it explicitly on the operator. If it's just ADK boilerplate, document why the default exists. Silently extracting params from the pass-through dict is surprising behavior. ########## providers/common/ai/src/airflow/providers/common/ai/operators/adk_agent.py: ########## @@ -0,0 +1,111 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Operator for running Google ADK agents with tools and multi-turn reasoning.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from functools import cached_property +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel + +from airflow.providers.common.compat.sdk import BaseOperator + +if TYPE_CHECKING: + from airflow.providers.common.ai.hooks.adk import AdkHook + from airflow.sdk import Context + + +class AdkAgentOperator(BaseOperator): + """ + Run a Google ADK Agent with tools and multi-turn reasoning. + + Uses Google's Agent Development Kit (``google-adk``) to orchestrate + multi-turn agent interactions with Gemini models. Tools are supplied as + plain Python callables whose docstrings are exposed to the LLM. + + :param prompt: The prompt to send to the agent. + :param model_id: Model identifier (e.g. ``"gemini-2.5-flash"``). + :param llm_conn_id: Airflow connection ID for the LLM provider. + When provided, API key and model can be read from the connection. + :param system_prompt: System-level instructions for the agent. + :param output_type: Expected output type. Default ``str``. Set to a Pydantic + ``BaseModel`` subclass for structured output. + :param tools: List of plain callables exposed as tools to the agent. + Each callable's docstring is sent to the LLM so it knows when and + how to call it. + :param agent_params: Additional keyword arguments passed to the ADK + ``Agent`` constructor. + """ + + template_fields: Sequence[str] = ( + "prompt", + "llm_conn_id", + "model_id", + "system_prompt", + "agent_params", + ) + + def __init__( + self, + *, + prompt: str, + model_id: str, + llm_conn_id: str = "", Review Comment: Empty string default diverges from the existing convention where `llm_conn_id` is required. If the intent is "connection optional, use env-based auth," make that explicit with `None` as default and document the behavior. ########## providers/common/ai/src/airflow/providers/common/ai/operators/TODO.md: ########## @@ -0,0 +1,3 @@ +- Ajustar Logging toolset operador Review Comment: Dev notes committed to the source tree — should be removed. ########## providers/common/ai/src/airflow/providers/common/ai/operators/adk_agent.py: ########## @@ -0,0 +1,111 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Operator for running Google ADK agents with tools and multi-turn reasoning.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from functools import cached_property +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel + +from airflow.providers.common.compat.sdk import BaseOperator + +if TYPE_CHECKING: + from airflow.providers.common.ai.hooks.adk import AdkHook + from airflow.sdk import Context + + +class AdkAgentOperator(BaseOperator): + """ + Run a Google ADK Agent with tools and multi-turn reasoning. + + Uses Google's Agent Development Kit (``google-adk``) to orchestrate + multi-turn agent interactions with Gemini models. Tools are supplied as + plain Python callables whose docstrings are exposed to the LLM. + + :param prompt: The prompt to send to the agent. + :param model_id: Model identifier (e.g. ``"gemini-2.5-flash"``). + :param llm_conn_id: Airflow connection ID for the LLM provider. + When provided, API key and model can be read from the connection. + :param system_prompt: System-level instructions for the agent. + :param output_type: Expected output type. Default ``str``. Set to a Pydantic + ``BaseModel`` subclass for structured output. + :param tools: List of plain callables exposed as tools to the agent. + Each callable's docstring is sent to the LLM so it knows when and + how to call it. + :param agent_params: Additional keyword arguments passed to the ADK + ``Agent`` constructor. + """ + + template_fields: Sequence[str] = ( + "prompt", + "llm_conn_id", + "model_id", + "system_prompt", + "agent_params", + ) + + def __init__( + self, + *, + prompt: str, + model_id: str, + llm_conn_id: str = "", + system_prompt: str = "", + output_type: type = str, + tools: list[Callable] | None = None, + agent_params: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + + self.prompt = prompt + self.model_id = model_id + self.llm_conn_id = llm_conn_id + self.system_prompt = system_prompt + self.output_type = output_type + self.tools = tools + self.agent_params = agent_params or {} + + @cached_property + def hook(self) -> AdkHook: Review Comment: The existing `AgentOperator` names this `llm_hook`. Using `hook` here creates an inconsistent API across the provider. Should align with the established convention. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
