github-security-report 0.11.0__tar.gz → 0.11.2__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 (56) hide show
  1. {github_security_report-0.11.0 → github_security_report-0.11.2}/PKG-INFO +14 -1
  2. {github_security_report-0.11.0 → github_security_report-0.11.2}/README.md +13 -0
  3. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/_version.py +2 -2
  4. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/reads.py +87 -13
  5. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/transport.py +36 -13
  6. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/context.py +5 -5
  7. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/extras.py +1 -0
  8. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/models.py +8 -1
  9. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/posture.py +22 -0
  10. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/slack.py +156 -92
  11. github_security_report-0.11.2/src/github_security_report/render/slack_limits.py +287 -0
  12. {github_security_report-0.11.0 → github_security_report-0.11.2}/.gitignore +0 -0
  13. {github_security_report-0.11.0 → github_security_report-0.11.2}/LICENSE +0 -0
  14. {github_security_report-0.11.0 → github_security_report-0.11.2}/LICENSES/Apache-2.0.txt +0 -0
  15. {github_security_report-0.11.0 → github_security_report-0.11.2}/pyproject.toml +0 -0
  16. {github_security_report-0.11.0 → github_security_report-0.11.2}/scripts/README.md +0 -0
  17. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/__init__.py +0 -0
  18. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/categories.py +0 -0
  19. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/classify.py +0 -0
  20. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/__init__.py +0 -0
  21. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/__main__.py +0 -0
  22. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/app.py +0 -0
  23. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/modes.py +0 -0
  24. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/outputs.py +0 -0
  25. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/cli/serialise.py +0 -0
  26. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/__init__.py +0 -0
  27. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/endpoints.py +0 -0
  28. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/parsers.py +0 -0
  29. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/queries.py +0 -0
  30. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/client/writes.py +0 -0
  31. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/__init__.py +0 -0
  32. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/org.py +0 -0
  33. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/protocols.py +0 -0
  34. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/collect/repo.py +0 -0
  35. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/config/__init__.py +0 -0
  36. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/config/loader.py +0 -0
  37. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/config/models.py +0 -0
  38. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/config/schema.py +0 -0
  39. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/gating.py +0 -0
  40. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/gitctx.py +0 -0
  41. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/issues.py +0 -0
  42. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/ordering.py +0 -0
  43. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/py.typed +0 -0
  44. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/remediate.py +0 -0
  45. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/__init__.py +0 -0
  46. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/html.py +0 -0
  47. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/markdown.py +0 -0
  48. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/render/terminal.py +0 -0
  49. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/report.py +0 -0
  50. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/rulesets.py +0 -0
  51. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/runner.py +0 -0
  52. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/scope.py +0 -0
  53. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/severity.py +0 -0
  54. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/summary.py +0 -0
  55. {github_security_report-0.11.0 → github_security_report-0.11.2}/src/github_security_report/templates/index.html.j2 +0 -0
  56. {github_security_report-0.11.0 → github_security_report-0.11.2}/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.11.0
3
+ Version: 0.11.2
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
@@ -393,6 +393,19 @@ config asked for an uncapped one. `0` means "no limit" at every level. In a
393
393
  shared Slack channel the most generous value any contributing org configured for
394
394
  that category wins, matching the visibility rule above.
395
395
 
396
+ On Slack, `0` is best-effort rather than absolute. Slack imposes hard structural
397
+ limits on a message — 50 blocks per post, 3,000 characters per text object (a
398
+ section body or a context note) and 150 for a header — and rejects the **whole**
399
+ post if any is breached, so an uncapped table would cost the entire digest rather
400
+ than merely overflowing. The digest therefore sizes itself to fit: repository
401
+ name lists are trimmed first, then table rows, and whatever is left out is
402
+ reported by the usual `… and N more` tally so the numbers on screen stay honest.
403
+ Counts are never dropped, only names and rows. The other three surfaces have no
404
+ Slack-style ceiling, but they still apply their own row limits — only the
405
+ `report.json` artifact is unconditionally complete. The digest links to the
406
+ GitHub Pages report whenever `pages_url` is set and short enough to render as a
407
+ link.
408
+
396
409
  ### Per-category row ordering
397
410
 
398
411
  Each table ships a sensible default ordering — largest backlog first, stalest
@@ -349,6 +349,19 @@ config asked for an uncapped one. `0` means "no limit" at every level. In a
349
349
  shared Slack channel the most generous value any contributing org configured for
350
350
  that category wins, matching the visibility rule above.
351
351
 
352
+ On Slack, `0` is best-effort rather than absolute. Slack imposes hard structural
353
+ limits on a message — 50 blocks per post, 3,000 characters per text object (a
354
+ section body or a context note) and 150 for a header — and rejects the **whole**
355
+ post if any is breached, so an uncapped table would cost the entire digest rather
356
+ than merely overflowing. The digest therefore sizes itself to fit: repository
357
+ name lists are trimmed first, then table rows, and whatever is left out is
358
+ reported by the usual `… and N more` tally so the numbers on screen stay honest.
359
+ Counts are never dropped, only names and rows. The other three surfaces have no
360
+ Slack-style ceiling, but they still apply their own row limits — only the
361
+ `report.json` artifact is unconditionally complete. The digest links to the
362
+ GitHub Pages report whenever `pages_url` is set and short enough to render as a
363
+ link.
364
+
352
365
  ### Per-category row ordering
353
366
 
354
367
  Each table ships a sensible default ordering — largest backlog first, stalest
