airbyte-internal-ops 0.2.2__py3-none-any.whl → 0.2.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: airbyte-internal-ops
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: MCP and API interfaces that let the agents do the admin work
5
5
  Author-email: Aaron Steers <aj@airbyte.io>
6
6
  Keywords: admin,airbyte,api,mcp
@@ -2,7 +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=GeZ2_WWluMSrGkyqGvqUVFCy-5PD-lyzZbQ7eO-vyUo,5192
4
4
  airbyte_ops_mcp/gcp_auth.py,sha256=5k-k145ZoYhHLjyDES8nrA8f8BBihRI0ykrdD1IcfOs,3599
5
- airbyte_ops_mcp/github_actions.py,sha256=hcwwew98r0yetWsM7Qmdar3ATLBJQGIn3fJfJ_n59So,8599
5
+ airbyte_ops_mcp/github_actions.py,sha256=wKnuIVmF4u1gMYNdSoryD_PUmvMz5SaHgOvbU0dsolA,9957
6
+ airbyte_ops_mcp/github_api.py,sha256=uupbYKAkm7yLHK_1cDXYKl1bOYhUygZhG5IHspS7duE,8104
6
7
  airbyte_ops_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
8
  airbyte_ops_mcp/_legacy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
9
  airbyte_ops_mcp/_legacy/airbyte_ci/README.md,sha256=qEYx4geDR8AEDjrcA303h7Nol-CMDLojxUyiGzQprM8,236
@@ -354,7 +355,7 @@ airbyte_ops_mcp/cli/_shared.py,sha256=jg-xMyGzTCGPqKd8VTfE_3kGPIyO_3Kx5sQbG4rPc0
354
355
  airbyte_ops_mcp/cli/app.py,sha256=SEdBpqFUG2O8zGV5ifwptxrLGFph_dLr66-MX9d69gQ,789
355
356
  airbyte_ops_mcp/cli/cloud.py,sha256=JJusGl67ca41rvoS8BPl5Kmb7Kyu7iMH-tvbaJsKsPs,41359
356
357
  airbyte_ops_mcp/cli/gh.py,sha256=91b1AxFXvHQCFyXhrrym-756ZjnMCqvxFdmwCtma1zI,2046
357
- airbyte_ops_mcp/cli/registry.py,sha256=-yiLJWSslV_qGi6ImXZYfXOJSE4oJBO7yICkyA_RiUo,5792
358
+ airbyte_ops_mcp/cli/registry.py,sha256=tcf_CDiUVJpSdBRNqlEL3zFKMqK53AhFpJjAETM4gLs,9781
358
359
  airbyte_ops_mcp/cli/repo.py,sha256=G1hoQpH0XYhUH3FFOsia9xabGB0LP9o3XcwBuqvFVo0,16331
359
360
  airbyte_ops_mcp/cloud_admin/__init__.py,sha256=cqE96Q10Kp6elhH9DAi6TVsIwSUy3sooDLLrxTaktGk,816
360
361
  airbyte_ops_mcp/cloud_admin/api_client.py,sha256=tx1kwGIKMPesibflQkFOlbNp0t0CfJD4Ab097ngsjHA,19126
@@ -369,13 +370,13 @@ airbyte_ops_mcp/mcp/__init__.py,sha256=QqkNkxzdXlg-W03urBAQ3zmtOKFPf35rXgO9ceUjp
369
370
  airbyte_ops_mcp/mcp/_guidance.py,sha256=48tQSnDnxqXtyGJxxgjz0ZiI814o_7Fj7f6R8jpQ7so,2375
370
371
  airbyte_ops_mcp/mcp/_http_headers.py,sha256=9TAH2RYhFR3z2JugW4Q3WrrqJIdaCzAbyA1GhtQ_EMM,7278
371
372
  airbyte_ops_mcp/mcp/_mcp_utils.py,sha256=WNwcGzF7XGKZNAYRt0Uhj5BkRfmwqnFABCrk77OZjRw,11512
