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.
Files changed (72) hide show
  1. {github_security_report-0.12.0 → github_security_report-0.12.1}/PKG-INFO +1 -1
  2. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/_version.py +2 -2
  3. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/transport.py +186 -105
  4. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/ordering.py +4 -0
  5. github_security_report-0.12.1/src/github_security_report/pulls/__init__.py +148 -0
  6. github_security_report-0.12.1/src/github_security_report/pulls/columns.py +53 -0
  7. github_security_report-0.12.1/src/github_security_report/pulls/counting.py +140 -0
  8. github_security_report-0.12.1/src/github_security_report/pulls/presentation.py +133 -0
  9. github_security_report-0.12.1/src/github_security_report/pulls/table.py +218 -0
  10. github_security_report-0.12.1/src/github_security_report/report/__init__.py +103 -0
  11. github_security_report-0.12.1/src/github_security_report/report/aggregate.py +129 -0
  12. github_security_report-0.12.1/src/github_security_report/report/display.py +181 -0
  13. github_security_report-0.12.1/src/github_security_report/report/signals.py +95 -0
  14. github_security_report-0.12.1/src/github_security_report/report/tables.py +148 -0
  15. github_security_report-0.12.0/src/github_security_report/pulls.py +0 -499
  16. github_security_report-0.12.0/src/github_security_report/report.py +0 -531
  17. {github_security_report-0.12.0 → github_security_report-0.12.1}/.gitignore +0 -0
  18. {github_security_report-0.12.0 → github_security_report-0.12.1}/LICENSE +0 -0
  19. {github_security_report-0.12.0 → github_security_report-0.12.1}/LICENSES/Apache-2.0.txt +0 -0
  20. {github_security_report-0.12.0 → github_security_report-0.12.1}/README.md +0 -0
  21. {github_security_report-0.12.0 → github_security_report-0.12.1}/pyproject.toml +0 -0
  22. {github_security_report-0.12.0 → github_security_report-0.12.1}/scripts/README.md +0 -0
  23. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/__init__.py +0 -0
  24. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/authors.py +0 -0
  25. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/categories.py +0 -0
  26. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/classify.py +0 -0
  27. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/__init__.py +0 -0
  28. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/__main__.py +0 -0
  29. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/app.py +0 -0
  30. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/modes.py +0 -0
  31. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/outputs.py +0 -0
  32. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/cli/serialise.py +0 -0
  33. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/__init__.py +0 -0
  34. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/endpoints.py +0 -0
  35. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/org_reads.py +0 -0
  36. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/parsers.py +0 -0
  37. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/queries.py +0 -0
  38. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/reads.py +0 -0
  39. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/client/writes.py +0 -0
  40. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/__init__.py +0 -0
  41. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/context.py +0 -0
  42. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/extras.py +0 -0
  43. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/org.py +0 -0
  44. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/protocols.py +0 -0
  45. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/collect/repo.py +0 -0
  46. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/config/__init__.py +0 -0
  47. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/config/loader.py +0 -0
  48. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/config/models.py +0 -0
  49. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/config/schema.py +0 -0
  50. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/gating.py +0 -0
  51. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/gitctx.py +0 -0
  52. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/issues.py +0 -0
  53. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/models.py +0 -0
  54. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/posture/__init__.py +0 -0
  55. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/posture/enablement.py +0 -0
  56. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/posture/facts.py +0 -0
  57. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/posture/releases.py +0 -0
  58. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/py.typed +0 -0
  59. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/remediate.py +0 -0
  60. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/__init__.py +0 -0
  61. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/html.py +0 -0
  62. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/markdown.py +0 -0
  63. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/slack.py +0 -0
  64. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/slack_limits.py +0 -0
  65. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/render/terminal.py +0 -0
  66. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/rulesets.py +0 -0
  67. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/runner.py +0 -0
  68. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/scope.py +0 -0
  69. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/severity.py +0 -0
  70. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/summary.py +0 -0
  71. {github_security_report-0.12.0 → github_security_report-0.12.1}/src/github_security_report/templates/index.html.j2 +0 -0
  72. {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.0
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.0'
22
- __version_tuple__ = version_tuple = (0, 12, 0)
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
- ``API_MAX_TOTAL_WAIT_SECONDS`` of cumulative waiting. A transport
180
- failure (DNS/TLS/connect or read timeout) to the GitHub API that
181
- outlives the whole budget raises :class:`NetworkError` to abort the run
182
- -- a report built without live data would be misleading. The same
183
- failure against the third-party Scorecard endpoint instead degrades to
184
- an indeterminate 503, so one flaky external API never aborts the report.
185
- Server errors (5xx) and rate-limit responses (403/429) back off on the
186
- same schedule and, once exhausted, return the response for the caller
187
- to handle: per-signal probes degrade to unknown, while callers whose
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
- attempt = 0
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
- attempt >= self._max_retries
207
- or waited + delay > API_MAX_TOTAL_WAIT_SECONDS
208
- )
209
- if exhausted:
210
- if is_external:
211
- # Third-party (Scorecard) endpoint: degrade this one
212
- # signal rather than aborting the whole GitHub report.
213
- log.warning(
214
- "external request to %s failed after %d attempt(s): "
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 AuthError(
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
- retry_after = resp.headers.get("retry-after")
264
- remaining = resp.headers.get("x-ratelimit-remaining")
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
- if server_error:
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 asyncio.sleep(delay)
310
- waited += delay
311
- attempt += 1
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 = "+"