@@ -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.11.0'
22
- __version_tuple__ = version_tuple = (0, 11, 0)
21
+ __version__ = version = '0.11.2'
22
+ __version_tuple__ = version_tuple = (0, 11, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -19,12 +19,45 @@ from github_security_report.client.queries import (
19
19
  _DEPENDABOT_ENABLED_QUERY,
20
20
  _REPO_GRAPH_FRAGMENT,
21
21
  )
22
- from github_security_report.client.transport import Transport
22
+ from github_security_report.client.transport import NetworkError, Transport
23
23
  from github_security_report.models import Repo, RepoGraphData
24
24
 
25
25
  log = logging.getLogger(__name__)
26
26
 
27
27
 
28
+ def _aliases_with_errors(errors: object, alias_count: int) -> set[str]:
29
+ """Alias keys implicated by a batched query's ``errors`` array.
30
+
31
+ GitHub reports a *field-level* failure with HTTP 200: the alias is still a
32
+ populated dictionary, the field that failed is null, and an ``errors``
33
+ entry carries its path (e.g. ``["r3", "latestRelease"]``). Parsing such a
34
+ node would convert a read failure into a confident negative -- a nulled
35
+ ``latestRelease`` is indistinguishable from "never released" -- so the
36
+ whole alias is treated as unreadable rather than partially trusted.
37
+
38
+ The alias is failed wholesale rather than per field: a finer-grained flag
39
+ per field would have to be threaded through every table to be honest
40
+ about which half of a row is trustworthy, whereas one unknown repository
41
+ is already a state every table renders correctly.
42
+
43
+ An error whose path names no alias cannot be attributed, so it implicates
44
+ every alias in the batch: with no way to tell which repositories it
45
+ touched, treating any of them as successfully read would be a guess.
46
+ """
47
+ all_aliases = {f"r{i}" for i in range(alias_count)}
48
+ if not isinstance(errors, list):
49
+ return set()
50
+ affected: set[str] = set()
51
+ for entry in errors:
52
+ path = entry.get("path") if isinstance(entry, dict) else None
53
+ head = path[0] if isinstance(path, list) and path else None
54
+ if isinstance(head, str) and head in all_aliases:
55
+ affected.add(head)
56
+ else:
57
+ return all_aliases
58
+ return affected
59
+
60
+
28
61
  class ReadClient(Transport):
29
62
  """The reporting reads: sweeps, per-repo probes and batched prefetch."""
30
63
 
@@ -334,12 +367,21 @@ class ReadClient(Transport):
334
367
  ) -> dict[str, RepoGraphData]:
335
368
  """Prefetch per-repo data for many repositories in one GraphQL query.
336
369
 
337
- Returns a ``RepoGraphData`` per requested name. Repositories that cannot
338
- be read (a ``null`` alias) or a wholly failed query degrade to default
339
- ``RepoGraphData``, so they drop out of the dependent tables rather than
340
- being mislabelled. An empty ``names`` issues no request.
370
+ Returns a ``RepoGraphData`` per requested name. This data is
371
+ load-bearing for whole report sections (releases/tags, Dependabot
372
+ enablement, open issues), and its defaults are indistinguishable from
373
+ confident negatives ("never released"), so a wholly failed query --
374
+ a non-200 response that survived the shared retry/backoff policy, or
375
+ a 200 carrying no ``data`` object -- raises :class:`NetworkError` to
376
+ abort the run rather than fabricating results. A repository that
377
+ cannot be fully read -- a ``null`` alias, or a populated alias whose
378
+ ``errors`` entry shows a field failed to resolve -- degrades to
379
+ ``RepoGraphData(unreadable=True)`` so downstream tables report it as
380
+ unknown. An empty ``names`` issues no request.
341
381
  """
342
- out = {name: RepoGraphData() for name in names}
382
+ # Seed every requested name as unreadable; only a successfully parsed
383
+ # alias replaces its entry, so nothing failed can masquerade as read.
384
+ out = {name: RepoGraphData(unreadable=True) for name in names}
343
385
  if not names:
344
386
  return out
345
387
  aliases = "\n".join(
@@ -360,17 +402,25 @@ class ReadClient(Transport):
360
402
  json={"query": query, "variables": variables},
361
403
  )
362
404
  if resp.status_code != 200:
405
+ status = resp.status_code
363
406
  await resp.aclose() # unread body would leak a pooled connection
364
- return out
407
+ raise NetworkError(
408
+ f"GraphQL prefetch for {org} failed with HTTP {status} after "
409
+ "exhausting retries; aborting because the release/tag, "
410
+ "Dependabot-enablement and open-issues data for "
411
+ f"{len(names)} repositories would otherwise be fabricated "
412
+ "from defaults (e.g. reported as never released)."
413
+ )
365
414
  body = resp.json()
366
- data = body.get("data") or {}
415
+ data = body.get("data")
367
416
  await resp.aclose() # release the connection once the body is read
368
417
  # GitHub answers a partially-refused query with HTTP 200: the readable
369
- # aliases populated, the rest null, and an ``errors`` array explaining
370
- # why. Silently dropping it hides exactly the case where a field was
371
- # served as null because the token could not read it, so the paths are
372
- # logged once per batch for diagnosis.
418
+ # aliases populated, the rest null or missing individual fields, and an
419
+ # ``errors`` array explaining why. The paths are both logged for
420
+ # diagnosis and used to fail the affected aliases, since a field nulled
421
+ # by a failed read is indistinguishable from a genuine absence.
373
422
  errors = body.get("errors")
423
+ errored_aliases = _aliases_with_errors(errors, len(names))
374
424
  if errors:
375
425
  log.warning(
376
426
  "GraphQL prefetch for %s returned %d error(s); affected data is "
@@ -384,8 +434,32 @@ class ReadClient(Transport):
384
434
  if isinstance(e, dict)
385
435
  ),
386
436
  )
437
+ if not isinstance(data, dict):
438
+ # HTTP 200 with no data object at all: the whole batch failed
439
+ # (e.g. a timed-out or refused query). Same stakes as a non-200.
440
+ raise NetworkError(
441
+ f"GraphQL prefetch for {org} returned no data for any of "
442
+ f"{len(names)} repositories; aborting rather than reporting "
443
+ "fabricated defaults. "
444
+ f"errors={errors!r}"
445
+ )
387
446
  for i, name in enumerate(names):
388
- node = data.get(f"r{i}")
447
+ alias = f"r{i}"
448
+ if alias in errored_aliases:
449
+ # A field of this alias failed to resolve, so its null fields
450
+ # cannot be told apart from genuine absences. Leave the
451
+ # pre-seeded unreadable default in place.
452
+ continue
453
+ node = data.get(alias)
389
454
  if isinstance(node, dict):
390
455
  out[name] = _parse_repo_node(node)
456
+ unreadable = sorted(name for name, d in out.items() if d.unreadable)
457
+ if unreadable:
458
+ log.warning(
459
+ "GraphQL prefetch for %s could not read %d repositories "
460
+ "(reported as unknown): %s",
461
+ org,
462
+ len(unreadable),
463
+ ", ".join(unreadable),
464
+ )
391
465
  return out
@@ -35,15 +35,17 @@ _TransportT = TypeVar("_TransportT", bound="Transport")
35
35
 
36
36
 
37
37
  class NetworkError(RuntimeError):
38
- """The GitHub API was unreachable after exhausting the retry budget.
38
+ """The GitHub API was unusable after exhausting the retry budget.
39
39
 
40
40
  Raised for transport-level failures (DNS, connection, TLS, or read
41
41
  timeout) against the GitHub API that persist across every retry within
