airbyte-internal-ops 0.4.0__py3-none-any.whl → 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {airbyte_internal_ops-0.4.0.dist-info → airbyte_internal_ops-0.4.2.dist-info}/METADATA +1 -1
- {airbyte_internal_ops-0.4.0.dist-info → airbyte_internal_ops-0.4.2.dist-info}/RECORD +14 -14
- airbyte_ops_mcp/cli/cloud.py +15 -3
- airbyte_ops_mcp/cli/gh.py +105 -2
- airbyte_ops_mcp/cli/registry.py +2 -2
- airbyte_ops_mcp/github_actions.py +6 -100
- airbyte_ops_mcp/github_api.py +163 -5
- airbyte_ops_mcp/mcp/{github.py → github_actions.py} +117 -5
- airbyte_ops_mcp/mcp/prerelease.py +7 -55
- airbyte_ops_mcp/mcp/regression_tests.py +4 -6
- airbyte_ops_mcp/mcp/server.py +2 -2
- airbyte_ops_mcp/regression_tests/ci_output.py +143 -67
- {airbyte_internal_ops-0.4.0.dist-info → airbyte_internal_ops-0.4.2.dist-info}/WHEEL +0 -0
- {airbyte_internal_ops-0.4.0.dist-info → airbyte_internal_ops-0.4.2.dist-info}/entry_points.txt +0 -0
|
@@ -2,8 +2,8 @@ airbyte_ops_mcp/__init__.py,sha256=tuzdlMkfnWBnsri5KGHM2M_xuNnzFk2u_aR79mmN7Yg,7
|
|
|
2
2
|
airbyte_ops_mcp/_annotations.py,sha256=MO-SBDnbykxxHDESG7d8rviZZ4WlZgJKv0a8eBqcEzQ,1757
|
|
3
3
|
airbyte_ops_mcp/constants.py,sha256=khcv9W3WkApIyPygEGgE2noBIqLomjoOMLxFBU1ArjA,5308
|
|
4
4
|
airbyte_ops_mcp/gcp_auth.py,sha256=i0cm1_xX4fj_31iKlfARpNvTaSr85iGTSw9KMf4f4MU,7206
|
|
5
|
-
airbyte_ops_mcp/github_actions.py,sha256=
|
|
6
|
-
airbyte_ops_mcp/github_api.py,sha256=
|
|
5
|
+
airbyte_ops_mcp/github_actions.py,sha256=FSi_tjS9TbwRVp8dwlDZhFOi7lJXEZQLhPm2KpcjNlY,7022
|
|
6
|
+
airbyte_ops_mcp/github_api.py,sha256=ezpMR1vjqQ-1f5yOLBVbxW70OPtUferl1uA0u_gUVo8,12733
|
|
7
7
|
airbyte_ops_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
8
|
airbyte_ops_mcp/_legacy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
airbyte_ops_mcp/_legacy/airbyte_ci/README.md,sha256=qEYx4geDR8AEDjrcA303h7Nol-CMDLojxUyiGzQprM8,236
|
|
@@ -353,9 +353,9 @@ airbyte_ops_mcp/cli/__init__.py,sha256=XpL7FyVfgabfBF2JR7u7NwJ2krlYqjd_OwLcWf-Xc
|
|
|
353
353
|
airbyte_ops_mcp/cli/_base.py,sha256=I8tWnyQf0ks4r3J8N8h-5GZxyn37T-55KsbuHnxYlcg,415
|
|
354
354
|
airbyte_ops_mcp/cli/_shared.py,sha256=jg-xMyGzTCGPqKd8VTfE_3kGPIyO_3Kx5sQbG4rPc0Y,1311
|
|
355
355
|
airbyte_ops_mcp/cli/app.py,sha256=SEdBpqFUG2O8zGV5ifwptxrLGFph_dLr66-MX9d69gQ,789
|
|
356
|
-
airbyte_ops_mcp/cli/cloud.py,sha256=
|
|
357
|
-
airbyte_ops_mcp/cli/gh.py,sha256=
|
|
358
|
-
airbyte_ops_mcp/cli/registry.py,sha256=
|
|
356
|
+
airbyte_ops_mcp/cli/cloud.py,sha256=7Iy8gpIM-Im5GMy2aShi3gE-BSPZABXPICnOyrZrJAw,44621
|
|
357
|
+
airbyte_ops_mcp/cli/gh.py,sha256=koJPu0MDB6AW7mJq2z4dZV65ofvsZTkqoeitGF8KJR8,5364
|
|
358
|
+
airbyte_ops_mcp/cli/registry.py,sha256=L4nDKhlegr31gSE-GUvDFSq10KgDz5kJuZXgLIxYIyg,9785
|
|
359
359
|
airbyte_ops_mcp/cli/repo.py,sha256=G1hoQpH0XYhUH3FFOsia9xabGB0LP9o3XcwBuqvFVo0,16331
|
|
360
360
|
airbyte_ops_mcp/cloud_admin/__init__.py,sha256=cqE96Q10Kp6elhH9DAi6TVsIwSUy3sooDLLrxTaktGk,816
|
|
361
361
|
airbyte_ops_mcp/cloud_admin/api_client.py,sha256=ysTztSbLX0SZSK3qneHTSKVODRzVmLbHBC3ND0j_LTc,38020
|
|
@@ -376,15 +376,15 @@ airbyte_ops_mcp/mcp/cloud_connector_versions.py,sha256=5qUYRZapYBprmmc5J3lKQzeQ3
|
|
|
376
376
|
airbyte_ops_mcp/mcp/connector_analysis.py,sha256=OC4KrOSkMkKPkOisWnSv96BDDE5TQYHq-Jxa2vtjJpo,298
|
|
377
377
|
airbyte_ops_mcp/mcp/connector_qa.py,sha256=aImpqdnqBPDrz10BS0owsV4kuIU2XdalzgbaGZsbOL0,258
|
|
378
378
|
airbyte_ops_mcp/mcp/gcp_logs.py,sha256=IPtq4098_LN1Cgeba4jATO1iYFFFpL2-aRO0pGcOdzs,2689
|
|
379
|
-
airbyte_ops_mcp/mcp/
|
|
379
|
+
airbyte_ops_mcp/mcp/github_actions.py,sha256=_mAVTl6UX3F7S_HeV1-M5R4jMNzNQGI3ADs3sBzden8,11760
|
|
380
380
|
airbyte_ops_mcp/mcp/github_repo_ops.py,sha256=PiERpt8abo20Gz4CfXhrDNlVM4o4FOt5sweZJND2a0s,5314
|
|
381
381
|
airbyte_ops_mcp/mcp/metadata.py,sha256=fwGW97WknR5lfKcQnFtK6dU87aA6TmLj1NkKyqDAV9g,270
|
|
382
|
-
airbyte_ops_mcp/mcp/prerelease.py,sha256=
|
|
382
|
+
airbyte_ops_mcp/mcp/prerelease.py,sha256=OoZxwy3-PAiTiKuWhhbfv_DrsHBDs9LVz4Y6EuL0Qkk,10596
|
|
383
383
|
airbyte_ops_mcp/mcp/prod_db_queries.py,sha256=VsiBBnVbOjc8lBb2Xr1lmcH3wu7QHQfjd4lORarEE1s,42700
|
|
384
384
|
airbyte_ops_mcp/mcp/prompts.py,sha256=mJld9mdPECXYZffWXGSvNs4Xevx3rxqUGNlzGKVC2_s,1599
|
|
385
385
|
airbyte_ops_mcp/mcp/registry.py,sha256=PW-VYUj42qx2pQ_apUkVaoUFq7VgB9zEU7-aGrkSCCw,290
|
|
386
|
-
airbyte_ops_mcp/mcp/regression_tests.py,sha256=
|
|
387
|
-
airbyte_ops_mcp/mcp/server.py,sha256=
|
|
386
|
+
airbyte_ops_mcp/mcp/regression_tests.py,sha256=dmM22ODwUTbVisKiRcJunzEgMKrZOkpsbkUm0_hFWYk,16752
|
|
387
|
+
airbyte_ops_mcp/mcp/server.py,sha256=dMOFXPFeHBIqicOWs8UsPfzgsWnzsWDsZJ79E_OYjT0,5341
|
|
388
388
|
airbyte_ops_mcp/mcp/server_info.py,sha256=Yi4B1auW64QZGBDas5mro_vwTjvrP785TFNSBP7GhRg,2361
|
|
389
389
|
airbyte_ops_mcp/prod_db_access/__init__.py,sha256=5pxouMPY1beyWlB0UwPnbaLTKTHqU6X82rbbgKY2vYU,1069
|
|
390
390
|
airbyte_ops_mcp/prod_db_access/db_engine.py,sha256=VUqEWZtharJUR-Cri_pMwtGh1C4Neu4s195mbEXlm-w,9190
|
|
@@ -396,7 +396,7 @@ airbyte_ops_mcp/registry/models.py,sha256=B4L4TKr52wo0xs0CqvCBrpowqjShzVnZ5eTr2-
|
|
|
396
396
|
airbyte_ops_mcp/registry/publish.py,sha256=VoPxsM2_0zJ829orzCRN-kjgcJtuBNyXgW4I9J680ro,12717
|
|
397
397
|
airbyte_ops_mcp/regression_tests/__init__.py,sha256=8pwJIdz1Lb9oFV6UQ3DSjYKd8HCSqU8RpH5SDgEcEBA,1038
|
|
398
398
|
airbyte_ops_mcp/regression_tests/cdk_secrets.py,sha256=iRjqqBS96KZoswfgT7ju-pE_pfbYoDy4PfrK-K8uyYs,3204
|
|
399
|
-
airbyte_ops_mcp/regression_tests/ci_output.py,sha256=
|
|
399
|
+
airbyte_ops_mcp/regression_tests/ci_output.py,sha256=DwBVCaCZAhI4MNf39EpUDIzOsHx5T1BZp268IAdaayg,15180
|
|
400
400
|
airbyte_ops_mcp/regression_tests/config.py,sha256=dwWeY0tatdbwl9BqbhZ7EljoZDCtKmGO5fvOAIxeXmA,5873
|
|
401
401
|
airbyte_ops_mcp/regression_tests/connection_fetcher.py,sha256=5wIiA0VvCFNEc-fr6Po18gZMX3E5fyPOGf2SuVOqv5U,12799
|
|
402
402
|
airbyte_ops_mcp/regression_tests/connection_secret_retriever.py,sha256=FhWNVWq7sON4nwUmVJv8BgXBOqg1YV4b5WuWyCzZ0LU,4695
|
|
@@ -414,7 +414,7 @@ airbyte_ops_mcp/regression_tests/regression/comparators.py,sha256=MJkLZEKHivgrG0
|
|
|
414
414
|
airbyte_ops_mcp/regression_tests/validation/__init__.py,sha256=MBEwGOoNuqT4_oCahtoK62OKWIjUCfWa7vZTxNj_0Ek,1532
|
|
415
415
|
airbyte_ops_mcp/regression_tests/validation/catalog_validators.py,sha256=jqqVAMOk0mtdPgwu4d0hA0ZEjtsNh5gapvGydRv3_qk,12553
|
|
416
416
|
airbyte_ops_mcp/regression_tests/validation/record_validators.py,sha256=RjauAhKWNwxMBTu0eNS2hMFNQVs5CLbQU51kp6FOVDk,7432
|
|
417
|
-
airbyte_internal_ops-0.4.
|
|
418
|
-
airbyte_internal_ops-0.4.
|
|
419
|
-
airbyte_internal_ops-0.4.
|
|
420
|
-
airbyte_internal_ops-0.4.
|
|
417
|
+
airbyte_internal_ops-0.4.2.dist-info/METADATA,sha256=RP-Eu-2phGMnspxD4d5rQBI9Q6jnU-Zr0Fdi8hrzpqY,5679
|
|
418
|
+
airbyte_internal_ops-0.4.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
419
|
+
airbyte_internal_ops-0.4.2.dist-info/entry_points.txt,sha256=WxP0l7bRFss4Cr5uQqVj9mTEKwnRKouNuphXQF0lotA,171
|
|
420
|
+
airbyte_internal_ops-0.4.2.dist-info/RECORD,,
|
airbyte_ops_mcp/cli/cloud.py
CHANGED
|
@@ -53,7 +53,7 @@ from airbyte_ops_mcp.mcp.cloud_connector_versions import (
|
|
|
53
53
|
from airbyte_ops_mcp.regression_tests.cdk_secrets import get_first_config_from_secrets
|
|
54
54
|
from airbyte_ops_mcp.regression_tests.ci_output import (
|
|
55
55
|
generate_regression_report,
|
|
56
|
-
|
|
56
|
+
generate_single_version_report,
|
|
57
57
|
write_github_output,
|
|
58
58
|
write_github_outputs,
|
|
59
59
|
write_github_summary,
|
|
@@ -988,6 +988,18 @@ def regression_test(
|
|
|
988
988
|
},
|
|
989
989
|
)
|
|
990
990
|
|
|
991
|
+
# Generate report.md with detailed metrics
|
|
992
|
+
report_path = generate_single_version_report(
|
|
993
|
+
connector_image=resolved_test_image,
|
|
994
|
+
command=command,
|
|
995
|
+
result=result,
|
|
996
|
+
output_dir=output_path,
|
|
997
|
+
)
|
|
998
|
+
print_success(f"Generated report: {report_path}")
|
|
999
|
+
|
|
1000
|
+
# Write report to GITHUB_STEP_SUMMARY (if env var exists)
|
|
1001
|
+
write_github_summary(report_path.read_text())
|
|
1002
|
+
|
|
991
1003
|
if result["success"]:
|
|
992
1004
|
print_success(
|
|
993
1005
|
f"Single-version regression test passed for {resolved_test_image}"
|
|
@@ -1059,8 +1071,8 @@ def regression_test(
|
|
|
1059
1071
|
)
|
|
1060
1072
|
print_success(f"Generated regression report: {report_path}")
|
|
1061
1073
|
|
|
1062
|
-
|
|
1063
|
-
write_github_summary(
|
|
1074
|
+
# Write report to GITHUB_STEP_SUMMARY (if env var exists)
|
|
1075
|
+
write_github_summary(report_path.read_text())
|
|
1064
1076
|
|
|
1065
1077
|
if regression_detected:
|
|
1066
1078
|
exit_with_error(
|
airbyte_ops_mcp/cli/gh.py
CHANGED
|
@@ -3,17 +3,23 @@
|
|
|
3
3
|
|
|
4
4
|
Commands:
|
|
5
5
|
airbyte-ops gh workflow status - Check GitHub Actions workflow status
|
|
6
|
+
airbyte-ops gh workflow trigger - Trigger a GitHub Actions CI workflow
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from __future__ import annotations
|
|
9
10
|
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
10
13
|
from typing import Annotated
|
|
11
14
|
|
|
12
15
|
from cyclopts import App, Parameter
|
|
13
16
|
|
|
14
17
|
from airbyte_ops_mcp.cli._base import app
|
|
15
18
|
from airbyte_ops_mcp.cli._shared import exit_with_error, print_json
|
|
16
|
-
from airbyte_ops_mcp.mcp.
|
|
19
|
+
from airbyte_ops_mcp.mcp.github_actions import (
|
|
20
|
+
check_ci_workflow_status,
|
|
21
|
+
trigger_ci_workflow,
|
|
22
|
+
)
|
|
17
23
|
|
|
18
24
|
# Create the gh sub-app
|
|
19
25
|
gh_app = App(name="gh", help="GitHub operations.")
|
|
@@ -62,10 +68,107 @@ def workflow_status(
|
|
|
62
68
|
"Must provide either --url OR all of (--owner, --repo, --run-id)."
|
|
63
69
|
)
|
|
64
70
|
|
|
65
|
-
result =
|
|
71
|
+
result = check_ci_workflow_status(
|
|
66
72
|
workflow_url=url,
|
|
67
73
|
owner=owner,
|
|
68
74
|
repo=repo,
|
|
69
75
|
run_id=run_id,
|
|
70
76
|
)
|
|
71
77
|
print_json(result.model_dump())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@workflow_app.command(name="trigger")
|
|
81
|
+
def workflow_trigger(
|
|
82
|
+
owner: Annotated[
|
|
83
|
+
str,
|
|
84
|
+
Parameter(help="Repository owner (e.g., 'airbytehq')."),
|
|
85
|
+
],
|
|
86
|
+
repo: Annotated[
|
|
87
|
+
str,
|
|
88
|
+
Parameter(help="Repository name (e.g., 'airbyte')."),
|
|
89
|
+
],
|
|
90
|
+
workflow_file: Annotated[
|
|
91
|
+
str,
|
|
92
|
+
Parameter(help="Workflow file name (e.g., 'connector-regression-test.yml')."),
|
|
93
|
+
],
|
|
94
|
+
workflow_definition_ref: Annotated[
|
|
95
|
+
str | None,
|
|
96
|
+
Parameter(
|
|
97
|
+
help="Branch name or PR number for the workflow definition to use. "
|
|
98
|
+
"If a PR number is provided, it resolves to the PR's head branch name. "
|
|
99
|
+
"Defaults to 'main' if not specified."
|
|
100
|
+
),
|
|
101
|
+
] = None,
|
|
102
|
+
inputs: Annotated[
|
|
103
|
+
str | None,
|
|
104
|
+
Parameter(
|
|
105
|
+
help='Workflow inputs as a JSON string (e.g., \'{"key": "value"}\').'
|
|
106
|
+
),
|
|
107
|
+
] = None,
|
|
108
|
+
wait: Annotated[
|
|
109
|
+
bool,
|
|
110
|
+
Parameter(help="Wait for the workflow to complete before returning."),
|
|
111
|
+
] = False,
|
|
112
|
+
wait_seconds: Annotated[
|
|
113
|
+
int,
|
|
114
|
+
Parameter(
|
|
115
|
+
help="Maximum seconds to wait for workflow completion (default: 600)."
|
|
116
|
+
),
|
|
117
|
+
] = 600,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Trigger a GitHub Actions CI workflow via workflow_dispatch.
|
|
120
|
+
|
|
121
|
+
This command triggers a workflow in any GitHub repository that has workflow_dispatch
|
|
122
|
+
enabled. It resolves PR numbers to branch names automatically.
|
|
123
|
+
"""
|
|
124
|
+
# Parse inputs JSON if provided
|
|
125
|
+
parsed_inputs: dict[str, str] | None = None
|
|
126
|
+
if inputs:
|
|
127
|
+
try:
|
|
128
|
+
parsed_inputs = json.loads(inputs)
|
|
129
|
+
except json.JSONDecodeError as e:
|
|
130
|
+
exit_with_error(f"Invalid JSON for --inputs: {e}")
|
|
131
|
+
|
|
132
|
+
# Trigger the workflow
|
|
133
|
+
result = trigger_ci_workflow(
|
|
134
|
+
owner=owner,
|
|
135
|
+
repo=repo,
|
|
136
|
+
workflow_file=workflow_file,
|
|
137
|
+
workflow_definition_ref=workflow_definition_ref,
|
|
138
|
+
inputs=parsed_inputs,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
print_json(result.model_dump())
|
|
142
|
+
|
|
143
|
+
# If wait is enabled and we have a run_id, poll for completion
|
|
144
|
+
if wait and result.run_id:
|
|
145
|
+
print(f"\nWaiting for workflow to complete (timeout: {wait_seconds}s)...")
|
|
146
|
+
start_time = time.time()
|
|
147
|
+
poll_interval = 10 # seconds
|
|
148
|
+
|
|
149
|
+
while time.time() - start_time < wait_seconds:
|
|
150
|
+
status_result = check_ci_workflow_status(
|
|
151
|
+
owner=owner,
|
|
152
|
+
repo=repo,
|
|
153
|
+
run_id=result.run_id,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if status_result.status == "completed":
|
|
157
|
+
print(
|
|
158
|
+
f"\nWorkflow completed with conclusion: {status_result.conclusion}"
|
|
159
|
+
)
|
|
160
|
+
print_json(status_result.model_dump())
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
elapsed = int(time.time() - start_time)
|
|
164
|
+
print(f" Status: {status_result.status} (elapsed: {elapsed}s)")
|
|
165
|
+
time.sleep(poll_interval)
|
|
166
|
+
|
|
167
|
+
print(f"\nTimeout reached after {wait_seconds}s. Workflow still running.")
|
|
168
|
+
# Print final status
|
|
169
|
+
final_status = check_ci_workflow_status(
|
|
170
|
+
owner=owner,
|
|
171
|
+
repo=repo,
|
|
172
|
+
run_id=result.run_id,
|
|
173
|
+
)
|
|
174
|
+
print_json(final_status.model_dump())
|
airbyte_ops_mcp/cli/registry.py
CHANGED
|
@@ -28,11 +28,11 @@ from airbyte_ops_mcp.cli._shared import (
|
|
|
28
28
|
print_json,
|
|
29
29
|
print_success,
|
|
30
30
|
)
|
|
31
|
-
from airbyte_ops_mcp.
|
|
31
|
+
from airbyte_ops_mcp.github_api import (
|
|
32
32
|
get_file_contents_at_ref,
|
|
33
33
|
resolve_github_token,
|
|
34
34
|
)
|
|
35
|
-
from airbyte_ops_mcp.mcp.
|
|
35
|
+
from airbyte_ops_mcp.mcp.github_actions import get_docker_image_info
|
|
36
36
|
from airbyte_ops_mcp.mcp.prerelease import (
|
|
37
37
|
compute_prerelease_docker_image_tag,
|
|
38
38
|
publish_connector_to_airbyte_registry,
|
|
@@ -1,72 +1,23 @@
|
|
|
1
1
|
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
2
|
"""GitHub Actions API utilities.
|
|
3
3
|
|
|
4
|
-
This module provides
|
|
5
|
-
including workflow dispatch, run discovery, and
|
|
4
|
+
This module provides utilities for interacting with GitHub Actions workflows,
|
|
5
|
+
including workflow dispatch, run discovery, and job status. These utilities
|
|
6
6
|
are used by MCP tools but are not MCP-specific.
|
|
7
|
+
|
|
8
|
+
For general GitHub API utilities (authentication, PR info, file contents),
|
|
9
|
+
see the github_api module.
|
|
7
10
|
"""
|
|
8
11
|
|
|
9
12
|
from __future__ import annotations
|
|
10
13
|
|
|
11
|
-
import os
|
|
12
|
-
import shutil
|
|
13
|
-
import subprocess
|
|
14
14
|
import time
|
|
15
15
|
from dataclasses import dataclass
|
|
16
16
|
from datetime import datetime, timedelta
|
|
17
17
|
|
|
18
18
|
import requests
|
|
19
19
|
|
|
20
|
-
GITHUB_API_BASE
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def resolve_github_token(preferred_env_vars: list[str] | None = None) -> str:
|
|
24
|
-
"""Resolve GitHub token from environment variables or gh CLI.
|
|
25
|
-
|
|
26
|
-
Checks environment variables in order of preference, returning the first
|
|
27
|
-
non-empty value found. If no environment variables are set, attempts to
|
|
28
|
-
get a token from the gh CLI tool using 'gh auth token'.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
preferred_env_vars: List of environment variable names to check in order.
|
|
32
|
-
Defaults to ["GITHUB_CI_WORKFLOW_TRIGGER_PAT", "GITHUB_TOKEN"].
|
|
33
|
-
|
|
34
|
-
Returns:
|
|
35
|
-
GitHub token string.
|
|
36
|
-
|
|
37
|
-
Raises:
|
|
38
|
-
ValueError: If no GitHub token is found in env vars or gh CLI.
|
|
39
|
-
"""
|
|
40
|
-
if preferred_env_vars is None:
|
|
41
|
-
preferred_env_vars = ["GITHUB_CI_WORKFLOW_TRIGGER_PAT", "GITHUB_TOKEN"]
|
|
42
|
-
|
|
43
|
-
# Check environment variables first
|
|
44
|
-
for env_var in preferred_env_vars:
|
|
45
|
-
token = os.getenv(env_var)
|
|
46
|
-
if token:
|
|
47
|
-
return token
|
|
48
|
-
|
|
49
|
-
# Fall back to gh CLI if available
|
|
50
|
-
gh_path = shutil.which("gh")
|
|
51
|
-
if gh_path:
|
|
52
|
-
try:
|
|
53
|
-
result = subprocess.run(
|
|
54
|
-
[gh_path, "auth", "token"],
|
|
55
|
-
capture_output=True,
|
|
56
|
-
text=True,
|
|
57
|
-
timeout=5,
|
|
58
|
-
check=False,
|
|
59
|
-
)
|
|
60
|
-
if result.returncode == 0 and result.stdout.strip():
|
|
61
|
-
return result.stdout.strip()
|
|
62
|
-
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
63
|
-
pass
|
|
64
|
-
|
|
65
|
-
env_var_list = ", ".join(preferred_env_vars)
|
|
66
|
-
raise ValueError(
|
|
67
|
-
f"No GitHub token found. Set one of: {env_var_list} environment variable, "
|
|
68
|
-
"or authenticate with 'gh auth login'."
|
|
69
|
-
)
|
|
20
|
+
from airbyte_ops_mcp.github_api import GITHUB_API_BASE, resolve_github_token
|
|
70
21
|
|
|
71
22
|
|
|
72
23
|
@dataclass
|
|
@@ -106,51 +57,6 @@ class WorkflowJobInfo:
|
|
|
106
57
|
"""ISO 8601 timestamp when the job completed"""
|
|
107
58
|
|
|
108
59
|
|
|
109
|
-
def get_file_contents_at_ref(
|
|
110
|
-
owner: str,
|
|
111
|
-
repo: str,
|
|
112
|
-
path: str,
|
|
113
|
-
ref: str,
|
|
114
|
-
token: str | None = None,
|
|
115
|
-
) -> str | None:
|
|
116
|
-
"""Fetch file contents from GitHub at a specific ref.
|
|
117
|
-
|
|
118
|
-
Uses the GitHub Contents API to retrieve file contents at a specific
|
|
119
|
-
commit SHA, branch, or tag. This allows reading files without having
|
|
120
|
-
the repository checked out locally.
|
|
121
|
-
|
|
122
|
-
Args:
|
|
123
|
-
owner: Repository owner (e.g., "airbytehq")
|
|
124
|
-
repo: Repository name (e.g., "airbyte")
|
|
125
|
-
path: Path to the file within the repository
|
|
126
|
-
ref: Git ref (commit SHA, branch name, or tag)
|
|
127
|
-
token: GitHub API token (optional for public repos, but recommended
|
|
128
|
-
to avoid rate limiting)
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
File contents as a string, or None if the file doesn't exist.
|
|
132
|
-
|
|
133
|
-
Raises:
|
|
134
|
-
requests.HTTPError: If API request fails (except 404).
|
|
135
|
-
"""
|
|
136
|
-
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}"
|
|
137
|
-
headers = {
|
|
138
|
-
"Accept": "application/vnd.github.raw+json",
|
|
139
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
140
|
-
}
|
|
141
|
-
if token:
|
|
142
|
-
headers["Authorization"] = f"Bearer {token}"
|
|
143
|
-
|
|
144
|
-
params = {"ref": ref}
|
|
145
|
-
|
|
146
|
-
response = requests.get(url, headers=headers, params=params, timeout=30)
|
|
147
|
-
if response.status_code == 404:
|
|
148
|
-
return None
|
|
149
|
-
response.raise_for_status()
|
|
150
|
-
|
|
151
|
-
return response.text
|
|
152
|
-
|
|
153
|
-
|
|
154
60
|
def get_workflow_jobs(
|
|
155
61
|
owner: str,
|
|
156
62
|
repo: str,
|
airbyte_ops_mcp/github_api.py
CHANGED
|
@@ -1,20 +1,178 @@
|
|
|
1
1
|
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
-
"""GitHub API utilities
|
|
2
|
+
"""GitHub API utilities.
|
|
3
3
|
|
|
4
|
-
This module provides utilities for interacting with GitHub's REST API
|
|
5
|
-
|
|
6
|
-
used by MCP tools
|
|
4
|
+
This module provides core utilities for interacting with GitHub's REST API,
|
|
5
|
+
including authentication, user/comment operations, PR information retrieval,
|
|
6
|
+
and file content fetching. These utilities are used by MCP tools and other
|
|
7
|
+
modules but are not MCP-specific.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
12
|
+
import os
|
|
11
13
|
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
12
16
|
from dataclasses import dataclass
|
|
13
17
|
from urllib.parse import urlparse
|
|
14
18
|
|
|
15
19
|
import requests
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
GITHUB_API_BASE = "https://api.github.com"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_github_token(preferred_env_vars: list[str] | None = None) -> str:
|
|
25
|
+
"""Resolve GitHub token from environment variables or gh CLI.
|
|
26
|
+
|
|
27
|
+
Checks environment variables in order of preference, returning the first
|
|
28
|
+
non-empty value found. If no environment variables are set, attempts to
|
|
29
|
+
get a token from the gh CLI tool using 'gh auth token'.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
preferred_env_vars: List of environment variable names to check in order.
|
|
33
|
+
Defaults to ["GITHUB_CI_WORKFLOW_TRIGGER_PAT", "GITHUB_TOKEN"].
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
GitHub token string.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If no GitHub token is found in env vars or gh CLI.
|
|
40
|
+
"""
|
|
41
|
+
if preferred_env_vars is None:
|
|
42
|
+
preferred_env_vars = ["GITHUB_CI_WORKFLOW_TRIGGER_PAT", "GITHUB_TOKEN"]
|
|
43
|
+
|
|
44
|
+
# Check environment variables first
|
|
45
|
+
for env_var in preferred_env_vars:
|
|
46
|
+
token = os.getenv(env_var)
|
|
47
|
+
if token:
|
|
48
|
+
return token
|
|
49
|
+
|
|
50
|
+
# Fall back to gh CLI if available
|
|
51
|
+
gh_path = shutil.which("gh")
|
|
52
|
+
if gh_path:
|
|
53
|
+
try:
|
|
54
|
+
result = subprocess.run(
|
|
55
|
+
[gh_path, "auth", "token"],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
timeout=5,
|
|
59
|
+
check=False,
|
|
60
|
+
)
|
|
61
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
62
|
+
return result.stdout.strip()
|
|
63
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
env_var_list = ", ".join(preferred_env_vars)
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"No GitHub token found. Set one of: {env_var_list} environment variable, "
|
|
69
|
+
"or authenticate with 'gh auth login'."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class PRHeadInfo:
|
|
75
|
+
"""Information about a PR's head commit."""
|
|
76
|
+
|
|
77
|
+
ref: str
|
|
78
|
+
"""Branch name of the PR's head"""
|
|
79
|
+
|
|
80
|
+
sha: str
|
|
81
|
+
"""Full commit SHA of the PR's head"""
|
|
82
|
+
|
|
83
|
+
short_sha: str
|
|
84
|
+
"""First 7 characters of the commit SHA"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_pr_head_ref(
|
|
88
|
+
owner: str,
|
|
89
|
+
repo: str,
|
|
90
|
+
pr_number: int,
|
|
91
|
+
token: str,
|
|
92
|
+
) -> PRHeadInfo:
|
|
93
|
+
"""Get the head ref (branch name) and SHA for a PR.
|
|
94
|
+
|
|
95
|
+
This is useful for resolving a PR number to the actual branch name,
|
|
96
|
+
which is required for workflow_dispatch API calls (which don't accept
|
|
97
|
+
refs/pull/{pr}/head format).
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
owner: Repository owner (e.g., "airbytehq")
|
|
101
|
+
repo: Repository name (e.g., "airbyte")
|
|
102
|
+
pr_number: Pull request number
|
|
103
|
+
token: GitHub API token
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
PRHeadInfo with ref (branch name), sha, and short_sha.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: If PR not found.
|
|
110
|
+
requests.HTTPError: If API request fails.
|
|
111
|
+
"""
|
|
112
|
+
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}"
|
|
113
|
+
headers = {
|
|
114
|
+
"Authorization": f"Bearer {token}",
|
|
115
|
+
"Accept": "application/vnd.github+json",
|
|
116
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
response = requests.get(url, headers=headers, timeout=30)
|
|
120
|
+
if response.status_code == 404:
|
|
121
|
+
raise ValueError(f"PR {owner}/{repo}#{pr_number} not found")
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
|
|
124
|
+
pr_data = response.json()
|
|
125
|
+
sha = pr_data["head"]["sha"]
|
|
126
|
+
return PRHeadInfo(
|
|
127
|
+
ref=pr_data["head"]["ref"],
|
|
128
|
+
sha=sha,
|
|
129
|
+
short_sha=sha[:7],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_file_contents_at_ref(
|
|
134
|
+
owner: str,
|
|
135
|
+
repo: str,
|
|
136
|
+
path: str,
|
|
137
|
+
ref: str,
|
|
138
|
+
token: str | None = None,
|
|
139
|
+
) -> str | None:
|
|
140
|
+
"""Fetch file contents from GitHub at a specific ref.
|
|
141
|
+
|
|
142
|
+
Uses the GitHub Contents API to retrieve file contents at a specific
|
|
143
|
+
commit SHA, branch, or tag. This allows reading files without having
|
|
144
|
+
the repository checked out locally.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
owner: Repository owner (e.g., "airbytehq")
|
|
148
|
+
repo: Repository name (e.g., "airbyte")
|
|
149
|
+
path: Path to the file within the repository
|
|
150
|
+
ref: Git ref (commit SHA, branch name, or tag)
|
|
151
|
+
token: GitHub API token (optional for public repos, but recommended
|
|
152
|
+
to avoid rate limiting)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
File contents as a string, or None if the file doesn't exist.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
requests.HTTPError: If API request fails (except 404).
|
|
159
|
+
"""
|
|
160
|
+
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}"
|
|
161
|
+
headers = {
|
|
162
|
+
"Accept": "application/vnd.github.raw+json",
|
|
163
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
164
|
+
}
|
|
165
|
+
if token:
|
|
166
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
167
|
+
|
|
168
|
+
params = {"ref": ref}
|
|
169
|
+
|
|
170
|
+
response = requests.get(url, headers=headers, params=params, timeout=30)
|
|
171
|
+
if response.status_code == 404:
|
|
172
|
+
return None
|
|
173
|
+
response.raise_for_status()
|
|
174
|
+
|
|
175
|
+
return response.text
|
|
18
176
|
|
|
19
177
|
|
|
20
178
|
class GitHubCommentParseError(Exception):
|
|
@@ -15,12 +15,22 @@ from fastmcp import FastMCP
|
|
|
15
15
|
from pydantic import BaseModel, Field
|
|
16
16
|
|
|
17
17
|
from airbyte_ops_mcp.github_actions import (
|
|
18
|
-
GITHUB_API_BASE,
|
|
19
18
|
get_workflow_jobs,
|
|
19
|
+
trigger_workflow_dispatch,
|
|
20
|
+
)
|
|
21
|
+
from airbyte_ops_mcp.github_api import (
|
|
22
|
+
GITHUB_API_BASE,
|
|
23
|
+
get_pr_head_ref,
|
|
20
24
|
resolve_github_token,
|
|
21
25
|
)
|
|
22
26
|
from airbyte_ops_mcp.mcp._mcp_utils import mcp_tool, register_mcp_tools
|
|
23
27
|
|
|
28
|
+
# Token env vars for workflow triggering (in order of preference)
|
|
29
|
+
WORKFLOW_TRIGGER_TOKEN_ENV_VARS = [
|
|
30
|
+
"GITHUB_CI_WORKFLOW_TRIGGER_PAT",
|
|
31
|
+
"GITHUB_TOKEN",
|
|
32
|
+
]
|
|
33
|
+
|
|
24
34
|
DOCKERHUB_API_BASE = "https://hub.docker.com/v2"
|
|
25
35
|
|
|
26
36
|
|
|
@@ -36,7 +46,7 @@ class JobInfo(BaseModel):
|
|
|
36
46
|
|
|
37
47
|
|
|
38
48
|
class WorkflowRunStatus(BaseModel):
|
|
39
|
-
"""Response model for
|
|
49
|
+
"""Response model for check_ci_workflow_status MCP tool."""
|
|
40
50
|
|
|
41
51
|
run_id: int
|
|
42
52
|
status: str
|
|
@@ -116,7 +126,7 @@ def _get_workflow_run(
|
|
|
116
126
|
idempotent=True,
|
|
117
127
|
open_world=True,
|
|
118
128
|
)
|
|
119
|
-
def
|
|
129
|
+
def check_ci_workflow_status(
|
|
120
130
|
workflow_url: Annotated[
|
|
121
131
|
str | None,
|
|
122
132
|
Field(
|
|
@@ -196,6 +206,108 @@ def check_workflow_status(
|
|
|
196
206
|
)
|
|
197
207
|
|
|
198
208
|
|
|
209
|
+
class TriggerCIWorkflowResult(BaseModel):
|
|
210
|
+
"""Response model for trigger_ci_workflow MCP tool."""
|
|
211
|
+
|
|
212
|
+
success: bool
|
|
213
|
+
message: str
|
|
214
|
+
workflow_url: str
|
|
215
|
+
run_id: int | None = None
|
|
216
|
+
run_url: str | None = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@mcp_tool(
|
|
220
|
+
read_only=False,
|
|
221
|
+
idempotent=False,
|
|
222
|
+
open_world=True,
|
|
223
|
+
)
|
|
224
|
+
def trigger_ci_workflow(
|
|
225
|
+
owner: Annotated[
|
|
226
|
+
str,
|
|
227
|
+
Field(description="Repository owner (e.g., 'airbytehq')"),
|
|
228
|
+
],
|
|
229
|
+
repo: Annotated[
|
|
230
|
+
str,
|
|
231
|
+
Field(description="Repository name (e.g., 'airbyte')"),
|
|
232
|
+
],
|
|
233
|
+
workflow_file: Annotated[
|
|
234
|
+
str,
|
|
235
|
+
Field(description="Workflow file name (e.g., 'connector-regression-test.yml')"),
|
|
236
|
+
],
|
|
237
|
+
workflow_definition_ref: Annotated[
|
|
238
|
+
str | None,
|
|
239
|
+
Field(
|
|
240
|
+
description="Branch name or PR number for the workflow definition to use. "
|
|
241
|
+
"If a PR number (integer string) is provided, it resolves to the PR's head branch name. "
|
|
242
|
+
"If a branch name is provided, it is used directly. "
|
|
243
|
+
"Defaults to the repository's default branch if not specified."
|
|
244
|
+
),
|
|
245
|
+
] = None,
|
|
246
|
+
inputs: Annotated[
|
|
247
|
+
dict[str, str] | None,
|
|
248
|
+
Field(
|
|
249
|
+
description="Workflow inputs as a dictionary of string key-value pairs. "
|
|
250
|
+
"These are passed to the workflow_dispatch event."
|
|
251
|
+
),
|
|
252
|
+
] = None,
|
|
253
|
+
) -> TriggerCIWorkflowResult:
|
|
254
|
+
"""Trigger a GitHub Actions CI workflow via workflow_dispatch.
|
|
255
|
+
|
|
256
|
+
This tool triggers a workflow in any GitHub repository that has workflow_dispatch
|
|
257
|
+
enabled. It resolves PR numbers to branch names automatically since GitHub's
|
|
258
|
+
workflow_dispatch API only accepts branch names, not refs/pull/{pr}/head format.
|
|
259
|
+
|
|
260
|
+
Requires GITHUB_CI_WORKFLOW_TRIGGER_PAT or GITHUB_TOKEN environment variable
|
|
261
|
+
with 'actions:write' permission.
|
|
262
|
+
"""
|
|
263
|
+
# Guard: Check for required token
|
|
264
|
+
token = resolve_github_token(WORKFLOW_TRIGGER_TOKEN_ENV_VARS)
|
|
265
|
+
|
|
266
|
+
# Resolve workflow definition ref
|
|
267
|
+
# If a PR number is provided (integer string), resolve to the PR's head branch name
|
|
268
|
+
# Otherwise use the provided branch name or default to repo's default branch
|
|
269
|
+
if workflow_definition_ref:
|
|
270
|
+
if workflow_definition_ref.isdigit():
|
|
271
|
+
# Resolve PR number to branch name via GitHub API
|
|
272
|
+
pr_head_info = get_pr_head_ref(
|
|
273
|
+
owner,
|
|
274
|
+
repo,
|
|
275
|
+
int(workflow_definition_ref),
|
|
276
|
+
token,
|
|
277
|
+
)
|
|
278
|
+
resolved_ref = pr_head_info.ref
|
|
279
|
+
else:
|
|
280
|
+
resolved_ref = workflow_definition_ref
|
|
281
|
+
else:
|
|
282
|
+
# Default to main (most common default branch)
|
|
283
|
+
resolved_ref = "main"
|
|
284
|
+
|
|
285
|
+
# Trigger the workflow
|
|
286
|
+
result = trigger_workflow_dispatch(
|
|
287
|
+
owner=owner,
|
|
288
|
+
repo=repo,
|
|
289
|
+
workflow_file=workflow_file,
|
|
290
|
+
ref=resolved_ref,
|
|
291
|
+
inputs=inputs or {},
|
|
292
|
+
token=token,
|
|
293
|
+
find_run=True,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Build response message
|
|
297
|
+
if result.run_id:
|
|
298
|
+
message = f"Successfully triggered workflow {workflow_file} on {owner}/{repo} (ref: {resolved_ref}). Run ID: {result.run_id}"
|
|
299
|
+
else:
|
|
300
|
+
message = f"Successfully triggered workflow {workflow_file} on {owner}/{repo} (ref: {resolved_ref}). Run ID not yet available."
|
|
301
|
+
|
|
302
|
+
return TriggerCIWorkflowResult(
|
|
303
|
+
success=True,
|
|
304
|
+
message=message,
|
|
305
|
+
workflow_url=result.workflow_url,
|
|
306
|
+
run_id=result.run_id,
|
|
307
|
+
run_url=result.run_url,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
199
311
|
class DockerImageInfo(BaseModel):
|
|
200
312
|
"""Response model for get_docker_image_info MCP tool."""
|
|
201
313
|
|
|
@@ -282,8 +394,8 @@ def get_docker_image_info(
|
|
|
282
394
|
)
|
|
283
395
|
|
|
284
396
|
|
|
285
|
-
def
|
|
286
|
-
"""Register GitHub tools with the FastMCP app.
|
|
397
|
+
def register_github_actions_tools(app: FastMCP) -> None:
|
|
398
|
+
"""Register GitHub Actions tools with the FastMCP app.
|
|
287
399
|
|
|
288
400
|
Args:
|
|
289
401
|
app: FastMCP application instance
|
|
@@ -18,7 +18,11 @@ import yaml
|
|
|
18
18
|
from fastmcp import FastMCP
|
|
19
19
|
from pydantic import BaseModel, Field
|
|
20
20
|
|
|
21
|
-
from airbyte_ops_mcp.
|
|
21
|
+
from airbyte_ops_mcp.github_api import (
|
|
22
|
+
GITHUB_API_BASE,
|
|
23
|
+
get_pr_head_ref,
|
|
24
|
+
resolve_github_token,
|
|
25
|
+
)
|
|
22
26
|
from airbyte_ops_mcp.mcp._mcp_utils import mcp_tool, register_mcp_tools
|
|
23
27
|
|
|
24
28
|
|
|
@@ -87,14 +91,6 @@ def compute_prerelease_docker_image_tag(base_version: str, sha: str) -> str:
|
|
|
87
91
|
return f"{base_version}-{PRERELEASE_TAG_PREFIX}.{short_sha}"
|
|
88
92
|
|
|
89
93
|
|
|
90
|
-
class PRHeadInfo(BaseModel):
|
|
91
|
-
"""Information about a PR's head commit."""
|
|
92
|
-
|
|
93
|
-
ref: str
|
|
94
|
-
sha: str
|
|
95
|
-
short_sha: str
|
|
96
|
-
|
|
97
|
-
|
|
98
94
|
class PrereleaseWorkflowResult(BaseModel):
|
|
99
95
|
"""Response model for publish_connector_to_airbyte_registry MCP tool."""
|
|
100
96
|
|
|
@@ -107,47 +103,6 @@ class PrereleaseWorkflowResult(BaseModel):
|
|
|
107
103
|
docker_image_tag: str | None = None
|
|
108
104
|
|
|
109
105
|
|
|
110
|
-
def _get_pr_head_info(
|
|
111
|
-
owner: str,
|
|
112
|
-
repo: str,
|
|
113
|
-
pr_number: int,
|
|
114
|
-
token: str,
|
|
115
|
-
) -> PRHeadInfo:
|
|
116
|
-
"""Get the head ref and SHA for a PR.
|
|
117
|
-
|
|
118
|
-
Args:
|
|
119
|
-
owner: Repository owner (e.g., "airbytehq")
|
|
120
|
-
repo: Repository name (e.g., "airbyte")
|
|
121
|
-
pr_number: Pull request number
|
|
122
|
-
token: GitHub API token
|
|
123
|
-
|
|
124
|
-
Returns:
|
|
125
|
-
PRHeadInfo with ref, sha, and short_sha.
|
|
126
|
-
|
|
127
|
-
Raises:
|
|
128
|
-
ValueError: If PR not found or API error.
|
|
129
|
-
"""
|
|
130
|
-
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{pr_number}"
|
|
131
|
-
headers = {
|
|
132
|
-
"Authorization": f"Bearer {token}",
|
|
133
|
-
"Accept": "application/vnd.github+json",
|
|
134
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
response = requests.get(url, headers=headers, timeout=30)
|
|
138
|
-
if response.status_code == 404:
|
|
139
|
-
raise ValueError(f"PR {owner}/{repo}#{pr_number} not found")
|
|
140
|
-
response.raise_for_status()
|
|
141
|
-
|
|
142
|
-
pr_data = response.json()
|
|
143
|
-
sha = pr_data["head"]["sha"]
|
|
144
|
-
return PRHeadInfo(
|
|
145
|
-
ref=pr_data["head"]["ref"],
|
|
146
|
-
sha=sha,
|
|
147
|
-
short_sha=sha[:7],
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
|
|
151
106
|
def _get_connector_metadata(
|
|
152
107
|
owner: str,
|
|
153
108
|
repo: str,
|
|
@@ -305,16 +260,13 @@ def publish_connector_to_airbyte_registry(
|
|
|
305
260
|
|
|
306
261
|
# Get the PR's head SHA for computing the docker image tag
|
|
307
262
|
# Note: We no longer pass gitref to the workflow - it derives the ref from PR number
|
|
308
|
-
head_info =
|
|
309
|
-
DEFAULT_REPO_OWNER, target_repo_name, pr_number, token
|
|
310
|
-
)
|
|
263
|
+
head_info = get_pr_head_ref(DEFAULT_REPO_OWNER, target_repo_name, pr_number, token)
|
|
311
264
|
|
|
312
265
|
# Prepare workflow inputs
|
|
313
|
-
# The workflow uses refs/pull/{pr}/head directly - no gitref needed
|
|
314
|
-
# Note: The workflow auto-detects modified connectors from the PR
|
|
315
266
|
workflow_inputs = {
|
|
316
267
|
"repo": f"{DEFAULT_REPO_OWNER}/{target_repo_name}",
|
|
317
268
|
"pr": str(pr_number),
|
|
269
|
+
"connector": connector_name,
|
|
318
270
|
}
|
|
319
271
|
|
|
320
272
|
# Trigger the workflow on the default branch
|
|
@@ -31,11 +31,8 @@ from airbyte.exceptions import (
|
|
|
31
31
|
from fastmcp import FastMCP
|
|
32
32
|
from pydantic import BaseModel, Field
|
|
33
33
|
|
|
34
|
-
from airbyte_ops_mcp.github_actions import
|
|
35
|
-
|
|
36
|
-
resolve_github_token,
|
|
37
|
-
trigger_workflow_dispatch,
|
|
38
|
-
)
|
|
34
|
+
from airbyte_ops_mcp.github_actions import trigger_workflow_dispatch
|
|
35
|
+
from airbyte_ops_mcp.github_api import GITHUB_API_BASE, resolve_github_token
|
|
39
36
|
from airbyte_ops_mcp.mcp._mcp_utils import mcp_tool, register_mcp_tools
|
|
40
37
|
from airbyte_ops_mcp.mcp.prerelease import ConnectorRepo
|
|
41
38
|
|
|
@@ -285,7 +282,7 @@ class RunRegressionTestsResponse(BaseModel):
|
|
|
285
282
|
)
|
|
286
283
|
github_run_id: int | None = Field(
|
|
287
284
|
default=None,
|
|
288
|
-
description="GitHub Actions workflow run ID (use with
|
|
285
|
+
description="GitHub Actions workflow run ID (use with check_ci_workflow_status)",
|
|
289
286
|
)
|
|
290
287
|
github_run_url: str | None = Field(
|
|
291
288
|
default=None,
|
|
@@ -421,6 +418,7 @@ def run_regression_tests(
|
|
|
421
418
|
workflow_inputs["override_control_image"] = override_control_image
|
|
422
419
|
|
|
423
420
|
mode_description = "single-version" if skip_compare else "comparison"
|
|
421
|
+
|
|
424
422
|
try:
|
|
425
423
|
dispatch_result = trigger_workflow_dispatch(
|
|
426
424
|
owner=REGRESSION_TEST_REPO_OWNER,
|
airbyte_ops_mcp/mcp/server.py
CHANGED
|
@@ -25,7 +25,7 @@ from airbyte_ops_mcp.mcp.cloud_connector_versions import (
|
|
|
25
25
|
register_cloud_connector_version_tools,
|
|
26
26
|
)
|
|
27
27
|
from airbyte_ops_mcp.mcp.gcp_logs import register_gcp_logs_tools
|
|
28
|
-
from airbyte_ops_mcp.mcp.
|
|
28
|
+
from airbyte_ops_mcp.mcp.github_actions import register_github_actions_tools
|
|
29
29
|
from airbyte_ops_mcp.mcp.github_repo_ops import register_github_repo_ops_tools
|
|
30
30
|
from airbyte_ops_mcp.mcp.prerelease import register_prerelease_tools
|
|
31
31
|
from airbyte_ops_mcp.mcp.prod_db_queries import register_prod_db_query_tools
|
|
@@ -59,7 +59,7 @@ def register_server_assets(app: FastMCP) -> None:
|
|
|
59
59
|
"""
|
|
60
60
|
register_server_info_resources(app)
|
|
61
61
|
register_github_repo_ops_tools(app)
|
|
62
|
-
|
|
62
|
+
register_github_actions_tools(app)
|
|
63
63
|
register_prerelease_tools(app)
|
|
64
64
|
register_cloud_connector_version_tools(app)
|
|
65
65
|
register_prod_db_query_tools(app)
|
|
@@ -171,7 +171,7 @@ def _get_github_artifacts_url() -> str | None:
|
|
|
171
171
|
return f"{run_url}#artifacts"
|
|
172
172
|
|
|
173
173
|
|
|
174
|
-
def
|
|
174
|
+
def generate_action_test_comparison_report(
|
|
175
175
|
target_image: str,
|
|
176
176
|
control_image: str,
|
|
177
177
|
command: str,
|
|
@@ -179,16 +179,17 @@ def generate_regression_report(
|
|
|
179
179
|
control_result: dict[str, Any],
|
|
180
180
|
output_dir: Path,
|
|
181
181
|
) -> Path:
|
|
182
|
-
"""Generate a markdown
|
|
182
|
+
"""Generate a markdown comparison report for a single action (command).
|
|
183
183
|
|
|
184
184
|
This creates a comprehensive report with context, message counts comparison,
|
|
185
|
-
and record counts per stream (for read commands). The
|
|
186
|
-
|
|
185
|
+
and record counts per stream (for read commands). The report starts with an
|
|
186
|
+
L2 header containing the command name, making it easy to consolidate multiple
|
|
187
|
+
command reports into a single document.
|
|
187
188
|
|
|
188
189
|
Args:
|
|
189
190
|
target_image: The target (new version) connector image.
|
|
190
191
|
control_image: The control (baseline version) connector image.
|
|
191
|
-
command: The Airbyte command that was run.
|
|
192
|
+
command: The Airbyte command that was run (e.g., "spec", "check", "discover", "read").
|
|
192
193
|
target_result: Results dict from running target connector.
|
|
193
194
|
control_result: Results dict from running control connector.
|
|
194
195
|
output_dir: Directory to write the report to.
|
|
@@ -204,51 +205,22 @@ def generate_regression_report(
|
|
|
204
205
|
target_record_counts = target_result.get("record_counts_per_stream", {})
|
|
205
206
|
control_record_counts = control_result.get("record_counts_per_stream", {})
|
|
206
207
|
|
|
207
|
-
|
|
208
|
-
artifact_name = (
|
|
209
|
-
f"regression-test-artifacts-{run_id}" if run_id else "regression-test-artifacts"
|
|
210
|
-
)
|
|
211
|
-
|
|
208
|
+
# Extract version tags for the summary table
|
|
212
209
|
target_version = (
|
|
213
210
|
target_image.rsplit(":", 1)[-1] if ":" in target_image else "unknown"
|
|
214
211
|
)
|
|
215
212
|
control_version = (
|
|
216
213
|
control_image.rsplit(":", 1)[-1] if ":" in control_image else "unknown"
|
|
217
214
|
)
|
|
218
|
-
connector_name = (
|
|
219
|
-
target_image.rsplit(":", 1)[0] if ":" in target_image else target_image
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
run_url = _get_github_run_url()
|
|
223
|
-
artifacts_url = _get_github_artifacts_url()
|
|
224
215
|
|
|
216
|
+
# Start with L2 header containing the command name (no L1 header)
|
|
217
|
+
# This allows multiple command reports to be concatenated into a single document
|
|
218
|
+
# Note: Context block (connector, versions, workflow links) is added at the workflow level
|
|
225
219
|
lines: list[str] = [
|
|
226
|
-
"
|
|
227
|
-
"",
|
|
228
|
-
"## Context",
|
|
220
|
+
f"## `{command.upper()}` Test Results",
|
|
229
221
|
"",
|
|
230
|
-
f"- **Test Date:** {datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
|
|
231
|
-
f"- **Connector:** `{connector_name}`",
|
|
232
|
-
f"- **Control Version:** `{control_version}`",
|
|
233
|
-
f"- **Target Version:** `{target_version}`",
|
|
234
|
-
f"- **Command:** `{command.upper()}`",
|
|
235
222
|
]
|
|
236
223
|
|
|
237
|
-
if run_url:
|
|
238
|
-
lines.append(f"- **Workflow Run:** [View Execution]({run_url})")
|
|
239
|
-
if artifacts_url:
|
|
240
|
-
lines.append(f"- **Artifacts:** [Download `{artifact_name}`]({artifacts_url})")
|
|
241
|
-
else:
|
|
242
|
-
lines.append(f"- **Artifacts:** `{artifact_name}`")
|
|
243
|
-
|
|
244
|
-
lines.extend(
|
|
245
|
-
[
|
|
246
|
-
"",
|
|
247
|
-
"## Summary",
|
|
248
|
-
"",
|
|
249
|
-
]
|
|
250
|
-
)
|
|
251
|
-
|
|
252
224
|
if regression_detected:
|
|
253
225
|
if target_result["success"] and not control_result["success"]:
|
|
254
226
|
lines.append("**Result:** Target succeeded, control failed (improvement)")
|
|
@@ -261,20 +233,24 @@ def generate_regression_report(
|
|
|
261
233
|
else:
|
|
262
234
|
lines.append("**Result:** Both versions failed")
|
|
263
235
|
|
|
236
|
+
# Use emojis for better scanability
|
|
237
|
+
control_emoji = "✅" if control_result["success"] else "❌"
|
|
238
|
+
target_emoji = "✅" if target_result["success"] else "❌"
|
|
239
|
+
|
|
264
240
|
lines.extend(
|
|
265
241
|
[
|
|
266
242
|
"",
|
|
267
|
-
"| Version | Exit Code |
|
|
268
|
-
"
|
|
269
|
-
f"| Control ({control_version}) | {control_result['exit_code']} | {
|
|
270
|
-
f"| Target ({target_version}) | {target_result['exit_code']} | {
|
|
243
|
+
"| Version | Exit Code | Result |",
|
|
244
|
+
"|---------|-----------|--------|",
|
|
245
|
+
f"| Control (`{control_version}`) | {control_result['exit_code']} | {control_emoji} |",
|
|
246
|
+
f"| Target (`{target_version}`) | {target_result['exit_code']} | {target_emoji} |",
|
|
271
247
|
"",
|
|
272
248
|
]
|
|
273
249
|
)
|
|
274
250
|
|
|
275
251
|
lines.extend(
|
|
276
252
|
[
|
|
277
|
-
"
|
|
253
|
+
"### Command Execution Metrics",
|
|
278
254
|
"",
|
|
279
255
|
]
|
|
280
256
|
)
|
|
@@ -282,7 +258,7 @@ def generate_regression_report(
|
|
|
282
258
|
if target_counts or control_counts:
|
|
283
259
|
lines.extend(
|
|
284
260
|
[
|
|
285
|
-
"
|
|
261
|
+
"#### Message Types",
|
|
286
262
|
"",
|
|
287
263
|
"| Type | Control | Target | Delta |",
|
|
288
264
|
"|------|---------|--------|-------|",
|
|
@@ -294,14 +270,14 @@ def generate_regression_report(
|
|
|
294
270
|
target_count = target_counts.get(msg_type, 0)
|
|
295
271
|
delta = target_count - control_count
|
|
296
272
|
lines.append(
|
|
297
|
-
f"| {msg_type} | {control_count} | {target_count} | {_format_delta(delta)} |"
|
|
273
|
+
f"| `{msg_type}` | {control_count} | {target_count} | {_format_delta(delta)} |"
|
|
298
274
|
)
|
|
299
275
|
lines.append("")
|
|
300
276
|
|
|
301
277
|
if target_record_counts or control_record_counts:
|
|
302
278
|
lines.extend(
|
|
303
279
|
[
|
|
304
|
-
"
|
|
280
|
+
"#### Record Count per Stream",
|
|
305
281
|
"",
|
|
306
282
|
"| Stream | Control | Target | Delta |",
|
|
307
283
|
"|--------|---------|--------|-------|",
|
|
@@ -330,7 +306,7 @@ def generate_regression_report(
|
|
|
330
306
|
if control_http or target_http:
|
|
331
307
|
lines.extend(
|
|
332
308
|
[
|
|
333
|
-
"
|
|
309
|
+
"#### HTTP Metrics",
|
|
334
310
|
"",
|
|
335
311
|
"| Version | Flow Count | Duplicate Flows |",
|
|
336
312
|
"|---------|------------|-----------------|",
|
|
@@ -340,25 +316,130 @@ def generate_regression_report(
|
|
|
340
316
|
]
|
|
341
317
|
)
|
|
342
318
|
|
|
319
|
+
# Note: Execution Details section removed as redundant with Summary table
|
|
320
|
+
|
|
321
|
+
report_content = "\n".join(lines)
|
|
322
|
+
report_path = output_dir / "report.md"
|
|
323
|
+
report_path.write_text(report_content)
|
|
324
|
+
|
|
325
|
+
return report_path
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# Backwards-compatible alias for the old function name
|
|
329
|
+
generate_regression_report = generate_action_test_comparison_report
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def generate_single_version_report(
|
|
333
|
+
connector_image: str,
|
|
334
|
+
command: str,
|
|
335
|
+
result: dict[str, Any],
|
|
336
|
+
output_dir: Path,
|
|
337
|
+
) -> Path:
|
|
338
|
+
"""Generate a markdown report for a single-version regression test.
|
|
339
|
+
|
|
340
|
+
This creates a report with message counts and record counts per stream for a single
|
|
341
|
+
connector run. The report starts with an L2 header containing the command name,
|
|
342
|
+
making it easy to consolidate multiple command reports.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
connector_image: The connector image that was tested.
|
|
346
|
+
command: The Airbyte command that was run (e.g., "spec", "check", "discover", "read").
|
|
347
|
+
result: Results dict from running the connector.
|
|
348
|
+
output_dir: Directory to write the report to.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Path to the generated report.md file.
|
|
352
|
+
"""
|
|
353
|
+
message_counts = result.get("message_counts", {})
|
|
354
|
+
record_counts = result.get("record_counts_per_stream", {})
|
|
355
|
+
|
|
356
|
+
run_id = os.getenv("GITHUB_RUN_ID", "")
|
|
357
|
+
artifact_name = (
|
|
358
|
+
f"regression-test-artifacts-{command}-{run_id}"
|
|
359
|
+
if run_id
|
|
360
|
+
else f"regression-test-artifacts-{command}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
version = (
|
|
364
|
+
connector_image.rsplit(":", 1)[-1] if ":" in connector_image else "unknown"
|
|
365
|
+
)
|
|
366
|
+
connector_name = (
|
|
367
|
+
connector_image.rsplit(":", 1)[0] if ":" in connector_image else connector_image
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
run_url = _get_github_run_url()
|
|
371
|
+
artifacts_url = _get_github_artifacts_url()
|
|
372
|
+
|
|
373
|
+
# Start with L2 header containing the command name (no L1 header)
|
|
374
|
+
lines: list[str] = [
|
|
375
|
+
f"## `{command.upper()}` Test Results",
|
|
376
|
+
"",
|
|
377
|
+
"### Context",
|
|
378
|
+
"",
|
|
379
|
+
f"- **Test Date:** {datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
|
|
380
|
+
f"- **Connector:** `{connector_name}`",
|
|
381
|
+
f"- **Version:** `{version}`",
|
|
382
|
+
f"- **Command:** `{command.upper()}`",
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
if run_url:
|
|
386
|
+
lines.append(f"- **Workflow Run:** [View Execution]({run_url})")
|
|
387
|
+
if artifacts_url:
|
|
388
|
+
lines.append(f"- **Artifacts:** [Download `{artifact_name}`]({artifacts_url})")
|
|
389
|
+
else:
|
|
390
|
+
lines.append(f"- **Artifacts:** `{artifact_name}`")
|
|
391
|
+
|
|
343
392
|
lines.extend(
|
|
344
393
|
[
|
|
345
|
-
"## Execution Details",
|
|
346
394
|
"",
|
|
347
|
-
"###
|
|
395
|
+
"### Summary",
|
|
396
|
+
"",
|
|
397
|
+
f"**Result:** {'PASS' if result['success'] else 'FAIL'}",
|
|
348
398
|
"",
|
|
349
|
-
f"- **
|
|
350
|
-
f"- **
|
|
351
|
-
f"- **Success:** {control_result['success']}",
|
|
352
|
-
f"- **Stdout:** `{control_result.get('stdout_file', 'N/A')}`",
|
|
353
|
-
f"- **Stderr:** `{control_result.get('stderr_file', 'N/A')}`",
|
|
399
|
+
f"- **Exit Code:** {result['exit_code']}",
|
|
400
|
+
f"- **Success:** {result['success']}",
|
|
354
401
|
"",
|
|
355
|
-
|
|
402
|
+
]
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if message_counts:
|
|
406
|
+
lines.extend(
|
|
407
|
+
[
|
|
408
|
+
"### Message Types",
|
|
409
|
+
"",
|
|
410
|
+
"| Type | Count |",
|
|
411
|
+
"|------|-------|",
|
|
412
|
+
]
|
|
413
|
+
)
|
|
414
|
+
for msg_type in sorted(message_counts.keys()):
|
|
415
|
+
count = message_counts[msg_type]
|
|
416
|
+
lines.append(f"| `{msg_type}` | {count} |")
|
|
417
|
+
lines.append("")
|
|
418
|
+
|
|
419
|
+
if record_counts:
|
|
420
|
+
lines.extend(
|
|
421
|
+
[
|
|
422
|
+
"### Record Count per Stream",
|
|
423
|
+
"",
|
|
424
|
+
"| Stream | Count |",
|
|
425
|
+
"|--------|-------|",
|
|
426
|
+
]
|
|
427
|
+
)
|
|
428
|
+
total = 0
|
|
429
|
+
for stream in sorted(record_counts.keys()):
|
|
430
|
+
count = record_counts[stream]
|
|
431
|
+
total += count
|
|
432
|
+
lines.append(f"| {stream} | {count} |")
|
|
433
|
+
lines.append(f"| **Total** | **{total}** |")
|
|
434
|
+
lines.append("")
|
|
435
|
+
|
|
436
|
+
lines.extend(
|
|
437
|
+
[
|
|
438
|
+
"### Execution Details",
|
|
356
439
|
"",
|
|
357
|
-
f"- **Image:** `{
|
|
358
|
-
f"- **
|
|
359
|
-
f"- **
|
|
360
|
-
f"- **Stdout:** `{target_result.get('stdout_file', 'N/A')}`",
|
|
361
|
-
f"- **Stderr:** `{target_result.get('stderr_file', 'N/A')}`",
|
|
440
|
+
f"- **Image:** `{connector_image}`",
|
|
441
|
+
f"- **Stdout:** `{result.get('stdout_file', 'N/A')}`",
|
|
442
|
+
f"- **Stderr:** `{result.get('stderr_file', 'N/A')}`",
|
|
362
443
|
"",
|
|
363
444
|
]
|
|
364
445
|
)
|
|
@@ -373,9 +454,6 @@ def generate_regression_report(
|
|
|
373
454
|
def get_report_summary(report_path: Path) -> str:
|
|
374
455
|
"""Get a brief summary pointing to the full report.
|
|
375
456
|
|
|
376
|
-
Args:
|
|
377
|
-
report_path: Path to the full report.md file.
|
|
378
|
-
|
|
379
457
|
Returns:
|
|
380
458
|
Brief markdown summary for GITHUB_STEP_SUMMARY.
|
|
381
459
|
"""
|
|
@@ -393,7 +471,5 @@ def get_report_summary(report_path: Path) -> str:
|
|
|
393
471
|
|
|
394
472
|
return f"""## Regression Test Report
|
|
395
473
|
|
|
396
|
-
Full report available in
|
|
397
|
-
|
|
398
|
-
See the Checks tab for the complete report with message counts and execution details.
|
|
474
|
+
Full report available in artifact {artifact_link}.
|
|
399
475
|
"""
|
|
File without changes
|
{airbyte_internal_ops-0.4.0.dist-info → airbyte_internal_ops-0.4.2.dist-info}/entry_points.txt
RENAMED
|
File without changes
|