372
- airbyte_ops_mcp/mcp/cloud_connector_versions.py,sha256=1ppgqRo6GU_KltSLzTZluPiPlAK74gMygCJJue7bezs,14751
373
+ airbyte_ops_mcp/mcp/cloud_connector_versions.py,sha256=sSMTMk1_2zqD-fr5EENZ1FgbBT6mpNHBrRJuk0jm_iI,15391
373
374
  airbyte_ops_mcp/mcp/connector_analysis.py,sha256=OC4KrOSkMkKPkOisWnSv96BDDE5TQYHq-Jxa2vtjJpo,298
374
375
  airbyte_ops_mcp/mcp/connector_qa.py,sha256=aImpqdnqBPDrz10BS0owsV4kuIU2XdalzgbaGZsbOL0,258
375
376
  airbyte_ops_mcp/mcp/github.py,sha256=h3M3VJrq09y_F9ueQVCq3bUbVBNFuTNKprHtGU_ttio,8045
376
377
  airbyte_ops_mcp/mcp/github_repo_ops.py,sha256=PiERpt8abo20Gz4CfXhrDNlVM4o4FOt5sweZJND2a0s,5314
377
378
  airbyte_ops_mcp/mcp/metadata.py,sha256=fwGW97WknR5lfKcQnFtK6dU87aA6TmLj1NkKyqDAV9g,270
378
- airbyte_ops_mcp/mcp/prerelease.py,sha256=6G4zMo0KeCIYJPEIryHKHoZUiBHQMagPJU-uw-IzK94,8939
379
+ airbyte_ops_mcp/mcp/prerelease.py,sha256=nc6VU03ADVHWM3OjGKxbS5XqY4VoyRyrZNU_fyAtaOI,10465
379
380
  airbyte_ops_mcp/mcp/prod_db_queries.py,sha256=FfGoq3aEj6ZUT4ysBIs1w7LzzwBeRXTaRvPGEx62RzI,25474
380
381
  airbyte_ops_mcp/mcp/prompts.py,sha256=mJld9mdPECXYZffWXGSvNs4Xevx3rxqUGNlzGKVC2_s,1599
381
382
  airbyte_ops_mcp/mcp/registry.py,sha256=PW-VYUj42qx2pQ_apUkVaoUFq7VgB9zEU7-aGrkSCCw,290
@@ -410,7 +411,7 @@ airbyte_ops_mcp/regression_tests/regression/comparators.py,sha256=MJkLZEKHivgrG0
410
411
  airbyte_ops_mcp/regression_tests/validation/__init__.py,sha256=MBEwGOoNuqT4_oCahtoK62OKWIjUCfWa7vZTxNj_0Ek,1532
411
412
  airbyte_ops_mcp/regression_tests/validation/catalog_validators.py,sha256=jqqVAMOk0mtdPgwu4d0hA0ZEjtsNh5gapvGydRv3_qk,12553
412
413
  airbyte_ops_mcp/regression_tests/validation/record_validators.py,sha256=RjauAhKWNwxMBTu0eNS2hMFNQVs5CLbQU51kp6FOVDk,7432
413
- airbyte_internal_ops-0.2.2.dist-info/METADATA,sha256=1ah9ZGR3rZ1676mhAJzJvrbfqOQsV_fuedRitpJG9h8,5679
414
- airbyte_internal_ops-0.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
415
- airbyte_internal_ops-0.2.2.dist-info/entry_points.txt,sha256=WxP0l7bRFss4Cr5uQqVj9mTEKwnRKouNuphXQF0lotA,171
416
- airbyte_internal_ops-0.2.2.dist-info/RECORD,,
414
+ airbyte_internal_ops-0.2.4.dist-info/METADATA,sha256=MN7C0ze-rXRcES0sQ2IS_HjBQHKTaZ_8tP3N1WrVT88,5679
415
+ airbyte_internal_ops-0.2.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
416
+ airbyte_internal_ops-0.2.4.dist-info/entry_points.txt,sha256=WxP0l7bRFss4Cr5uQqVj9mTEKwnRKouNuphXQF0lotA,171
417
+ airbyte_internal_ops-0.2.4.dist-info/RECORD,,
@@ -5,6 +5,7 @@ This module provides CLI wrappers for registry operations. The core logic
5
5
  lives in the `airbyte_ops_mcp.registry` capability module.