42
- ``API_MAX_TOTAL_WAIT_SECONDS``. The run aborts rather than rendering a
43
- report from missing data: when the API itself cannot be reached, an empty
44
- or "all clean / all unknown" report is actively misleading. Transport
45
- failures against the third-party Scorecard endpoint do not raise this --
46
- they degrade that one signal instead.
42
+ ``API_MAX_TOTAL_WAIT_SECONDS``, and by callers whose data is load-bearing
43
+ for the whole report (the batched GraphQL prefetch) when GitHub keeps
44
+ answering with server errors. The run aborts rather than rendering a
45
+ report from missing data: when the API itself cannot be relied on, an
46
+ empty or "all clean / all unknown" report is actively misleading.
47
+ Transport failures against the third-party Scorecard endpoint do not
48
+ raise this -- they degrade that one signal instead.
47
49
  """
48
50
 
49
51
 
@@ -163,8 +165,11 @@ class Transport:
163
165
  -- a report built without live data would be misleading. The same
164
166
  failure against the third-party Scorecard endpoint instead degrades to
165
167
  an indeterminate 503, so one flaky external API never aborts the report.
166
- Rate-limit responses (403/429) back off on the same schedule and, once
167
- exhausted, return the throttled response for per-signal degradation.
168
+ Server errors (5xx) and rate-limit responses (403/429) back off on the
169
+ same schedule and, once exhausted, return the response for the caller
170
+ to handle: per-signal probes degrade to unknown, while callers whose
171
+ data is load-bearing (the GraphQL prefetch) abort the run instead of
172
+ fabricating results.
168
173
  """
169
174
  http = client or self._client
170
175
  is_external = http is self._ext_client
@@ -216,10 +221,11 @@ class Transport:
216
221
  waited += delay
217
222
  attempt += 1
218
223
  continue
219
- if resp.status_code not in (403, 429):
224
+ if resp.status_code not in (403, 429) and resp.status_code < 500:
220
225
  return resp
221
- # Reachable but possibly rate limited: distinguish secondary/primary
222
- # rate limiting from a genuine 403, then back off on the shared
226
+ # Reachable but degraded: a 5xx (GitHub infrastructure trouble) or
227
+ # a possible rate limit. Distinguish secondary/primary rate
228
+ # limiting from a genuine 403, then back off on the shared
223
229
  # schedule (honouring Retry-After) within the wait budget.
224
230
  retry_after = resp.headers.get("retry-after")
225
231
  remaining = resp.headers.get("x-ratelimit-remaining")
@@ -235,18 +241,35 @@ class Transport:
235
241
  rate_limited = (
236
242
  resp.status_code == 429 or retry_after is not None or remaining == "0"
237
243
  )
244
+ # Any 5xx is retried: GitHub's infrastructure wobbles produce
245
+ # transient 500/502/503 responses that, if returned un-retried,
246
+ # would silently degrade (or falsify) whole report sections.
247
+ server_error = resp.status_code >= 500
238
248
  delay = (
239
249
  retry_after_secs
240
250
  if retry_after_secs is not None
241
251
  else self._backoff_delay(attempt)
242
252
  )
243
253
  if (
244
- not rate_limited
254
+ not (rate_limited or server_error)
245
255
  or attempt >= self._max_retries
246
256
  or waited + delay > API_MAX_TOTAL_WAIT_SECONDS
247
257
  ):
258
+ # Retries exhausted (or a genuine 403): hand the response back
259
+ # so the caller can degrade its signal to unknown -- or, when
260
+ # its data is load-bearing, abort the run.
248
261
  return resp
249
- log.warning("rate limited on %s; backing off %.0fs", url, delay)
262
+ if server_error:
263
+ log.warning(
264
+ "server error %d on %s; retrying in %.0fs (retry %d of %d)",
265
+ resp.status_code,
266
+ url,
267
+ delay,
268
+ attempt + 1,
269
+ self._max_retries,
270
+ )
271
+ else:
272
+ log.warning("rate limited on %s; backing off %.0fs", url, delay)
250
273
  # The discarded response must be closed; we are retrying and will
251
274
  # not read its body, so leaving it open would leak a pool connection.
252
275
  await resp.aclose()
@@ -60,13 +60,13 @@ class OrgCollectContext:
60
60
  probe_scorecard: bool
61
61
 
62
62
  def graph_for(self, name: str) -> RepoGraphData:
63
- """Prefetched GraphQL data for a repository, or empty defaults.
63
+ """Prefetched GraphQL data for a repository, or unreadable defaults.
64
64
 
65
- A repository missing from the prefetch (an unreadable alias, or a wholly
66
- failed batch) degrades to defaults so it drops out of the dependent
67
- tables instead of being mislabelled.
65
+ A repository missing from the prefetch entirely is marked
66
+ ``unreadable`` so the dependent tables report it as unknown instead of
67
+ mislabelling it with confident negatives (e.g. "never released").
68
68
  """
69
- return self.graph.get(name, RepoGraphData())
69
+ return self.graph.get(name, RepoGraphData(unreadable=True))
70
70
 
71
71
  def ruleset_signals(self, name: str) -> set[str]:
72
72
  """Signals an org ruleset enforces for a repository (possibly none)."""
@@ -55,6 +55,7 @@ async def _posture_for_repo(repo: Repo, ctx: OrgCollectContext) -> RepoPosture:
55
55
  )
56
56
  return RepoPosture(
57
57
  repo=repo,
58
+ graph_unreadable=graph.unreadable,
58
59
  dependabot_alerts=graph.dependabot_alerts_enabled,
59
60
  security_updates=security_updates,
60
61
  private_vulnerability_reporting=pvr,
@@ -171,9 +171,16 @@ class RepoGraphData:
171
171
  ``open_issues``, and should present its own totals as covering the window
172
172
  rather than the whole backlog. The window is ordered oldest-first, so the
173
173
  oldest issue -- the one the age check reports -- is always present even when
174
- the window truncates.
174
+ window truncates.
175
175
  """
176
176
 
177
+ # True when this repository's data could not be read at all (a ``null``
178
+ # GraphQL alias, or the repository missing from the prefetch entirely).
179
+ # Downstream tables must report such repositories as unknown: the other
180
+ # defaults below are indistinguishable from "feature absent" readings
181
+ # (e.g. ``latest_release_at is None`` also means "never released"), so
182
+ # without this flag a failed read silently renders as false negatives.
183
+ unreadable: bool = False
177
184
  dependabot_alerts_enabled: bool | None = None
178
185
  latest_tag_at: dt.datetime | None = None
179
186
  # Publish time of the "Latest" release, for release/tag staleness.
@@ -45,6 +45,11 @@ class RepoPosture:
45
45
  """Per-repository configuration/freshness facts for the extra sections."""
