github-security-report 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Dependabot configuration posture and release/tag staleness.
4
+
5
+ These reporting categories sit outside the four-state per-signal model: they are
6
+ configuration-posture and freshness checks rendered as plain tables.
7
+
8
+ - **Dependabot** (beneath the open-alert table): three plain tables -- repos
9
+ with vulnerability **alerts** not enabled, repos with **security updates** not
10
+ enabled (two separate single-feature tables, not a combined matrix), and
11
+ configured ecosystems that set no update *cooldown* (a mandatory requirement
12
+ here -- any cooldown value passes). Only the two features GitHub exposes a
13
+ public per-repository API for are checked.
14
+ - **Releases / Tagging**: repositories that have gone too long without a release
15
+ or tag. Repositories younger than a configurable age are excluded (0 = none
16
+ excluded); specific repositories can also be excluded on demand. Releases and
17
+ tags are reported in separate columns; a hidden compound sort score (the sum
18
+ of the release-staleness and tag-staleness day counts, so a repo with neither
19
+ counts its age twice) ranks the worst offenders first but is never displayed.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import datetime as dt
25
+ import logging
26
+ from dataclasses import dataclass
27
+
28
+ import yaml
29
+
30
+ from github_security_report.models import Repo
31
+ from github_security_report.report import TableRow, TableSection
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ @dataclass
37
+ class RepoPosture:
38
+ """Per-repository configuration/freshness facts for the extra sections."""
39
+
40
+ repo: Repo
41
+ # Dependabot repo-level feature flags (None = indeterminate).
42
+ dependabot_alerts: bool | None = None
43
+ security_updates: bool | None = None
44
+ # Ecosystems declared in .github/dependabot.yml that set no cooldown.
45
+ cooldown_missing: tuple[str, ...] = ()
46
+ # True when .github/dependabot.yml exists and declares version updates.
47
+ has_dependabot_config: bool = False
48
+ # Releases / tagging (UTC; None = none found).
49
+ latest_release_at: dt.datetime | None = None
50
+ latest_tag_at: dt.datetime | None = None
51
+
52
+
53
+ def cooldown_missing_ecosystems(dependabot_yaml: str) -> tuple[str, ...]:
54
+ """Ecosystems in a ``dependabot.yml`` that declare no ``cooldown``.
55
+
56
+ Any ``cooldown`` value passes. Returns the ``package-ecosystem`` of each
57
+ ``updates`` entry that omits a cooldown, de-duplicated and ordered. A
58
+ malformed document yields an empty tuple (treated as "nothing to flag").
59
+ """
60
+ try:
61
+ data = yaml.safe_load(dependabot_yaml)
62
+ except yaml.YAMLError as exc: # malformed config; do not crash the run
63
+ log.warning("could not parse dependabot.yml: %s", exc)
64
+ return ()
65
+ if not isinstance(data, dict):
66
+ return ()
67
+ updates = data.get("updates")
68
+ if not isinstance(updates, list):
69
+ return ()
70
+ missing: list[str] = []
71
+ for entry in updates:
72
+ if not isinstance(entry, dict):
73
+ continue
74
+ ecosystem = entry.get("package-ecosystem")
75
+ if not isinstance(ecosystem, str) or not ecosystem:
76
+ continue
77
+ if "cooldown" not in entry and ecosystem not in missing:
78
+ missing.append(ecosystem)
79
+ return tuple(missing)
80
+
81
+
82
+ def is_release_excluded(
83
+ repo: Repo,
84
+ *,
85
+ generated_at: dt.datetime,
86
+ min_age_days: int,
87
+ exclude: frozenset[str] | set[str] | tuple[str, ...],
88
+ ) -> bool:
89
+ """Whether a repository is left out of the Releases / Tagging requirement.
90
+
91
+ A repository is excluded when its name is in ``exclude`` (never released /
92
+ not consumed externally) or when it was created within ``min_age_days``
93
+ (``0`` disables the age hold, so every repository is included). Used both to
94
+ skip the release/tag probes during collection and to filter the rendered
95
+ table, keeping the two decisions identical.
96
+ """
97
+ if repo.name in exclude:
98
+ return True
99
+ repo_age = _age_days(repo.created_at, generated_at)
100
+ return min_age_days > 0 and repo_age is not None and repo_age < min_age_days
101
+
102
+
103
+ def _age_days(when: dt.datetime | None, now: dt.datetime) -> int | None:
104
+ """Whole days between ``when`` and ``now`` (>= 0), or None when absent."""
105
+ if when is None:
106
+ return None
107
+ delta = (now - when).days
108
+ return max(delta, 0)
109
+
110
+
111
+ def _age_cell(age: int | None) -> str:
112
+ if age is None:
113
+ return "never"
114
+ if age == 0:
115
+ return "today"
116
+ if age == 1:
117
+ return "1 day ago"
118
+ return f"{age} days ago"
119
+
120
+
121
+ def build_alerts_table(postures: list[RepoPosture]) -> TableSection:
122
+ """Repositories where Dependabot vulnerability alerts are not enabled."""
123
+ rows = [
124
+ TableRow(repo=p.repo, cells=())
125
+ for p in sorted(postures, key=lambda p: p.repo.name)
126
+ if p.dependabot_alerts is False
127
+ ]
128
+ return TableSection(
129
+ title="Alerts Not Enabled",
130
+ columns=("Repository",),
131
+ rows=rows,
132
+ empty_note="No in-scope repository has Dependabot alerts confirmed disabled.",
133
+ )
134
+
135
+
136
+ def build_security_updates_table(postures: list[RepoPosture]) -> TableSection:
137
+ """Repositories where Dependabot security updates are not enabled."""
138
+ rows = [
139
+ TableRow(repo=p.repo, cells=())
140
+ for p in sorted(postures, key=lambda p: p.repo.name)
141
+ if p.security_updates is False
142
+ ]
143
+ return TableSection(
144
+ title="Dependabot: Security Updates",
145
+ columns=("Repositories NOT Enabled",),
146
+ rows=rows,
147
+ empty_note=(
148
+ "No in-scope repository has Dependabot security updates confirmed "
149
+ "disabled."
150
+ ),
151
+ )
152
+
153
+
154
+ def build_cooldown_table(postures: list[RepoPosture]) -> TableSection:
155
+ """A table of repositories/ecosystems that configure no update cooldown."""
156
+ rows = [
157
+ TableRow(repo=p.repo, cells=(", ".join(p.cooldown_missing),))
158
+ for p in sorted(postures, key=lambda p: p.repo.name)
159
+ if p.cooldown_missing
160
+ ]
161
+ return TableSection(
162
+ title="Dependabot: Cooldown Settings",
163
+ columns=("Repository", "Ecosystems without cooldown"),
164
+ rows=rows,
165
+ empty_note=(
166
+ "Every configured Dependabot ecosystem sets an update cooldown."
167
+ ),
168
+ note=(
169
+ "A cooldown is mandatory; any cooldown value passes. Repositories "
170
+ "with no Dependabot configuration are not listed here."
171
+ ),
172
+ )
173
+
174
+
175
+ def build_dependabot_tables(postures: list[RepoPosture]) -> list[TableSection]:
176
+ """All extra Dependabot posture tables, in render order.
177
+
178
+ The alerts and security-updates enablement checks are deliberately two
179
+ separate single-feature tables (rather than one multi-column matrix): with
180
+ only two public-API features the matrix read as contradictory.
181
+ """
182
+ return [
183
+ build_alerts_table(postures),
184
+ build_security_updates_table(postures),
185
+ build_cooldown_table(postures),
186
+ ]
187
+
188
+
189
+ def build_releases_table(
190
+ postures: list[RepoPosture],
191
+ *,
192
+ generated_at: dt.datetime,
193
+ min_age_days: int = 28,
194
+ exclude: tuple[str, ...] = (),
195
+ ) -> TableSection:
196
+ """The Releases / Tagging table, oldest-overall first.
197
+
198
+ Repositories created within ``min_age_days`` are excluded (0 = none
199
+ excluded), as are any whose name is in ``exclude``. Ranking uses a hidden
200
+ compound score = release-staleness-days + tag-staleness-days, where an
201
+ absent release or tag contributes the full repository age (so a repository
202
+ with neither effectively counts its age twice).
203
+ """
204
+ excluded = frozenset(exclude)
205
+ ranked: list[tuple[int, RepoPosture, int | None, int | None]] = []
206
+ for posture in postures:
207
+ repo = posture.repo
208
+ if is_release_excluded(
209
+ repo,
210
+ generated_at=generated_at,
211
+ min_age_days=min_age_days,
212
+ exclude=excluded,
213
+ ):
214
+ continue
215
+ repo_age = _age_days(repo.created_at, generated_at)
216
+ release_age = _age_days(posture.latest_release_at, generated_at)
217
+ tag_age = _age_days(posture.latest_tag_at, generated_at)
218
+ # Absent release/tag contributes the full repository age; an unknown
219
+ # creation date falls back to the staleness we do know (or zero).
220
+ fallback = repo_age if repo_age is not None else 0
221
+ compound = (release_age if release_age is not None else fallback) + (
222
+ tag_age if tag_age is not None else fallback
223
+ )
224
+ ranked.append((compound, posture, release_age, tag_age))
225
+ ranked.sort(key=lambda item: (-item[0], item[1].repo.name))
226
+ rows = [
227
+ TableRow(
228
+ repo=posture.repo,
229
+ cells=(_age_cell(release_age), _age_cell(tag_age)),
230
+ )
231
+ for _compound, posture, release_age, tag_age in ranked
232
+ ]
233
+ if min_age_days > 0:
234
+ age_note = (
235
+ f"Repositories created within {min_age_days} day(s) are excluded. "
236
+ )
237
+ else:
238
+ age_note = "All repositories are included (no minimum age). "
239
+ return TableSection(
240
+ title="Releases / Tagging",
241
+ columns=("Repository", "Last release", "Last tag"),
242
+ rows=rows,
243
+ empty_note=(
244
+ "No repositories to report (all were excluded by the minimum age "
245
+ "or the exclusion list)."
246
+ ),
247
+ note=(
248
+ age_note
249
+ + "Ranked by combined release and tag staleness (oldest first). "
250
+ "A repository with neither a release nor a tag ranks highest."
251
+ ),
252
+ )
253
+
254
+
255
+ __all__ = [
256
+ "RepoPosture",
257
+ "is_release_excluded",
258
+ "cooldown_missing_ecosystems",
259
+ "build_dependabot_tables",
260
+ "build_releases_table",
261
+ "build_alerts_table",
262
+ "build_security_updates_table",
263
+ "build_cooldown_table",
264
+ ]
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561. This package ships inline type information.
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Rendering of reports to Markdown, terminal, Slack, and HTML."""
@@ -0,0 +1,142 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """HTML rendering (Jinja2 + Simple-DataTables).
4
+
5
+ Renders each organisation to a single scrollable page with sortable/searchable
6
+ tables (Simple-DataTables, version-pinned), and a card-grid index linking to
7
+ every org -- the GitHub Pages layout. See ``docs/BRIEF.md`` section 11.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from jinja2 import Environment, PackageLoader, select_autoescape
15
+
16
+ from github_security_report.models import RepoSignal, SignalType
17
+ from github_security_report.render import markdown
18
+ from github_security_report.report import (
19
+ OrgReport,
20
+ SignalSection,
21
+ TableSection,
22
+ truncate,
23
+ )
24
+
25
+ # Pinned, not @latest (a security tool must not load a floating CDN asset).
26
+ DATATABLES_VERSION = "9.0.3"
27
+ # Subresource Integrity (sha384) for the exact pinned files: the browser
28
+ # verifies the fetched bytes against these, so a compromised/substituted CDN
29
+ # asset is rejected. Regenerate if DATATABLES_VERSION changes, e.g.:
30
+ # curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A
31
+ DATATABLES_CSS_SRI = "sha384-xnK68E/OAsSGcbvbeWEOyhjix2K7rBxt8Eytj/Ow9zuPG7WwFGGqMPQ8SbexlsL0"
32
+ DATATABLES_JS_SRI = "sha384-JYQd44jQWQbU+FdjWIUlbjzENGRHPdOQcj7dAgjJEvSyt2js5lE85kaPOdC53JVu"
33
+
34
+ _env = Environment(
35
+ loader=PackageLoader("github_security_report", "templates"),
36
+ autoescape=select_autoescape(["html", "j2"]),
37
+ )
38
+
39
+
40
+ # Anything outside this set is replaced; this also strips path separators and
41
+ # dots, so a hostile org name (e.g. "../etc") cannot escape the output dir.
42
+ _SLUG_UNSAFE = re.compile(r"[^a-z0-9_-]+")
43
+
44
+
45
+ def slugify(org: str) -> str:
46
+ """Lowercase, filesystem- and URL-safe slug for an organisation name.
47
+
48
+ The result is used to build on-disk Pages paths (``output_dir / slug``) and
49
+ URLs, so it must never contain path separators or ``..``. Any character
50
+ outside ``[a-z0-9_-]`` (including ``/``, ``.`` and whitespace) collapses to
51
+ a single ``-``; a value that reduces to empty falls back to ``"org"``.
52
+ """
53
+ slug = _SLUG_UNSAFE.sub("-", org.strip().lower()).strip("-")
54
+ return slug or "org"
55
+
56
+
57
+ def _row_cells(sig: RepoSignal) -> list[str]:
58
+ # Reuse the Markdown row shape (public API), dropping the leading repo cell.
59
+ return markdown.row_cells(sig)[1:]
60
+
61
+
62
+ def _table_context(section: TableSection, top_n: int | None = None) -> dict:
63
+ """Context for a generic posture/freshness table (Dependabot, releases)."""
64
+ rows, hidden = truncate(section.rows, top_n)
65
+ return {
66
+ "title": section.title,
67
+ "columns": list(section.columns),
68
+ "rows": [
69
+ {"name": row.repo.name, "url": row.repo.html_url, "cells": list(row.cells)}
70
+ for row in rows
71
+ ],
72
+ "hidden": hidden,
73
+ "empty_note": section.empty_note,
74
+ "note": section.note,
75
+ }
76
+
77
+
78
+ def _section_context(section: SignalSection, top_n: int | None = None) -> dict:
79
+ offenders, hidden = truncate(section.offenders, top_n)
80
+ nag, nag_hidden = truncate(section.nag_repos, top_n)
81
+ return {
82
+ "title": section.signal.heading,
83
+ "columns": markdown.columns(section.signal),
84
+ "rows": [
85
+ {"name": s.repo.name, "url": s.repo.html_url, "cells": _row_cells(s)}
86
+ for s in offenders
87
+ ],
88
+ "hidden": hidden,
89
+ "clean_count": section.clean_count,
90
+ "nag": [{"name": r.name, "url": r.html_url} for r in nag],
91
+ "nag_hidden": nag_hidden,
92
+ "unknown_count": section.unknown_count,
93
+ }
94
+
95
+
96
+ def render_org_html(org: OrgReport, *, top_n: int | None = None) -> str:
97
+ template = _env.get_template("report.html.j2")
98
+ sections: list[dict] = []
99
+ for section in org.sections:
100
+ ctx = _section_context(section, top_n)
101
+ # The Dependabot posture sub-tables render beneath the Dependabot
102
+ # Alerts section, inside the same card.
103
+ if section.signal is SignalType.DEPENDABOT:
104
+ ctx["extra_tables"] = [
105
+ _table_context(t, top_n) for t in org.dependabot_tables
106
+ ]
107
+ sections.append(ctx)
108
+ excluded_shown, excluded_hidden = truncate(org.excluded_repos, top_n)
109
+ return str(
110
+ template.render(
111
+ org=org.org,
112
+ repo_count=org.repo_count,
113
+ generated_at=org.generated_at.strftime("%Y-%m-%d %H:%M UTC"),
114
+ partial=org.partial,
115
+ excluded=[
116
+ {"name": r.name, "url": r.html_url} for r in excluded_shown
117
+ ],
118
+ excluded_total=len(org.excluded_repos),
119
+ excluded_hidden=excluded_hidden,
120
+ sections=sections,
121
+ releases=_table_context(org.releases, top_n) if org.releases else None,
122
+ datatables_version=DATATABLES_VERSION,
123
+ datatables_css_sri=DATATABLES_CSS_SRI,
124
+ datatables_js_sri=DATATABLES_JS_SRI,
125
+ )
126
+ )
127
+
128
+
129
+ def render_index_html(orgs: list[OrgReport]) -> str:
130
+ template = _env.get_template("index.html.j2")
131
+ generated_at = (
132
+ max(o.generated_at for o in orgs).strftime("%Y-%m-%d %H:%M UTC") if orgs else ""
133
+ )
134
+ return str(
135
+ template.render(
136
+ orgs=[
137
+ {"name": o.org, "slug": slugify(o.org), "repo_count": o.repo_count}
138
+ for o in orgs
139
+ ],
140
+ generated_at=generated_at,
141
+ )
142
+ )
@@ -0,0 +1,172 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Canonical Markdown rendering.
4
+
5
+ One heading per signal, immediately followed by a table of offenders
6
+ (worst-first), then a clean count, a nag list, and an unknown-status footnote.
7
+ This is the canonical artifact; Slack and the job summary derive from the same
8
+ report model. See ``docs/BRIEF.md`` sections 4-6, 11.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from github_security_report.models import Repo, RepoSignal, SignalType
14
+ from github_security_report.report import (
15
+ OrgReport,
16
+ Report,
17
+ SignalSection,
18
+ TableSection,
19
+ truncate,
20
+ )
21
+
22
+
23
+ def _link(repo: Repo) -> str:
24
+ return f"[{repo.name}]({repo.html_url})"
25
+
26
+
27
+ def _columns(signal: SignalType) -> list[str]:
28
+ if signal is SignalType.SECRET_SCANNING:
29
+ return ["Repository", "Open"]
30
+ if signal is SignalType.SCORECARD:
31
+ return ["Repository", "Score", "Critical", "High", "Medium", "Low"]
32
+ return ["Repository", "Critical", "High", "Medium", "Low", "Total"]
33
+
34
+
35
+ def _row(sig: RepoSignal) -> list[str]:
36
+ c = sig.counts
37
+ if sig.signal is SignalType.SECRET_SCANNING:
38
+ return [_link(sig.repo), str(c.total)]
39
+ if sig.signal is SignalType.SCORECARD:
40
+ score = f"{sig.score:.1f}" if sig.score is not None else "—"
41
+ return [_link(sig.repo), score, str(c.critical), str(c.high), str(c.medium), str(c.low)]
42
+ return [_link(sig.repo), str(c.critical), str(c.high), str(c.medium), str(c.low), str(c.total)]
43
+
44
+
45
+ def _table(section: SignalSection, top_n: int | None = None) -> list[str]:
46
+ cols = _columns(section.signal)
47
+ aligns = ["---"] + ["---:"] * (len(cols) - 1)
48
+ lines = ["| " + " | ".join(cols) + " |", "| " + " | ".join(aligns) + " |"]
49
+ offenders, hidden = truncate(section.offenders, top_n)
50
+ for sig in offenders:
51
+ lines.append("| " + " | ".join(_row(sig)) + " |")
52
+ if hidden:
53
+ lines.append("")
54
+ lines.append(f"_… and {hidden} more_")
55
+ return lines
56
+
57
+
58
+ # Public, render-surface-agnostic accessors for the per-signal table shape, so
59
+ # other renderers (e.g. HTML) do not reach into this module's private helpers.
60
+ def columns(signal: SignalType) -> list[str]:
61
+ """Column headings for a signal's offender table (repository first)."""
62
+ return _columns(signal)
63
+
64
+
65
+ def row_cells(sig: RepoSignal) -> list[str]:
66
+ """Cells for one offender row (the repository link is the first cell)."""
67
+ return _row(sig)
68
+
69
+
70
+ def render_section(section: SignalSection, *, top_n: int | None = None) -> str:
71
+ lines = [f"## {section.signal.heading}", ""]
72
+ if section.offenders:
73
+ lines.extend(_table(section, top_n))
74
+ lines.append("")
75
+ if section.clean_count:
76
+ lines.append(f"✅ {section.clean_count} repositories clean")
77
+ lines.append("")
78
+ if section.nag_repos:
79
+ nag, hidden = truncate(section.nag_repos, top_n)
80
+ lines.append("**Not enabled** — enable to appear in future reports:")
81
+ lines.append("")
82
+ lines.extend(f"- {_link(r)}" for r in nag)
83
+ if hidden:
84
+ lines.append(f"- _… and {hidden} more_")
85
+ lines.append("")
86
+ if section.unknown_count:
87
+ lines.append(
88
+ f"ℹ️ {section.unknown_count} repositories with unknown status "
89
+ "(insufficient permission or a transient read failure)"
90
+ )
91
+ lines.append("")
92
+ if not (
93
+ section.offenders
94
+ or section.clean_count
95
+ or section.nag_repos
96
+ or section.unknown_count
97
+ ):
98
+ lines.append("_No data available._")
99
+ lines.append("")
100
+ return "\n".join(lines).rstrip() + "\n"
101
+
102
+
103
+ def render_table_section(
104
+ section: TableSection, *, level: int = 3, top_n: int | None = None
105
+ ) -> str:
106
+ """Render a generic posture/freshness table at the given heading level."""
107
+ heading = "#" * level
108
+ lines = [f"{heading} {section.title}", ""]
109
+ rows, hidden = truncate(section.rows, top_n)
110
+ if rows:
111
+ aligns = ["---"] * len(section.columns)
112
+ lines.append("| " + " | ".join(section.columns) + " |")
113
+ lines.append("| " + " | ".join(aligns) + " |")
114
+ for row in rows:
115
+ cells = [_link(row.repo), *row.cells]
116
+ lines.append("| " + " | ".join(cells) + " |")
117
+ lines.append("")
118
+ if hidden:
119
+ lines.append(f"_… and {hidden} more_")
120
+ lines.append("")
121
+ if section.note:
122
+ # The note describes a populated table; omit it when empty.
123
+ lines.append(f"_{section.note}_")
124
+ lines.append("")
125
+ elif section.empty_note:
126
+ lines.append(f"✅ {section.empty_note}")
127
+ lines.append("")
128
+ return "\n".join(lines).rstrip() + "\n"
129
+
130
+
131
+ def render_org(org: OrgReport, *, top_n: int | None = None) -> str:
132
+ when = org.generated_at.strftime("%Y-%m-%d %H:%M UTC")
133
+ parts = [
134
+ f"# Security report: {org.org}",
135
+ "",
136
+ f"_{org.repo_count} repositories analysed · generated {when}_",
137
+ "",
138
+ ]
139
+ if org.partial:
140
+ parts.append(
141
+ "> ⚠️ **Incomplete:** the repository listing could not be fully "
142
+ "read, so some repositories may be missing from this report."
143
+ )
144
+ parts.append("")
145
+ if org.excluded_repos:
146
+ shown, hidden = truncate(org.excluded_repos, top_n)
147
+ names = ", ".join(f"`{r.name}`" for r in shown)
148
+ if hidden:
149
+ names += f" … (+{hidden} more)"
150
+ parts.append(
151
+ f"⏩ **Excluded from analysis ({len(org.excluded_repos)}):** {names}"
152
+ )
153
+ parts.append("")
154
+ for section in org.sections:
155
+ parts.append(render_section(section, top_n=top_n))
156
+ # The Dependabot configuration-posture sub-tables nest beneath the
157
+ # Dependabot signal heading.
158
+ if section.signal is SignalType.DEPENDABOT:
159
+ parts.extend(
160
+ render_table_section(table, level=3, top_n=top_n)
161
+ for table in org.dependabot_tables
162
+ )
163
+ if org.releases is not None:
164
+ parts.append(render_table_section(org.releases, level=2, top_n=top_n))
165
+ return "\n".join(parts).rstrip() + "\n"
166
+
167
+
168
+ def render_report(report: Report, *, top_n: int | None = None) -> str:
169
+ return (
170
+ "\n\n".join(render_org(org, top_n=top_n) for org in report.orgs).rstrip()
171
+ + "\n"
172
+ )