github-security-report 0.12.0__tar.gz → 0.12.1__tar.gz
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.
- {github_security_report-0.12.0 → github_security_report-0.12.1}/PKG-INFO +1 -1
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/_version.py +2 -2
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/transport.py +186 -105
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/ordering.py +4 -0
- github_security_report-0.12.1/src/github_security_report/pulls/__init__.py +148 -0
- github_security_report-0.12.1/src/github_security_report/pulls/columns.py +53 -0
- github_security_report-0.12.1/src/github_security_report/pulls/counting.py +140 -0
- github_security_report-0.12.1/src/github_security_report/pulls/presentation.py +133 -0
- github_security_report-0.12.1/src/github_security_report/pulls/table.py +218 -0
- github_security_report-0.12.1/src/github_security_report/report/__init__.py +103 -0
- github_security_report-0.12.1/src/github_security_report/report/aggregate.py +129 -0
- github_security_report-0.12.1/src/github_security_report/report/display.py +181 -0
- github_security_report-0.12.1/src/github_security_report/report/signals.py +95 -0
- github_security_report-0.12.1/src/github_security_report/report/tables.py +148 -0
- github_security_report-0.12.0/src/github_security_report/pulls.py +0 -499
- github_security_report-0.12.0/src/github_security_report/report.py +0 -531
- {github_security_report-0.12.0 → github_security_report-0.12.1}/.gitignore +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/LICENSE +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/LICENSES/Apache-2.0.txt +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/README.md +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/pyproject.toml +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/scripts/README.md +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/__init__.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/authors.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/categories.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/classify.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/__init__.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/__main__.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/app.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/modes.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/outputs.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/serialise.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/__init__.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/endpoints.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/org_reads.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/parsers.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/queries.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/reads.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/writes.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/__init__.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/context.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/extras.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/org.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/protocols.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/repo.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/config/__init__.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/config/loader.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/config/models.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/config/schema.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/gating.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/gitctx.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/issues.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/models.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/posture/__init__.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/posture/enablement.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/posture/facts.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/posture/releases.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/py.typed +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/remediate.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/__init__.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/html.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/markdown.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/slack.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/slack_limits.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/terminal.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/rulesets.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/runner.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/scope.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/severity.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/summary.py +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/templates/index.html.j2 +0 -0
- {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/templates/report.html.j2 +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: github-security-report
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.1
|
|
4
4
|
Summary: Security and quality reporting across GitHub organisations
|
|
5
5
|
Project-URL: Homepage, https://github.com/lfreleng-actions/github-security-report-action
|
|
6
6
|
Project-URL: Repository, https://github.com/lfreleng-actions/github-security-report-action
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.12.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 12,
|
|
21
|
+
__version__ = version = '0.12.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 12, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import asyncio
|
|
14
14
|
import logging
|
|
15
15
|
import socket
|
|
16
|
+
from dataclasses import dataclass
|
|
16
17
|
from typing import TypeVar
|
|
17
18
|
|
|
18
19
|
import httpx
|
|
@@ -102,6 +103,102 @@ class AuthError(NetworkError):
|
|
|
102
103
|
"""
|
|
103
104
|
|
|
104
105
|
|
|
106
|
+
def _auth_error(method: str, url: str) -> AuthError:
|
|
107
|
+
"""The :class:`AuthError` for credentials GitHub rejected on ``url``.
|
|
108
|
+
|
|
109
|
+
Built on the very first 401 rather than retried or degraded, for the
|
|
110
|
+
reasons :class:`AuthError` gives. The message names the remedy, because
|
|
111
|
+
the operator's next action is to check the token, not to retry.
|
|
112
|
+
"""
|
|
113
|
+
return AuthError(
|
|
114
|
+
"Authentication error: GitHub rejected the credentials "
|
|
115
|
+
"(HTTP 401); aborting because every subsequent read would "
|
|
116
|
+
"fail the same way and the report would render as "
|
|
117
|
+
"'no data' or 'all clean' rather than as a failure.\n "
|
|
118
|
+
f"endpoint={method} {url}\n "
|
|
119
|
+
"Check that the token is set, has not expired, and has not "
|
|
120
|
+
"been revoked or rotated."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _transport_failure_result(
|
|
125
|
+
exc: httpx.HTTPError,
|
|
126
|
+
method: str,
|
|
127
|
+
url: str,
|
|
128
|
+
*,
|
|
129
|
+
attempt: int,
|
|
130
|
+
is_external: bool,
|
|
131
|
+
) -> httpx.Response:
|
|
132
|
+
"""Resolve a transport failure that outlived the whole retry budget.
|
|
133
|
+
|
|
134
|
+
A third-party (Scorecard) endpoint degrades to an indeterminate 503 so one
|
|
135
|
+
flaky external API never aborts the GitHub report. The GitHub API instead
|
|
136
|
+
raises :class:`NetworkError`: a report built without live data is actively
|
|
137
|
+
misleading, so the run aborts, carrying a diagnostics line that names the
|
|
138
|
+
endpoint that could not be reached.
|
|
139
|
+
"""
|
|
140
|
+
if is_external:
|
|
141
|
+
log.warning(
|
|
142
|
+
"external request to %s failed after %d attempt(s): "
|
|
143
|
+
"%s; signal degraded to unknown",
|
|
144
|
+
url,
|
|
145
|
+
attempt + 1,
|
|
146
|
+
exc,
|
|
147
|
+
)
|
|
148
|
+
return httpx.Response(503, request=httpx.Request(method, url))
|
|
149
|
+
diagnostics = await _endpoint_diagnostics(url)
|
|
150
|
+
raise NetworkError(
|
|
151
|
+
"Network error: the GitHub API is unreachable after "
|
|
152
|
+
f"{attempt + 1} attempt(s) within "
|
|
153
|
+
f"{API_MAX_TOTAL_WAIT_SECONDS:.0f}s; aborting because a "
|
|
154
|
+
"security report cannot be produced without live API "
|
|
155
|
+
"data.\n "
|
|
156
|
+
f"endpoint={method} {url} {diagnostics} "
|
|
157
|
+
f"cause={exc!s}"
|
|
158
|
+
) from exc
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _RetryBudget:
|
|
162
|
+
"""Attempts spent and seconds slept so far within one ``_request`` call.
|
|
163
|
+
|
|
164
|
+
Holds the state the retry loop carries across iterations, so deciding that
|
|
165
|
+
the budget has run out and spending it (sleep, then count the attempt) stay
|
|
166
|
+
together instead of being open-coded at every backoff site.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self, max_retries: int) -> None:
|
|
170
|
+
self.attempt = 0
|
|
171
|
+
self.waited = 0.0
|
|
172
|
+
self._max_retries = max_retries
|
|
173
|
+
|
|
174
|
+
def exhausted(self, delay: float) -> bool:
|
|
175
|
+
"""Whether sleeping ``delay`` would exceed the retry or wait budget."""
|
|
176
|
+
return (
|
|
177
|
+
self.attempt >= self._max_retries
|
|
178
|
+
or self.waited + delay > API_MAX_TOTAL_WAIT_SECONDS
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def sleep(self, delay: float) -> None:
|
|
182
|
+
"""Back off for ``delay`` seconds and charge them to the budget."""
|
|
183
|
+
await asyncio.sleep(delay)
|
|
184
|
+
self.waited += delay
|
|
185
|
+
self.attempt += 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class _RetryPlan:
|
|
190
|
+
"""How a reachable-but-degraded (403/429/5xx) response should be treated.
|
|
191
|
+
|
|
192
|
+
``retriable`` is false for a genuine permission error, which is handed
|
|
193
|
+
straight back to the caller; ``server_error`` only selects the wording of
|
|
194
|
+
the retry warning.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
retriable: bool
|
|
198
|
+
server_error: bool
|
|
199
|
+
delay: float
|
|
200
|
+
|
|
201
|
+
|
|
105
202
|
class Transport:
|
|
106
203
|
"""Connection lifecycle plus the shared retry/backoff request primitives."""
|
|
107
204
|
|
|
@@ -175,25 +272,20 @@ class Transport:
|
|
|
175
272
|
|
|
176
273
|
Retries follow the shared retry/backoff policy: exponential backoff,
|
|
177
274
|
at most ``max_retries`` retries (the constructor argument, defaulting to
|
|
178
|
-
``API_MAX_RETRIES``), and at most
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
data is load-bearing (the GraphQL prefetch) abort the run instead of
|
|
189
|
-
fabricating results. A 401 from the GitHub API is never degraded or
|
|
190
|
-
retried: it condemns every remaining read, so it raises
|
|
191
|
-
:class:`AuthError` immediately.
|
|
275
|
+
``API_MAX_RETRIES``), and at most ``API_MAX_TOTAL_WAIT_SECONDS`` of
|
|
276
|
+
cumulative waiting -- the two ceilings :class:`_RetryBudget` tracks.
|
|
277
|
+
What happens once that budget is spent depends on the failure and lives
|
|
278
|
+
in the helpers below: :func:`_transport_failure_result` for an endpoint
|
|
279
|
+
that could not be reached at all, and :meth:`_plan_degraded_retry` for a
|
|
280
|
+
server error (5xx) or a rate limit (403/429), which ends by returning
|
|
281
|
+
the response for the caller to handle -- per-signal probes degrade to
|
|
282
|
+
unknown, while callers whose data is load-bearing (the GraphQL
|
|
283
|
+
prefetch) abort the run instead of fabricating results. A 401 from the
|
|
284
|
+
GitHub API is never degraded or retried; see :func:`_auth_error`.
|
|
192
285
|
"""
|
|
193
286
|
http = client or self._client
|
|
194
287
|
is_external = http is self._ext_client
|
|
195
|
-
|
|
196
|
-
waited = 0.0
|
|
288
|
+
budget = _RetryBudget(self._max_retries)
|
|
197
289
|
while True:
|
|
198
290
|
try:
|
|
199
291
|
async with self._sem:
|
|
@@ -201,114 +293,103 @@ class Transport:
|
|
|
201
293
|
except httpx.HTTPError as exc:
|
|
202
294
|
# Transport failure: the endpoint could not be reached at all
|
|
203
295
|
# (DNS, connection, TLS, or read timeout).
|
|
204
|
-
delay = self._backoff_delay(attempt)
|
|
205
|
-
exhausted
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
"%s; signal degraded to unknown",
|
|
216
|
-
url,
|
|
217
|
-
attempt + 1,
|
|
218
|
-
exc,
|
|
219
|
-
)
|
|
220
|
-
return httpx.Response(503, request=httpx.Request(method, url))
|
|
221
|
-
diagnostics = await _endpoint_diagnostics(url)
|
|
222
|
-
raise NetworkError(
|
|
223
|
-
"Network error: the GitHub API is unreachable after "
|
|
224
|
-
f"{attempt + 1} attempt(s) within "
|
|
225
|
-
f"{API_MAX_TOTAL_WAIT_SECONDS:.0f}s; aborting because a "
|
|
226
|
-
"security report cannot be produced without live API "
|
|
227
|
-
"data.\n "
|
|
228
|
-
f"endpoint={method} {url} {diagnostics} "
|
|
229
|
-
f"cause={exc!s}"
|
|
230
|
-
) from exc
|
|
231
|
-
log.warning(
|
|
232
|
-
"request to %s failed: %s; retrying in %.0fs (retry %d of %d)",
|
|
233
|
-
url,
|
|
234
|
-
exc,
|
|
235
|
-
delay,
|
|
236
|
-
attempt + 1,
|
|
237
|
-
self._max_retries,
|
|
238
|
-
)
|
|
239
|
-
await asyncio.sleep(delay)
|
|
240
|
-
waited += delay
|
|
241
|
-
attempt += 1
|
|
296
|
+
delay = self._backoff_delay(budget.attempt)
|
|
297
|
+
if budget.exhausted(delay):
|
|
298
|
+
return await _transport_failure_result(
|
|
299
|
+
exc,
|
|
300
|
+
method,
|
|
301
|
+
url,
|
|
302
|
+
attempt=budget.attempt,
|
|
303
|
+
is_external=is_external,
|
|
304
|
+
)
|
|
305
|
+
self._log_transport_retry(url, exc, delay, budget.attempt)
|
|
306
|
+
await budget.sleep(delay)
|
|
242
307
|
continue
|
|
243
308
|
if resp.status_code == 401 and not is_external:
|
|
244
309
|
# Credentials rejected: fail fast on the very first request
|
|
245
310
|
# rather than letting a hundred more rejections accumulate into
|
|
246
311
|
# an empty, plausible-looking report.
|
|
247
312
|
await resp.aclose() # unread body would leak a pooled connection
|
|
248
|
-
raise
|
|
249
|
-
"Authentication error: GitHub rejected the credentials "
|
|
250
|
-
"(HTTP 401); aborting because every subsequent read would "
|
|
251
|
-
"fail the same way and the report would render as "
|
|
252
|
-
"'no data' or 'all clean' rather than as a failure.\n "
|
|
253
|
-
f"endpoint={method} {url}\n "
|
|
254
|
-
"Check that the token is set, has not expired, and has not "
|
|
255
|
-
"been revoked or rotated."
|
|
256
|
-
)
|
|
313
|
+
raise _auth_error(method, url)
|
|
257
314
|
if resp.status_code not in (403, 429) and resp.status_code < 500:
|
|
258
315
|
return resp
|
|
259
316
|
# Reachable but degraded: a 5xx (GitHub infrastructure trouble) or
|
|
260
317
|
# a possible rate limit. Distinguish secondary/primary rate
|
|
261
318
|
# limiting from a genuine 403, then back off on the shared
|
|
262
319
|
# schedule (honouring Retry-After) within the wait budget.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
retry_after_secs = _parse_retry_after(retry_after)
|
|
266
|
-
# A 429 is by definition "Too Many Requests", so always back off on
|
|
267
|
-
# it even when GitHub (or an intermediary) omits Retry-After and the
|
|
268
|
-
# x-ratelimit-remaining header; falling through would return the 429
|
|
269
|
-
# un-retried. A 403 is a rate limit only when one of those headers
|
|
270
|
-
# says so (otherwise it is a genuine permission error); the mere
|
|
271
|
-
# *presence* of Retry-After counts, so a malformed/unparsable value
|
|
272
|
-
# still triggers a backoff (falling back to the exponential schedule
|
|
273
|
-
# below) rather than being mistaken for a permission error.
|
|
274
|
-
rate_limited = (
|
|
275
|
-
resp.status_code == 429 or retry_after is not None or remaining == "0"
|
|
276
|
-
)
|
|
277
|
-
# Any 5xx is retried: GitHub's infrastructure wobbles produce
|
|
278
|
-
# transient 500/502/503 responses that, if returned un-retried,
|
|
279
|
-
# would silently degrade (or falsify) whole report sections.
|
|
280
|
-
server_error = resp.status_code >= 500
|
|
281
|
-
delay = (
|
|
282
|
-
retry_after_secs
|
|
283
|
-
if retry_after_secs is not None
|
|
284
|
-
else self._backoff_delay(attempt)
|
|
285
|
-
)
|
|
286
|
-
if (
|
|
287
|
-
not (rate_limited or server_error)
|
|
288
|
-
or attempt >= self._max_retries
|
|
289
|
-
or waited + delay > API_MAX_TOTAL_WAIT_SECONDS
|
|
290
|
-
):
|
|
320
|
+
plan = self._plan_degraded_retry(resp, budget.attempt)
|
|
321
|
+
if not plan.retriable or budget.exhausted(plan.delay):
|
|
291
322
|
# Retries exhausted (or a genuine 403): hand the response back
|
|
292
323
|
# so the caller can degrade its signal to unknown -- or, when
|
|
293
324
|
# its data is load-bearing, abort the run.
|
|
294
325
|
return resp
|
|
295
|
-
|
|
296
|
-
log.warning(
|
|
297
|
-
"server error %d on %s; retrying in %.0fs (retry %d of %d)",
|
|
298
|
-
resp.status_code,
|
|
299
|
-
url,
|
|
300
|
-
delay,
|
|
301
|
-
attempt + 1,
|
|
302
|
-
self._max_retries,
|
|
303
|
-
)
|
|
304
|
-
else:
|
|
305
|
-
log.warning("rate limited on %s; backing off %.0fs", url, delay)
|
|
326
|
+
self._log_degraded_retry(resp, url, plan, budget.attempt)
|
|
306
327
|
# The discarded response must be closed; we are retrying and will
|
|
307
328
|
# not read its body, so leaving it open would leak a pool connection.
|
|
308
329
|
await resp.aclose()
|
|
309
|
-
await
|
|
310
|
-
|
|
311
|
-
|
|
330
|
+
await budget.sleep(plan.delay)
|
|
331
|
+
|
|
332
|
+
def _plan_degraded_retry(self, resp: httpx.Response, attempt: int) -> _RetryPlan:
|
|
333
|
+
"""Decide whether a 403/429/5xx response is retriable, and after how long.
|
|
334
|
+
|
|
335
|
+
A 429 is by definition "Too Many Requests", so always back off on it
|
|
336
|
+
even when GitHub (or an intermediary) omits Retry-After and the
|
|
337
|
+
x-ratelimit-remaining header; falling through would return the 429
|
|
338
|
+
un-retried. A 403 is a rate limit only when one of those headers
|
|
339
|
+
says so (otherwise it is a genuine permission error); the mere
|
|
340
|
+
*presence* of Retry-After counts, so a malformed/unparsable value
|
|
341
|
+
still triggers a backoff (falling back to the exponential schedule)
|
|
342
|
+
rather than being mistaken for a permission error.
|
|
343
|
+
|
|
344
|
+
Any 5xx is retried: GitHub's infrastructure wobbles produce transient
|
|
345
|
+
500/502/503 responses that, if returned un-retried, would silently
|
|
346
|
+
degrade (or falsify) whole report sections.
|
|
347
|
+
"""
|
|
348
|
+
retry_after = resp.headers.get("retry-after")
|
|
349
|
+
remaining = resp.headers.get("x-ratelimit-remaining")
|
|
350
|
+
retry_after_secs = _parse_retry_after(retry_after)
|
|
351
|
+
rate_limited = (
|
|
352
|
+
resp.status_code == 429 or retry_after is not None or remaining == "0"
|
|
353
|
+
)
|
|
354
|
+
server_error = resp.status_code >= 500
|
|
355
|
+
return _RetryPlan(
|
|
356
|
+
retriable=rate_limited or server_error,
|
|
357
|
+
server_error=server_error,
|
|
358
|
+
delay=(
|
|
359
|
+
retry_after_secs
|
|
360
|
+
if retry_after_secs is not None
|
|
361
|
+
else self._backoff_delay(attempt)
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def _log_transport_retry(
|
|
366
|
+
self, url: str, exc: httpx.HTTPError, delay: float, attempt: int
|
|
367
|
+
) -> None:
|
|
368
|
+
"""Warn that an unreachable endpoint will be retried after ``delay``."""
|
|
369
|
+
log.warning(
|
|
370
|
+
"request to %s failed: %s; retrying in %.0fs (retry %d of %d)",
|
|
371
|
+
url,
|
|
372
|
+
exc,
|
|
373
|
+
delay,
|
|
374
|
+
attempt + 1,
|
|
375
|
+
self._max_retries,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def _log_degraded_retry(
|
|
379
|
+
self, resp: httpx.Response, url: str, plan: _RetryPlan, attempt: int
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Warn that a degraded response will be retried after ``plan.delay``."""
|
|
382
|
+
if plan.server_error:
|
|
383
|
+
log.warning(
|
|
384
|
+
"server error %d on %s; retrying in %.0fs (retry %d of %d)",
|
|
385
|
+
resp.status_code,
|
|
386
|
+
url,
|
|
387
|
+
plan.delay,
|
|
388
|
+
attempt + 1,
|
|
389
|
+
self._max_retries,
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
log.warning("rate limited on %s; backing off %.0fs", url, plan.delay)
|
|
312
393
|
|
|
313
394
|
async def _get_list(self, url: str, **params: object) -> tuple[int, list[dict]]:
|
|
314
395
|
"""GET a paginated list, returning (status, items collected).
|
|
@@ -362,6 +362,10 @@ def resolve_signal_terms(
|
|
|
362
362
|
folded = _SIGNAL_ALIASES.get(folded, folded)
|
|
363
363
|
column = columns.get(folded)
|
|
364
364
|
if column is None:
|
|
365
|
+
# ``columns`` holds sort-column headings, never a finding value.
|
|
366
|
+
# CodeQL's py/clear-text-logging-sensitive-data fires only because
|
|
367
|
+
# ``_SECRET_SCANNING_COLUMNS`` matches its "secret" heuristic -- a
|
|
368
|
+
# feature name. Alert 68 is dismissed as a false positive.
|
|
365
369
|
log.warning(
|
|
366
370
|
"ignoring unknown sort column %r for the %s table; available "
|
|
367
371
|
"columns are: %s",
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Open pull requests per repository, split by author and by what blocks them.
|
|
4
|
+
|
|
5
|
+
A reporting category outside the four-state per-signal model, and a sibling of
|
|
6
|
+
:mod:`issues`: a plain table counting each repository's open pull requests
|
|
7
|
+
across two independent groupings.
|
|
8
|
+
|
|
9
|
+
**Who raised it.** ``Human`` and ``Auto`` partition the counted pull requests:
|
|
10
|
+
``Auto`` is recognised automation (Dependabot, pre-commit.ci, Renovate and any
|
|
11
|
+
other bot actor -- see :mod:`authors`), ``Human`` is everyone else. ``Ext``
|
|
12
|
+
counts the human pull requests raised from outside the organisation, so it is a
|
|
13
|
+
**subset of Human**, never a bot. Counting automation as external would be
|
|
14
|
+
literally true by GitHub's association (``dependabot[bot]`` reports
|
|
15
|
+
``CONTRIBUTOR`` or ``NONE``) and useless in practice: it would bury genuine
|
|
16
|
+
outside contributions under routine dependency updates.
|
|
17
|
+
|
|
18
|
+
**What is holding it up.** ``Conflict``, ``Fail`` and ``Draft`` are independent
|
|
19
|
+
of the author split and of each other, so one pull request can be counted in
|
|
20
|
+
several of them. They therefore do not sum to the total, and are not meant to.
|
|
21
|
+
|
|
22
|
+
The same bounded-window caveat as the issues table applies: ``Total`` is exact
|
|
23
|
+
at any size because it comes from ``totalCount``, while the breakdown columns
|
|
24
|
+
only see the collected window, so they can sum to less than ``Total``. A row
|
|
25
|
+
whose window truncated is marked, so a partial breakdown is visible as such.
|
|
26
|
+
|
|
27
|
+
**Who is expected to move it.** Aggregate rows beneath the totals split the
|
|
28
|
+
same pull requests by assignment. Only ``Unassigned`` is a property of the pull
|
|
29
|
+
request itself; ``Mine`` and ``Others`` are read relative to the account the
|
|
30
|
+
report authenticated as, and so are confined twice over -- to runs that
|
|
31
|
+
authenticated as a person, and to the surface that person reads.
|
|
32
|
+
|
|
33
|
+
The implementation is split across ``columns`` (the table's schema),
|
|
34
|
+
``counting`` (placing each pull request in the column and assignment buckets),
|
|
35
|
+
``presentation`` (cell wording, emphasis and the description's caveats) and
|
|
36
|
+
``table`` (the builder and the two public entry points). This module re-exports
|
|
37
|
+
the public surface, so importing from ``github_security_report.pulls`` is
|
|
38
|
+
unchanged.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
# Re-exported for their own sake rather than used here: each was resolvable on
|
|
44
|
+
# the flat module this package replaces. The ``X as X`` form marks them as
|
|
45
|
+
# deliberate re-exports rather than imports nothing uses.
|
|
46
|
+
from collections.abc import Callable as Callable
|
|
47
|
+
from collections.abc import Mapping as Mapping
|
|
48
|
+
from collections.abc import Set as Set
|
|
49
|
+
|
|
50
|
+
from github_security_report.authors import is_automation_author as is_automation_author
|
|
51
|
+
from github_security_report.authors import is_external_author as is_external_author
|
|
52
|
+
from github_security_report.categories import CategoryKey as CategoryKey
|
|
53
|
+
from github_security_report.categories import category_meta as category_meta
|
|
54
|
+
from github_security_report.models import PullRequestRef as PullRequestRef
|
|
55
|
+
from github_security_report.models import Repo as Repo
|
|
56
|
+
from github_security_report.models import RepoGraphData as RepoGraphData
|
|
57
|
+
from github_security_report.pulls.columns import (
|
|
58
|
+
ALL_COLUMNS,
|
|
59
|
+
ASSIGNMENT_ROWS,
|
|
60
|
+
AUTOMATION_COLUMN,
|
|
61
|
+
BREAKDOWN_COLUMNS,
|
|
62
|
+
CONFLICT_COLUMN,
|
|
63
|
+
DRAFT_COLUMN,
|
|
64
|
+
EXTERNAL_COLUMN,
|
|
65
|
+
FAILING_COLUMN,
|
|
66
|
+
HUMAN_COLUMN,
|
|
67
|
+
MINE_ROW,
|
|
68
|
+
OTHERS_ROW,
|
|
69
|
+
PERSONAL_ASSIGNMENT_ROWS,
|
|
70
|
+
REPOSITORY_COLUMN,
|
|
71
|
+
TOTAL_COLUMN,
|
|
72
|
+
TRUNCATED_MARKER,
|
|
73
|
+
UNASSIGNED_ROW,
|
|
74
|
+
)
|
|
75
|
+
from github_security_report.pulls.counting import (
|
|
76
|
+
_blocked_count,
|
|
77
|
+
_is_automation,
|
|
78
|
+
_is_external,
|
|
79
|
+
assignment_counts,
|
|
80
|
+
assignment_rows,
|
|
81
|
+
count_pull_requests,
|
|
82
|
+
is_mine,
|
|
83
|
+
)
|
|
84
|
+
from github_security_report.pulls.presentation import (
|
|
85
|
+
_cell_levels,
|
|
86
|
+
_describe,
|
|
87
|
+
_total_cell,
|
|
88
|
+
automation_level,
|
|
89
|
+
)
|
|
90
|
+
from github_security_report.pulls.table import (
|
|
91
|
+
_build_table,
|
|
92
|
+
build_assigned_pull_requests_table,
|
|
93
|
+
build_pull_requests_table,
|
|
94
|
+
)
|
|
95
|
+
from github_security_report.report import (
|
|
96
|
+
CELL_BAD,
|
|
97
|
+
CELL_GOOD,
|
|
98
|
+
CELL_WARN,
|
|
99
|
+
TableRow,
|
|
100
|
+
TableSection,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# The flat module declared no ``__all__``, so ``import *`` exported exactly its
|
|
104
|
+
# non-underscore top-level names. Splitting it into a package binds the four
|
|
105
|
+
# submodules here too, which would silently widen that surface, so the former
|
|
106
|
+
# surface is pinned explicitly. Private names stay importable by name, as
|
|
107
|
+
# before; they were never part of the star export.
|
|
108
|
+
__all__ = [
|
|
109
|
+
"ALL_COLUMNS",
|
|
110
|
+
"ASSIGNMENT_ROWS",
|
|
111
|
+
"AUTOMATION_COLUMN",
|
|
112
|
+
"BREAKDOWN_COLUMNS",
|
|
113
|
+
"CELL_BAD",
|
|
114
|
+
"CELL_GOOD",
|
|
115
|
+
"CELL_WARN",
|
|
116
|
+
"CONFLICT_COLUMN",
|
|
117
|
+
"Callable",
|
|
118
|
+
"CategoryKey",
|
|
119
|
+
"DRAFT_COLUMN",
|
|
120
|
+
"EXTERNAL_COLUMN",
|
|
121
|
+
"FAILING_COLUMN",
|
|
122
|
+
"HUMAN_COLUMN",
|
|
123
|
+
"MINE_ROW",
|
|
124
|
+
"Mapping",
|
|
125
|
+
"OTHERS_ROW",
|
|
126
|
+
"PERSONAL_ASSIGNMENT_ROWS",
|
|
127
|
+
"PullRequestRef",
|
|
128
|
+
"REPOSITORY_COLUMN",
|
|
129
|
+
"Repo",
|
|
130
|
+
"RepoGraphData",
|
|
131
|
+
"Set",
|
|
132
|
+
"TOTAL_COLUMN",
|
|
133
|
+
"TRUNCATED_MARKER",
|
|
134
|
+
"TableRow",
|
|
135
|
+
"TableSection",
|
|
136
|
+
"UNASSIGNED_ROW",
|
|
137
|
+
"annotations",
|
|
138
|
+
"assignment_counts",
|
|
139
|
+
"assignment_rows",
|
|
140
|
+
"automation_level",
|
|
141
|
+
"build_assigned_pull_requests_table",
|
|
142
|
+
"build_pull_requests_table",
|
|
143
|
+
"category_meta",
|
|
144
|
+
"count_pull_requests",
|
|
145
|
+
"is_automation_author",
|
|
146
|
+
"is_external_author",
|
|
147
|
+
"is_mine",
|
|
148
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""The pull-request table's schema: its columns and its aggregate rows.
|
|
4
|
+
|
|
5
|
+
Names and order for everything the table draws, kept in one place because the
|
|
6
|
+
counting, the cell emphasis and the table builder all key off them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
REPOSITORY_COLUMN = "Repository"
|
|
12
|
+
HUMAN_COLUMN = "Human"
|
|
13
|
+
AUTOMATION_COLUMN = "Auto"
|
|
14
|
+
DRAFT_COLUMN = "Draft"
|
|
15
|
+
EXTERNAL_COLUMN = "Ext"
|
|
16
|
+
FAILING_COLUMN = "Fail"
|
|
17
|
+
CONFLICT_COLUMN = "Conflict"
|
|
18
|
+
TOTAL_COLUMN = "Total"
|
|
19
|
+
|
|
20
|
+
# Counted columns in render order, framed by the repository and the total.
|
|
21
|
+
# Ordered so related columns read together: the author split first, with Ext
|
|
22
|
+
# beside Human because it qualifies it (Ext is a subset of Human, never of
|
|
23
|
+
# Auto), then the blockers, worst first -- a conflict needs a human to rebase,
|
|
24
|
+
# a failing check may only need a re-run, and a draft is not blocked at all.
|
|
25
|
+
BREAKDOWN_COLUMNS = (
|
|
26
|
+
HUMAN_COLUMN,
|
|
27
|
+
EXTERNAL_COLUMN,
|
|
28
|
+
AUTOMATION_COLUMN,
|
|
29
|
+
CONFLICT_COLUMN,
|
|
30
|
+
FAILING_COLUMN,
|
|
31
|
+
DRAFT_COLUMN,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
ALL_COLUMNS = (REPOSITORY_COLUMN, *BREAKDOWN_COLUMNS, TOTAL_COLUMN)
|
|
35
|
+
|
|
36
|
+
# Aggregate rows drawn beneath the totals, splitting the same pull requests by
|
|
37
|
+
# who is expected to move them. A partition, not another set of columns: every
|
|
38
|
+
# collected pull request falls in exactly one, so the rows sum to the total.
|
|
39
|
+
#
|
|
40
|
+
# Only ``Unassigned`` is a fact about the pull request. ``Mine`` and ``Others``
|
|
41
|
+
# are read relative to the account the report authenticated as, so they exist
|
|
42
|
+
# only when that account is a person (see :func:`assignment_rows`) and are
|
|
43
|
+
# rendered only on the surface that person reads (see
|
|
44
|
+
# ``TableSection.personal_footer_labels``).
|
|
45
|
+
UNASSIGNED_ROW = "Unassigned"
|
|
46
|
+
OTHERS_ROW = "Others"
|
|
47
|
+
MINE_ROW = "Mine"
|
|
48
|
+
PERSONAL_ASSIGNMENT_ROWS = (OTHERS_ROW, MINE_ROW)
|
|
49
|
+
ASSIGNMENT_ROWS = (UNASSIGNED_ROW, *PERSONAL_ASSIGNMENT_ROWS)
|
|
50
|
+
|
|
51
|
+
# Marker appended to a repository's total when its open pull requests exceed the
|
|
52
|
+
# collected window, so a partial breakdown is visible as such.
|
|
53
|
+
TRUNCATED_MARKER = "+"
|