46
46
 
47
47
  repo: Repo
48
+ # True when the batched GraphQL prefetch could not read this repository at
49
+ # all: the release/tag and dependabot.yml facts below are then unknown, not
50
+ # absent, and the tables must count the repository as unknown rather than
51
+ # render confident negatives such as "never released".
52
+ graph_unreadable: bool = False
48
53
  # Dependabot repo-level feature flags (None = indeterminate).
49
54
  dependabot_alerts: bool | None = None
50
55
  security_updates: bool | None = None
@@ -217,12 +222,16 @@ def build_cooldown_table(postures: list[RepoPosture]) -> TableSection:
217
222
  with_cooldown = sum(
218
223
  1 for p in postures if p.has_dependabot_config and not p.cooldown_missing
219
224
  )
225
+ # An unreadable prefetch means the dependabot.yml itself is unknown, not
226
+ # absent -- count it as unknown rather than silently dropping the repo.
227
+ indeterminate = sum(1 for p in postures if p.graph_unreadable)
220
228
  return TableSection(
221
229
  category=category_meta(CategoryKey.DEPENDABOT_COOLDOWN),
222
230
  columns=("Repository", "Ecosystems without cooldown"),
223
231
  rows=rows,
224
232
  pass_count=with_cooldown,
225
233
  fail_count=missing,
234
+ unknown_count=indeterminate,
226
235
  )
227
236
 
228
237
 
