airbyte-source-github 2.2.0.dev202601122124__py3-none-any.whl → 2.2.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_source_github-2.2.2.dist-info/METADATA +29 -0
- {airbyte_source_github-2.2.0.dev202601122124.dist-info → airbyte_source_github-2.2.2.dist-info}/RECORD +13 -15
- source_github/backoff_strategies.py +27 -18
- source_github/errors_handlers.py +118 -26
- source_github/graphql.py +48 -0
- source_github/manifest.yaml +1363 -0
- source_github/run.py +53 -7
- source_github/schemas/releases.json +13 -24
- source_github/source.py +222 -95
- source_github/streams.py +306 -167
- source_github/utils.py +1 -163
- airbyte_source_github-2.2.0.dev202601122124.dist-info/METADATA +0 -126
- source_github/schemas/repositories.json +0 -427
- source_github/schemas/workflow_run_attempts.json +0 -585
- source_github/spec.json +0 -190
- {airbyte_source_github-2.2.0.dev202601122124.dist-info → airbyte_source_github-2.2.2.dist-info}/WHEEL +0 -0
- {airbyte_source_github-2.2.0.dev202601122124.dist-info → airbyte_source_github-2.2.2.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: airbyte-source-github
|
|
3
|
+
Version: 2.2.2
|
|
4
|
+
Summary: Source implementation for GitHub.
|
|
5
|
+
Home-page: https://airbyte.com
|
|
6
|
+
License: ELv2
|
|
7
|
+
Author: Airbyte
|
|
8
|
+
Author-email: contact@airbyte.io
|
|
9
|
+
Requires-Python: >=3.10,<3.14
|
|
10
|
+
Classifier: License :: Other/Proprietary License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Dist: airbyte-cdk (>=7.28.2,<8.0.0)
|
|
17
|
+
Requires-Dist: sgqlc (==16.3)
|
|
18
|
+
Project-URL: Documentation, https://docs.airbyte.com/integrations/sources/github
|
|
19
|
+
Project-URL: Repository, https://github.com/airbytehq/airbyte
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# GitHub
|
|
23
|
+
|
|
24
|
+
This package contains the Airbyte GitHub connector.
|
|
25
|
+
|
|
26
|
+
Airbyte is open-source data movement for ELT pipelines and AI agents. It moves data from APIs, databases, and files to warehouses, lakes, and AI applications.
|
|
27
|
+
|
|
28
|
+
Airbyte provides a catalog of 600+ connectors for APIs, databases, data warehouses, data lakes, and AI applications. Use this package with [PyAirbyte](https://docs.airbyte.com/developers/pyairbyte) to run the connector from Python.
|
|
29
|
+
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
source_github/__init__.py,sha256=8hLLjFC1wI1mMU442Y5qCcND9qIwyhaANQwT-GAIK0s,1135
|
|
2
|
-
source_github/backoff_strategies.py,sha256=
|
|
2
|
+
source_github/backoff_strategies.py,sha256=H1n3xNkkrlto7MqJY-Yp0el2G5S4yuJ7z3kg6aimab0,2343
|
|
3
3
|
source_github/config_migrations.py,sha256=guUJAdNP-liUciVaJB4ackEEMCN4jd7bNd85QFmDNlU,3932
|
|
4
4
|
source_github/constants.py,sha256=Hj3Q4y7OoU-Iff4m9gEC2CjwmWJYXhNbHVNjg8EBLmQ,238
|
|
5
|
-
source_github/errors_handlers.py,sha256=
|
|
5
|
+
source_github/errors_handlers.py,sha256=y0SjYuxKo3zBRhk2kZe0NZWsKen4W9x-wD84Q0S3sjU,11211
|
|
6
6
|
source_github/github_schema.py,sha256=2AXmTN_s_VqvZAR1GPqus5HhSXWokS4N7HMQQqefaYw,1600315
|
|
7
|
-
source_github/graphql.py,sha256=
|
|
8
|
-
source_github/
|
|
7
|
+
source_github/graphql.py,sha256=fGuYzDynYjJOR7u3GX0t6eBJMzdFxKIp4gHP73MCpos,13518
|
|
8
|
+
source_github/manifest.yaml,sha256=JDINDUIMSofsNdDqjHlEES5s9d7k83mVa9mUvwOKcA0,57776
|
|
9
|
+
source_github/run.py,sha256=gfWy8TcMHa0XRwaVUyq4KmslooST38fvfCLuP5D-5Fo,2391
|
|
9
10
|
source_github/schemas/assignees.json,sha256=_WmGIwYmTmEi9WWkvXdFF3CLsMzG2usoIjaqw-tb4NA,2335
|
|
10
11
|
source_github/schemas/branches.json,sha256=3CIkdEi2XVFoVZrpO_U04mMI7-jSb9i-CT3wbIEgPik,2734
|
|
11
12
|
source_github/schemas/collaborators.json,sha256=ZeRsZvj9lUr43hnjX0l84rl2TTeKKbFKk-tmzBYdOmo,3505
|
|
@@ -32,8 +33,7 @@ source_github/schemas/pull_request_comment_reactions.json,sha256=shrzajYnEHg_UlU
|
|
|
32
33
|
source_github/schemas/pull_request_commits.json,sha256=WICcuxUsExJQe1qlXvXVN-VvkqbjWxR_E__WKO-Ta7E,4800
|
|
33
34
|
source_github/schemas/pull_request_stats.json,sha256=harNH5ha6su-2OhxszHOqDKJ34OZzUsL_4nBnaOD16o,3497
|
|
34
35
|
source_github/schemas/pull_requests.json,sha256=Yp9Sq3jgpfBn0YFbcZC_sy06rlJ-AMkCbHNq90KXT_Y,12196
|
|
35
|
-
source_github/schemas/releases.json,sha256=
|
|
36
|
-
source_github/schemas/repositories.json,sha256=WpC5ZjoycnHUP5DNz8ImEXROYAel8DGRkBN10W1mQ2w,13319
|
|
36
|
+
source_github/schemas/releases.json,sha256=3ITMlMFML3ZSfo_EAkVajJdaS_Xxz_8V4LhL9GRwWrY,5121
|
|
37
37
|
source_github/schemas/review_comments.json,sha256=HVH3OE-x_wYVvg0Qxn6rzXOwAGEk0y3De8_VLCgE3mA,4905
|
|
38
38
|
source_github/schemas/reviews.json,sha256=OPqo9Dz6slRGojamq8pBMKKRZgz96kDRkYiUJjJQH_E,2731
|
|
39
39
|
source_github/schemas/shared/events/comment.json,sha256=6dcatpi1gitgcR_JscPllyGGoSb8Pgce5OS9vh1HKP4,7400
|
|
@@ -52,14 +52,12 @@ source_github/schemas/team_memberships.json,sha256=ECr0ICzCwcUVp-btBicm0EemVxiOP
|
|
|
52
52
|
source_github/schemas/teams.json,sha256=_GQOy0IOO9d8BcpnvqIxQLjtPJusfU7fC7IUJmSi6R4,1796
|
|
53
53
|
source_github/schemas/users.json,sha256=by1CEx3MW740S5F54KZfUDc2N1_UZ98Bb3fWlgCtgxc,2530
|
|
54
54
|
source_github/schemas/workflow_jobs.json,sha256=Kk3N3FcCYBqOdR7FYvz2nAAFtLZBuVfR8Lxg-ORD39U,3934
|
|
55
|
-
source_github/schemas/workflow_run_attempts.json,sha256=XDmIsjtzka-ItEonImD3ZATZjxRNkbFo5-MPTtZDARA,19453
|
|
56
55
|
source_github/schemas/workflow_runs.json,sha256=XDmIsjtzka-ItEonImD3ZATZjxRNkbFo5-MPTtZDARA,19453
|
|
57
56
|
source_github/schemas/workflows.json,sha256=gSNw8WZaVKbX4AL97PbjZHzvxcOltXqv9Ao1RNQOFXM,1470
|
|
58
|
-
source_github/source.py,sha256=
|
|
59
|
-
source_github/
|
|
60
|
-
source_github/
|
|
61
|
-
|
|
62
|
-
airbyte_source_github-2.2.
|
|
63
|
-
airbyte_source_github-2.2.
|
|
64
|
-
airbyte_source_github-2.2.
|
|
65
|
-
airbyte_source_github-2.2.0.dev202601122124.dist-info/RECORD,,
|
|
57
|
+
source_github/source.py,sha256=oAwpfXCMwZf8_lbxr49rSm5qzGffhSq0qIbvIFEy2xs,22559
|
|
58
|
+
source_github/streams.py,sha256=MYPvzAEbGk9nyN0mYamFXEAxepbf7O7aN5Hepf-Bj80,88297
|
|
59
|
+
source_github/utils.py,sha256=rkOk8VG6ey1xDl-9t4B99N6HrvTTPCTGhvifrKwYG9Y,706
|
|
60
|
+
airbyte_source_github-2.2.2.dist-info/METADATA,sha256=1YJ8kLVAVSrdY-KZwq86eicGzaX0EwIbKSYIzL0avn4,1248
|
|
61
|
+
airbyte_source_github-2.2.2.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
62
|
+
airbyte_source_github-2.2.2.dist-info/entry_points.txt,sha256=gYhqVrTAZvMwuYByg0b_-o115yUFLLcfNxMrLZmiW9k,55
|
|
63
|
+
airbyte_source_github-2.2.2.dist-info/RECORD,,
|
|
@@ -10,38 +10,47 @@ import requests
|
|
|
10
10
|
from airbyte_cdk import BackoffStrategy
|
|
11
11
|
from airbyte_cdk.sources.streams.http import HttpStream
|
|
12
12
|
|
|
13
|
+
from .errors_handlers import is_rate_limited_response
|
|
14
|
+
|
|
13
15
|
|
|
14
16
|
class GithubStreamABCBackoffStrategy(BackoffStrategy):
|
|
15
|
-
def __init__(self, stream: HttpStream, **kwargs): # type: ignore # noqa
|
|
17
|
+
def __init__(self, stream: HttpStream, max_wait_time_seconds: float = 120 * 60, **kwargs): # type: ignore # noqa
|
|
16
18
|
self.stream = stream
|
|
19
|
+
self.max_wait_time_seconds = max_wait_time_seconds
|
|
17
20
|
super().__init__(**kwargs)
|
|
18
21
|
|
|
19
22
|
def backoff_time(
|
|
20
23
|
self, response_or_exception: Optional[Union[requests.Response, requests.RequestException]], **kwargs: Any
|
|
21
24
|
) -> Optional[float]:
|
|
22
|
-
# This method is called if we run into the rate limit. GitHub limits requests to 5000 per hour and provides
|
|
23
|
-
# `X-RateLimit-Reset` header which contains time when this hour will be finished and limits will be reset so
|
|
24
|
-
# we again could have 5000 per another hour.
|
|
25
25
|
if isinstance(response_or_exception, requests.Response):
|
|
26
|
+
if not is_rate_limited_response(
|
|
27
|
+
response_or_exception,
|
|
28
|
+
self.stream.check_graphql_rate_limited,
|
|
29
|
+
self.stream.logger,
|
|
30
|
+
):
|
|
31
|
+
return None
|
|
32
|
+
|
|
26
33
|
min_backoff_time = 60.0
|
|
27
34
|
retry_after = response_or_exception.headers.get("Retry-After")
|
|
28
35
|
if retry_after is not None:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
wait_time = max(float(retry_after), min_backoff_time)
|
|
37
|
+
else:
|
|
38
|
+
reset_time = response_or_exception.headers.get("X-RateLimit-Reset")
|
|
39
|
+
if not reset_time:
|
|
40
|
+
return None
|
|
41
|
+
wait_time = max(float(reset_time) - time.time(), min_backoff_time)
|
|
42
|
+
|
|
43
|
+
if wait_time > self.max_wait_time_seconds:
|
|
44
|
+
self.stream.logger.warning(
|
|
45
|
+
"Rate limit wait for stream `%s` is %.2f seconds, exceeding the maximum of %.2f seconds; using default backoff.",
|
|
46
|
+
self.stream.name,
|
|
47
|
+
wait_time,
|
|
48
|
+
self.max_wait_time_seconds,
|
|
49
|
+
)
|
|
50
|
+
return None
|
|
51
|
+
return wait_time
|
|
36
52
|
return None
|
|
37
53
|
|
|
38
|
-
def get_waiting_time(self, backoff_time_in_seconds: Optional[float]) -> Optional[float]:
|
|
39
|
-
if backoff_time_in_seconds < 60 * 10: # type: ignore[operator]
|
|
40
|
-
return backoff_time_in_seconds
|
|
41
|
-
else:
|
|
42
|
-
self.stream._http_client._session.auth.update_token() # New token will be used in next request
|
|
43
|
-
return 1
|
|
44
|
-
|
|
45
54
|
|
|
46
55
|
class ContributorActivityBackoffStrategy(BackoffStrategy):
|
|
47
56
|
def backoff_time(
|
source_github/errors_handlers.py
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
|
|
3
3
|
#
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Callable, Optional, Union
|
|
6
7
|
|
|
7
8
|
import requests
|
|
8
9
|
|
|
@@ -14,6 +15,9 @@ from airbyte_cdk.sources.streams.http.error_handlers.default_error_mapping impor
|
|
|
14
15
|
from . import constants
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
logger = logging.getLogger("airbyte")
|
|
19
|
+
|
|
20
|
+
|
|
17
21
|
GITHUB_DEFAULT_ERROR_MAPPING = DEFAULT_ERROR_MAPPING | {
|
|
18
22
|
401: ErrorResolution(
|
|
19
23
|
response_action=ResponseAction.RETRY,
|
|
@@ -21,9 +25,14 @@ GITHUB_DEFAULT_ERROR_MAPPING = DEFAULT_ERROR_MAPPING | {
|
|
|
21
25
|
error_message="Conflict.",
|
|
22
26
|
),
|
|
23
27
|
403: ErrorResolution(
|
|
24
|
-
response_action=ResponseAction.
|
|
28
|
+
response_action=ResponseAction.FAIL,
|
|
25
29
|
failure_type=FailureType.config_error,
|
|
26
|
-
error_message=
|
|
30
|
+
error_message=(
|
|
31
|
+
"GitHub denied access (HTTP 403). Your token may be missing required scopes "
|
|
32
|
+
"(this connector typically needs: repo, read:org, read:user, read:project, workflow), "
|
|
33
|
+
"or this organization may require SAML SSO authorization. "
|
|
34
|
+
"See https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api"
|
|
35
|
+
),
|
|
27
36
|
),
|
|
28
37
|
404: ErrorResolution(
|
|
29
38
|
response_action=ResponseAction.RETRY,
|
|
@@ -36,20 +45,73 @@ GITHUB_DEFAULT_ERROR_MAPPING = DEFAULT_ERROR_MAPPING | {
|
|
|
36
45
|
error_message="Conflict.",
|
|
37
46
|
),
|
|
38
47
|
410: ErrorResolution(
|
|
39
|
-
response_action=ResponseAction.
|
|
48
|
+
response_action=ResponseAction.FAIL,
|
|
40
49
|
failure_type=FailureType.config_error,
|
|
41
|
-
error_message=
|
|
50
|
+
error_message=(
|
|
51
|
+
"GitHub returned 410 Gone for an unexpected reason. "
|
|
52
|
+
"The endpoint or API version may be deprecated. "
|
|
53
|
+
"Verify the connector version is current and the endpoint is still supported."
|
|
54
|
+
),
|
|
42
55
|
),
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
|
|
46
59
|
def is_conflict_with_empty_repository(response_or_exception: Optional[Union[requests.Response, Exception]] = None) -> bool:
|
|
47
60
|
if isinstance(response_or_exception, requests.Response) and response_or_exception.status_code == requests.codes.CONFLICT:
|
|
48
|
-
|
|
61
|
+
try:
|
|
62
|
+
response_data = response_or_exception.json()
|
|
63
|
+
except ValueError:
|
|
64
|
+
logger.warning(
|
|
65
|
+
"is_conflict_with_empty_repository received non-JSON 409 response (first 50 chars: %r).",
|
|
66
|
+
response_or_exception.text[:50],
|
|
67
|
+
)
|
|
68
|
+
return False
|
|
49
69
|
return response_data.get("message") == "Git Repository is empty."
|
|
50
70
|
return False
|
|
51
71
|
|
|
52
72
|
|
|
73
|
+
def is_gone_with_feature_disabled(response_or_exception: Optional[Union[requests.Response, Exception]] = None) -> bool:
|
|
74
|
+
if isinstance(response_or_exception, requests.Response) and response_or_exception.status_code == requests.codes.GONE:
|
|
75
|
+
try:
|
|
76
|
+
message = (response_or_exception.json().get("message") or "").lower()
|
|
77
|
+
except ValueError:
|
|
78
|
+
logger.warning(
|
|
79
|
+
"is_gone_with_feature_disabled received non-JSON 410 response (first 50 chars: %r).",
|
|
80
|
+
response_or_exception.text[:50],
|
|
81
|
+
)
|
|
82
|
+
return False
|
|
83
|
+
return "are disabled" in message or "is disabled" in message
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_rate_limited_response(
|
|
88
|
+
response: requests.Response, graphql_rate_limit_checker: Callable[[dict], bool], logger: logging.Logger
|
|
89
|
+
) -> bool:
|
|
90
|
+
# GitHub GraphQL resource limitations:
|
|
91
|
+
# https://docs.github.com/en/graphql/overview/resource-limitations
|
|
92
|
+
if response.headers.get("X-RateLimit-Resource") == "graphql":
|
|
93
|
+
try:
|
|
94
|
+
body = response.json()
|
|
95
|
+
except ValueError:
|
|
96
|
+
logger.warning(
|
|
97
|
+
"GraphQL rate-limit check received non-JSON response (HTTP %s, first 50 chars: %r).",
|
|
98
|
+
response.status_code,
|
|
99
|
+
response.text[:50],
|
|
100
|
+
)
|
|
101
|
+
graphql_rate_limited = False
|
|
102
|
+
else:
|
|
103
|
+
graphql_rate_limited = graphql_rate_limit_checker(body or {})
|
|
104
|
+
|
|
105
|
+
if graphql_rate_limited:
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
# GitHub REST rate-limit HTTP headers:
|
|
109
|
+
# https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limit-http-headers
|
|
110
|
+
# GitHub secondary rate limits:
|
|
111
|
+
# https://docs.github.com/en/rest/overview/resources-in-the-rest-api#secondary-rate-limits
|
|
112
|
+
return (response.status_code != 200 and response.headers.get("X-RateLimit-Remaining") == "0") or "Retry-After" in response.headers
|
|
113
|
+
|
|
114
|
+
|
|
53
115
|
class GithubStreamABCErrorHandler(HttpStatusErrorHandler):
|
|
54
116
|
def __init__(self, stream: HttpStream, **kwargs): # type: ignore # noqa
|
|
55
117
|
self.stream = stream
|
|
@@ -57,19 +119,10 @@ class GithubStreamABCErrorHandler(HttpStatusErrorHandler):
|
|
|
57
119
|
|
|
58
120
|
def interpret_response(self, response_or_exception: Optional[Union[requests.Response, Exception]] = None) -> ErrorResolution:
|
|
59
121
|
if isinstance(response_or_exception, requests.Response):
|
|
60
|
-
retry_flag = (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
response_or_exception.headers.get("X-RateLimit-Resource") == "graphql"
|
|
65
|
-
and self.stream.check_graphql_rate_limited(response_or_exception.json())
|
|
66
|
-
)
|
|
67
|
-
# Rate limit HTTP headers
|
|
68
|
-
# https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limit-http-headers
|
|
69
|
-
or (response_or_exception.status_code != 200 and response_or_exception.headers.get("X-RateLimit-Remaining") == "0")
|
|
70
|
-
# Secondary rate limits
|
|
71
|
-
# https://docs.github.com/en/rest/overview/resources-in-the-rest-api#secondary-rate-limits
|
|
72
|
-
or "Retry-After" in response_or_exception.headers
|
|
122
|
+
retry_flag = is_rate_limited_response(
|
|
123
|
+
response_or_exception,
|
|
124
|
+
self.stream.check_graphql_rate_limited,
|
|
125
|
+
self._logger,
|
|
73
126
|
)
|
|
74
127
|
if retry_flag:
|
|
75
128
|
headers = [
|
|
@@ -92,11 +145,26 @@ class GithubStreamABCErrorHandler(HttpStatusErrorHandler):
|
|
|
92
145
|
return ErrorResolution(
|
|
93
146
|
response_action=ResponseAction.RATE_LIMITED,
|
|
94
147
|
failure_type=FailureType.transient_error,
|
|
95
|
-
error_message=
|
|
148
|
+
error_message=(
|
|
149
|
+
f"GitHub rate limit hit for stream `{self.stream.name}` "
|
|
150
|
+
f"(HTTP {response_or_exception.status_code}). "
|
|
151
|
+
f"Waiting for the rate limit window to reset before retrying."
|
|
152
|
+
),
|
|
96
153
|
)
|
|
97
154
|
|
|
98
155
|
if is_conflict_with_empty_repository(response_or_exception=response_or_exception):
|
|
99
|
-
log_message =
|
|
156
|
+
log_message = (
|
|
157
|
+
f"Skipping `{self.stream.name}` for this repository: GitHub returned 409 Conflict "
|
|
158
|
+
f"with message 'Git Repository is empty.' This means the repository has no commits."
|
|
159
|
+
)
|
|
160
|
+
return ErrorResolution(
|
|
161
|
+
response_action=ResponseAction.IGNORE,
|
|
162
|
+
failure_type=FailureType.config_error,
|
|
163
|
+
error_message=log_message,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if is_gone_with_feature_disabled(response_or_exception=response_or_exception):
|
|
167
|
+
log_message = f"Skipping stream slice for '{response_or_exception.url}': {response_or_exception.json().get('message', 'Feature disabled')}."
|
|
100
168
|
return ErrorResolution(
|
|
101
169
|
response_action=ResponseAction.IGNORE,
|
|
102
170
|
failure_type=FailureType.config_error,
|
|
@@ -106,7 +174,7 @@ class GithubStreamABCErrorHandler(HttpStatusErrorHandler):
|
|
|
106
174
|
return super().interpret_response(response_or_exception)
|
|
107
175
|
|
|
108
176
|
|
|
109
|
-
class ContributorActivityErrorHandler(
|
|
177
|
+
class ContributorActivityErrorHandler(GithubStreamABCErrorHandler):
|
|
110
178
|
"""
|
|
111
179
|
This custom error handler is needed for streams based on repository statistics endpoints like ContributorActivity because
|
|
112
180
|
when requesting data that hasn't been cached yet when the request is made, you'll receive a 202 response. And these requests
|
|
@@ -128,25 +196,49 @@ class ContributorActivityErrorHandler(HttpStatusErrorHandler):
|
|
|
128
196
|
|
|
129
197
|
|
|
130
198
|
class GitHubGraphQLErrorHandler(GithubStreamABCErrorHandler):
|
|
199
|
+
def _safe_json_get_errors(self, response: requests.Response) -> bool:
|
|
200
|
+
try:
|
|
201
|
+
body = response.json()
|
|
202
|
+
except ValueError:
|
|
203
|
+
return False
|
|
204
|
+
return bool((body or {}).get("errors"))
|
|
205
|
+
|
|
131
206
|
def interpret_response(self, response_or_exception: Optional[Union[requests.Response, Exception]] = None) -> ErrorResolution:
|
|
132
207
|
if isinstance(response_or_exception, requests.Response):
|
|
133
208
|
if response_or_exception.status_code in (requests.codes.BAD_GATEWAY, requests.codes.GATEWAY_TIMEOUT):
|
|
134
|
-
|
|
209
|
+
# Halve the page size on every 502/504 to reduce GraphQL query cost,
|
|
210
|
+
# but never let it drop below 1 — a page_size of 0 would request no
|
|
211
|
+
# records and cause infinite paging.
|
|
212
|
+
previous_page_size = self.stream.page_size
|
|
213
|
+
self.stream.page_size = max(1, int(self.stream.page_size / 2))
|
|
214
|
+
self._logger.info(
|
|
215
|
+
"GitHub GraphQL endpoint returned HTTP %s for stream `%s`; reducing GraphQL page_size from %s to %s and retrying.",
|
|
216
|
+
response_or_exception.status_code,
|
|
217
|
+
self.stream.name,
|
|
218
|
+
previous_page_size,
|
|
219
|
+
self.stream.page_size,
|
|
220
|
+
)
|
|
135
221
|
return ErrorResolution(
|
|
136
222
|
response_action=ResponseAction.RETRY,
|
|
137
223
|
failure_type=FailureType.transient_error,
|
|
138
|
-
error_message=
|
|
224
|
+
error_message=(
|
|
225
|
+
f"GitHub GraphQL endpoint returned HTTP {response_or_exception.status_code} "
|
|
226
|
+
f"for stream `{self.stream.name}`. Reducing GraphQL page size and retrying."
|
|
227
|
+
),
|
|
139
228
|
)
|
|
140
229
|
|
|
141
230
|
self.stream.page_size = (
|
|
142
231
|
constants.DEFAULT_PAGE_SIZE_FOR_LARGE_STREAM if self.stream.large_stream else constants.DEFAULT_PAGE_SIZE
|
|
143
232
|
)
|
|
144
233
|
|
|
145
|
-
if
|
|
234
|
+
if self._safe_json_get_errors(response_or_exception):
|
|
146
235
|
return ErrorResolution(
|
|
147
236
|
response_action=ResponseAction.RETRY,
|
|
148
237
|
failure_type=FailureType.transient_error,
|
|
149
|
-
error_message=
|
|
238
|
+
error_message=(
|
|
239
|
+
f"GitHub GraphQL endpoint returned errors in the response body "
|
|
240
|
+
f"for stream `{self.stream.name}` (HTTP {response_or_exception.status_code}). Retrying."
|
|
241
|
+
),
|
|
150
242
|
)
|
|
151
243
|
|
|
152
244
|
return super().interpret_response(response_or_exception)
|
source_github/graphql.py
CHANGED
|
@@ -165,6 +165,54 @@ def get_query_issue_reactions(owner, name, first, after, number=None):
|
|
|
165
165
|
return str(op)
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def get_query_releases(owner, name, first, after):
|
|
169
|
+
kwargs = {"first": first, "order_by": {"field": "CREATED_AT", "direction": "ASC"}}
|
|
170
|
+
if after:
|
|
171
|
+
kwargs["after"] = after
|
|
172
|
+
|
|
173
|
+
op = sgqlc.operation.Operation(_schema_root.query_type)
|
|
174
|
+
repository = op.repository(owner=owner, name=name)
|
|
175
|
+
repository.name()
|
|
176
|
+
repository.owner.login()
|
|
177
|
+
releases = repository.releases(**kwargs)
|
|
178
|
+
releases.nodes.__fields__(
|
|
179
|
+
id="node_id",
|
|
180
|
+
database_id="id",
|
|
181
|
+
name=True,
|
|
182
|
+
tag_name="tag_name",
|
|
183
|
+
created_at="created_at",
|
|
184
|
+
published_at="published_at",
|
|
185
|
+
updated_at="updated_at",
|
|
186
|
+
is_draft="draft",
|
|
187
|
+
is_prerelease="prerelease",
|
|
188
|
+
description="body",
|
|
189
|
+
description_html="body_html",
|
|
190
|
+
url="html_url",
|
|
191
|
+
)
|
|
192
|
+
releases.nodes.tag_commit.oid(__alias__="target_commitish")
|
|
193
|
+
author = releases.nodes.author(__alias__="author").__as__(_schema_root.User)
|
|
194
|
+
select_user_fields(author)
|
|
195
|
+
release_assets = releases.nodes.release_assets(first=100, __alias__="assets")
|
|
196
|
+
release_assets.nodes.__fields__(
|
|
197
|
+
id="node_id",
|
|
198
|
+
name=True,
|
|
199
|
+
content_type="content_type",
|
|
200
|
+
size=True,
|
|
201
|
+
download_count="download_count",
|
|
202
|
+
created_at="created_at",
|
|
203
|
+
updated_at="updated_at",
|
|
204
|
+
download_url="browser_download_url",
|
|
205
|
+
url=True,
|
|
206
|
+
)
|
|
207
|
+
release_assets.nodes.uploaded_by(__alias__="uploader").__as__(_schema_root.User).__fields__(database_id="id")
|
|
208
|
+
release_assets.page_info.__fields__(has_next_page=True)
|
|
209
|
+
releases.nodes.reaction_groups(__alias__="reaction_groups").__fields__(content=True)
|
|
210
|
+
releases.nodes.reaction_groups(__alias__="reaction_groups").reactors.__fields__(total_count=True)
|
|
211
|
+
releases.nodes.mentions(first=0, __alias__="mentions_connection").total_count()
|
|
212
|
+
releases.page_info.__fields__(has_next_page=True, end_cursor=True)
|
|
213
|
+
return str(op)
|
|
214
|
+
|
|
215
|
+
|
|
168
216
|
class QueryReactions:
|
|
169
217
|
# AVERAGE_REVIEWS - optimal number of reviews to fetch inside every pull request.
|
|
170
218
|
# If we try to fetch too many (up to 100) we will spend too many scores of query cost.
|