Skip to content

Commit a445997

Browse files
committed
refactor(github): convert github utils to TaskFlow @task, chain, and decouple dispatches
1 parent 2c38a3e commit a445997

3 files changed

Lines changed: 235 additions & 43 deletions

File tree

dags/multipod/maxtext_e2e_tests.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
"""
2020
import datetime
2121
from airflow import models
22+
from airflow.models.baseoperator import chain
2223
from airflow.models.param import Param
23-
from airflow.operators.python import PythonOperator
2424
from airflow.operators.trigger_dagrun import TriggerDagRunOperator
2525
from airflow.utils.trigger_rule import TriggerRule
26-
from xlml.utils.github import fire_github_callback, validate_git_trigger
26+
from xlml.utils.github import trigger_github_repository_dispatch, validate_git_trigger
2727

2828
with models.DAG(
2929
dag_id="maxtext_e2e_tests",
@@ -41,7 +41,7 @@
4141
type="string",
4242
description="Build mode: stable or nightly",
4343
),
44-
"maxtext_sha": Param(
44+
"commit_sha": Param(
4545
type="string",
4646
description="Commit SHA being tested",
4747
),
@@ -53,7 +53,7 @@
5353
type="string",
5454
description="GitHub repository in owner/repo format",
5555
),
56-
"github_callback_token": Param(
56+
"github_token": Param(
5757
type="string",
5858
description="GitHub PAT used to fire the repository_dispatch callback",
5959
),
@@ -79,24 +79,25 @@
7979
poke_interval=600, # check every 10 minutes for child DAG completion
8080
)
8181

82-
github_callback_pre_training = PythonOperator(
82+
github_callback_pre_training = trigger_github_repository_dispatch.override(
8383
task_id="fire_github_callback_pre_training",
84-
python_callable=fire_github_callback,
85-
op_kwargs={"test_type": "pre_training"},
8684
trigger_rule=TriggerRule.ALL_SUCCESS,
87-
)
85+
)(test_type="pre_training")
8886

89-
github_callback_post_training = PythonOperator(
87+
github_callback_post_training = trigger_github_repository_dispatch.override(
9088
task_id="fire_github_callback_post_training",
91-
python_callable=fire_github_callback,
92-
op_kwargs={"test_type": "post_training"},
9389
trigger_rule=TriggerRule.ALL_SUCCESS,
94-
)
90+
)(test_type="post_training")
9591

96-
validate_task = PythonOperator(
97-
task_id="validate_trigger",
98-
python_callable=validate_git_trigger,
99-
)
92+
validate_task = validate_git_trigger()
10093

101-
(validate_task >> trigger_pre_training >> github_callback_pre_training)
102-
(validate_task >> trigger_post_training >> github_callback_post_training)
94+
chain(
95+
validate_task,
96+
trigger_pre_training,
97+
github_callback_pre_training,
98+
)
99+
chain(
100+
validate_task,
101+
trigger_post_training,
102+
github_callback_post_training,
103+
)

xlml/utils/github.py

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,54 +14,89 @@
1414

1515
"""Utilities for GitHub API integration."""
1616

17+
from typing import Any
1718
import requests
19+
from airflow.decorators import task
1820
from airflow.exceptions import AirflowFailException
21+
from airflow.operators.python import get_current_context
1922

2023

21-
def validate_git_trigger(**context):
22-
"""Validates that the DAG run has required GitHub parameters.
24+
@task
25+
def validate_git_trigger() -> None:
26+
"""Validates that the DAG run has required GitHub parameters and was externally triggered."""
27+
context = get_current_context()
28+
dag_run = context.get("dag_run")
2329

