-
Notifications
You must be signed in to change notification settings - Fork 15
[python] PoC: AI Guard test under INTEGRATION_FRAMEWORKS #7257
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
avara1986
merged 15 commits into
main
from
avara/appsec-68977-integration-frameworks-ai-guard-poc
Jul 20, 2026
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
4de1a84
Add AI Guard<->OpenAI integration test under INTEGRATION_FRAMEWORKS (…
avara1986 885e13f
Use real AI Guard keys when recording cassettes (APPSEC-68977)
avara1986 e19802e
Record openai + aiguard cassettes for TestOpenAiAiGuard (APPSEC-68977)
avara1986 61966c4
Merge branch 'main' into avara/appsec-68977-integration-frameworks-ai…
avara1986 824409e
Apply suggestion from @avara1986
avara1986 8ddfd08
fix codestyle
avara1986 155c452
fix test
avara1986 d95adff
fix test
avara1986 d33bff7
Merge branch 'main' into avara/appsec-68977-integration-frameworks-ai…
avara1986 0656125
update tests
avara1986 fbb1373
fix(ai_guard): address PoC review findings on the INTEGRATION_FRAMEWO…
avara1986 3e817df
style: apply ruff format to test_openai_ai_guard.py
avara1986 bc915c9
test(ai_guard): drop after-model streaming test to fix Finding 1
avara1986 4682af8
Merge branch 'main' into avara/appsec-68977-integration-frameworks-ai…
avara1986 4efb9f3
Merge branch 'main' into avara/appsec-68977-integration-frameworks-ai…
avara1986 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
183 changes: 183 additions & 0 deletions
183
tests/integration_frameworks/llm/openai/test_openai_ai_guard.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| """AI Guard <-> OpenAI integration tests, run under the INTEGRATION_FRAMEWORKS scenario. | ||
|
|
||
| Investigation task: https://datadoghq.atlassian.net/browse/APPSEC-68977 | ||
|
|
||
| Unlike ``tests/ai_guard/test_ai_guard_sdk.py`` (which drives the AI Guard SDK directly via | ||
| ``/ai_guard/evaluate``), this suite exercises the *integration* between AI Guard and the | ||
| OpenAI client, the same way the LLM Observability suite does: it calls the OpenAI SDK | ||
| directly through the existing weblog endpoints (``/chat/completions``). When | ||
| ``DD_AI_GUARD_ENABLED=true``, ``ai_guard_listen()`` auto-wires into the OpenAI SDK, so AI | ||
| Guard evaluates the call at three points with no manual ``evaluate()`` call: | ||
|
|
||
| - **before-model**: the request/prompt is evaluated before the model is called; | ||
| - **tool-call**: tool calls produced by the model are evaluated. | ||
|
|
||
| The after-model evaluation is intentionally not covered here: exercising it end-to-end needs | ||
| the streamed-response path (``DD_AI_GUARD_ANALYZE_STREAM_RESPONSES_ENABLED``), which is not | ||
| implemented across the other tracer libraries yet, so we keep this suite at cross-language | ||
| parity. | ||
|
|
||
| We assert that the integration wires each evaluation point: that it emits an ``ai_guard`` | ||
| span for the specific evaluation being exercised (identified by ``ai_guard.target`` and by | ||
| the messages captured in ``meta_struct.ai_guard``) and tags the local root span with | ||
| ``ai_guard.event:true``. We do not assert trace *linkage* to the ``openai.request`` span: | ||
| the tracer does not deterministically nest the ``ai_guard`` span in the OpenAI trace (it | ||
| may be emitted as its own trace), so a shared ``trace_id`` is not guaranteed. The | ||
| evaluation *outcome* (ALLOW / DENY / ABORT) is already covered by the ``AI_GUARD`` scenario | ||
| and is intentionally not re-asserted here. | ||
| """ | ||
|
|
||
| import time | ||
|
|
||
| import pytest | ||
| import requests | ||
|
|
||
| from utils import features, scenarios | ||
| from utils.docker_fixtures import FrameworkTestClientApi, TestAgentAPI | ||
|
|
||
| from .utils import TOOLS, BaseOpenaiTest | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def library_env() -> dict[str, str]: | ||
| # The AI Guard client also needs DD_API_KEY / DD_APP_KEY, but those are injected via the | ||
| # scenario environment (see IntegrationFrameworksScenario._required_cassette_generation_api_keys) | ||
| # rather than here: library_env is copied into the JSON report metadata, so keeping secrets | ||
| # out of it prevents real keys from leaking into logs/artifacts during cassette generation. | ||
| return { | ||
| "DD_AI_GUARD_ENABLED": "true", | ||
| } | ||
|
|
||
|
|
||
| def _ai_guard_spans(traces: list[list[dict]]) -> list[dict]: | ||
| return [span for trace in traces for span in trace if span.get("resource") == "ai_guard"] | ||
|
|
||
|
|
||
| def _guard_messages(span: dict) -> list[dict]: | ||
| """The messages AI Guard evaluated, as captured in ``meta_struct.ai_guard.messages``.""" | ||
| return span.get("meta_struct", {}).get("ai_guard", {}).get("messages", []) | ||
|
|
||
|
|
||
| def _ai_guard_event_root_spans(traces: list[list[dict]]) -> list[dict]: | ||
| """Local root (service-entry) spans tagged ``ai_guard.event:true``. | ||
|
|
||
| When AI Guard evaluates a call it tags the trace's local root span with | ||
| ``ai_guard.event:true`` (dd-trace-py ``appsec/ai_guard/_api_client.py``). This is a | ||
| tracer-emitted marker that AI Guard ran on the trace, and is what we assert on here. | ||
|
|
||
| Note: the ``_dd.ai_guard.enabled:1`` facet that is searchable in the Datadog UI is NOT | ||
| present in the raw payloads captured by the test agent (it is not emitted by the tracer; | ||
| it is produced somewhere in intake), so it cannot be asserted on directly. | ||
| """ | ||
| return [ | ||
| span | ||
| for trace in traces | ||
| for span in trace | ||
| if span.get("parent_id") in (0, None) and span.get("meta", {}).get("ai_guard.event", False) in (True, "true") | ||
| ] | ||
|
|
||
|
|
||
| def _wait_for_ai_guard_spans( | ||
| test_agent: TestAgentAPI, *, target: str | None = None, wait_loops: int = 30 | ||
| ) -> list[dict]: | ||
| """Poll the test agent until at least one matching ``ai_guard`` span is received. | ||
|
|
||
| We assert on the presence of the ``ai_guard`` span rather than on a fixed number of | ||
| traces: the tracer does not deterministically group the ``ai_guard`` span with the | ||
| OpenAI span. The ``ai_guard`` span may be emitted either nested in the OpenAI trace | ||
| (1 trace) or as its own trace (2 traces), so ``wait_for_num_traces`` with a hard-coded | ||
| count is inherently racy. When ``target`` is given, only spans whose ``ai_guard.target`` | ||
| matches are considered (so we keep polling until the specific evaluation point we care | ||
| about has arrived). | ||
| """ | ||
| spans: list[dict] = [] | ||
| for _ in range(wait_loops): | ||
| try: | ||
| traces = test_agent.traces(clear=False) | ||
| except requests.exceptions.RequestException: | ||
| pass | ||
| else: | ||
| spans = _ai_guard_spans(traces) | ||
| if target is not None: | ||
| spans = [span for span in spans if span["meta"].get("ai_guard.target") == target] | ||
| if spans: | ||
| return spans | ||
| time.sleep(0.1) | ||
| return spans | ||
|
|
||
|
|
||
| def _wait_for_ai_guard_event_root_spans(test_agent: TestAgentAPI, *, wait_loops: int = 30) -> list[dict]: | ||
| """Poll the test agent until at least one root span tagged ``ai_guard.event:true`` arrives. | ||
|
|
||
| Like the ``ai_guard`` span itself, the tagged local root span may land in a later trace | ||
| chunk than the evaluation span, so we poll rather than reading a single snapshot. | ||
| """ | ||
| spans: list[dict] = [] | ||
| for _ in range(wait_loops): | ||
| try: | ||
| traces = test_agent.traces(clear=False) | ||
| except requests.exceptions.RequestException: | ||
| pass | ||
| else: | ||
| spans = _ai_guard_event_root_spans(traces) | ||
| if spans: | ||
| return spans | ||
| time.sleep(0.1) | ||
| return spans | ||
|
|
||
|
|
||
| @features.ai_guard | ||
| @scenarios.integration_frameworks | ||
| class TestOpenAiAiGuard(BaseOpenaiTest): | ||
| """AI Guard evaluation triggered through the auto-instrumented OpenAI integration.""" | ||
|
|
||
| def test_before_model_validation(self, test_agent: TestAgentAPI, test_client: FrameworkTestClientApi): | ||
| """The prompt is evaluated by AI Guard before the OpenAI model is called.""" | ||
| with test_agent.vcr_context(): | ||
| test_client.request( | ||
| "POST", | ||
| "/chat/completions", | ||
| dict( | ||
| model="gpt-4o-mini", | ||
| messages=[{"role": "user", "content": "What is the weather like today?"}], | ||
| parameters=dict(max_tokens=35), | ||
| ), | ||
| ) | ||
|
|
||
| guard_spans = _wait_for_ai_guard_spans(test_agent, target="prompt") | ||
| assert guard_spans, "expected a before-model ai_guard span with target 'prompt'" | ||
|
|
||
| event_root_spans = _wait_for_ai_guard_event_root_spans(test_agent) | ||
| assert event_root_spans, "expected a local root span tagged ai_guard.event:true" | ||
|
|
||
| def test_tool_call_validation(self, test_agent: TestAgentAPI, test_client: FrameworkTestClientApi): | ||
| """Tool calls produced by the model are evaluated by AI Guard.""" | ||
| with test_agent.vcr_context(): | ||
| test_client.request( | ||
| "POST", | ||
| "/chat/completions", | ||
| dict( | ||
| model="gpt-4o-mini", | ||
| messages=[ | ||
| { | ||
| "role": "user", | ||
| "content": "Bob is a student at Stanford University. He is studying computer science.", | ||
| } | ||
| ], | ||
| parameters=dict(tool_choice="auto", tools=TOOLS), | ||
| ), | ||
| ) | ||
|
|
||
| guard_spans = _wait_for_ai_guard_spans(test_agent, target="tool") | ||
| assert guard_spans, "expected a tool-call ai_guard span with target 'tool'" | ||
|
avara1986 marked this conversation as resolved.
|
||
| # ``target == "tool"`` alone can also come from an ordinary after-model eval of an | ||
| # assistant response, so require the assistant tool_calls entry to actually be in the | ||
| # payload sent to AI Guard - that is what proves the tool-call path was forwarded. | ||
| assert any( | ||
| msg.get("role") == "assistant" and msg.get("tool_calls") | ||
| for span in guard_spans | ||
| for msg in _guard_messages(span) | ||
| ), "expected the assistant tool_calls entry in the ai_guard evaluation payload" | ||
|
|
||
| event_root_spans = _wait_for_ai_guard_event_root_spans(test_agent) | ||
| assert event_root_spans, "expected a local root span tagged ai_guard.event:true" | ||
38 changes: 38 additions & 0 deletions
38
...ls/vcr-cassettes/aiguard/test_before_model_validation_aiguard_evaluate_post_ca44130a.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| { | ||
| "request": { | ||
| "method": "POST", | ||
| "url": "https://app.datadoghq.com/api/v2/ai-guard/evaluate", | ||
| "headers": { | ||
| "Accept-Encoding": "identity", | ||
| "Content-Length": "148", | ||
| "Content-Type": "application/json", | ||
| "DD-AI-GUARD-VERSION": "4.10.6", | ||
| "DD-AI-GUARD-SOURCE": "SDK", | ||
| "DD-AI-GUARD-LANGUAGE": "python", | ||
| "Datadog-Entity-ID": "in-115200" | ||
| }, | ||
| "body": "{\"data\": {\"attributes\": {\"messages\": [{\"role\": \"user\", \"content\": \"What is the weather like today?\"}], \"meta\": {\"service\": \"openai\", \"env\": null}}}}" | ||
| }, | ||
| "response": { | ||
| "status": { | ||
| "code": 200, | ||
| "message": "OK" | ||
| }, | ||
| "headers": { | ||
| "content-security-policy": "frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com", | ||
| "content-type": "application/vnd.api+json", | ||
| "vary": "Accept-Encoding", | ||
| "x-frame-options": "SAMEORIGIN", | ||
| "content-length": "216", | ||
| "date": "Fri, 03 Jul 2026 14:49:53 GMT", | ||
| "x-content-type-options": "nosniff", | ||
| "strict-transport-security": "max-age=31536000; includeSubDomains; preload", | ||
| "x-ratelimit-limit": "5000", | ||
| "x-ratelimit-period": "60", | ||
| "x-ratelimit-remaining": "4999", | ||
| "x-ratelimit-reset": "7", | ||
| "x-ratelimit-name": "ai_guard_evaluate_per_org" | ||
| }, | ||
| "body": "{\"data\":{\"id\":\"f0863f0c-9c8c-4041-b178-d932f449d0f2\",\"type\":\"evaluations\",\"attributes\":{\"action\":\"ALLOW\",\"global_prob\":0.00244140625,\"is_blocking_enabled\":false,\"reason\":\"No rule match.\",\"tag_probs\":null,\"tags\":[]}}}" | ||
| } | ||
| } |
38 changes: 38 additions & 0 deletions
38
...ls/vcr-cassettes/aiguard/test_before_model_validation_aiguard_evaluate_post_d13cc790.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| { | ||
| "request": { | ||
| "method": "POST", | ||
| "url": "https://app.datadoghq.com/api/v2/ai-guard/evaluate", | ||
| "headers": { | ||
| "Accept-Encoding": "identity", | ||
| "Content-Length": "394", | ||
| "Content-Type": "application/json", | ||
| "DD-AI-GUARD-VERSION": "4.10.6", | ||
| "DD-AI-GUARD-SOURCE": "SDK", | ||
| "DD-AI-GUARD-LANGUAGE": "python", | ||
| "Datadog-Entity-ID": "in-115200" | ||
| }, | ||
| "body": "{\"data\": {\"attributes\": {\"messages\": [{\"role\": \"user\", \"content\": \"What is the weather like today?\"}, {\"role\": \"assistant\", \"content\": \"I'm unable to provide real-time information, including current weather updates. I recommend checking a reliable weather website or app for the most accurate and up-to-date information about today's weather in\"}], \"meta\": {\"service\": \"openai\", \"env\": null}}}}" | ||
| }, | ||
| "response": { | ||
| "status": { | ||
| "code": 200, | ||
| "message": "OK" | ||
| }, | ||
| "headers": { | ||
| "content-security-policy": "frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com", | ||
| "content-type": "application/vnd.api+json", | ||
| "vary": "Accept-Encoding", | ||
| "x-frame-options": "SAMEORIGIN", | ||
| "content-length": "221", | ||
| "date": "Fri, 03 Jul 2026 14:49:58 GMT", | ||
| "x-content-type-options": "nosniff", | ||
| "strict-transport-security": "max-age=31536000; includeSubDomains; preload", | ||
| "x-ratelimit-limit": "5000", | ||
| "x-ratelimit-period": "60", | ||
| "x-ratelimit-remaining": "4998", | ||
| "x-ratelimit-reset": "2", | ||
| "x-ratelimit-name": "ai_guard_evaluate_per_org" | ||
| }, | ||
| "body": "{\"data\":{\"id\":\"6f8dceb7-054d-4d1f-8e35-7c4487977669\",\"type\":\"evaluations\",\"attributes\":{\"action\":\"ALLOW\",\"global_prob\":0.0013275146484375,\"is_blocking_enabled\":false,\"reason\":\"No rule match.\",\"tag_probs\":null,\"tags\":[]}}}" | ||
| } | ||
| } |
38 changes: 38 additions & 0 deletions
38
...utils/vcr-cassettes/aiguard/test_tool_call_validation_aiguard_evaluate_post_9e556a11.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| { | ||
| "request": { | ||
| "method": "POST", | ||
| "url": "https://app.datadoghq.com/api/v2/ai-guard/evaluate", | ||
| "headers": { | ||
| "Accept-Encoding": "identity", | ||
| "Content-Length": "417", | ||
| "Content-Type": "application/json", | ||
| "DD-AI-GUARD-VERSION": "4.10.6", | ||
| "DD-AI-GUARD-SOURCE": "SDK", | ||
| "DD-AI-GUARD-LANGUAGE": "python", | ||
| "Datadog-Entity-ID": "in-115716" | ||
| }, | ||
| "body": "{\"data\": {\"attributes\": {\"messages\": [{\"role\": \"user\", \"content\": \"Bob is a student at Stanford University. He is studying computer science.\"}, {\"role\": \"assistant\", \"tool_calls\": [{\"id\": \"call_C5dXIRBkTQjYoQrBr3WrkQYY\", \"function\": {\"name\": \"extract_student_info\", \"arguments\": \"{\\\"name\\\":\\\"Bob\\\",\\\"major\\\":\\\"computer science\\\",\\\"school\\\":\\\"Stanford University\\\"}\"}}]}], \"meta\": {\"service\": \"openai\", \"env\": null}}}}" | ||
| }, | ||
| "response": { | ||
| "status": { | ||
| "code": 200, | ||
| "message": "OK" | ||
| }, | ||
| "headers": { | ||
| "content-security-policy": "frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com", | ||
| "content-type": "application/vnd.api+json", | ||
| "vary": "Accept-Encoding", | ||
| "x-frame-options": "SAMEORIGIN", | ||
| "content-length": "219", | ||
| "date": "Fri, 03 Jul 2026 14:50:13 GMT", | ||
| "x-content-type-options": "nosniff", | ||
| "strict-transport-security": "max-age=31536000; includeSubDomains; preload", | ||
| "x-ratelimit-limit": "5000", | ||
| "x-ratelimit-period": "60", | ||
| "x-ratelimit-remaining": "4997", | ||
| "x-ratelimit-reset": "47", | ||
| "x-ratelimit-name": "ai_guard_evaluate_per_org" | ||
| }, | ||
| "body": "{\"data\":{\"id\":\"4aefdbd6-761e-4bb5-ba69-f543586e8b4e\",\"type\":\"evaluations\",\"attributes\":{\"action\":\"ALLOW\",\"global_prob\":0.00628662109375,\"is_blocking_enabled\":false,\"reason\":\"No rule match.\",\"tag_probs\":null,\"tags\":[]}}}" | ||
| } | ||
| } |
38 changes: 38 additions & 0 deletions
38
...utils/vcr-cassettes/aiguard/test_tool_call_validation_aiguard_evaluate_post_a304603c.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| { | ||
| "request": { | ||
| "method": "POST", | ||
| "url": "https://app.datadoghq.com/api/v2/ai-guard/evaluate", | ||
| "headers": { | ||
| "Accept-Encoding": "identity", | ||
| "Content-Length": "190", | ||
| "Content-Type": "application/json", | ||
| "DD-AI-GUARD-VERSION": "4.10.6", | ||
| "DD-AI-GUARD-SOURCE": "SDK", | ||
| "DD-AI-GUARD-LANGUAGE": "python", | ||
| "Datadog-Entity-ID": "in-115716" | ||
| }, | ||
| "body": "{\"data\": {\"attributes\": {\"messages\": [{\"role\": \"user\", \"content\": \"Bob is a student at Stanford University. He is studying computer science.\"}], \"meta\": {\"service\": \"openai\", \"env\": null}}}}" | ||
| }, | ||
| "response": { | ||
| "status": { | ||
| "code": 200, | ||
| "message": "OK" | ||
| }, | ||
| "headers": { | ||
| "content-security-policy": "frame-ancestors 'self'; report-uri https://logs.browser-intake-datadoghq.com/api/v2/logs?dd-api-key=pube4f163c23bbf91c16b8f57f56af9fc58&dd-evp-origin=content-security-policy&ddsource=csp-report&ddtags=site%3Adatadoghq.com", | ||
| "content-type": "application/vnd.api+json", | ||
| "vary": "Accept-Encoding", | ||
| "x-frame-options": "SAMEORIGIN", | ||
| "content-length": "219", | ||
| "date": "Fri, 03 Jul 2026 14:50:11 GMT", | ||
| "x-content-type-options": "nosniff", | ||
| "strict-transport-security": "max-age=31536000; includeSubDomains; preload", | ||
| "x-ratelimit-limit": "5000", | ||
| "x-ratelimit-period": "60", | ||
| "x-ratelimit-remaining": "4998", | ||
| "x-ratelimit-reset": "49", | ||
| "x-ratelimit-name": "ai_guard_evaluate_per_org" | ||
| }, | ||
| "body": "{\"data\":{\"id\":\"c3372620-7e64-47c1-bbd6-63a2c664334c\",\"type\":\"evaluations\",\"attributes\":{\"action\":\"ALLOW\",\"global_prob\":0.00885009765625,\"is_blocking_enabled\":false,\"reason\":\"No rule match.\",\"tag_probs\":null,\"tags\":[]}}}" | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.