airbyte-internal-ops 0.2.3__py3-none-any.whl → 0.3.0__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.2.3.dist-info → airbyte_internal_ops-0.3.0.dist-info}/METADATA +1 -1
- {airbyte_internal_ops-0.2.3.dist-info → airbyte_internal_ops-0.3.0.dist-info}/RECORD +13 -9
- airbyte_ops_mcp/cli/cloud.py +79 -0
- airbyte_ops_mcp/cloud_admin/api_client.py +463 -69
- airbyte_ops_mcp/constants.py +3 -0
- airbyte_ops_mcp/gcp_logs/__init__.py +18 -0
- airbyte_ops_mcp/gcp_logs/error_lookup.py +383 -0
- airbyte_ops_mcp/github_api.py +264 -0
- airbyte_ops_mcp/mcp/cloud_connector_versions.py +68 -33
- airbyte_ops_mcp/mcp/gcp_logs.py +92 -0
- airbyte_ops_mcp/mcp/server.py +2 -0
- {airbyte_internal_ops-0.2.3.dist-info → airbyte_internal_ops-0.3.0.dist-info}/WHEEL +0 -0
- {airbyte_internal_ops-0.2.3.dist-info → airbyte_internal_ops-0.3.0.dist-info}/entry_points.txt +0 -0
airbyte_ops_mcp/constants.py
CHANGED
|
@@ -10,6 +10,9 @@ from airbyte.exceptions import PyAirbyteInputError
|
|
|
10
10
|
MCP_SERVER_NAME = "airbyte-internal-ops"
|
|
11
11
|
"""The name of the MCP server."""
|
|
12
12
|
|
|
13
|
+
USER_AGENT = "Airbyte-Internal-Ops Python client"
|
|
14
|
+
"""User-Agent string for HTTP requests to Airbyte Cloud APIs."""
|
|
15
|
+
|
|
13
16
|
# Environment variable names for internal admin authentication
|
|
14
17
|
ENV_AIRBYTE_INTERNAL_ADMIN_FLAG = "AIRBYTE_INTERNAL_ADMIN_FLAG"
|
|
15
18
|
ENV_AIRBYTE_INTERNAL_ADMIN_USER = "AIRBYTE_INTERNAL_ADMIN_USER"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
+
"""GCP Cloud Logging utilities for fetching error details by error ID."""
|
|
3
|
+
|
|
4
|
+
from airbyte_ops_mcp.gcp_logs.error_lookup import (
|
|
5
|
+
GCPLogEntry,
|
|
6
|
+
GCPLogPayload,
|
|
7
|
+
GCPLogSearchResult,
|
|
8
|
+
GCPSeverity,
|
|
9
|
+
fetch_error_logs,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"GCPLogEntry",
|
|
14
|
+
"GCPLogPayload",
|
|
15
|
+
"GCPLogSearchResult",
|
|
16
|
+
"GCPSeverity",
|
|
17
|
+
"fetch_error_logs",
|
|
18
|
+
]
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
|
|
2
|
+
"""Fetch full stack traces from Google Cloud Logs by error ID.
|
|
3
|
+
|
|
4
|
+
This module provides functionality to look up error details from GCP Cloud Logging
|
|
5
|
+
using an error ID (UUID). This is useful for debugging API errors that return
|
|
6
|
+
only an error ID in the response.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from airbyte_ops_mcp.gcp_logs import fetch_error_logs
|
|
10
|
+
|
|
11
|
+
result = fetch_error_logs(
|
|
12
|
+
error_id="3173452e-8f22-4286-a1ec-b0f16c1e078a",
|
|
13
|
+
project="prod-ab-cloud-proj",
|
|
14
|
+
lookback_days=7,
|
|
15
|
+
)
|
|
16
|
+
for entry in result.entries:
|
|
17
|
+
print(entry.message)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from datetime import UTC, datetime, timedelta
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from google.cloud import logging
|
|
28
|
+
from google.cloud.logging_v2 import entries
|
|
29
|
+
from pydantic import BaseModel, Field
|
|
30
|
+
|
|
31
|
+
# Default GCP project for Airbyte Cloud
|
|
32
|
+
DEFAULT_GCP_PROJECT = "prod-ab-cloud-proj"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GCPSeverity(StrEnum):
|
|
36
|
+
"""Valid GCP Cloud Logging severity levels."""
|
|
37
|
+
|
|
38
|
+
DEBUG = "DEBUG"
|
|
39
|
+
INFO = "INFO"
|
|
40
|
+
NOTICE = "NOTICE"
|
|
41
|
+
WARNING = "WARNING"
|
|
42
|
+
ERROR = "ERROR"
|
|
43
|
+
CRITICAL = "CRITICAL"
|
|
44
|
+
ALERT = "ALERT"
|
|
45
|
+
EMERGENCY = "EMERGENCY"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GCPLogResourceLabels(BaseModel):
|
|
49
|
+
"""Resource labels from a GCP log entry."""
|
|
50
|
+
|
|
51
|
+
pod_name: str | None = Field(default=None, description="Kubernetes pod name")
|
|
52
|
+
container_name: str | None = Field(
|
|
53
|
+
default=None, description="Container name within the pod"
|
|
54
|
+
)
|
|
55
|
+
namespace_name: str | None = Field(default=None, description="Kubernetes namespace")
|
|
56
|
+
cluster_name: str | None = Field(default=None, description="GKE cluster name")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class GCPLogResource(BaseModel):
|
|
60
|
+
"""Resource information from a GCP log entry."""
|
|
61
|
+
|
|
62
|
+
type: str | None = Field(default=None, description="Resource type")
|
|
63
|
+
labels: GCPLogResourceLabels = Field(
|
|
64
|
+
default_factory=GCPLogResourceLabels, description="Resource labels"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GCPLogSourceLocation(BaseModel):
|
|
69
|
+
"""Source location information from a GCP log entry."""
|
|
70
|
+
|
|
71
|
+
file: str | None = Field(default=None, description="Source file path")
|
|
72
|
+
line: int | None = Field(default=None, description="Line number")
|
|
73
|
+
function: str | None = Field(default=None, description="Function name")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class GCPLogEntry(BaseModel):
|
|
77
|
+
"""A single log entry from GCP Cloud Logging."""
|
|
78
|
+
|
|
79
|
+
timestamp: datetime | None = Field(
|
|
80
|
+
default=None, description="When the log entry was created"
|
|
81
|
+
)
|
|
82
|
+
severity: str | None = Field(
|
|
83
|
+
default=None, description="Log severity (DEBUG, INFO, WARNING, ERROR, etc.)"
|
|
84
|
+
)
|
|
85
|
+
log_name: str | None = Field(default=None, description="Full log name path")
|
|
86
|
+
insert_id: str | None = Field(
|
|
87
|
+
default=None, description="Unique identifier for the log entry"
|
|
88
|
+
)
|
|
89
|
+
trace: str | None = Field(
|
|
90
|
+
default=None, description="Trace ID for distributed tracing"
|
|
91
|
+
)
|
|
92
|
+
span_id: str | None = Field(default=None, description="Span ID within the trace")
|
|
93
|
+
payload: Any = Field(default=None, description="Log entry payload (text or struct)")
|
|
94
|
+
payload_type: str | None = Field(
|
|
95
|
+
default=None, description="Type of payload (text, struct, protobuf)"
|
|
96
|
+
)
|
|
97
|
+
resource: GCPLogResource = Field(
|
|
98
|
+
default_factory=GCPLogResource, description="Resource information"
|
|
99
|
+
)
|
|
100
|
+
source_location: GCPLogSourceLocation | None = Field(
|
|
101
|
+
default=None, description="Source code location"
|
|
102
|
+
)
|
|
103
|
+
labels: dict[str, str] = Field(
|
|
104
|
+
default_factory=dict, description="User-defined labels"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class GCPLogPayload(BaseModel):
|
|
109
|
+
"""Extracted and combined payload from grouped log entries."""
|
|
110
|
+
|
|
111
|
+
timestamp: datetime | None = Field(
|
|
112
|
+
default=None, description="Timestamp of the first entry in the group"
|
|
113
|
+
)
|
|
114
|
+
severity: str | None = Field(default=None, description="Severity of the log group")
|
|
115
|
+
resource: GCPLogResource = Field(
|
|
116
|
+
default_factory=GCPLogResource, description="Resource information"
|
|
117
|
+
)
|
|
118
|
+
num_log_lines: int = Field(
|
|
119
|
+
default=0, description="Number of log lines combined into this payload"
|
|
120
|
+
)
|
|
121
|
+
message: str = Field(default="", description="Combined message from all log lines")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class GCPLogSearchResult(BaseModel):
|
|
125
|
+
"""Result of searching GCP Cloud Logging for an error ID."""
|
|
126
|
+
|
|
127
|
+
error_id: str = Field(description="The error ID that was searched for")
|
|
128
|
+
project: str = Field(description="GCP project that was searched")
|
|
129
|
+
lookback_days_searched: int = Field(
|
|
130
|
+
description="Number of lookback days that were searched"
|
|
131
|
+
)
|
|
132
|
+
total_entries_found: int = Field(
|
|
133
|
+
description="Total number of log entries found (including related entries)"
|
|
134
|
+
)
|
|
135
|
+
entries: list[GCPLogEntry] = Field(
|
|
136
|
+
default_factory=list, description="Raw log entries found"
|
|
137
|
+
)
|
|
138
|
+
payloads: list[GCPLogPayload] = Field(
|
|
139
|
+
default_factory=list,
|
|
140
|
+
description="Extracted and grouped payloads (reconstructed stack traces)",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_filter(
|
|
145
|
+
error_id: str,
|
|
146
|
+
lookback_days: int,
|
|
147
|
+
min_severity_filter: GCPSeverity | None,
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Build the Cloud Logging filter query."""
|
|
150
|
+
filter_parts = [f'"{error_id}"']
|
|
151
|
+
|
|
152
|
+
start_time = datetime.now(UTC) - timedelta(days=lookback_days)
|
|
153
|
+
filter_parts.append(f'timestamp >= "{start_time.isoformat()}"')
|
|
154
|
+
|
|
155
|
+
if min_severity_filter:
|
|
156
|
+
filter_parts.append(f"severity>={min_severity_filter}")
|
|
157
|
+
|
|
158
|
+
return " AND ".join(filter_parts)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _entry_to_model(
|
|
162
|
+
entry: entries.StructEntry | entries.TextEntry | entries.ProtobufEntry,
|
|
163
|
+
) -> GCPLogEntry:
|
|
164
|
+
"""Convert a GCP log entry to a Pydantic model."""
|
|
165
|
+
resource_labels = {}
|
|
166
|
+
if entry.resource and entry.resource.labels:
|
|
167
|
+
resource_labels = dict(entry.resource.labels)
|
|
168
|
+
|
|
169
|
+
resource = GCPLogResource(
|
|
170
|
+
type=entry.resource.type if entry.resource else None,
|
|
171
|
+
labels=GCPLogResourceLabels(
|
|
172
|
+
pod_name=resource_labels.get("pod_name"),
|
|
173
|
+
container_name=resource_labels.get("container_name"),
|
|
174
|
+
namespace_name=resource_labels.get("namespace_name"),
|
|
175
|
+
cluster_name=resource_labels.get("cluster_name"),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
source_location = None
|
|
180
|
+
if entry.source_location:
|
|
181
|
+
source_location = GCPLogSourceLocation(
|
|
182
|
+
file=entry.source_location.get("file"),
|
|
183
|
+
line=entry.source_location.get("line"),
|
|
184
|
+
function=entry.source_location.get("function"),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
payload: Any = None
|
|
188
|
+
payload_type = "unknown"
|
|
189
|
+
if isinstance(entry, entries.StructEntry):
|
|
190
|
+
payload = entry.payload
|
|
191
|
+
payload_type = "struct"
|
|
192
|
+
elif isinstance(entry, entries.TextEntry):
|
|
193
|
+
payload = entry.payload
|
|
194
|
+
payload_type = "text"
|
|
195
|
+
elif isinstance(entry, entries.ProtobufEntry):
|
|
196
|
+
payload = str(entry.payload)
|
|
197
|
+
payload_type = "protobuf"
|
|
198
|
+
|
|
199
|
+
return GCPLogEntry(
|
|
200
|
+
timestamp=entry.timestamp,
|
|
201
|
+
severity=entry.severity,
|
|
202
|
+
log_name=entry.log_name,
|
|
203
|
+
insert_id=entry.insert_id,
|
|
204
|
+
trace=entry.trace,
|
|
205
|
+
span_id=entry.span_id,
|
|
206
|
+
payload=payload,
|
|
207
|
+
payload_type=payload_type,
|
|
208
|
+
resource=resource,
|
|
209
|
+
source_location=source_location,
|
|
210
|
+
labels=dict(entry.labels) if entry.labels else {},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _group_entries_by_occurrence(
|
|
215
|
+
log_entries: list[GCPLogEntry],
|
|
216
|
+
) -> list[list[GCPLogEntry]]:
|
|
217
|
+
"""Group log entries by occurrence (timestamp clusters within 1 second)."""
|
|
218
|
+
if not log_entries:
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
sorted_entries = sorted(
|
|
222
|
+
log_entries, key=lambda x: x.timestamp or datetime.min.replace(tzinfo=UTC)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
groups: list[list[GCPLogEntry]] = []
|
|
226
|
+
current_group = [sorted_entries[0]]
|
|
227
|
+
current_timestamp = sorted_entries[0].timestamp or datetime.min.replace(tzinfo=UTC)
|
|
228
|
+
|
|
229
|
+
for entry in sorted_entries[1:]:
|
|
230
|
+
entry_timestamp = entry.timestamp or datetime.min.replace(tzinfo=UTC)
|
|
231
|
+
time_diff = abs((entry_timestamp - current_timestamp).total_seconds())
|
|
232
|
+
|
|
233
|
+
current_pod = current_group[0].resource.labels.pod_name
|
|
234
|
+
entry_pod = entry.resource.labels.pod_name
|
|
235
|
+
|
|
236
|
+
if time_diff <= 1 and entry_pod == current_pod:
|
|
237
|
+
current_group.append(entry)
|
|
238
|
+
else:
|
|
239
|
+
groups.append(current_group)
|
|
240
|
+
current_group = [entry]
|
|
241
|
+
current_timestamp = entry_timestamp
|
|
242
|
+
|
|
243
|
+
if current_group:
|
|
244
|
+
groups.append(current_group)
|
|
245
|
+
|
|
246
|
+
return groups
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _extract_payloads(log_entries: list[GCPLogEntry]) -> list[GCPLogPayload]:
|
|
250
|
+
"""Extract and group payloads by occurrence."""
|
|
251
|
+
if not log_entries:
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
grouped = _group_entries_by_occurrence(log_entries)
|
|
255
|
+
|
|
256
|
+
results = []
|
|
257
|
+
for group in grouped:
|
|
258
|
+
payloads = []
|
|
259
|
+
for entry in group:
|
|
260
|
+
if entry.payload:
|
|
261
|
+
payload_text = str(entry.payload)
|
|
262
|
+
payload_text = re.sub(r"\x1b\[[0-9;]*m", "", payload_text)
|
|
263
|
+
payloads.append(payload_text)
|
|
264
|
+
|
|
265
|
+
combined_message = "\n".join(payloads)
|
|
266
|
+
|
|
267
|
+
first_entry = group[0]
|
|
268
|
+
result = GCPLogPayload(
|
|
269
|
+
timestamp=first_entry.timestamp,
|
|
270
|
+
severity=first_entry.severity,
|
|
271
|
+
resource=first_entry.resource,
|
|
272
|
+
num_log_lines=len(group),
|
|
273
|
+
message=combined_message,
|
|
274
|
+
)
|
|
275
|
+
results.append(result)
|
|
276
|
+
|
|
277
|
+
return results
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def fetch_error_logs(
|
|
281
|
+
error_id: str,
|
|
282
|
+
project: str = DEFAULT_GCP_PROJECT,
|
|
283
|
+
lookback_days: int = 7,
|
|
284
|
+
min_severity_filter: GCPSeverity | None = None,
|
|
285
|
+
include_log_envelope_seconds: float = 1.0,
|
|
286
|
+
max_log_entries: int | None = None,
|
|
287
|
+
) -> GCPLogSearchResult:
|
|
288
|
+
"""Fetch logs from Google Cloud Logging by error ID.
|
|
289
|
+
|
|
290
|
+
This function searches GCP Cloud Logging for log entries containing the
|
|
291
|
+
specified error ID, then fetches related log entries (multi-line stack traces)
|
|
292
|
+
from the same timestamp and resource.
|
|
293
|
+
"""
|
|
294
|
+
client_options = {"quota_project_id": project}
|
|
295
|
+
client = logging.Client(project=project, client_options=client_options)
|
|
296
|
+
|
|
297
|
+
filter_str = _build_filter(error_id, lookback_days, min_severity_filter)
|
|
298
|
+
|
|
299
|
+
entries_iterator = client.list_entries(
|
|
300
|
+
filter_=filter_str,
|
|
301
|
+
order_by=logging.DESCENDING,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
initial_matches = list(entries_iterator)
|
|
305
|
+
|
|
306
|
+
if not initial_matches:
|
|
307
|
+
return GCPLogSearchResult(
|
|
308
|
+
error_id=error_id,
|
|
309
|
+
project=project,
|
|
310
|
+
lookback_days_searched=lookback_days,
|
|
311
|
+
total_entries_found=0,
|
|
312
|
+
entries=[],
|
|
313
|
+
payloads=[],
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
all_results: list[GCPLogEntry] = []
|
|
317
|
+
seen_insert_ids: set[str] = set()
|
|
318
|
+
|
|
319
|
+
for match in initial_matches:
|
|
320
|
+
timestamp = match.timestamp
|
|
321
|
+
resource_type_val = match.resource.type if match.resource else None
|
|
322
|
+
resource_labels = (
|
|
323
|
+
dict(match.resource.labels)
|
|
324
|
+
if match.resource and match.resource.labels
|
|
325
|
+
else {}
|
|
326
|
+
)
|
|
327
|
+
log_name = match.log_name
|
|
328
|
+
|
|
329
|
+
start_time = timestamp - timedelta(seconds=include_log_envelope_seconds)
|
|
330
|
+
end_time = timestamp + timedelta(seconds=include_log_envelope_seconds)
|
|
331
|
+
|
|
332
|
+
related_filter_parts = [
|
|
333
|
+
f'timestamp >= "{start_time.isoformat()}"',
|
|
334
|
+
f'timestamp <= "{end_time.isoformat()}"',
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
if log_name:
|
|
338
|
+
related_filter_parts.append(f'logName="{log_name}"')
|
|
339
|
+
|
|
340
|
+
if resource_type_val:
|
|
341
|
+
related_filter_parts.append(f'resource.type="{resource_type_val}"')
|
|
342
|
+
|
|
343
|
+
if "pod_name" in resource_labels:
|
|
344
|
+
related_filter_parts.append(
|
|
345
|
+
f'resource.labels.pod_name="{resource_labels["pod_name"]}"'
|
|
346
|
+
)
|
|
347
|
+
if "container_name" in resource_labels:
|
|
348
|
+
related_filter_parts.append(
|
|
349
|
+
f'resource.labels.container_name="{resource_labels["container_name"]}"'
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Note: resource_type_val is extracted from the matched entry, and
|
|
353
|
+
# min_severity_filter is already applied in the initial search filter
|
|
354
|
+
|
|
355
|
+
related_filter = " AND ".join(related_filter_parts)
|
|
356
|
+
|
|
357
|
+
related_entries = client.list_entries(
|
|
358
|
+
filter_=related_filter,
|
|
359
|
+
order_by=logging.ASCENDING,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
for entry in related_entries:
|
|
363
|
+
if entry.insert_id and entry.insert_id not in seen_insert_ids:
|
|
364
|
+
seen_insert_ids.add(entry.insert_id)
|
|
365
|
+
all_results.append(_entry_to_model(entry))
|
|
366
|
+
|
|
367
|
+
all_results.sort(
|
|
368
|
+
key=lambda x: x.timestamp or datetime.min.replace(tzinfo=UTC), reverse=True
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if max_log_entries:
|
|
372
|
+
all_results = all_results[:max_log_entries]
|
|
373
|
+
|
|
374
|
+
payloads = _extract_payloads(all_results)
|
|
375
|
+
|
|
376
|
+
return GCPLogSearchResult(
|
|
377
|
+
error_id=error_id,
|
|
378
|
+
project=project,
|
|
379
|
+
lookback_days_searched=lookback_days,
|
|
380
|
+
total_entries_found=len(all_results),
|
|
381
|
+
entries=all_results,
|
|
382
|
+
payloads=payloads,
|
|
383
|
+
)
|
|
@@ -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
|