24-
Prevents manual runs from the Airflow UI by ensuring github_run_id,
25-
github_repo, and github_callback_token are present.
26-
"""
27-
params = context["params"]
30+
if not dag_run or not dag_run.external_trigger:
31+
raise AirflowFailException(
32+
"This DAG should not be run manually from the Airflow UI."
33+
)
34+
35+
params = context.get("params", {})
2836
run_id = params.get("github_run_id")
2937
repo = params.get("github_repo")
30-
token = params.get("github_callback_token")
38+
token = params.get("github_token") or params.get("github_callback_token")
3139

3240
if not run_id or not repo or not token:
3341
raise AirflowFailException(
34-
"Missing required GitHub parameters (run_id, repo, token). "
35-
"This DAG should not be run manually from the Airflow UI."
42+
"Missing required GitHub parameters (run_id, repo, token)."
3643
)
3744

3845

39-
def fire_github_callback(test_type: str | None = None, **context):
46+
@task
47+
def trigger_github_repository_dispatch(
48+
test_type: str | None = None,
49+
repo: str | None = None,
50+
token: str | None = None,
51+
event_type: str = "airflow-dag-complete",
52+
client_payload: dict[str, Any] | None = None,
53+
) -> None:
4054
"""Fires a GitHub repository_dispatch callback with the DAG run result."""
41-
params = context["params"]
42-
dag_run = context["dag_run"]
55+
context = get_current_context()
56+
params = context.get("params", {})
57+
dag_run = context.get("dag_run")
58+
59+
target_repo = repo or params.get("github_repo")
60+
target_token = (
61+
token or params.get("github_token") or params.get("github_callback_token")
62+
)
63+
64+
if not target_repo or not target_token:
65+
raise AirflowFailException(
66+
"Missing required GitHub parameters (repo, token) to fire callback."
67+
)
4368

44-
client_payload = {
45-
"state": "success",
46-
"dag_id": dag_run.dag_id,
47-
"dag_run_id": dag_run.run_id,
48-
"sha": params["maxtext_sha"],
49-
"github_run_id": params["github_run_id"],
50-
}
51-
if test_type:
52-
client_payload["test_type"] = test_type
69+
if client_payload is None:
70+
payload: dict[str, Any] = {
71+
"state": "success",
72+
"dag_id": dag_run.dag_id if dag_run else None,
73+
"dag_run_id": dag_run.run_id if dag_run else None,
74+
"sha": params.get("commit_sha")
75+
or params.get("maxtext_sha")
76+
or params.get("sha"),
77+
"github_run_id": params.get("github_run_id"),
78+
}
79+
if test_type:
80+
payload["test_type"] = test_type
81+
else:
82+
payload = dict(client_payload)
83+
if test_type and "test_type" not in payload:
84+
payload["test_type"] = test_type
5385

5486
response = requests.post(
55-
f"https://api.github.com/repos/{params['github_repo']}/dispatches",
87+
f"https://api.github.com/repos/{target_repo}/dispatches",
5688
headers={
57-
"Authorization": f"Bearer {params['github_callback_token']}",
89+
"Authorization": f"Bearer {target_token}",
5890
"Accept": "application/vnd.github+json",
5991
"X-GitHub-Api-Version": "2022-11-28",
6092
},
6193
json={
62-
"event_type": "airflow-dag-complete",
63-
"client_payload": client_payload,
94+
"event_type": event_type,
95+
"client_payload": payload,
6496
},
6597
timeout=30,
6698
)
6799
response.raise_for_status()
100+
101+
102+
fire_github_callback = trigger_github_repository_dispatch

xlml/utils/github_test.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for github.py."""
16+
17+
import unittest
18+
from unittest import mock
19+
from airflow.exceptions import AirflowFailException
20+
from xlml.utils import github
21+
22+
23+
class GithubTest(unittest.TestCase):
24+
25+
@mock.patch("xlml.utils.github.get_current_context")
26+
def test_validate_git_trigger_success(self, mock_context):
27+
mock_dag_run = mock.MagicMock()
28+
mock_dag_run.external_trigger = True
29+
mock_context.return_value = {
30+
"dag_run": mock_dag_run,
31+
"params": {
32+
"github_run_id": "12345",
33+
"github_repo": "owner/repo",
34+
"github_token": "secret_token",
35+
},
36+
}
37+
# Should not raise exception
38+
github.validate_git_trigger.function()
39+
40+
@mock.patch("xlml.utils.github.get_current_context")
41+
def test_validate_git_trigger_manual_run(self, mock_context):
42+
mock_dag_run = mock.MagicMock()
43+
mock_dag_run.external_trigger = False
44+
mock_context.return_value = {
45+
"dag_run": mock_dag_run,
46+
"params": {
47+
"github_run_id": "12345",
48+
"github_repo": "owner/repo",
49+
"github_token": "secret_token",
50+
},
51+
}
52+
with self.assertRaises(AirflowFailException):
53+
github.validate_git_trigger.function()
54+
55+
@mock.patch("xlml.utils.github.get_current_context")
56+
def test_validate_git_trigger_missing_params(self, mock_context):
57+
mock_dag_run = mock.MagicMock()
58+
mock_dag_run.external_trigger = True
59+
mock_context.return_value = {
60+
"dag_run": mock_dag_run,
61+
"params": {
62+
"github_run_id": "12345",
63+
# missing repo and token
64+
},
65+
}
66+
with self.assertRaises(AirflowFailException):
67+
github.validate_git_trigger.function()
68+
69+
@mock.patch("requests.post")
70+
@mock.patch("xlml.utils.github.get_current_context")
71+
def test_fire_github_callback_success(self, mock_context, mock_post):
72+
mock_dag_run = mock.MagicMock()
73+
mock_dag_run.dag_id = "test_dag"
74+
mock_dag_run.run_id = "manual__1"
75+
76+
mock_context.return_value = {
77+
"dag_run": mock_dag_run,
78+
"params": {
79+
"github_repo": "owner/repo",
80+
"github_run_id": "12345",
81+
"github_token": "secret_token",
82+
"commit_sha": "abc1234",
83+
},
84+
}
85+
86+
mock_response = mock.MagicMock()
87+
mock_post.return_value = mock_response
88+
89+
github.fire_github_callback.function(test_type="pre_training")
90+
91+
mock_post.assert_called_once_with(
92+
"https://api.github.com/repos/owner/repo/dispatches",
93+
headers={
94+
"Authorization": "Bearer secret_token",
95+
"Accept": "application/vnd.github+json",
96+
"X-GitHub-Api-Version": "2022-11-28",
97+
},
98+
json={
99+
"event_type": "airflow-dag-complete",
100+
"client_payload": {
101+
"state": "success",
102+
"dag_id": "test_dag",
103+
"dag_run_id": "manual__1",
104+
"sha": "abc1234",
105+
"github_run_id": "12345",
106+
"test_type": "pre_training",
107+
},
108+
},
109+
timeout=30,
110+
)
111+
mock_response.raise_for_status.assert_called_once()
112+
113+
@mock.patch("requests.post")
114+
@mock.patch("xlml.utils.github.get_current_context")
115+
def test_trigger_github_repository_dispatch_explicit_args(
116+
self, mock_context, mock_post
117+
):
118+
mock_context.return_value = {}
119+
mock_response = mock.MagicMock()
120+
mock_post.return_value = mock_response
121+
122+
github.trigger_github_repository_dispatch.function(
123+
repo="custom/repo",
124+
token="custom_token",
125+
event_type="custom-event",
126+
client_payload={"foo": "bar"},
127+
)
128+
129+
mock_post.assert_called_once_with(
130+
"https://api.github.com/repos/custom/repo/dispatches",
131+
headers={
132+
"Authorization": "Bearer custom_token",
133+
"Accept": "application/vnd.github+json",
134+
"X-GitHub-Api-Version": "2022-11-28",
135+
},
136+
json={
137+
"event_type": "custom-event",
138+
"client_payload": {"foo": "bar"},
139+
},
140+
timeout=30,
141+
)
142+
mock_response.raise_for_status.assert_called_once()
143+
144+
@mock.patch("xlml.utils.github.get_current_context")
145+
def test_fire_github_callback_missing_token(self, mock_context):
146+
mock_context.return_value = {
147+
"params": {
148+
"github_repo": "owner/repo",
149+
}
150+
}
151+
with self.assertRaises(AirflowFailException):
152+
github.fire_github_callback.function()
153+
154+
155+
if __name__ == "__main__":
156+
unittest.main()

0 commit comments

Comments
 (0)