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,215 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Slack rendering.
4
+
5
+ Slack mrkdwn cannot render Markdown tables, so the digest uses fixed-width
6
+ code-fenced blocks (the only way to align columns) showing the worst N
7
+ offenders per signal, plus a prominent link to the full GitHub Pages report.
8
+ Produces a ``chat.postMessage`` payload. See ``docs/BRIEF.md`` section 11.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from github_security_report.models import RepoSignal, SignalType
14
+ from github_security_report.report import (
15
+ OrgReport,
16
+ SignalSection,
17
+ TableSection,
18
+ truncate,
19
+ )
20
+
21
+ # Slack rejects a chat.postMessage with more than 50 blocks, so a digest
22
+ # spanning many orgs must be capped or the whole message fails to deliver.
23
+ _SLACK_MAX_BLOCKS = 50
24
+
25
+
26
+ def _plain_columns(signal: SignalType) -> list[str]:
27
+ if signal is SignalType.SECRET_SCANNING:
28
+ return ["repo", "open"]
29
+ if signal is SignalType.SCORECARD:
30
+ return ["repo", "score", "C", "H", "M", "L"]
31
+ return ["repo", "C", "H", "M", "L"]
32
+
33
+
34
+ def _plain_row(sig: RepoSignal) -> list[str]:
35
+ c = sig.counts
36
+ if sig.signal is SignalType.SECRET_SCANNING:
37
+ return [sig.repo.name, str(c.total)]
38
+ if sig.signal is SignalType.SCORECARD:
39
+ score = f"{sig.score:.1f}" if sig.score is not None else "-"
40
+ return [sig.repo.name, score, str(c.critical), str(c.high), str(c.medium), str(c.low)]
41
+ return [sig.repo.name, str(c.critical), str(c.high), str(c.medium), str(c.low)]
42
+
43
+
44
+ def _fixed_table(section: SignalSection, top_n: int) -> str:
45
+ cols = _plain_columns(section.signal)
46
+ shown, hidden = truncate(section.offenders, top_n)
47
+ rows = [_plain_row(s) for s in shown]
48
+ widths = [len(c) for c in cols]
49
+ for row in rows:
50
+ for i, cell in enumerate(row):
51
+ widths[i] = max(widths[i], len(cell))
52
+ # First column left-aligned (repo name), numeric columns right-aligned.
53
+ def fmt(row: list[str]) -> str:
54
+ cells = [row[0].ljust(widths[0])]
55
+ cells += [row[i].rjust(widths[i]) for i in range(1, len(row))]
56
+ return " ".join(cells)
57
+
58
+ lines = [fmt(cols)] + [fmt(row) for row in rows]
59
+ if hidden:
60
+ # Match the posture/release tables: surface the hidden count.
61
+ lines.append(f"… and {hidden} more")
62
+ return "\n".join(lines)
63
+
64
+
65
+ def _summary(section: SignalSection) -> str:
66
+ bits = []
67
+ if section.offenders:
68
+ bits.append(f"{len(section.offenders)} with findings")
69
+ if section.clean_count:
70
+ bits.append(f"{section.clean_count} clean")
71
+ if section.nag_repos:
72
+ bits.append(f"{len(section.nag_repos)} not enabled")
73
+ if section.unknown_count:
74
+ bits.append(f"{section.unknown_count} unknown")
75
+ return ", ".join(bits) or "no data"
76
+
77
+
78
+ def _fixed_table_generic(columns: tuple[str, ...], rows: list[list[str]]) -> str:
79
+ """Fixed-width text table for a generic posture/freshness table."""
80
+ widths = [len(c) for c in columns]
81
+ for row in rows:
82
+ for i, cell in enumerate(row):
83
+ widths[i] = max(widths[i], len(cell))
84
+
85
+ def fmt(row: list[str]) -> str:
86
+ return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))
87
+
88
+ lines = [fmt(list(columns))] + [fmt(row) for row in rows]
89
+ return "\n".join(lines)
90
+
91
+
92
+ def _table_block(section: TableSection, top_n: int) -> dict | None:
93
+ """A Slack section block for a posture/freshness table (None when empty).
94
+
95
+ Emoji cell glyphs are dropped from the fixed-width rendering so columns stay
96
+ aligned in Slack's monospace block; only the worst ``top_n`` rows are shown.
97
+ """
98
+ if not section.rows:
99
+ return None
100
+ shown, hidden = truncate(section.rows, top_n)
101
+ rows = [
102
+ [row.repo.name, *(cell.replace("✅", "y").replace("❌", "n").replace("❓", "?") for cell in row.cells)]
103
+ for row in shown
104
+ ]
105
+ table = _fixed_table_generic(section.columns, rows)
106
+ if hidden:
107
+ table += f"\n… and {hidden} more"
108
+ return {
109
+ "type": "section",
110
+ "text": {"type": "mrkdwn", "text": f"*{section.title}*\n```\n{table}\n```"},
111
+ }
112
+
113
+
114
+ def render_org_blocks(org: OrgReport, *, top_n: int, pages_url: str | None) -> list[dict]:
115
+ """Slack blocks for one organisation."""
116
+ blocks: list[dict] = [
117
+ {
118
+ "type": "header",
119
+ "text": {"type": "plain_text", "text": f"🔐 Security report: {org.org}"},
120
+ }
121
+ ]
122
+ if org.partial:
123
+ blocks.append(
124
+ {
125
+ "type": "context",
126
+ "elements": [
127
+ {
128
+ "type": "mrkdwn",
129
+ "text": "⚠️ Incomplete: the repository listing could not "
130
+ "be fully read; some repositories may be missing.",
131
+ }
132
+ ],
133
+ }
134
+ )
135
+ if org.excluded_repos:
136
+ shown, hidden = truncate(org.excluded_repos, top_n)
137
+ names = ", ".join(r.name for r in shown)
138
+ if hidden:
139
+ names += f" … (+{hidden} more)"
140
+ blocks.append(
141
+ {
142
+ "type": "context",
143
+ "elements": [
144
+ {
145
+ "type": "mrkdwn",
146
+ "text": f"⏩ Excluded from analysis "
147
+ f"({len(org.excluded_repos)}): {names}",
148
+ }
149
+ ],
150
+ }
151
+ )
152
+ for section in org.sections:
153
+ summary = _summary(section)
154
+ text = f"*{section.signal.heading}* — {summary}"
155
+ if section.offenders:
156
+ table = _fixed_table(section, top_n)
157
+ text += f"\n```\n{table}\n```"
158
+ blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
159
+ # Dependabot posture sub-tables follow the Dependabot signal block.
160
+ if section.signal is SignalType.DEPENDABOT:
161
+ for table_section in org.dependabot_tables:
162
+ block = _table_block(table_section, top_n)
163
+ if block is not None:
164
+ blocks.append(block)
165
+ if org.releases is not None:
166
+ block = _table_block(org.releases, top_n)
167
+ if block is not None:
168
+ blocks.append(block)
169
+ if pages_url:
170
+ blocks.append(
171
+ {
172
+ "type": "context",
173
+ "elements": [
174
+ {"type": "mrkdwn", "text": f"<{pages_url}|View the full report>"}
175
+ ],
176
+ }
177
+ )
178
+ return blocks
179
+
180
+
181
+ def _enforce_block_limit(blocks: list[dict], pages_url: str | None) -> list[dict]:
182
+ """Cap blocks at Slack's per-message limit, noting any truncation.
183
+
184
+ A digest covering many orgs can exceed 50 blocks, which makes Slack reject
185
+ the entire message (no digest delivered). Keep the first blocks and replace
186
+ the overflow with a single note pointing at the full report.
187
+ """
188
+ if len(blocks) <= _SLACK_MAX_BLOCKS:
189
+ return blocks
190
+ if pages_url:
191
+ note = (
192
+ f"… digest truncated to Slack's {_SLACK_MAX_BLOCKS}-block limit; "
193
+ f"<{pages_url}|view the full report>."
194
+ )
195
+ else:
196
+ note = f"… digest truncated to Slack's {_SLACK_MAX_BLOCKS}-block limit."
197
+ kept = blocks[: _SLACK_MAX_BLOCKS - 1]
198
+ kept.append({"type": "context", "elements": [{"type": "mrkdwn", "text": note}]})
199
+ return kept
200
+
201
+
202
+ def render_payload(
203
+ orgs: list[OrgReport], *, channel: str, top_n: int = 10, pages_url: str | None = None
204
+ ) -> dict:
205
+ """Build a ``chat.postMessage`` payload across one or more organisations."""
206
+ blocks: list[dict] = []
207
+ for org in orgs:
208
+ blocks.extend(render_org_blocks(org, top_n=top_n, pages_url=pages_url))
209
+ blocks = _enforce_block_limit(blocks, pages_url)
210
+ names = ", ".join(o.org for o in orgs)
211
+ return {
212
+ "channel": channel,
213
+ "text": f"🔐 Security report: {names}",
214
+ "blocks": blocks,
215
+ }
@@ -0,0 +1,163 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Rich terminal rendering.
4
+
5
+ The default presentation for local/TTY runs: one coloured table per signal,
6
+ worst-first, with clean/nag/unknown summaries beneath. The CLI falls back to a
7
+ plain console (no colour) in CI / non-TTY contexts. See ``docs/BRIEF.md``
8
+ sections 10-11.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from collections.abc import Sequence
15
+
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from github_security_report.models import Repo, RepoSignal, SignalType
20
+ from github_security_report.report import (
21
+ OrgReport,
22
+ SignalSection,
23
+ TableSection,
24
+ truncate,
25
+ )
26
+
27
+ _SEVERITY_STYLE = {"critical": "bold red", "high": "red", "medium": "yellow", "low": "dim"}
28
+
29
+
30
+ def _split_sentences(text: str) -> list[str]:
31
+ """Split a footnote into one sentence per line for readable terminal output.
32
+
33
+ Splits on a sentence-ending period followed by whitespace, keeping the
34
+ period. A semicolon does not end a sentence, so a clause such as
35
+ "mandatory; any value passes." stays on one line. A single-sentence note is
36
+ returned unchanged as one line.
37
+ """
38
+ parts = re.split(r"(?<=\.)\s+", text.strip())
39
+ return [part for part in parts if part]
40
+
41
+
42
+ def _add_columns(table: Table, signal: SignalType) -> None:
43
+ table.add_column("Repository", overflow="fold")
44
+ if signal is SignalType.SECRET_SCANNING:
45
+ table.add_column("Open", justify="right")
46
+ return
47
+ if signal is SignalType.SCORECARD:
48
+ table.add_column("Score", justify="right")
49
+ for name, style in _SEVERITY_STYLE.items():
50
+ table.add_column(name.capitalize(), justify="right", style=style)
51
+ if signal is not SignalType.SCORECARD:
52
+ table.add_column("Total", justify="right")
53
+
54
+
55
+ def _row(sig: RepoSignal) -> list[str]:
56
+ c = sig.counts
57
+ if sig.signal is SignalType.SECRET_SCANNING:
58
+ return [sig.repo.name, str(c.total)]
59
+ base = [str(c.critical), str(c.high), str(c.medium), str(c.low)]
60
+ if sig.signal is SignalType.SCORECARD:
61
+ score = f"{sig.score:.1f}" if sig.score is not None else "—"
62
+ return [sig.repo.name, score, *base]
63
+ return [sig.repo.name, *base, str(c.total)]
64
+
65
+
66
+ def _names(repos: Sequence[Repo], top_n: int | None) -> str:
67
+ """Comma-joined repo names limited to ``top_n`` with a '(+N more)' tail."""
68
+ shown, hidden = truncate(repos, top_n)
69
+ text = ", ".join(r.name for r in shown)
70
+ if hidden:
71
+ text += f" … (+{hidden} more)"
72
+ return text
73
+
74
+
75
+ def render_section(
76
+ section: SignalSection,
77
+ console: Console,
78
+ *,
79
+ excluded: Sequence[Repo] = (),
80
+ top_n: int | None = None,
81
+ ) -> None:
82
+ offenders, hidden_offenders = truncate(section.offenders, top_n)
83
+ if offenders:
84
+ table = Table(title=section.signal.heading, title_justify="left", title_style="bold")
85
+ _add_columns(table, section.signal)
86
+ for sig in offenders:
87
+ table.add_row(*_row(sig))
88
+ console.print(table)
89
+ if hidden_offenders:
90
+ console.print(f" [dim]… and {hidden_offenders} more[/dim]")
91
+ else:
92
+ console.print(f"[bold]{section.signal.heading}[/bold]")
93
+ # Numerical totals first (each on its own line, always the true total), then
94
+ # the repository-name breakdowns -- numbers and names are never mixed on one
95
+ # line, and the name lists honour the same offender limit as the tables.
96
+ totals: list[str] = []
97
+ if section.clean_count:
98
+ totals.append(f"[green]✅ {section.clean_count} Clean[/green]")
99
+ if section.nag_repos:
100
+ totals.append(f"[yellow]❌ {len(section.nag_repos)} Disabled[/yellow]")
101
+ if excluded:
102
+ totals.append(f"[blue]⏩ {len(excluded)} Excluded[/blue]")
103
+ if section.unknown_count:
104
+ totals.append(f"[dim]❓ {section.unknown_count} Unknown[/dim]")
105
+ if not (offenders or totals):
106
+ totals.append("[dim]No data[/dim]")
107
+ for line in totals:
108
+ console.print(" " + line)
109
+ if section.nag_repos:
110
+ console.print(f" [yellow]Disabled:[/yellow] {_names(section.nag_repos, top_n)}")
111
+ if excluded:
112
+ console.print(f" [blue]Excluded:[/blue] {_names(excluded, top_n)}")
113
+ console.print()
114
+
115
+
116
+ def render_table_section(
117
+ section: TableSection, console: Console, *, top_n: int | None = None
118
+ ) -> None:
119
+ """Render a generic posture/freshness table to the terminal."""
120
+ rows, hidden = truncate(section.rows, top_n)
121
+ if rows:
122
+ table = Table(title=section.title, title_justify="left", title_style="bold")
123
+ for i, col in enumerate(section.columns):
124
+ table.add_column(col, overflow="fold", justify="left" if i == 0 else "right")
125
+ for row in rows:
126
+ table.add_row(row.repo.name, *row.cells)
127
+ console.print(table)
128
+ if hidden:
129
+ console.print(f" [dim]… and {hidden} more[/dim]")
130
+ if section.note:
131
+ # A long footnote reads better split one sentence per line. It only
132
+ # describes a populated table, so it is omitted when empty.
133
+ for sentence in _split_sentences(section.note):
134
+ console.print(f" [dim]{sentence}[/dim]")
135
+ else:
136
+ console.print(f"[bold]{section.title}[/bold]")
137
+ if section.empty_note:
138
+ console.print(f" [green]✅ {section.empty_note}[/green]")
139
+ console.print()
140
+
141
+
142
+ def render_org(org: OrgReport, console: Console, *, top_n: int | None = None) -> None:
143
+ console.rule(f"[bold]Security report: {org.org}[/bold]")
144
+ console.print(f"[dim]{org.repo_count} repositories analysed[/dim]\n")
145
+ if org.partial:
146
+ console.print(
147
+ "[yellow]⚠ Incomplete: the repository listing could not be fully "
148
+ "read; some repositories may be missing.[/yellow]\n"
149
+ )
150
+ for section in org.sections:
151
+ render_section(section, console, excluded=org.excluded_repos, top_n=top_n)
152
+ if section.signal is SignalType.DEPENDABOT:
153
+ for table in org.dependabot_tables:
154
+ render_table_section(table, console, top_n=top_n)
155
+ if org.releases is not None:
156
+ render_table_section(org.releases, console, top_n=top_n)
157
+
158
+
159
+ def render_orgs(
160
+ orgs: list[OrgReport], console: Console, *, top_n: int | None = None
161
+ ) -> None:
162
+ for org in orgs:
163
+ render_org(org, console, top_n=top_n)
@@ -0,0 +1,173 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Report aggregation.
4
+
5
+ Groups classified :class:`RepoSignal` results into the renderable report
6
+ structure: one section per signal, each with ranked offenders (full list -- the
7
+ top-N limit applies only to Slack), a clean count, a nag list (archived/test
8
+ repos excluded), and an unknown count. See ``docs/BRIEF.md`` sections 4-6, 11.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import datetime as dt
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass, field
16
+ from typing import TypeVar
17
+
18
+ from github_security_report import scope
19
+ from github_security_report.models import (
20
+ Repo,
21
+ RepoSignal,
22
+ RepoState,
23
+ SignalType,
24
+ rank_offenders,
25
+ )
26
+
27
+ # Render order for the five sections.
28
+ SIGNAL_ORDER: tuple[SignalType, ...] = (
29
+ SignalType.CODEQL,
30
+ SignalType.SCORECARD,
31
+ SignalType.ZIZMOR,
32
+ SignalType.DEPENDABOT,
33
+ SignalType.SECRET_SCANNING,
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class SignalSection:
39
+ """One signal's results for one organisation."""
40
+
41
+ signal: SignalType
42
+ offenders: list[RepoSignal] = field(default_factory=list) # ranked worst-first
43
+ clean_count: int = 0
44
+ nag_repos: list[Repo] = field(default_factory=list)
45
+ unknown_count: int = 0
46
+
47
+ def top(self, n: int) -> list[RepoSignal]:
48
+ """The worst N offenders (used for the Slack digest only)."""
49
+ return self.offenders[:n]
50
+
51
+
52
+ @dataclass
53
+ class TableRow:
54
+ """A generic, repository-keyed table row with pre-formatted cells.
55
+
56
+ Used by the Dependabot posture and Releases/Tagging tables, which do not fit
57
+ the four-state :class:`SignalSection` model. ``cells`` excludes the leading
58
+ repository link cell (each renderer supplies that from ``repo``).
59
+ """
60
+
61
+ repo: Repo
62
+ cells: tuple[str, ...]
63
+
64
+
65
+ @dataclass
66
+ class TableSection:
67
+ """A generic titled table rendered as a sub-section under a heading.
68
+
69
+ The **first** column is always the repository column -- every renderer puts
70
+ the repository link/name there (from each :class:`TableRow`'s ``repo``).
71
+ Its header *label* is free-form (usually ``"Repository"``, but a single-list
72
+ table may describe its contents instead, e.g. ``"Repositories NOT
73
+ Enabled"``); downstream consumers should treat column 0 as the repository
74
+ regardless of the label.
75
+ """
76
+
77
+ title: str
78
+ columns: tuple[str, ...] # column 0 is the repository column (label varies)
79
+ rows: list[TableRow] = field(default_factory=list)
80
+ # Shown in place of the table when there are no rows (a clean state).
81
+ empty_note: str = ""
82
+ # Optional explanatory footnote rendered beneath the table.
83
+ note: str = ""
84
+
85
+
86
+ @dataclass
87
+ class OrgReport:
88
+ org: str
89
+ sections: list[SignalSection]
90
+ repo_count: int
91
+ generated_at: dt.datetime
92
+ # True when the repository listing was incomplete (e.g. a truncated or
93
+ # forbidden org repos read), so the report may omit repositories.
94
+ partial: bool = False
95
+ # Repositories removed from analysis by the per-org ``exclude`` list. These
96
+ # are reported as "excluded" (counted, never analysed) so an explicit
97
+ # exclusion is visible and distinct from a "not enabled" nag.
98
+ excluded_repos: list[Repo] = field(default_factory=list)
99
+ # Extra Dependabot posture tables rendered as sub-sections beneath the
100
+ # Dependabot signal heading (alerts not enabled, security updates not
101
+ # enabled, cooldown settings). Empty in repo mode / when not collected.
102
+ dependabot_tables: list[TableSection] = field(default_factory=list)
103
+ # The Releases / Tagging table (release and tag staleness). None only when
104
+ # not collected (repo mode); org mode always assigns a section, which may
105
+ # have zero rows and render its empty_note instead.
106
+ releases: TableSection | None = None
107
+
108
+
109
+ @dataclass
110
+ class Report:
111
+ orgs: list[OrgReport]
112
+ generated_at: dt.datetime
113
+
114
+
115
+ _T = TypeVar("_T")
116
+
117
+
118
+ def truncate(items: Sequence[_T], top_n: int | None) -> tuple[list[_T], int]:
119
+ """Limit a sequence for display, returning ``(shown, hidden_count)``.
120
+
121
+ The single place every render surface applies an offender limit, so the
122
+ GitHub Pages, terminal and Slack outputs truncate tables and name lists
123
+ identically. ``top_n`` of ``None`` or a negative value (or one at least the
124
+ sequence length) shows everything and reports ``0`` hidden -- the negative
125
+ case is a defensive no-op, since negative slicing would otherwise drop
126
+ items from the end.
127
+ """
128
+ seq = list(items)
129
+ if top_n is None or top_n < 0 or len(seq) <= top_n:
130
+ return seq, 0
131
+ return seq[:top_n], len(seq) - top_n
132
+
133
+
134
+ def build_org_report(
135
+ org: str,
136
+ repo_signals: list[RepoSignal],
137
+ *,
138
+ repo_count: int,
139
+ generated_at: dt.datetime | None = None,
140
+ partial: bool = False,
141
+ excluded_repos: list[Repo] | None = None,
142
+ ) -> OrgReport:
143
+ """Assemble an :class:`OrgReport` from a flat list of classified signals."""
144
+ when = generated_at or dt.datetime.now(dt.timezone.utc)
145
+ by_signal: dict[SignalType, list[RepoSignal]] = {s: [] for s in SIGNAL_ORDER}
146
+ for sig in repo_signals:
147
+ by_signal.setdefault(sig.signal, []).append(sig)
148
+
149
+ sections: list[SignalSection] = []
150
+ for signal in SIGNAL_ORDER:
151
+ results = by_signal.get(signal, [])
152
+ nag = [
153
+ s.repo
154
+ for s in results
155
+ if s.state is RepoState.NAG and scope.in_nag_scope(s.repo)
156
+ ]
157
+ sections.append(
158
+ SignalSection(
159
+ signal=signal,
160
+ offenders=rank_offenders(results),
161
+ clean_count=sum(1 for s in results if s.state is RepoState.CLEAN),
162
+ nag_repos=sorted(nag, key=lambda r: r.name),
163
+ unknown_count=sum(1 for s in results if s.state is RepoState.UNKNOWN),
164
+ )
165
+ )
166
+ return OrgReport(
167
+ org=org,
168
+ sections=sections,
169
+ repo_count=repo_count,
170
+ generated_at=when,
171
+ partial=partial,
172
+ excluded_repos=sorted(excluded_repos or [], key=lambda r: r.name),
173
+ )
@@ -0,0 +1,137 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Org repository-ruleset coverage for workflow-driven tools.
4
+
5
+ Some tools (e.g. zizmor) are not enabled per-repository; they are enforced
6
+ across the estate by an **organisation repository ruleset** containing a
7
+ ``workflows`` rule that requires a central workflow on every pull request. Such
8
+ a repo runs the tool even though it has no matching ``.github/workflows`` file
9
+ of its own, so the per-repo enabled-probe would wrongly nag it.
10
+
11
+ This module reads the authoritative ruleset definitions and computes, for a
12
+ given repository, which signals are covered. A signal is mapped to a ruleset by
13
+ a case-insensitive keyword match against the required-workflow path (e.g. the
14
+ ``zizmor`` signal matches ``.github/workflows/zizmor.yaml``). See the
15
+ ``GET /orgs/{org}/rulesets`` and ``GET /repos/{o}/{r}/rules/branches/{branch}``
16
+ GitHub APIs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import fnmatch
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass
24
+
25
+ # Patterns GitHub uses to mean "every repository".
26
+ _MATCH_ALL = {"*", "~all"}
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class WorkflowRuleset:
31
+ """An active org ruleset that requires one or more workflows."""
32
+
33
+ name: str
34
+ workflow_paths: tuple[str, ...]
35
+ include: tuple[str, ...] # repository_name include patterns
36
+ exclude: tuple[str, ...] # repository_name exclude patterns
37
+
38
+
39
+ def parse_workflow_rulesets(details: list[dict]) -> list[WorkflowRuleset]:
40
+ """Extract active, branch-targeted rulesets that require workflows."""
41
+ out: list[WorkflowRuleset] = []
42
+ for ruleset in details:
43
+ if ruleset.get("enforcement") != "active":
44
+ continue
45
+ if ruleset.get("target") not in (None, "branch"):
46
+ continue
47
+ paths: list[str] = []
48
+ for rule in ruleset.get("rules") or []:
49
+ if rule.get("type") != "workflows":
50
+ continue
51
+ for wf in (rule.get("parameters") or {}).get("workflows") or []:
52
+ path = wf.get("path")
53
+ if path:
54
+ paths.append(path)
55
+ if not paths:
56
+ continue
57
+ cond = (ruleset.get("conditions") or {}).get("repository_name") or {}
58
+ out.append(
59
+ WorkflowRuleset(
60
+ name=ruleset.get("name", ""),
61
+ workflow_paths=tuple(paths),
62
+ include=tuple(cond.get("include", [])),
63
+ exclude=tuple(cond.get("exclude", [])),
64
+ )
65
+ )
66
+ return out
67
+
68
+
69
+ def _name_matches(name: str, patterns: tuple[str, ...]) -> bool:
70
+ candidate = name.lower()
71
+ for pattern in patterns:
72
+ lowered = pattern.lower()
73
+ if lowered in _MATCH_ALL or fnmatch.fnmatch(candidate, lowered):
74
+ return True
75
+ return False
76
+
77
+
78
+ def repo_covered(name: str, ruleset: WorkflowRuleset) -> bool:
79
+ """Whether a repository name is targeted by the ruleset's conditions."""
80
+ return _name_matches(name, ruleset.include) and not _name_matches(
81
+ name, ruleset.exclude
82
+ )
83
+
84
+
85
+ def _paths_match_keyword(paths: tuple[str, ...] | list[str], keyword: str) -> bool:
86
+ kw = keyword.lower()
87
+ # An empty keyword would substring-match every path; treat it as no match
88
+ # so a misconfigured mapping cannot mark every repo as covered.
89
+ if not kw:
90
+ return False
91
+ return any(kw in path.lower() for path in paths)
92
+
93
+
94
+ def signals_covered(
95
+ name: str,
96
+ rulesets: list[WorkflowRuleset],
97
+ signal_keywords: Mapping[str, str],
98
+ ) -> set[str]:
99
+ """Signals (by value) that an org ruleset enforces for this repository.
100
+
101
+ ``signal_keywords`` maps a signal value (e.g. ``"zizmor"``) to a keyword
102
+ that must appear in a required-workflow path.
103
+ """
104
+ covered: set[str] = set()
105
+ for signal, keyword in signal_keywords.items():
106
+ for ruleset in rulesets:
107
+ if _paths_match_keyword(ruleset.workflow_paths, keyword) and repo_covered(
108
+ name, ruleset
109
+ ):
110
+ covered.add(signal)
111
+ break
112
+ return covered
113
+
114
+
115
+ def signals_from_branch_rules(
116
+ rules: list[dict],
117
+ signal_keywords: Mapping[str, str],
118
+ ) -> set[str]:
119
+ """Signals covered for a single repo, from its effective branch rules.
120
+
121
+ Used in repo mode: ``GET /repos/{o}/{r}/rules/branches/{branch}`` already
122
+ returns the rules in effect for this repository (including inherited org
123
+ rulesets), so no name matching is needed.
124
+ """
125
+ paths: list[str] = []
126
+ for rule in rules:
127
+ if rule.get("type") != "workflows":
128
+ continue
129
+ for wf in (rule.get("parameters") or {}).get("workflows") or []:
130
+ path = wf.get("path")
131
+ if path:
132
+ paths.append(path)
133
+ return {
134
+ signal
135
+ for signal, keyword in signal_keywords.items()
136
+ if _paths_match_keyword(paths, keyword)
137
+ }