@@ -283,6 +292,7 @@ def build_releases_table(
283
292
  excluded = frozenset(exclude)
284
293
  ranked: list[tuple[int, int, RepoPosture, int | None, int | None]] = []
285
294
  current_count = 0
295
+ unknown_count = 0
286
296
  for posture in postures:
287
297
  repo = posture.repo
288
298
  if is_release_excluded(
@@ -292,6 +302,12 @@ def build_releases_table(
292
302
  exclude=excluded,
293
303
  ):
294
304
  continue
305
+ if posture.graph_unreadable:
306
+ # The release/tag data could not be read for this repository, so
307
+ # its staleness is unknown -- never "never released/tagged", which
308
+ # is a confident negative the evidence does not support.
309
+ unknown_count += 1
310
+ continue
295
311
  release_age = _age_days(posture.latest_release_at, generated_at)
296
312
  tag_age = _age_days(posture.latest_tag_at, generated_at)
297
313
  if _release_is_current(release_age, tag_age, release_max_age_days):
@@ -340,6 +356,7 @@ def build_releases_table(
340
356
  rows=rows,
341
357
  pass_count=current_count,
342
358
  fail_count=len(rows),
359
+ unknown_count=unknown_count,
343
360
  description=age_note + stale_note + meta.description,
344
361
  )
345
362
 
@@ -361,6 +378,11 @@ def build_mutable_releases_table(postures: list[RepoPosture]) -> TableSection:
361
378
  clean_count = 0
362
379
  indeterminate_count = 0
363
380
  for posture in postures:
381
+ if posture.graph_unreadable:
382
+ # The release data could not be read at all: whether any release
383
+ # exists (let alone is immutable) is unknown.
384
+ indeterminate_count += 1
385
+ continue
364
386
  seen: set[str] = set()
365
387
  candidates: list[ReleaseRef] = []
366
388
  for ref in (posture.latest_release, posture.last_published_release):
@@ -8,6 +8,10 @@ offenders per signal, plus a prominent link to the full GitHub Pages report.
8
8
  Like the terminal, Slack is a brevity-first surface: it carries the
9
9
  standardised summary footer but omits the per-category explanatory description.
10
10
  Produces a ``chat.postMessage`` payload. See ``docs/BRIEF.md`` section 11.
11
+
12
+ Slack validates the payload as a whole and rejects all of it if any structural
13
+ limit is breached, so every block built here is sized by
14
+ :mod:`~github_security_report.render.slack_limits` before it is emitted.
11
15
  """
12
16
 
13
17
  from __future__ import annotations
@@ -16,6 +20,16 @@ from collections.abc import Callable, Sequence
16
20
 
17
21
  from github_security_report.categories import CategoryKey
18
22
  from github_security_report.models import Repo, RepoSignal, SignalType
23
+ from github_security_report.render.html import slugify
24
+ from github_security_report.render.slack_limits import (
25
+ MAX_TEXT_CHARS,
26
+ context_block,
27
+ enforce_block_limit,
28
+ fallback_text,
29
+ fit_section_text,
30
+ header_block,
31
+ text_length,
32
+ )
19
33
  from github_security_report.report import (
20
34
  ORG_SETUP_DOC_URL,
21
35
  SKIP_MESSAGE,
@@ -33,10 +47,6 @@ from github_security_report.report import (
33
47
  truncate,
34
48
  )
35
49
 
36
- # Slack rejects a chat.postMessage with more than 50 blocks, so a digest
37
- # spanning many orgs must be capped or the whole message fails to deliver.
38
- _SLACK_MAX_BLOCKS = 50
39
-
40
50
  # Summary kinds whose repository names are listed beneath the count line.
41
51
  _NAME_LIST_LABEL = {"disabled": "Disabled", "excluded": "Excluded"}
42
52
 
@@ -98,8 +108,17 @@ def _plain_total_row(
98
108
  return ["Total", *base, *info]
99
109
 
100
110
 
101
- def _fixed_table(section: SignalSection, top_n: int) -> str:
102
- shown, hidden = truncate(section.offenders, top_n)
111
+ def _fixed_table(section: SignalSection, shown_count: int) -> str:
112
+ """The fenced offender table showing the first ``shown_count`` rows.
113
+
114
+ Takes an absolute row count rather than a limit so the character budget in
115
+ :mod:`~github_security_report.render.slack_limits` can shed rows further
116
+ without a second truncation mechanism: the hidden tally is always derived
117
+ from the full offender list, so it stays honest no matter which cap did the
118
+ trimming.
119
+ """
120
+ shown = section.offenders[:shown_count]
121
+ hidden = len(section.offenders) - len(shown)
103
122
  informational = section_shows_informational(shown)
104
123
  cols = _plain_columns(section.signal, informational=informational)
105
124
  rows = [_plain_row(s, informational=informational) for s in shown]
@@ -127,28 +146,75 @@ def _fixed_table(section: SignalSection, top_n: int) -> str:
127
146
  return "\n".join(lines)
128
147
 
129
148
 
130
- def _summary_text(lines: Sequence[SummaryLine], *, top_n: int) -> str:
149
+ def _summary_text(lines: Sequence[SummaryLine], *, names: int) -> str:
131
150
  """The standardised footer as Slack mrkdwn: count lines then name lists.
132
151
 
133
152
  One line per count (failures first), each prefixed with its shared glyph,
134
153
  followed by the disabled/excluded repository name lists. Brevity-first, so
135
154
  no per-category description is emitted.
155
+
156
+ ``names`` caps each name list at an absolute number of entries. It is
157
+ resolved by :func:`_name_cap` before it gets here, so ``0`` means "list no
158
+ names" -- not ``truncate``'s "no limit" -- and drops the enumerations
159
+ entirely. Nothing is lost by that: every name list has a count line above
160
+ it, and the count lines always survive.
136
161
  """
137
162
  out: list[str] = []
138
163
  for line in lines:
139
164
  out.append(f"{SUMMARY_EMOJI[line.kind]} {line.text}")
165
+ if names <= 0:
166
+ return "\n".join(out)
140
167
  for line in lines:
141
168
  label = _NAME_LIST_LABEL.get(line.kind)
142
169
  if not (label and line.names):
143
170
  continue
144
- shown, hidden = truncate(line.names, top_n)
145
- names = ", ".join(shown)
171
+ shown, hidden = truncate(line.names, names)
172
+ names_text = ", ".join(shown)
146
173
  if hidden:
147
- names += f" … (+{hidden} more)"
148
- out.append(f"{label}: {names}")
174
+ names_text += f" … (+{hidden} more)"
175
+ out.append(f"{label}: {names_text}")
149
176
  return "\n".join(out)
150
177
 
151
178
 
179
+ def _name_breaks(lines: Sequence[SummaryLine]) -> tuple[int, ...]:
180
+ """Lengths of the rendered name lists, where the allowance stops being
181
+ monotonic.
182
+
183
+ A list completing mid-range drops its "… (+N more)" suffix, which can
184
+ shorten the block even as the allowance rises. Handing these to
185
+ :func:`fit_section_text` puts each transition on a search boundary.
186
+ """
187
+ return tuple(
188
+ sorted(
189
+ {
190
+ len(line.names)
191
+ for line in lines
192
+ if line.kind in _NAME_LIST_LABEL and line.names
193
+ }
194
+ )
195
+ )
196
+
197
+
198
+ def _name_cap(lines: Sequence[SummaryLine], top_n: int) -> int:
199
+ """Resolve the configured limit into an absolute name-list allowance.
200
+
201
+ ``top_n`` carries the documented "``0`` means no limit" convention, which
202
+ the character budget cannot work with -- it needs to be able to ask for
203
+ *fewer* names, including none. Resolving "no limit" to the longest list
204
+ present makes every allowance an ordinary count.
205
+
206
+ The result is bounded by that longest list either way. ``top_n`` is
207
+ operator-supplied with no schema maximum, and an allowance beyond the names
208
+ that exist renders identically to one that stops at them -- so passing the
209
+ raw value through would only make the budget's search probe a wide range of
210
+ indistinguishable outcomes before it could move on to shedding rows.
211
+ """
212
+ longest = max((len(line.names) for line in lines), default=0)
213
+ if top_n > 0:
214
+ return min(top_n, longest)
215
+ return longest
216
+
217
+
152
218
  def _fixed_table_generic(columns: tuple[str, ...], rows: list[list[str]]) -> str:
153
219
  """Fixed-width text table for a generic posture/freshness table."""
154
220
  widths = [len(c) for c in columns]
@@ -174,30 +240,73 @@ def _table_block(
174
240
  (genuinely no data) is skipped, keeping the brevity-first digest tight. The
175
241
  explanatory description is omitted: Slack is a brevity-first surface.
176
242
  """
177
- shown, hidden = truncate(section.rows, top_n)
178
- summary = _summary_text(
179
- build_summary(section.summary_counts(excluded)), top_n=top_n
180
- )
181
- if not shown and not summary:
243
+ lines = build_summary(section.summary_counts(excluded))
244
+ row_cap = len(truncate(section.rows, top_n)[0])
245
+ name_cap = _name_cap(lines, top_n)
246
+ if not row_cap and not _summary_text(lines, names=name_cap):
182
247
  return None
183
- text = f"*{section.title}*"
184
- if shown:
185
- rows = [[row.repo.name, *row.cells] for row in shown]
186
- totals = table_column_totals(section, shown)
187
- if totals is not None:
188
- rows.append(list(totals))
189
- table = _fixed_table_generic(section.columns, rows)
190
- if hidden:
191
- table += f"\n… and {hidden} more"
192
- text += f"\n```\n{table}\n```"
193
- if summary:
194
- text += f"\n{summary}"
248
+
249
+ def build(rows: int, names: int) -> str:
250
+ text = f"*{section.title}*"
251
+ if rows:
252
+ shown = section.rows[:rows]
253
+ cells = [[row.repo.name, *row.cells] for row in shown]
254
+ totals = table_column_totals(section, shown)
255
+ if totals is not None:
256
+ cells.append(list(totals))
257
+ table = _fixed_table_generic(section.columns, cells)
258
+ hidden = len(section.rows) - len(shown)
259
+ if hidden:
260
+ table += f"\n… and {hidden} more"
261
+ text += f"\n```\n{table}\n```"
262
+ summary = _summary_text(lines, names=names)
263
+ if summary:
264
+ text += f"\n{summary}"
265
+ return text
266
+
195
267
  return {
196
268
  "type": "section",
197
- "text": {"type": "mrkdwn", "text": text},
269
+ "text": {
270
+ "type": "mrkdwn",
271
+ "text": fit_section_text(
272
+ build,
273
+ rows=row_cap,
274
+ names=name_cap,
275
+ name_breaks=_name_breaks(lines),
276
+ ),
277
+ },
198
278
  }
199
279
 
200
280
 
281
+ def _signal_block(
282
+ section: SignalSection, top_n: int, *, excluded: Sequence[Repo]
283
+ ) -> dict:
284
+ """A Slack section block for one signal's offender table and footer."""
285
+ lines = build_summary(section.summary_counts(excluded))
286
+
287
+ def build(rows: int, names: int) -> str:
288
+ text = f"*{section.signal.heading}*"
289
+ if section.offenders:
290
+ text += f"\n```\n{_fixed_table(section, rows)}\n```"
291
+ summary = _summary_text(lines, names=names)
292
+ if summary:
293
+ text += f"\n{summary}"
294
+ elif not section.offenders:
295
+ # Only genuine absence of data (no rows and no countable state)
296
+ # warrants "no data"; an all-offender table has nothing to add.
297
+ text += "\nno data"
298
+ return text
299
+
300
+ row_cap = len(truncate(section.offenders, top_n)[0])
301
+ text = fit_section_text(
302
+ build,
303
+ rows=row_cap,
304
+ names=_name_cap(lines, top_n),
305
+ name_breaks=_name_breaks(lines),
306
+ )
307
+ return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
308
+
309
+
201
310
  def render_org_blocks(
202
311
  org: OrgReport,
203
312
  *,
@@ -215,24 +324,13 @@ def render_org_blocks(
215
324
  # identically (both mean "no limit"), so normalise None to 0 here.
216
325
  return resolve(key) or 0
217
326
 
218
- blocks: list[dict] = [
219
- {
220
- "type": "header",
221
- "text": {"type": "plain_text", "text": f"🔐 Security report: {org.org}"},
222
- }
223
- ]
327
+ blocks: list[dict] = [header_block(f"🔐 Security report: {org.org}")]
224
328
  if org.partial:
225
329
  blocks.append(
226
- {
227
- "type": "context",
228
- "elements": [
229
- {
230
- "type": "mrkdwn",
231
- "text": "⚠️ Incomplete: the repository listing could not "
232
- "be fully read; some repositories may be missing.",
233
- }
234
- ],
235
- }
330
+ context_block(
331
+ "⚠️ Incomplete: the repository listing could not "
332
+ "be fully read; some repositories may be missing."
333
+ )
236
334
  )
237
335
  excluded = org.excluded_repos
238
336
 
@@ -249,12 +347,11 @@ def render_org_blocks(
249
347
  for section in org.sections:
250
348
  key = section.signal.category_key
251
349
  if visible(key):
252
- section_top_n = limit_for(key)
253
- text = f"*{section.signal.heading}*"
254
350
  if section.skipped:
255
351
  # Feature gating found no organisation support: one skip line
256
352
  # linking the setup guide, instead of a table and footer.
257
- text += (
353
+ text = (
354
+ f"*{section.signal.heading}*"
258
355
  f"\n{SUMMARY_EMOJI['excluded']} {SKIP_MESSAGE} — "
259
356
  f"<{ORG_SETUP_DOC_URL}|setup guide>"
260
357
  )
@@ -262,19 +359,7 @@ def render_org_blocks(
262
359
  {"type": "section", "text": {"type": "mrkdwn", "text": text}}
263
360
  )
264
361
  continue
265
- if section.offenders:
266
- table = _fixed_table(section, section_top_n)
267
- text += f"\n```\n{table}\n```"
268
- summary = _summary_text(
269
- build_summary(section.summary_counts(excluded)), top_n=section_top_n
270
- )
271
- if summary:
272
- text += f"\n{summary}"
273
- elif not section.offenders:
274
- # Only genuine absence of data (no rows and no countable state)
275
- # warrants "no data"; an all-offender table has nothing to add.
276
- text += "\nno data"
277
- blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
362
+ blocks.append(_signal_block(section, limit_for(key), excluded=excluded))
278
363
  # Dependabot posture sub-tables follow the Dependabot signal block.
279
364
  if section.signal is SignalType.DEPENDABOT:
280
365
  for table_section in org.dependabot_tables:
@@ -284,38 +369,17 @@ def render_org_blocks(
284
369
  add_table(org.private_vulnerability_reporting)
285
370
  add_table(org.issues)
286
371
  if pages_url:
287
- blocks.append(
288
- {
289
- "type": "context",
290
- "elements": [
291
- {"type": "mrkdwn", "text": f"<{pages_url}|View the full report>"}
292
- ],
293
- }
294
- )
372
+ # Link straight to this organisation's latest report page rather than
373
+ # the Pages index: the digest is per-org, so the index is a detour.
374
+ org_url = f"{pages_url.rstrip('/')}/{slugify(org.org)}/report.html"
375
+ link = f"<{org_url}|View the full report>"
376
+ # Omit the link rather than clamp it: a cut URL resolves elsewhere,
377
+ # which is a wrong answer rather than a missing one.
378
+ if text_length(link) <= MAX_TEXT_CHARS:
379
+ blocks.append(context_block(link))
295
380
  return blocks
296
381
 
297
382
 
298
- def _enforce_block_limit(blocks: list[dict], pages_url: str | None) -> list[dict]:
299
- """Cap blocks at Slack's per-message limit, noting any truncation.
300
-
301
- A digest covering many orgs can exceed 50 blocks, which makes Slack reject
302
- the entire message (no digest delivered). Keep the first blocks and replace
303
- the overflow with a single note pointing at the full report.
304
- """
305
- if len(blocks) <= _SLACK_MAX_BLOCKS:
306
- return blocks
307
- if pages_url:
308
- note = (
309
- f"… digest truncated to Slack's {_SLACK_MAX_BLOCKS}-block limit; "
310
- f"<{pages_url}|view the full report>."
311
- )
312
- else:
313
- note = f"… digest truncated to Slack's {_SLACK_MAX_BLOCKS}-block limit."
314
- kept = blocks[: _SLACK_MAX_BLOCKS - 1]
315
- kept.append({"type": "context", "elements": [{"type": "mrkdwn", "text": note}]})
316
- return kept
317
-
318
-
319
383
  def render_payload(
320
384
  orgs: list[OrgReport],
321
385
  *,
@@ -333,10 +397,10 @@ def render_payload(
333
397
  org, top_n=top_n, pages_url=pages_url, show=show, limit=limit
334
398
  )
335
399
  )
336
- blocks = _enforce_block_limit(blocks, pages_url)
400
+ blocks = enforce_block_limit(blocks, pages_url)
337
401
  names = ", ".join(o.org for o in orgs)
338
402
  return {
339
403
  "channel": channel,
340
- "text": f"🔐 Security report: {names}",
404
+ "text": fallback_text(f"🔐 Security report: {names}"),
341
405
  "blocks": blocks,
342
406
  }
@@ -0,0 +1,287 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Keeping a Slack payload inside Slack's hard structural limits.
4
+
5
+ Slack validates a ``chat.postMessage`` payload as a whole: exceeding any one
6
+ limit rejects the **entire** message, so a single oversized table costs the
7
+ whole digest rather than degrading it. This module is the one place those
8
+ limits are named and enforced:
9
+
10
+ * the **50-block** per-message ceiling, which a digest spanning many
11
+ organisations crosses (see :func:`enforce_block_limit`);
12
+ * the **3,000-character** ceiling on a text object, which an uncapped table
13
+ crosses long before it reaches 50 blocks (see :func:`fit_section_text`).
14
+ The same limit applies to a ``context`` element, whose text carries the
15
+ caller-supplied ``pages_url`` and so is *not* bounded by construction (see
16
+ :func:`context_block`);
17
+ * the **150-character** ceiling on a ``header``, whose text carries a
18
+ configured organisation name (see :func:`header_block`);
19
+ * the **40,000-character** ceiling on the top-level ``text`` fallback, which
20
+ concatenates every organisation name (see :func:`fallback_text`).
21
+
22
+ Only one limit is left unguarded, and it is genuinely structural rather than
23
+ data-dependent: a ``context`` block may hold 10 elements and every one built
24
+ here holds exactly one. Everything an operator can influence -- configured
25
+ organisation names, ``pages_url``, and any "no limit" row setting -- is
26
+ measured rather than assumed.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Callable, Sequence
32
+
33
+ # Slack rejects a chat.postMessage with more than 50 blocks, so a digest
34
+ # spanning many orgs must be capped or the whole message fails to deliver.
35
+ MAX_BLOCKS = 50
36
+
37
+ # Slack's text object caps ``text`` at 3,000 characters wherever it appears --
38
+ # section bodies and context elements alike -- and rejects the whole payload
39
+ # with it. Reachable through any documented "no limit" setting (``top_n: 0`` at
40
+ # report, surface or category level), which uncaps a table long before 50 blocks
41
+ # are in play.
42
+ MAX_TEXT_CHARS = 3000
43
+
44
+ # A header block uses a plain_text object with its own, much tighter ceiling.
45
+ MAX_HEADER_CHARS = 150
46
+
47
+ # The top-level ``text`` fallback (notification preview) has a far larger one.
48
+ MAX_FALLBACK_CHARS = 40000
49
+
50
+ # Appended when even a row-less, name-less block will not fit: a guaranteed
51
+ # last resort so an over-budget block can never leave this module.
52
+ _CLAMP_NOTE = "\n… truncated"
53
+ _FENCE = "```"
54
+
55
+
56
+ def text_length(text: str) -> int:
57
+ """Length of ``text`` as Slack measures it, in UTF-16 code units.
58
+
59
+ Slack's limits are enforced by a JavaScript-facing API, where string length
60
+ counts UTF-16 code units, so an astral-plane emoji costs two. Python's
61
+ ``len`` counts code points and would under-count, letting a payload that
62
+ Slack rejects slip through. Counting UTF-16 is also the safe choice if
63
+ Slack in fact counts code points: it is never smaller, so it can only make
64
+ this module conservative, never permissive.
65
+
66
+ ``surrogatepass`` because operator-controlled strings can carry an unpaired
67
+ surrogate: :func:`json.loads` accepts ``\\ud800`` in a configured
68
+ organisation name, and POSIX argument decoding turns undecodable bytes into
69
+ lone surrogates. The strict encoder raises ``UnicodeEncodeError`` on those,
70
+ which would abort report generation outright -- a worse failure than the
71
+ oversized payload this module exists to prevent, and one introduced by the
72
+ measuring rather than by the data.
73
+ """
74
+ return len(text.encode("utf-16-le", "surrogatepass")) // 2
75
+
76
+
77
+ def clamp(text: str, budget: int = MAX_TEXT_CHARS) -> str:
78
+ """Hard-cut ``text`` to ``budget``, closing any code fence left open.
79
+
80
+ The unconditional backstop beneath :func:`fit_section_text`: it makes the
81
+ limit an invariant of this module rather than something every caller has to
82
+ get right. Cutting mid-table would leave an unterminated code fence, which
83
+ Slack renders as the rest of the block swallowed into a code span, so an odd
84
+ fence count is balanced before the truncation note is appended.
85
+
86
+ A budget too small to hold that note degrades further rather than
87
+ overshooting -- a bare ellipsis, then nothing at all -- because a backstop
88
+ that quietly exceeds its own budget is worse than no backstop. Only an
89
+ explicit ``budget`` argument can reach those cases; every production caller
90
+ passes :data:`MAX_TEXT_CHARS`.
91
+ """
92
+ if text_length(text) <= budget:
93
+ return text
94
+ reserved = text_length(_CLAMP_NOTE) + text_length(f"\n{_FENCE}")
95
+ if budget < reserved:
96
+ return _ellipsize(text, budget)
97
+ room = budget - reserved
98
+ cut = text[:room]
99
+ # Re-measure rather than trusting the slice: ``room`` is a UTF-16 budget but
100
+ # the slice is by code point, so a surrogate pair can overshoot it.
101
+ while cut and text_length(cut) > room:
102
+ cut = cut[:-1]
103
+ if cut.count(_FENCE) % 2:
104
+ cut += f"\n{_FENCE}"
105
+ return cut + _CLAMP_NOTE
106
+
107
+
108
+ def _largest_fitting(
109
+ render: Callable[[int], str],
110
+ count: int,
111
+ budget: int,
112
+ breaks: Sequence[int] = (),
113
+ ) -> int:
114
+ """Largest ``n <= count`` whose rendered text fits ``budget`` (or ``0``).
115
+
116
+ A binary search, which is valid only where rendered length is non-decreasing
117
+ in ``n``. ``breaks`` names the values where that fails, splitting the range
118
+ into windows that are individually monotonic; each is searched and the best
119
+ result wins. The answer is therefore exact without enumerating every
120
+ candidate -- a few binary searches rather than one linear scan.
121
+
122
+ **Rows need no breaks.** :func:`fit_section_text` searches them only after
123
+ the render at ``count`` has been measured and rejected, so every ``n``
124
+ probed leaves rows hidden and the single "… and N more" note is present
125
+ throughout. Each additional row adds at least a newline while that one note
126
+ can shrink by at most a digit, so length never falls. The step that would
127
+ shorten it -- the note vanishing once nothing is left over -- happens only
128
+ at ``count``, which the precondition excludes.
129
+
130
+ **Names do need them,** because that argument does not survive a second
131
+ list. Each name list carries its own note, and a list shorter than the
132
+ allowance completes *inside* the range: its ``… (+N more)`` suffix vanishes
133
+ while only one entry is added, so with short repository names the render
134
+ gets shorter as the allowance rises. Passing each list's length as a break
135
+ puts every such transition on a window boundary, where it cannot mislead the
136
+ search. Without them a rejected midpoint discards a fitting range above it
137
+ and hides names that would have fitted.
138
+ """
139
+ starts = sorted({0, *(b for b in breaks if 0 < b <= count)})
140
+ best = 0
141
+ for i, start in enumerate(starts):
142
+ end = starts[i + 1] - 1 if i + 1 < len(starts) else count
143
+ # Within a window length is non-decreasing, so if the smallest value
144
+ # does not fit then nothing above it in this window does either.
145
+ if text_length(render(start)) > budget:
146
+ continue
147
+ lo, hi = start, end
148
+ while lo < hi:
149
+ mid = (lo + hi + 1) // 2
150
+ if text_length(render(mid)) <= budget:
151
+ lo = mid
152
+ else:
153
+ hi = mid - 1
154
+ best = max(best, lo)
155
+ return best
156
+
157
+
158
+ def fit_section_text(
159
+ render: Callable[[int, int], str],
160
+ *,
161
+ rows: int,
162
+ names: int,
163
+ name_breaks: Sequence[int] = (),
164
+ budget: int = MAX_TEXT_CHARS,
165
+ ) -> str:
166
+ """Fit a section block's text into ``budget`` by shedding content.
167
+
168
+ ``render(rows, names)`` must build the complete block text showing the first
169
+ ``rows`` table rows and the first ``names`` entries of each repository name
170
+ list, *including* the notes accounting for whatever it left out. Because
171
+ both are plain prefix counts, every reduction here flows back through the
172
+ same ``truncate``-shaped ``(shown, hidden)`` accounting the other surfaces
173
+ use, so the "… and N more" tallies stay honest instead of being tracked by a
174
+ second, parallel mechanism.
175
+
176
+ Content is shed in ascending order of value. Repository **name lists** go
177
+ first: they are pure enumeration, they are the part that grows without bound
178
+ on a large organisation, and dropping them costs nothing that matters --
179
+ their *counts* live on separate summary lines that always survive. **Table
180
+ rows** go next, and only as far as needed; they are ordered worst-first, so
181
+ a prefix is the most valuable part of the table.
182
+
183
+ Rows are shed rather than split across additional blocks. Blocks are a
184
+ scarce *global* resource -- 50 for the whole digest, shared by every
185
+ organisation -- so spending them on one oversized table would evict other
186
+ organisations from the message entirely, trading a partial table for total
187
+ data loss elsewhere. Slack is a brevity-first surface and the digest links
188
+ the full report whenever a usable URL is configured, so shedding rows and
189
+ saying so is the right degradation.
190
+
191
+ One probe decides which of the two is actually at fault. If the block still
192
+ will not fit with *no* names at all, then no name allowance can save it and
193
+ the rows are the problem, so the name search is skipped entirely rather than
194
+ rebuilding a large table across a range of allowances that cannot help.
195
+
196
+ ``name_breaks`` carries the length of each repository name list, marking
197
+ where the name allowance stops behaving monotonically; see
198
+ :func:`_largest_fitting`.
199
+ """
200
+ text = render(rows, names)
201
+ if text_length(text) <= budget:
202
+ return text
203
+ if text_length(render(rows, 0)) <= budget:
204
+ names = _largest_fitting(lambda n: render(rows, n), names, budget, name_breaks)
205
+ return render(rows, names)
206
+ rows = _largest_fitting(lambda n: render(n, 0), rows, budget)
207
+ return clamp(render(rows, 0), budget)
208
+
209
+
210
+ def _ellipsize(text: str, budget: int) -> str:
211
+ """Cut plain text to ``budget``, marking the cut with an ellipsis.
212
+
213
+ For the header and fallback strings, where :func:`clamp`'s code-fence
214
+ handling and multi-line note would be out of place, and as :func:`clamp`'s
215
+ own fallback when the budget cannot hold that note. A budget with no room
216
+ even for the ellipsis yields the empty string: there is nothing truthful
217
+ left to say in zero characters.
218
+ """
219
+ if text_length(text) <= budget:
220
+ return text
221
+ if budget <= 0:
222
+ return ""
223
+ room = budget - 1
224
+ cut = text[:room]
225
+ # Re-measure: the budget is in UTF-16 units but the slice is by code point.
226
+ while cut and text_length(cut) > room:
227
+ cut = cut[:-1]
228
+ return f"{cut}…"
229
+
230
+
231
+ def header_block(text: str) -> dict:
232
+ """A header block sized to Slack's plain_text ceiling.
233
+
234
+ The heading embeds a configured organisation name. GitHub caps its own
235
+ logins well below the limit, but the name reaching here comes from the
236
+ tool's configuration rather than from GitHub, so it is measured rather
237
+ than assumed.
238
+ """
239
+ return {
240
+ "type": "header",
241
+ "text": {"type": "plain_text", "text": _ellipsize(text, MAX_HEADER_CHARS)},
242
+ }
243
+
244
+
245
+ def context_block(text: str) -> dict:
246
+ """A context block sized to Slack's text object ceiling.
247
+
248
+ Context elements carry the caller-supplied ``pages_url``, which nothing
249
+ validates for length, so the same 3,000-character limit applies here as to
250
+ a section body. Callers that build a link must check it fits *before*
251
+ passing it in and omit it otherwise: a clamped URL points somewhere other
252
+ than the report, which is a wrong answer rather than a missing one.
253
+ """
254
+ return {"type": "context", "elements": [{"type": "mrkdwn", "text": clamp(text)}]}
255
+
256
+
257
+ def fallback_text(text: str) -> str:
258
+ """The top-level notification text, sized to Slack's ceiling.
259
+
260
+ It concatenates every organisation name in the digest, so it grows with the
261
+ configuration even though the limit is generous.
262
+ """
263
+ return _ellipsize(text, MAX_FALLBACK_CHARS)
264
+
265
+
266
+ def enforce_block_limit(blocks: list[dict], pages_url: str | None) -> list[dict]:
267
+ """Cap blocks at Slack's per-message limit, noting any truncation.
268
+
269
+ A digest covering many orgs can exceed 50 blocks, which makes Slack reject
270
+ the entire message (no digest delivered). Keep the first blocks and replace
271
+ the overflow with a single note pointing at the full report.
272
+ """
273
+ if len(blocks) <= MAX_BLOCKS:
274
+ return blocks
275
+ note = f"… digest truncated to Slack's {MAX_BLOCKS}-block limit."
276
+ if pages_url:
277
+ linked = (
278
+ f"… digest truncated to Slack's {MAX_BLOCKS}-block limit; "
279
+ f"<{pages_url}|view the full report>."
280
+ )
281
+ # Prefer the linked note, but fall back to the bare one rather than
282
+ # clamping a URL into something that no longer resolves.
283
+ if text_length(linked) <= MAX_TEXT_CHARS:
284
+ note = linked
285
+ kept = blocks[: MAX_BLOCKS - 1]
286
+ kept.append(context_block(note))
287
+ return kept