6
6
 
7
7
  Commands:
8
+ airbyte-ops registry connector compute-prerelease-tag - Compute prerelease version tag
8
9
  airbyte-ops registry connector publish-prerelease - Publish connector prerelease
9
10
  airbyte-ops registry connector publish - Publish connector (apply/rollback version override)
10
11
  airbyte-ops registry image inspect - Inspect Docker image on DockerHub
@@ -12,9 +13,12 @@ Commands:
12
13
 
13
14
  from __future__ import annotations
14
15
 
16
+ import contextlib
17
+ import sys
15
18
  from pathlib import Path
16
19
  from typing import Annotated
17
20
 
21
+ import yaml
18
22
  from cyclopts import App, Parameter
19
23
 
20
24
  from airbyte_ops_mcp.cli._base import app
@@ -24,8 +28,15 @@ from airbyte_ops_mcp.cli._shared import (
24
28
  print_json,
25
29
  print_success,
26
30
  )
31
+ from airbyte_ops_mcp.github_actions import (
32
+ get_file_contents_at_ref,
33
+ resolve_github_token,
34
+ )
27
35
  from airbyte_ops_mcp.mcp.github import get_docker_image_info
28
- from airbyte_ops_mcp.mcp.prerelease import publish_connector_to_airbyte_registry
36
+ from airbyte_ops_mcp.mcp.prerelease import (
37
+ compute_prerelease_docker_image_tag,
38
+ publish_connector_to_airbyte_registry,
39
+ )
29
40
  from airbyte_ops_mcp.registry import (
30
41
  ConnectorPublishResult,
31
42
  PublishAction,
@@ -47,6 +58,111 @@ image_app = App(name="image", help="Docker image operations.")
47
58
  registry_app.command(image_app)
48
59
 
49
60
 
61
+ AIRBYTE_REPO_OWNER = "airbytehq"
62
+ AIRBYTE_REPO_NAME = "airbyte"
63
+ CONNECTOR_PATH_PREFIX = "airbyte-integrations/connectors"
64
+
65
+
66
+ def _get_connector_version_from_github(
67
+ connector_name: str,
68
+ ref: str,
69
+ token: str | None = None,
70
+ ) -> str | None:
71
+ """Fetch connector version from metadata.yaml via GitHub API.
72
+
73
+ Args:
74
+ connector_name: Connector name (e.g., "source-github")
75
+ ref: Git ref (commit SHA, branch name, or tag)
76
+ token: GitHub API token (optional for public repos)
77
+
78
+ Returns:
79
+ Version string from metadata.yaml, or None if not found.
80
+ """
81
+ path = f"{CONNECTOR_PATH_PREFIX}/{connector_name}/metadata.yaml"
82
+ contents = get_file_contents_at_ref(
83
+ owner=AIRBYTE_REPO_OWNER,
84
+ repo=AIRBYTE_REPO_NAME,
85
+ path=path,
86
+ ref=ref,
87
+ token=token,
88
+ )
89
+ if contents is None:
90
+ return None
91
+
92
+ metadata = yaml.safe_load(contents)
93
+ return metadata.get("data", {}).get("dockerImageTag")
94
+
95
+
96
+ @connector_app.command(name="compute-prerelease-tag")
97
+ def compute_prerelease_tag(
98
+ connector_name: Annotated[
99
+ str,
100
+ Parameter(help="Connector name (e.g., 'source-github')."),
101
+ ],
102
+ sha: Annotated[
103
+ str,
104
+ Parameter(help="Git commit SHA (full or at least 7 characters)."),
105
+ ],
106
+ base_version: Annotated[
107
+ str | None,
108
+ Parameter(
109
+ help="Base version override. If not provided, fetched from metadata.yaml at the given SHA."
110
+ ),
111
+ ] = None,
112
+ ) -> None:
113
+ """Compute the pre-release docker image tag.
114
+
115
+ Outputs the version tag to stdout for easy capture in shell scripts.
116
+ This is the single source of truth for pre-release version format.
117
+
118
+ The command fetches the connector's metadata.yaml from GitHub at the given SHA
119
+ to determine the base version. It also compares against the master branch and
120
+ prints a warning to stderr if no version bump is detected.
121
+
122
+ If --base-version is provided, it is used directly instead of fetching from GitHub.
123
+
124
+ Example:
125
+ airbyte-ops registry connector compute-prerelease-tag --connector-name source-github --sha abcdef1234567
126
+ # Output: 1.2.3-preview.abcdef1
127
+
128
+ airbyte-ops registry connector compute-prerelease-tag --connector-name source-github --sha abcdef1234567 --base-version 1.2.3
129
+ # Output: 1.2.3-preview.abcdef1 (uses provided version, skips GitHub API)
130
+ """
131
+ # Try to get a GitHub token (optional, but helps avoid rate limiting)
132
+ # Token resolution may fail if no token is configured, which is fine for public repos
133
+ token: str | None = None
134
+ with contextlib.suppress(ValueError):
135
+ token = resolve_github_token()
136
+
137
+ # Determine base version
138
+ version: str
139
+ if base_version:
140
+ version = base_version
141
+ else:
142
+ # Fetch version from metadata.yaml at the given SHA
143
+ fetched_version = _get_connector_version_from_github(connector_name, sha, token)
144
+ if fetched_version is None:
145
+ print(
146
+ f"Error: Could not fetch metadata.yaml for {connector_name} at ref {sha}",
147
+ file=sys.stderr,
148
+ )
149
+ sys.exit(1)
150
+ version = fetched_version
151
+
152
+ # Compare with master branch version and warn if no bump detected
153
+ master_version = _get_connector_version_from_github(connector_name, "master", token)
154
+ if master_version and master_version == version:
155
+ print(
156
+ f"Warning: No version bump detected for {connector_name}. "
157
+ f"Version {version} matches master branch.",
158
+ file=sys.stderr,
159
+ )
160
+
161
+ # Compute and output the prerelease tag
162
+ tag = compute_prerelease_docker_image_tag(version, sha)
163
+ print(tag)
164
+
165
+
50
166
  @connector_app.command(name="publish-prerelease")
51
167
  def publish_prerelease(
52
168
  connector_name: Annotated[
@@ -106,6 +106,51 @@ class WorkflowJobInfo:
106
106
  """ISO 8601 timestamp when the job completed"""
107
107
 
108
108
 
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
+
109
154
  def get_workflow_jobs(
110
155
  owner: str,
111
156
  repo: str,
@@ -0,0 +1,264 @@
1
+ # Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2
+ """GitHub API utilities for user and comment operations.
3
+
4
+ This module provides utilities for interacting with GitHub's REST API
5
+ to retrieve user information and comment details. These utilities are
6
+ used by MCP tools for authorization and audit purposes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from urllib.parse import urlparse
14
+
15
+ import requests
16
+
17
+ from airbyte_ops_mcp.github_actions import GITHUB_API_BASE, resolve_github_token
18
+
19
+
20
+ class GitHubCommentParseError(Exception):
21
+ """Raised when a GitHub comment URL cannot be parsed."""
22
+
23
+
24
+ class GitHubUserEmailNotFoundError(Exception):
25
+ """Raised when a GitHub user's public email cannot be found."""
26
+
27
+
28
+ class GitHubAPIError(Exception):
29
+ """Raised when a GitHub API call fails."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class GitHubCommentInfo:
34
+ """Information about a GitHub comment and its author."""
35
+
36
+ comment_id: int
37
+ """The numeric comment ID."""
38
+
39
+ owner: str
40
+ """Repository owner (e.g., 'airbytehq')."""
41
+
42
+ repo: str
43
+ """Repository name (e.g., 'oncall')."""
44
+
45
+ author_login: str
46
+ """GitHub username of the comment author."""
47
+
48
+ author_association: str
49
+ """Author's association with the repo (e.g., 'MEMBER', 'OWNER', 'CONTRIBUTOR')."""
50
+
51
+ comment_type: str
52
+ """Type of comment: 'issue_comment' or 'review_comment'."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class GitHubUserInfo:
57
+ """Information about a GitHub user."""
58
+
59
+ login: str
60
+ """GitHub username."""
61
+
62
+ email: str | None
63
+ """Public email address, if set."""
64
+
65
+ name: str | None
66
+ """Display name, if set."""
67
+
68
+
69
+ def _parse_github_comment_url(url: str) -> tuple[str, str, int, str]:
70
+ """Parse a GitHub comment URL to extract owner, repo, comment_id, and comment_type.
71
+
72
+ Supports two URL formats:
73
+ - Issue/PR timeline comments: https://github.com/{owner}/{repo}/issues/{num}#issuecomment-{id}
74
+ - PR review comments: https://github.com/{owner}/{repo}/pull/{num}#discussion_r{id}
75
+
76
+ Args:
77
+ url: GitHub comment URL.
78
+
79
+ Returns:
80
+ Tuple of (owner, repo, comment_id, comment_type).
81
+ comment_type is either 'issue_comment' or 'review_comment'.
82
+
83
+ Raises:
84
+ GitHubCommentParseError: If the URL cannot be parsed.
85
+ """
86
+ parsed = urlparse(url)
87
+
88
+ if parsed.scheme != "https":
89
+ raise GitHubCommentParseError(
90
+ f"Invalid URL scheme: expected 'https', got '{parsed.scheme}'"
91
+ )
92
+
93
+ if parsed.netloc != "github.com":
94
+ raise GitHubCommentParseError(
95
+ f"Invalid URL host: expected 'github.com', got '{parsed.netloc}'"
96
+ )
97
+
98
+ path_parts = parsed.path.strip("/").split("/")
99
+ if len(path_parts) < 2:
100
+ raise GitHubCommentParseError(
101
+ f"Invalid URL path: expected at least owner/repo, got '{parsed.path}'"
102
+ )
103
+
104
+ owner = path_parts[0]
105
+ repo = path_parts[1]
106
+ fragment = parsed.fragment
107
+
108
+ issue_comment_match = re.match(r"^issuecomment-(\d+)$", fragment)
109
+ if issue_comment_match:
110
+ comment_id = int(issue_comment_match.group(1))
111
+ return owner, repo, comment_id, "issue_comment"
112
+
113
+ review_comment_match = re.match(r"^discussion_r(\d+)$", fragment)
114
+ if review_comment_match:
115
+ comment_id = int(review_comment_match.group(1))
116
+ return owner, repo, comment_id, "review_comment"
117
+
118
+ raise GitHubCommentParseError(
119
+ f"Invalid URL fragment: expected '#issuecomment-<id>' or '#discussion_r<id>', "
120
+ f"got '#{fragment}'"
121
+ )
122
+
123
+
124
+ def get_github_comment_info(
125
+ owner: str,
126
+ repo: str,
127
+ comment_id: int,
128
+ comment_type: str,
129
+ token: str | None = None,
130
+ ) -> GitHubCommentInfo:
131
+ """Fetch comment information from GitHub API.
132
+
133
+ Args:
134
+ owner: Repository owner.
135
+ repo: Repository name.
136
+ comment_id: Numeric comment ID.
137
+ comment_type: Either 'issue_comment' or 'review_comment'.
138
+ token: GitHub API token. If None, will be resolved from environment.
139
+
140
+ Returns:
141
+ GitHubCommentInfo with comment and author details.
142
+
143
+ Raises:
144
+ GitHubAPIError: If the API request fails.
145
+ ValueError: If comment_type is invalid.
146
+ """
147
+ if token is None:
148
+ token = resolve_github_token()
149
+
150
+ if comment_type == "issue_comment":
151
+ url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/comments/{comment_id}"
152
+ elif comment_type == "review_comment":
153
+ url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/comments/{comment_id}"
154
+ else:
155
+ raise ValueError(f"Invalid comment_type: {comment_type}")
156
+
157
+ headers = {
158
+ "Authorization": f"Bearer {token}",
159
+ "Accept": "application/vnd.github+json",
160
+ "X-GitHub-Api-Version": "2022-11-28",
161
+ }
162
+
163
+ response = requests.get(url, headers=headers, timeout=30)
164
+ if not response.ok:
165
+ raise GitHubAPIError(
166
+ f"Failed to fetch comment {comment_id} from {owner}/{repo}: "
167
+ f"{response.status_code} {response.text}"
168
+ )
169
+
170
+ data = response.json()
171
+ user = data.get("user", {})
172
+
173
+ return GitHubCommentInfo(
174
+ comment_id=comment_id,
175
+ owner=owner,
176
+ repo=repo,
177
+ author_login=user.get("login", ""),
178
+ author_association=data.get("author_association", "NONE"),
179
+ comment_type=comment_type,
180
+ )
181
+
182
+
183
+ def get_github_user_info(login: str, token: str | None = None) -> GitHubUserInfo:
184
+ """Fetch user information from GitHub API.
185
+
186
+ Args:
187
+ login: GitHub username.
188
+ token: GitHub API token. If None, will be resolved from environment.
189
+
190
+ Returns:
191
+ GitHubUserInfo with user details.
192
+
193
+ Raises:
194
+ GitHubAPIError: If the API request fails.
195
+ """
196
+ if token is None:
197
+ token = resolve_github_token()
198
+
199
+ url = f"{GITHUB_API_BASE}/users/{login}"
200
+ headers = {
201
+ "Authorization": f"Bearer {token}",
202
+ "Accept": "application/vnd.github+json",
203
+ "X-GitHub-Api-Version": "2022-11-28",
204
+ }
205
+
206
+ response = requests.get(url, headers=headers, timeout=30)
207
+ if not response.ok:
208
+ raise GitHubAPIError(
209
+ f"Failed to fetch user {login}: {response.status_code} {response.text}"
210
+ )
211
+
212
+ data = response.json()
213
+
214
+ return GitHubUserInfo(
215
+ login=data.get("login", login),
216
+ email=data.get("email"),
217
+ name=data.get("name"),
218
+ )
219
+
220
+
221
+ def get_admin_email_from_approval_comment(approval_comment_url: str) -> str:
222
+ """Derive the admin email from a GitHub approval comment URL.
223
+
224
+ This function:
225
+ 1. Parses the comment URL to extract owner, repo, and comment ID.
226
+ 2. Fetches the comment from GitHub API to get the author's username.
227
+ 3. Fetches the user's profile to get their public email.
228
+ 4. Validates the email is an @airbyte.io address.
229
+
230
+ Args:
231
+ approval_comment_url: GitHub comment URL where approval was given.
232
+
233
+ Returns:
234
+ The admin's @airbyte.io email address.
235
+
236
+ Raises:
237
+ GitHubCommentParseError: If the URL cannot be parsed.
238
+ GitHubAPIError: If GitHub API calls fail.
239
+ GitHubUserEmailNotFoundError: If the user has no public email or
240
+ the email is not an @airbyte.io address.
241
+ """
242
+ owner, repo, comment_id, comment_type = _parse_github_comment_url(
243
+ approval_comment_url
244
+ )
245
+
246
+ comment_info = get_github_comment_info(owner, repo, comment_id, comment_type)
247
+
248
+ user_info = get_github_user_info(comment_info.author_login)
249
+
250
+ if not user_info.email:
251
+ raise GitHubUserEmailNotFoundError(
252
+ f"GitHub user '{comment_info.author_login}' does not have a public email set. "
253
+ f"To use this tool, the approver must have a public @airbyte.io email "
254
+ f"configured on their GitHub profile (Settings > Public email)."
255
+ )
256
+
257
+ if not user_info.email.endswith("@airbyte.io"):
258
+ raise GitHubUserEmailNotFoundError(
259
+ f"GitHub user '{comment_info.author_login}' has public email '{user_info.email}' "
260
+ f"which is not an @airbyte.io address. Only @airbyte.io emails are authorized "
261
+ f"for admin operations."
262
+ )
263
+
264
+ return user_info.email
@@ -27,6 +27,12 @@ from airbyte_ops_mcp.cloud_admin.models import (
27
27
  ConnectorVersionInfo,
28
28
  VersionOverrideOperationResult,
29
29
  )
30
+ from airbyte_ops_mcp.github_api import (
31
+ GitHubAPIError,
32
+ GitHubCommentParseError,
33
+ GitHubUserEmailNotFoundError,
34
+ get_admin_email_from_approval_comment,
35
+ )
30
36
  from airbyte_ops_mcp.mcp._http_headers import (
31
37
  resolve_bearer_token,
32
38
  resolve_client_id,
@@ -155,6 +161,15 @@ def set_cloud_connector_version_override(
155
161
  Literal["source", "destination"],
156
162
  "The type of connector (source or destination)",
157
163
  ],
164
+ approval_comment_url: Annotated[
165
+ str,
166
+ Field(
167
+ description="URL to a GitHub comment where the admin has explicitly "
168
+ "requested or authorized this deployment. Must be a valid GitHub comment URL. "
169
+ "Required for authorization. The admin email is automatically derived from "
170
+ "the comment author's GitHub profile.",
171
+ ),
172
+ ],
158
173
  version: Annotated[
159
174
  str | None,
160
175
  Field(
@@ -186,14 +201,6 @@ def set_cloud_connector_version_override(
186
201
  default=None,
187
202
  ),
188
203
  ],
189
- admin_user_email: Annotated[
190
- str | None,
191
- Field(
192
- description="Email of the admin user authorizing this operation. "
193
- "Must be an @airbyte.io email address. Required for authorization.",
194
- default=None,
195
- ),
196
- ],
197
204
  issue_url: Annotated[
198
205
  str | None,
199
206
  Field(
@@ -202,15 +209,6 @@ def set_cloud_connector_version_override(
202
209
  default=None,
203
210
  ),
204
211
  ],
205
- approval_comment_url: Annotated[
206
- str | None,
207
- Field(
208
- description="URL to a GitHub comment where the admin has explicitly "
209
- "requested or authorized this deployment. Must be a valid GitHub comment URL. "
210
- "Required for authorization.",
211
- default=None,
212
- ),
213
- ],
214
212
  ai_agent_session_url: Annotated[
215
213
  str | None,
216
214
  Field(
@@ -224,9 +222,13 @@ def set_cloud_connector_version_override(
224
222
 
225
223
  **Admin-only operation** - Requires:
226
224
  - AIRBYTE_INTERNAL_ADMIN_FLAG=airbyte.io environment variable
227
- - admin_user_email parameter (must be @airbyte.io email)
228
225
  - issue_url parameter (GitHub issue URL for context)
229
- - approval_comment_url parameter (GitHub comment URL with approval)
226
+ - approval_comment_url parameter (GitHub comment URL with approval from an @airbyte.io user)
227
+
228
+ The admin user email is automatically derived from the approval_comment_url by:
229
+ 1. Fetching the comment from GitHub API to get the author's username
230
+ 2. Fetching the user's profile to get their public email
231
+ 3. Validating the email is an @airbyte.io address
230
232
 
231
233
  You must specify EXACTLY ONE of `version` OR `unset=True`, but not both.
232
234
  When setting a version, `override_reason` is required.
@@ -252,16 +254,9 @@ def set_cloud_connector_version_override(
252
254
  connector_type=actor_type,
253
255
  )
254
256
 
255
- # Validate new authorization parameters
257
+ # Validate authorization parameters
256
258
  validation_errors: list[str] = []
257
259
 
258
- if not admin_user_email:
259
- validation_errors.append("admin_user_email is required for authorization")
260
- elif "@airbyte.io" not in admin_user_email:
261
- validation_errors.append(
262
- f"admin_user_email must be an @airbyte.io email address, got: {admin_user_email}"
263
- )
264
-
265
260
  if not issue_url:
266
261
  validation_errors.append(
267
262
  "issue_url is required for authorization (GitHub issue URL)"
@@ -271,11 +266,7 @@ def set_cloud_connector_version_override(
271
266
  f"issue_url must be a valid GitHub URL (https://github.com/...), got: {issue_url}"
272
267
  )
273
268
 
274
- if not approval_comment_url:
275
- validation_errors.append(
276
- "approval_comment_url is required for authorization (GitHub comment URL)"
277
- )
278
- elif not approval_comment_url.startswith("https://github.com/"):
269
+ if not approval_comment_url.startswith("https://github.com/"):
279
270
  validation_errors.append(
280
271
  f"approval_comment_url must be a valid GitHub URL, got: {approval_comment_url}"
281
272
  )
@@ -296,6 +287,31 @@ def set_cloud_connector_version_override(
296
287
  connector_type=actor_type,
297
288
  )
298
289
 
290
+ # Derive admin email from approval comment URL
291
+ try:
292
+ admin_user_email = get_admin_email_from_approval_comment(approval_comment_url)
293
+ except GitHubCommentParseError as e:
294
+ return VersionOverrideOperationResult(
295
+ success=False,
296
+ message=f"Failed to parse approval comment URL: {e}",
297
+ connector_id=actor_id,
298
+ connector_type=actor_type,
299
+ )
300
+ except GitHubAPIError as e:
301
+ return VersionOverrideOperationResult(
302
+ success=False,
303
+ message=f"Failed to fetch approval comment from GitHub: {e}",
304
+ connector_id=actor_id,
305
+ connector_type=actor_type,
306
+ )
307
+ except GitHubUserEmailNotFoundError as e:
308
+ return VersionOverrideOperationResult(
309
+ success=False,
310
+ message=str(e),
311
+ connector_id=actor_id,
312
+ connector_type=actor_type,
313
+ )
314
+
299
315
  # Build enhanced override reason with audit fields (only for 'set' operations)
300
316
  enhanced_override_reason = override_reason
301
317
  if not unset and override_reason:
@@ -31,6 +31,45 @@ PRERELEASE_TOKEN_ENV_VARS = [
31
31
  "GITHUB_TOKEN",
32
32
  ]
33
33
 
34
+ # =============================================================================
35
+ # Pre-release Version Tag Constants
36
+ # =============================================================================
37
+
38
+ PRERELEASE_TAG_PREFIX = "preview"
39
+ """The prefix used for pre-release version tags (e.g., '1.2.3-preview.abcde12')."""
40
+
41
+ PRERELEASE_SHA_LENGTH = 7
42
+ """The number of characters from the git SHA to include in pre-release tags."""
43
+
44
+
45
+ def compute_prerelease_docker_image_tag(base_version: str, sha: str) -> str:
46
+ """Compute the pre-release docker image tag.
47
+
48
+ This is the SINGLE SOURCE OF TRUTH for pre-release version format.
49
+ All other code should receive this value as a parameter, not recompute it.
50
+
51
+ The format is: {base_version}-preview.{short_sha}
52
+
53
+ Where:
54
+ - base_version: The base version from metadata.yaml (e.g., "1.2.3")
55
+ - short_sha: The first 7 characters of the git commit SHA
56
+
57
+ Examples:
58
+ >>> compute_prerelease_docker_image_tag("1.2.3", "abcdef1234567890")
59
+ '1.2.3-preview.abcdef1'
60
+ >>> compute_prerelease_docker_image_tag("0.1.0", "1234567")
61
+ '0.1.0-preview.1234567'
62
+
63
+ Args:
64
+ base_version: The base version from metadata.yaml (e.g., "1.2.3")
65
+ sha: The full git commit SHA (or at least 7 characters)
66
+
67
+ Returns:
68
+ Pre-release version tag (e.g., "1.2.3-preview.abcde12")
69
+ """
70
+ short_sha = sha[:PRERELEASE_SHA_LENGTH]
71
+ return f"{base_version}-{PRERELEASE_TAG_PREFIX}.{short_sha}"
72
+
34
73
 
35
74
  class PRHeadInfo(BaseModel):
36
75
  """Information about a PR's head commit."""
@@ -268,7 +307,9 @@ def publish_connector_to_airbyte_registry(
268
307
  docker_image = data.get("dockerRepository")
269
308
  base_version = data.get("dockerImageTag")
270
309
  if base_version:
271
- docker_image_tag = f"{base_version}-preview.{head_info.short_sha}"
310
+ docker_image_tag = compute_prerelease_docker_image_tag(
311
+ base_version, head_info.sha
312
+ )
272
313
 
273
314
  return PrereleaseWorkflowResult(
274
315
  success=True,