github-security-report 0.14.0__tar.gz → 0.14.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {github_security_report-0.14.0 → github_security_report-0.14.1}/PKG-INFO +66 -4
- {github_security_report-0.14.0 → github_security_report-0.14.1}/README.md +64 -2
- {github_security_report-0.14.0 → github_security_report-0.14.1}/pyproject.toml +10 -1
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/_version.py +2 -2
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/app.py +8 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/boundary.py +12 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/options.py +10 -3
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/__init__.py +6 -2
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/alerts.py +2 -1
- github_security_report-0.14.1/src/github_security_report/client/batch_errors.py +247 -0
- github_security_report-0.14.1/src/github_security_report/client/errors.py +79 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/org_reads.py +15 -95
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/queries.py +2 -2
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/transport.py +1 -32
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/__init__.py +6 -1
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/context.py +0 -4
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/org.py +51 -9
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/loader.py +1 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/models.py +13 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/schema.py +8 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/.gitignore +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/LICENSE +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/LICENSES/Apache-2.0.txt +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/scripts/README.md +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/__init__.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/authors.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/categories.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/classify.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/__init__.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/__main__.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/modes.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/outputs.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/publish.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/serialise.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/copilot.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/endpoints.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/parsers.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/reads.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/writes.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/extras.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/protocols.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/repo.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/__init__.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/order.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/gating.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/gitctx.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/issues.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/layout.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/models.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/ordering.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/posture/__init__.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/posture/enablement.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/posture/facts.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/posture/releases.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/__init__.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/columns.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/counting.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/presentation.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/table.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/py.typed +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/remediate.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/__init__.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/html.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/markdown.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/slack.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/slack_limits.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/terminal.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/__init__.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/aggregate.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/display.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/signals.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/tables.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/rulesets.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/runner.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/scope.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/secret_patterns.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/severity.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/summary.py +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/templates/index.html.j2 +0 -0
- {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/templates/report.html.j2 +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: github-security-report
|
|
3
|
-
Version: 0.14.
|
|
3
|
+
Version: 0.14.1
|
|
4
4
|
Summary: Security and quality reporting across GitHub organisations
|
|
5
5
|
Project-URL: Homepage, https://github.com/lfreleng-actions/github-security-report-action
|
|
6
6
|
Project-URL: Repository, https://github.com/lfreleng-actions/github-security-report-action
|
|
@@ -29,7 +29,7 @@ Requires-Dist: jinja2==3.1.6
|
|
|
29
29
|
Requires-Dist: jsonschema==4.26.0
|
|
30
30
|
Requires-Dist: pyyaml==6.0.3
|
|
31
31
|
Requires-Dist: rich==15.0.0
|
|
32
|
-
Requires-Dist: typer==0.27.
|
|
32
|
+
Requires-Dist: typer==0.27.2
|
|
33
33
|
Provides-Extra: dev
|
|
34
34
|
Requires-Dist: mypy==2.3.1; extra == 'dev'
|
|
35
35
|
Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
|
|
@@ -302,6 +302,7 @@ environment-variable name, never embedded.
|
|
|
302
302
|
"include_test": false,
|
|
303
303
|
"repo_min_age_days": 28,
|
|
304
304
|
"release_max_age_days": 60,
|
|
305
|
+
"graph_batch": 10,
|
|
305
306
|
"order": { "style": "auto" }
|
|
306
307
|
},
|
|
307
308
|
"organizations": [
|
|
@@ -1127,6 +1128,55 @@ Gating decides **collection**; the per-category render toggles above decide
|
|
|
1127
1128
|
**presentation**. A skipped section still renders (as the one-line notice)
|
|
1128
1129
|
unless its category is also disabled.
|
|
1129
1130
|
|
|
1131
|
+
### GraphQL prefetch batch size
|
|
1132
|
+
|
|
1133
|
+
The releases/tags, Dependabot-enablement, open-issues and pull-request data
|
|
1134
|
+
for every in-scope repository comes from one aliased GraphQL query per batch
|
|
1135
|
+
of repositories rather than a round-trip per repository. GitHub bounds how
|
|
1136
|
+
much work a single GraphQL query may do — roughly ten seconds of execution —
|
|
1137
|
+
and reports a breach in one of three shapes, each after the query has already
|
|
1138
|
+
run for the full limit: an **HTTP 502** (or 504) gateway timeout from the
|
|
1139
|
+
edge, an HTTP 200 carrying no `data`, or an HTTP 200 whose `errors` say
|
|
1140
|
+
`Resource limits for this query exceeded` with the unresolved fields nulled.
|
|
1141
|
+
Each aliased repository adds a few hundred milliseconds, so the batch
|
|
1142
|
+
size is bounded by *latency*, not by rate-limit cost: 25 repositories measured
|
|
1143
|
+
at ~9 s against `lfreleng-actions` and failed intermittently; 10 measured at
|
|
1144
|
+
~3.5 s.
|
|
1145
|
+
|
|
1146
|
+
`report.graph_batch` (default `10`; minimum `1`) sets the starting batch size,
|
|
1147
|
+
globally or per organisation. It is a lever for a slow day rather than a hard
|
|
1148
|
+
limit: a batch that still fails on size after the transport's own retries is
|
|
1149
|
+
re-issued at **half** the size, and the smaller size is kept for the rest of
|
|
1150
|
+
that organisation's collection (each organisation in a multi-org run starts
|
|
1151
|
+
from its own configured size, since organisations differ in how much data a
|
|
1152
|
+
repository carries). Only a single-repository query GitHub still cannot
|
|
1153
|
+
answer, or a failure that a smaller query could not fix (a `403`, an
|
|
1154
|
+
exhausted rate limit, or a `500`/`503` outage rather than a `502`/`504`
|
|
1155
|
+
timeout), aborts the run with exit code `3`.
|
|
1156
|
+
|
|
1157
|
+
The value can be set three ways, in this order of precedence:
|
|
1158
|
+
|
|
1159
|
+
1. `--graph-batch N` on the command line (the action's `graph_batch` input).
|
|
1160
|
+
2. A **repository or organisation variable** passed to that input. The
|
|
1161
|
+
bundled `reporting.yaml` passes `vars.GSR_GRAPH_BATCH`, so the size can be
|
|
1162
|
+
adjusted from repository settings without a workflow edit or a release;
|
|
1163
|
+
a caller's own workflow can do the same. (The `vars` context is not
|
|
1164
|
+
available inside a composite action, so the action cannot read it itself.)
|
|
1165
|
+
3. `report.graph_batch` in the configuration; otherwise the built-in `10`.
|
|
1166
|
+
|
|
1167
|
+
Batching changes how many requests carry the data, not how many GraphQL nodes
|
|
1168
|
+
are resolved, so the node work is the same whatever the batch size. A smaller
|
|
1169
|
+
batch does cost slightly more of the rate-limit budget — each query is charged
|
|
1170
|
+
at least one point, and per-query cost rounding adds a little when one query
|
|
1171
|
+
becomes several — and a few more requests (a 124-repository organisation is 13
|
|
1172
|
+
queries at 10, 5 at 25). Against the 5,000-point hourly budget that is noise;
|
|
1173
|
+
a larger value buys nothing except a longer first failure.
|
|
1174
|
+
|
|
1175
|
+
> The flag and input are recent additions. An empty input is not passed to
|
|
1176
|
+
> the tool, so an unset variable cannot break a run; but a pinned tool version
|
|
1177
|
+
> predating the flag will reject a set one with `No such option`, so set
|
|
1178
|
+
> `GSR_GRAPH_BATCH` only once the pinned release supports it.
|
|
1179
|
+
|
|
1130
1180
|
### Pass/fail severity cutoff
|
|
1131
1181
|
|
|
1132
1182
|
The severity-ranked signals (CodeQL, Scorecard, Zizmor, aislop, Dependabot
|
|
@@ -1204,6 +1254,7 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
|
|
|
1204
1254
|
| `fail_threshold` | No | `none` | `none`/`low`/`medium`/`high`/`critical`/`any` (repo mode) |
|
|
1205
1255
|
| `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
|
|
1206
1256
|
| `hide` | No | `""` | Category keys to suppress on every output (space- or comma-separated). Overrides config, and is one-way: it cannot re-enable a disabled category |
|
|
1257
|
+
| `graph_batch` | No | `""` | Repositories per batched GraphQL prefetch query (minimum `1`; default: config, else `10`). Pass a repository or organisation variable such as `${{ vars.GSR_GRAPH_BATCH }}` to adjust it from settings. A batch that still fails is halved automatically, so this sets the starting size (see [GraphQL prefetch batch size](#graphql-prefetch-batch-size)) |
|
|
1207
1258
|
| `tool_version` | No | `""` | Published PyPI version to install. Empty (the default) uses the Dependabot-managed pin in `.github/runtime-pin/requirements.txt`; set a specific version to override. Ignored on pull requests or when `use_local_source` is `true` (both run from source) |
|
|
1208
1259
|
| `use_local_source` | No | `false` | Run from the checked-out source instead of PyPI (for testing unreleased code from any event) |
|
|
1209
1260
|
|
|
@@ -1247,7 +1298,7 @@ API in the same way.
|
|
|
1247
1298
|
| `0` | The report ran. |
|
|
1248
1299
|
| `1` | Repo mode only: findings met or exceeded `--fail-threshold`. |
|
|
1249
1300
|
| `2` | Usage or configuration error (bad flag, unreadable config). |
|
|
1250
|
-
| `3` | The GitHub API was
|
|
1301
|
+
| `3` | The GitHub API was unusable after the retry budget (see below). |
|
|
1251
1302
|
| `4` | GitHub rejected the credentials (HTTP 401). |
|
|
1252
1303
|
|
|
1253
1304
|
Codes `3` and `4` are **aborts, not reports**: nothing is written and no Pages
|
|
@@ -1258,8 +1309,19 @@ section `No data` or `All Clean` — and a scheduled job would then publish it
|
|
|
1258
1309
|
over the last good one. Reporting false data is worse than reporting none, so
|
|
1259
1310
|
the run stops at the first rejected request.
|
|
1260
1311
|
|
|
1312
|
+
Code `3` covers a GitHub API that could not be reached at all, and a GraphQL
|
|
1313
|
+
prefetch that failed as a whole: either a size-shaped failure (`502`/`504`
|
|
1314
|
+
timeout, `200` with no data, resource limits exceeded) that persisted even
|
|
1315
|
+
after the batch had been halved down to one repository, or a failure that
|
|
1316
|
+
halving could not have fixed (`403`, an exhausted GraphQL rate limit, a
|
|
1317
|
+
`500`/`503` outage), which aborts at once. The message reports the status or
|
|
1318
|
+
cause and ends with the matching next step — a smaller `--graph-batch`, retry
|
|
1319
|
+
later, wait for the rate-limit budget to reset, or check the token's
|
|
1320
|
+
permissions — so read it rather than assuming "retry later" (see
|
|
1321
|
+
[GraphQL prefetch batch size](#graphql-prefetch-batch-size)).
|
|
1322
|
+
|
|
1261
1323
|
The two are separate codes because the remedy differs: `4` means rotate or fix
|
|
1262
|
-
the token, `3` means retry later.
|
|
1324
|
+
the token, `3` usually means retry later.
|
|
1263
1325
|
|
|
1264
1326
|
## Remediation
|
|
1265
1327
|
|
|
@@ -258,6 +258,7 @@ environment-variable name, never embedded.
|
|
|
258
258
|
"include_test": false,
|
|
259
259
|
"repo_min_age_days": 28,
|
|
260
260
|
"release_max_age_days": 60,
|
|
261
|
+
"graph_batch": 10,
|
|
261
262
|
"order": { "style": "auto" }
|
|
262
263
|
},
|
|
263
264
|
"organizations": [
|
|
@@ -1083,6 +1084,55 @@ Gating decides **collection**; the per-category render toggles above decide
|
|
|
1083
1084
|
**presentation**. A skipped section still renders (as the one-line notice)
|
|
1084
1085
|
unless its category is also disabled.
|
|
1085
1086
|
|
|
1087
|
+
### GraphQL prefetch batch size
|
|
1088
|
+
|
|
1089
|
+
The releases/tags, Dependabot-enablement, open-issues and pull-request data
|
|
1090
|
+
for every in-scope repository comes from one aliased GraphQL query per batch
|
|
1091
|
+
of repositories rather than a round-trip per repository. GitHub bounds how
|
|
1092
|
+
much work a single GraphQL query may do — roughly ten seconds of execution —
|
|
1093
|
+
and reports a breach in one of three shapes, each after the query has already
|
|
1094
|
+
run for the full limit: an **HTTP 502** (or 504) gateway timeout from the
|
|
1095
|
+
edge, an HTTP 200 carrying no `data`, or an HTTP 200 whose `errors` say
|
|
1096
|
+
`Resource limits for this query exceeded` with the unresolved fields nulled.
|
|
1097
|
+
Each aliased repository adds a few hundred milliseconds, so the batch
|
|
1098
|
+
size is bounded by *latency*, not by rate-limit cost: 25 repositories measured
|
|
1099
|
+
at ~9 s against `lfreleng-actions` and failed intermittently; 10 measured at
|
|
1100
|
+
~3.5 s.
|
|
1101
|
+
|
|
1102
|
+
`report.graph_batch` (default `10`; minimum `1`) sets the starting batch size,
|
|
1103
|
+
globally or per organisation. It is a lever for a slow day rather than a hard
|
|
1104
|
+
limit: a batch that still fails on size after the transport's own retries is
|
|
1105
|
+
re-issued at **half** the size, and the smaller size is kept for the rest of
|
|
1106
|
+
that organisation's collection (each organisation in a multi-org run starts
|
|
1107
|
+
from its own configured size, since organisations differ in how much data a
|
|
1108
|
+
repository carries). Only a single-repository query GitHub still cannot
|
|
1109
|
+
answer, or a failure that a smaller query could not fix (a `403`, an
|
|
1110
|
+
exhausted rate limit, or a `500`/`503` outage rather than a `502`/`504`
|
|
1111
|
+
timeout), aborts the run with exit code `3`.
|
|
1112
|
+
|
|
1113
|
+
The value can be set three ways, in this order of precedence:
|
|
1114
|
+
|
|
1115
|
+
1. `--graph-batch N` on the command line (the action's `graph_batch` input).
|
|
1116
|
+
2. A **repository or organisation variable** passed to that input. The
|
|
1117
|
+
bundled `reporting.yaml` passes `vars.GSR_GRAPH_BATCH`, so the size can be
|
|
1118
|
+
adjusted from repository settings without a workflow edit or a release;
|
|
1119
|
+
a caller's own workflow can do the same. (The `vars` context is not
|
|
1120
|
+
available inside a composite action, so the action cannot read it itself.)
|
|
1121
|
+
3. `report.graph_batch` in the configuration; otherwise the built-in `10`.
|
|
1122
|
+
|
|
1123
|
+
Batching changes how many requests carry the data, not how many GraphQL nodes
|
|
1124
|
+
are resolved, so the node work is the same whatever the batch size. A smaller
|
|
1125
|
+
batch does cost slightly more of the rate-limit budget — each query is charged
|
|
1126
|
+
at least one point, and per-query cost rounding adds a little when one query
|
|
1127
|
+
becomes several — and a few more requests (a 124-repository organisation is 13
|
|
1128
|
+
queries at 10, 5 at 25). Against the 5,000-point hourly budget that is noise;
|
|
1129
|
+
a larger value buys nothing except a longer first failure.
|
|
1130
|
+
|
|
1131
|
+
> The flag and input are recent additions. An empty input is not passed to
|
|
1132
|
+
> the tool, so an unset variable cannot break a run; but a pinned tool version
|
|
1133
|
+
> predating the flag will reject a set one with `No such option`, so set
|
|
1134
|
+
> `GSR_GRAPH_BATCH` only once the pinned release supports it.
|
|
1135
|
+
|
|
1086
1136
|
### Pass/fail severity cutoff
|
|
1087
1137
|
|
|
1088
1138
|
The severity-ranked signals (CodeQL, Scorecard, Zizmor, aislop, Dependabot
|
|
@@ -1160,6 +1210,7 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
|
|
|
1160
1210
|
| `fail_threshold` | No | `none` | `none`/`low`/`medium`/`high`/`critical`/`any` (repo mode) |
|
|
1161
1211
|
| `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
|
|
1162
1212
|
| `hide` | No | `""` | Category keys to suppress on every output (space- or comma-separated). Overrides config, and is one-way: it cannot re-enable a disabled category |
|
|
1213
|
+
| `graph_batch` | No | `""` | Repositories per batched GraphQL prefetch query (minimum `1`; default: config, else `10`). Pass a repository or organisation variable such as `${{ vars.GSR_GRAPH_BATCH }}` to adjust it from settings. A batch that still fails is halved automatically, so this sets the starting size (see [GraphQL prefetch batch size](#graphql-prefetch-batch-size)) |
|
|
1163
1214
|
| `tool_version` | No | `""` | Published PyPI version to install. Empty (the default) uses the Dependabot-managed pin in `.github/runtime-pin/requirements.txt`; set a specific version to override. Ignored on pull requests or when `use_local_source` is `true` (both run from source) |
|
|
1164
1215
|
| `use_local_source` | No | `false` | Run from the checked-out source instead of PyPI (for testing unreleased code from any event) |
|
|
1165
1216
|
|
|
@@ -1203,7 +1254,7 @@ API in the same way.
|
|
|
1203
1254
|
| `0` | The report ran. |
|
|
1204
1255
|
| `1` | Repo mode only: findings met or exceeded `--fail-threshold`. |
|
|
1205
1256
|
| `2` | Usage or configuration error (bad flag, unreadable config). |
|
|
1206
|
-
| `3` | The GitHub API was
|
|
1257
|
+
| `3` | The GitHub API was unusable after the retry budget (see below). |
|
|
1207
1258
|
| `4` | GitHub rejected the credentials (HTTP 401). |
|
|
1208
1259
|
|
|
1209
1260
|
Codes `3` and `4` are **aborts, not reports**: nothing is written and no Pages
|
|
@@ -1214,8 +1265,19 @@ section `No data` or `All Clean` — and a scheduled job would then publish it
|
|
|
1214
1265
|
over the last good one. Reporting false data is worse than reporting none, so
|
|
1215
1266
|
the run stops at the first rejected request.
|
|
1216
1267
|
|
|
1268
|
+
Code `3` covers a GitHub API that could not be reached at all, and a GraphQL
|
|
1269
|
+
prefetch that failed as a whole: either a size-shaped failure (`502`/`504`
|
|
1270
|
+
timeout, `200` with no data, resource limits exceeded) that persisted even
|
|
1271
|
+
after the batch had been halved down to one repository, or a failure that
|
|
1272
|
+
halving could not have fixed (`403`, an exhausted GraphQL rate limit, a
|
|
1273
|
+
`500`/`503` outage), which aborts at once. The message reports the status or
|
|
1274
|
+
cause and ends with the matching next step — a smaller `--graph-batch`, retry
|
|
1275
|
+
later, wait for the rate-limit budget to reset, or check the token's
|
|
1276
|
+
permissions — so read it rather than assuming "retry later" (see
|
|
1277
|
+
[GraphQL prefetch batch size](#graphql-prefetch-batch-size)).
|
|
1278
|
+
|
|
1217
1279
|
The two are separate codes because the remedy differs: `4` means rotate or fix
|
|
1218
|
-
the token, `3` means retry later.
|
|
1280
|
+
the token, `3` usually means retry later.
|
|
1219
1281
|
|
|
1220
1282
|
## Remediation
|
|
1221
1283
|
|
|
@@ -45,7 +45,7 @@ keywords = [
|
|
|
45
45
|
]
|
|
46
46
|
dependencies = [
|
|
47
47
|
"httpx[http2]==0.28.1",
|
|
48
|
-
"typer==0.27.
|
|
48
|
+
"typer==0.27.2",
|
|
49
49
|
"rich==15.0.0",
|
|
50
50
|
"jinja2==3.1.6",
|
|
51
51
|
"jsonschema==4.26.0",
|
|
@@ -129,6 +129,15 @@ markers = [
|
|
|
129
129
|
[tool.coverage.run]
|
|
130
130
|
source = ["github_security_report"]
|
|
131
131
|
omit = ["tests/*"]
|
|
132
|
+
# Keep the transient coverage database out of the working tree.
|
|
133
|
+
# coverage.py resolves a relative data_file against the current
|
|
134
|
+
# directory, so the default '.coverage' lands wherever pytest runs and
|
|
135
|
+
# can be picked up by tests that inspect on-disk content. TEMP is
|
|
136
|
+
# set on Windows, where /tmp does not exist; elsewhere it is normally
|
|
137
|
+
# unset and the /tmp fallback applies. An explicit value also
|
|
138
|
+
# satisfies python-test-action's data_file configuration check.
|
|
139
|
+
# COVERAGE_FILE (env) still takes precedence when set.
|
|
140
|
+
data_file = "${TEMP-/tmp}/.coverage.github-security-report"
|
|
132
141
|
|
|
133
142
|
[tool.coverage.report]
|
|
134
143
|
show_missing = true
|
|
@@ -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.14.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 14,
|
|
21
|
+
__version__ = version = '0.14.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 14, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -166,6 +166,11 @@ def report(
|
|
|
166
166
|
"--include-test",
|
|
167
167
|
help="Analyse test repositories, which are excluded by default.",
|
|
168
168
|
),
|
|
169
|
+
graph_batch: int | None = typer.Option(
|
|
170
|
+
None,
|
|
171
|
+
"--graph-batch",
|
|
172
|
+
help="Repositories per batched GraphQL prefetch query (minimum 1; default: config, else 10). GitHub bounds how long one query may run, so a batch that still fails is halved automatically; this sets the starting size.",
|
|
173
|
+
),
|
|
169
174
|
no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
|
|
170
175
|
) -> None:
|
|
171
176
|
"""Generate a security and quality report."""
|
|
@@ -181,6 +186,7 @@ def report(
|
|
|
181
186
|
)
|
|
182
187
|
boundary.check_non_negative(console, "--repo-min-age-days", repo_min_age_days)
|
|
183
188
|
boundary.check_non_negative(console, "--release-max-age-days", release_max_age_days)
|
|
189
|
+
boundary.check_positive(console, "--graph-batch", graph_batch)
|
|
184
190
|
hidden = boundary.resolve_hidden(console, hide)
|
|
185
191
|
|
|
186
192
|
cfg = _load_config(config_file, config_data, org, token_env, console=console)
|
|
@@ -210,6 +216,7 @@ def report(
|
|
|
210
216
|
gating=False if no_gating else None,
|
|
211
217
|
include_archived=True if include_archived else None,
|
|
212
218
|
include_test=True if include_test else None,
|
|
219
|
+
graph_batch=graph_batch,
|
|
213
220
|
),
|
|
214
221
|
hidden=hidden,
|
|
215
222
|
)
|
|
@@ -236,6 +243,7 @@ def report(
|
|
|
236
243
|
("--no-gating", no_gating),
|
|
237
244
|
("--include-archived", include_archived),
|
|
238
245
|
("--include-test", include_test),
|
|
246
|
+
("--graph-batch", graph_batch is not None),
|
|
239
247
|
)
|
|
240
248
|
if supplied
|
|
241
249
|
],
|
|
@@ -41,6 +41,18 @@ def check_non_negative(console: Console, name: str, value: int | None) -> None:
|
|
|
41
41
|
raise typer.Exit(2)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def check_positive(console: Console, name: str, value: int | None) -> None:
|
|
45
|
+
"""Reject a zero or negative override where 0 has no meaning.
|
|
46
|
+
|
|
47
|
+
For a batch size, unlike a row limit, there is no "unlimited" reading of
|
|
48
|
+
0 -- a query carrying no repositories is not a query -- so the floor is 1,
|
|
49
|
+
matching the config schema's minimum for the same control.
|
|
50
|
+
"""
|
|
51
|
+
if value is not None and value < 1:
|
|
52
|
+
console.print(f"[red]{name} must be 1 or greater[/red]")
|
|
53
|
+
raise typer.Exit(2)
|
|
54
|
+
|
|
55
|
+
|
|
44
56
|
def check_limits(console: Console, limits: Sequence[tuple[str, int | None]]) -> None:
|
|
45
57
|
"""Reject a negative row limit, naming the flag and the 0 convention."""
|
|
46
58
|
for name, value in limits:
|
|
@@ -34,6 +34,10 @@ class ReportOverrides:
|
|
|
34
34
|
not on, and ``--include-archived`` / ``--include-test`` can widen the scope
|
|
35
35
|
but not narrow it, so a flag can loosen what the configuration asked for
|
|
36
36
|
without being able to tighten it behind the operator's back.
|
|
37
|
+
|
|
38
|
+
``graph_batch`` is an operational lever rather than report policy: it
|
|
39
|
+
changes how the GraphQL prefetch is issued, never what the report says, so
|
|
40
|
+
it may move in either direction.
|
|
37
41
|
"""
|
|
38
42
|
|
|
39
43
|
repo_min_age_days: int | None = None
|
|
@@ -42,13 +46,15 @@ class ReportOverrides:
|
|
|
42
46
|
gating: bool | None = None
|
|
43
47
|
include_archived: bool | None = None
|
|
44
48
|
include_test: bool | None = None
|
|
49
|
+
graph_batch: int | None = None
|
|
45
50
|
|
|
46
51
|
def apply(self, org_cfg: OrgConfig) -> tuple[OrgConfig, ReportConfig]:
|
|
47
52
|
"""The org and report configs to collect with, overrides applied.
|
|
48
53
|
|
|
49
|
-
The two age thresholds
|
|
50
|
-
applying one uniformly across every configured
|
|
51
|
-
reader of the flag expects, and matches how
|
|
54
|
+
The two age thresholds, the three booleans and the batch size are
|
|
55
|
+
scalar policy, so applying one uniformly across every configured
|
|
56
|
+
organisation is what a reader of the flag expects, and matches how
|
|
57
|
+
``--top-n`` already behaves.
|
|
52
58
|
|
|
53
59
|
``releases_exclude`` is not scalar: it is a curated per-organisation
|
|
54
60
|
list, and one flag replacing all of them loses data the config
|
|
@@ -63,6 +69,7 @@ class ReportOverrides:
|
|
|
63
69
|
"gating",
|
|
64
70
|
"include_archived",
|
|
65
71
|
"include_test",
|
|
72
|
+
"graph_batch",
|
|
66
73
|
):
|
|
67
74
|
value = getattr(self, name)
|
|
68
75
|
if value is not None:
|
|
@@ -35,6 +35,11 @@ from github_security_report.client.endpoints import (
|
|
|
35
35
|
SCORECARD_API,
|
|
36
36
|
_https_endpoint,
|
|
37
37
|
)
|
|
38
|
+
from github_security_report.client.errors import (
|
|
39
|
+
AuthError,
|
|
40
|
+
GraphBatchError,
|
|
41
|
+
NetworkError,
|
|
42
|
+
)
|
|
38
43
|
from github_security_report.client.org_reads import OrgReadClient
|
|
39
44
|
from github_security_report.client.parsers import (
|
|
40
45
|
_last_published,
|
|
@@ -52,8 +57,6 @@ from github_security_report.client.queries import (
|
|
|
52
57
|
)
|
|
53
58
|
from github_security_report.client.reads import ReadClient
|
|
54
59
|
from github_security_report.client.transport import (
|
|
55
|
-
AuthError,
|
|
56
|
-
NetworkError,
|
|
57
60
|
Transport,
|
|
58
61
|
_endpoint_diagnostics,
|
|
59
62
|
)
|
|
@@ -72,6 +75,7 @@ __all__ = [
|
|
|
72
75
|
"AlertReads",
|
|
73
76
|
"AuthError",
|
|
74
77
|
"GitHubClient",
|
|
78
|
+
"GraphBatchError",
|
|
75
79
|
"NetworkError",
|
|
76
80
|
"OrgReadClient",
|
|
77
81
|
"ReadClient",
|
|
@@ -27,7 +27,8 @@ import logging
|
|
|
27
27
|
from typing import NamedTuple
|
|
28
28
|
|
|
29
29
|
from github_security_report.client.endpoints import BULK_KINDS
|
|
30
|
-
from github_security_report.client.
|
|
30
|
+
from github_security_report.client.errors import AuthError, NetworkError
|
|
31
|
+
from github_security_report.client.transport import Transport
|
|
31
32
|
from github_security_report.secret_patterns import (
|
|
32
33
|
GENERIC_SECRET_TYPES,
|
|
33
34
|
PATTERN_CONFIG_PATH,
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Reading the ``errors`` array of a batched GraphQL query.
|
|
4
|
+
|
|
5
|
+
GitHub answers a partially-failed aliased query with HTTP 200: the readable
|
|
6
|
+
aliases populated, the rest ``null`` or missing individual fields, and an
|
|
7
|
+
``errors`` array explaining why. Deciding what those entries mean for each
|
|
8
|
+
repository -- unreadable, unreadable in one isolable field, or a symptom of the
|
|
9
|
+
whole batch being too large -- is a policy of its own, kept here so the client
|
|
10
|
+
that issues the query stays about issuing it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from github_security_report.client.errors import GraphBatchError
|
|
16
|
+
|
|
17
|
+
# Fields whose failure can be isolated to the data they feed instead of failing
|
|
18
|
+
# the whole repository. Only fields the model already carries a dedicated
|
|
19
|
+
# "unknown" for qualify: ``pullRequests`` has ``open_pull_requests=None``, which
|
|
20
|
+
# every pull-request table already renders as unknown, so a token that cannot
|
|
21
|
+
# read pull requests loses that one table rather than the repository's releases,
|
|
22
|
+
# issues, tags and Dependabot posture as well.
|
|
23
|
+
_ISOLABLE_FIELDS = frozenset({"pullRequests"})
|
|
24
|
+
|
|
25
|
+
# The message GitHub attaches to every error when a query is cut short for
|
|
26
|
+
# exceeding its execution budget. Unlike a field the token cannot read, this is
|
|
27
|
+
# a property of the query's size: the same repositories resolve in full when
|
|
28
|
+
# asked for in smaller batches, so it is reported as a batch failure to split
|
|
29
|
+
# rather than as repositories to mark unknown.
|
|
30
|
+
_RESOURCE_LIMIT_MESSAGE = "Resource limits for this query exceeded"
|
|
31
|
+
|
|
32
|
+
# The statuses GitHub's edge answers with when a GraphQL query runs past its
|
|
33
|
+
# time limit: 502 is the shape observed against lfreleng-actions (arriving
|
|
34
|
+
# ~10.7 s after each request), and 504 is the gateway's own timeout status. A
|
|
35
|
+
# 500 or 503 is an outage rather than a verdict on the query's size; splitting
|
|
36
|
+
# would hand each smaller batch a fresh retry budget against a service that is
|
|
37
|
+
# down, multiplying requests and delaying an abort that smaller queries cannot
|
|
38
|
+
# avert.
|
|
39
|
+
_QUERY_TIMEOUT_STATUSES = frozenset({502, 504})
|
|
40
|
+
|
|
41
|
+
# The ``type`` GitHub puts on every error when a GraphQL rate limit is spent.
|
|
42
|
+
# Two spellings are in use -- ``RATE_LIMITED`` for the hourly point budget and
|
|
43
|
+
# ``RATE_LIMIT`` (with ``code: graphql_rate_limit``) for the secondary limit on
|
|
44
|
+
# concurrent or bursty queries -- so the prefix is matched. Either arrives as
|
|
45
|
+
# HTTP 200 with a ``null`` ``data`` object, the same shape as a timed-out query,
|
|
46
|
+
# and is the one such response that a smaller batch cannot fix: every retry
|
|
47
|
+
# would burn another request against an exhausted budget.
|
|
48
|
+
_RATE_LIMIT_TYPE_PREFIX = "RATE_LIMIT"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _exceeded_resource_limits(errors: object) -> bool:
|
|
52
|
+
"""Whether ``errors`` shows GitHub cut the query short for its size."""
|
|
53
|
+
if not isinstance(errors, list):
|
|
54
|
+
return False
|
|
55
|
+
return any(
|
|
56
|
+
isinstance(e, dict) and _RESOURCE_LIMIT_MESSAGE in str(e.get("message", ""))
|
|
57
|
+
for e in errors
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _rate_limited(errors: object) -> bool:
|
|
62
|
+
"""Whether ``errors`` shows a GraphQL rate limit is exhausted."""
|
|
63
|
+
if not isinstance(errors, list):
|
|
64
|
+
return False
|
|
65
|
+
return any(
|
|
66
|
+
isinstance(e, dict)
|
|
67
|
+
and str(e.get("type", "")).startswith(_RATE_LIMIT_TYPE_PREFIX)
|
|
68
|
+
for e in errors
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _batch_response(
|
|
73
|
+
org: str, count: int, *, status: int, body: dict | None
|
|
74
|
+
) -> tuple[dict, list]:
|
|
75
|
+
"""The ``(data, errors)`` of a batched query, or the failure to abort on.
|
|
76
|
+
|
|
77
|
+
Raises :class:`GraphBatchError` when the query failed as a whole -- for
|
|
78
|
+
every one of the ``count`` repositories -- and says whether the caller may
|
|
79
|
+
retry them in smaller batches. Four shapes are told apart, in this order:
|
|
80
|
+
|
|
81
|
+
* A non-200 ``status`` (``body`` is then ``None``): a 502 or 504 is
|
|
82
|
+
GitHub's edge giving up on a query that ran too long, which a smaller
|
|
83
|
+
batch may avoid; any other status -- a 403/429 that outlived the
|
|
84
|
+
transport's retry budget, or a 500/503 outage -- would recur at any size.
|
|
85
|
+
* ``RATE_LIMITED`` or ``RATE_LIMIT`` in ``errors``: a GraphQL rate limit
|
|
86
|
+
(hourly points or the secondary burst limit) is spent, so a retry in any
|
|
87
|
+
batch size would only burn requests against it.
|
|
88
|
+
* "Resource limits exceeded" in ``errors``: GitHub ran out of execution
|
|
89
|
+
budget part-way through and nulled the rest. Those nulls are a symptom of
|
|
90
|
+
the batch's size, not per-repository read failures, so the batch is
|
|
91
|
+
failed for splitting rather than (typically most of) it marked unknown.
|
|
92
|
+
* No ``data`` object at all: with the rate limit ruled out, this is how
|
|
93
|
+
GitHub reports a query it could not finish in time.
|
|
94
|
+
|
|
95
|
+
Anything else is a response the caller can read alias by alias; a missing
|
|
96
|
+
or malformed ``errors`` array is returned as an empty list.
|
|
97
|
+
|
|
98
|
+
Each message ends with the remedy that fits the failure: a batch of
|
|
99
|
+
several repositories can be retried smaller, but by the time a failure
|
|
100
|
+
escapes the adaptive collector the batch is usually a single repository,
|
|
101
|
+
and advising a smaller batch then would be advice that cannot be followed;
|
|
102
|
+
a permission error was never retried, so it must not claim a retry budget
|
|
103
|
+
was spent.
|
|
104
|
+
"""
|
|
105
|
+
if body is None:
|
|
106
|
+
raise GraphBatchError(
|
|
107
|
+
f"GraphQL prefetch for {org} failed with HTTP {status}; aborting "
|
|
108
|
+
"because the release/tag, Dependabot-enablement and open-issues "
|
|
109
|
+
f"data for {count} repositories would otherwise be fabricated "
|
|
110
|
+
f"from defaults (e.g. reported as never released). "
|
|
111
|
+
f"{_status_remedy(status, count)}",
|
|
112
|
+
status=status,
|
|
113
|
+
splittable=status in _QUERY_TIMEOUT_STATUSES,
|
|
114
|
+
)
|
|
115
|
+
errors = body.get("errors")
|
|
116
|
+
if _rate_limited(errors):
|
|
117
|
+
raise GraphBatchError(
|
|
118
|
+
f"GraphQL prefetch for {org} was refused: a GraphQL API rate "
|
|
119
|
+
f"limit is exhausted; aborting rather than reporting "
|
|
120
|
+
f"{count} repositories from fabricated defaults. "
|
|
121
|
+
"Retry once the budget resets.",
|
|
122
|
+
status=status,
|
|
123
|
+
splittable=False,
|
|
124
|
+
reason="rate limited",
|
|
125
|
+
)
|
|
126
|
+
if _exceeded_resource_limits(errors):
|
|
127
|
+
raise GraphBatchError(
|
|
128
|
+
f"GraphQL prefetch for {org} exceeded GitHub's resource limits "
|
|
129
|
+
f"for a single query across {count} repositories; "
|
|
130
|
+
"aborting rather than reporting the unresolved fields as "
|
|
131
|
+
f"unknown. {_size_remedy(count)}",
|
|
132
|
+
status=status,
|
|
133
|
+
splittable=True,
|
|
134
|
+
reason="resource limits exceeded",
|
|
135
|
+
)
|
|
136
|
+
data = body.get("data")
|
|
137
|
+
if not isinstance(data, dict):
|
|
138
|
+
raise GraphBatchError(
|
|
139
|
+
f"GraphQL prefetch for {org} returned no data for any of "
|
|
140
|
+
f"{count} repositories; aborting rather than reporting "
|
|
141
|
+
f"fabricated defaults. {_size_remedy(count)} "
|
|
142
|
+
f"errors={errors!r}",
|
|
143
|
+
status=status,
|
|
144
|
+
splittable=True,
|
|
145
|
+
reason="HTTP 200 with no data",
|
|
146
|
+
)
|
|
147
|
+
return data, errors if isinstance(errors, list) else []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _size_remedy(count: int) -> str:
|
|
151
|
+
"""The operator's next step after a size-shaped failure of ``count`` repos."""
|
|
152
|
+
if count > 1:
|
|
153
|
+
return "Retry with a smaller --graph-batch."
|
|
154
|
+
return (
|
|
155
|
+
"This repository's query exceeds GitHub's limits on its own; retry "
|
|
156
|
+
"later, or exclude the repository if it persists."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _status_remedy(status: int, count: int) -> str:
|
|
161
|
+
"""The operator's next step after a non-200 answer to a batched query.
|
|
162
|
+
|
|
163
|
+
Mirrors the transport's own handling of each status: a gateway timeout
|
|
164
|
+
and a 5xx outage both outlived the retry budget, whereas a 403 is handed
|
|
165
|
+
back at once when it carries no rate-limit headers (a genuine permission
|
|
166
|
+
error) and only after the budget when it does, so it is described as
|
|
167
|
+
either without claiming a delay that may not have happened.
|
|
168
|
+
"""
|
|
169
|
+
if status in _QUERY_TIMEOUT_STATUSES:
|
|
170
|
+
return (
|
|
171
|
+
"GitHub timed the query out on every retry, which points at its "
|
|
172
|
+
f"size. {_size_remedy(count)}"
|
|
173
|
+
)
|
|
174
|
+
if status == 403:
|
|
175
|
+
return (
|
|
176
|
+
"HTTP 403 is either a permission error (check that the token can "
|
|
177
|
+
"read this organisation's repositories) or a secondary rate limit "
|
|
178
|
+
"that outlived the retry budget (retry later)."
|
|
179
|
+
)
|
|
180
|
+
if status == 429:
|
|
181
|
+
return "Rate limited beyond the retry budget; retry later."
|
|
182
|
+
if status >= 500:
|
|
183
|
+
return (
|
|
184
|
+
"GitHub answered with a server error on every retry, which is an "
|
|
185
|
+
"outage rather than a problem with the query; retry later."
|
|
186
|
+
)
|
|
187
|
+
return "Check the GitHub API status and retry."
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _alias_errors(errors: object, alias_count: int) -> tuple[set[str], set[str]]:
|
|
191
|
+
"""Alias keys implicated by a batched query's ``errors`` array.
|
|
192
|
+
|
|
193
|
+
Returns ``(unreadable, pull_requests_only)``: aliases that must be failed
|
|
194
|
+
wholesale, and aliases whose only failures were confined to fields in
|
|
195
|
+
:data:`_ISOLABLE_FIELDS`.
|
|
196
|
+
|
|
197
|
+
GitHub reports a *field-level* failure with HTTP 200: the alias is still a
|
|
198
|
+
populated dictionary, the field that failed is null, and an ``errors``
|
|
199
|
+
entry carries its path (e.g. ``["r3", "latestRelease"]``). Parsing such a
|
|
200
|
+
node would convert a read failure into a confident negative -- a nulled
|
|
201
|
+
``latestRelease`` is indistinguishable from "never released" -- so the
|
|
202
|
+
whole alias is treated as unreadable rather than partially trusted.
|
|
203
|
+
|
|
204
|
+
The alias is failed wholesale rather than per field, because a per-field
|
|
205
|
+
flag would have to be threaded through every table to be honest about which
|
|
206
|
+
half of a row is trustworthy, whereas one unknown repository is already a
|
|
207
|
+
state every table renders correctly. The exception is a field the model
|
|
208
|
+
*already* carries a dedicated unknown for: failing the whole repository for
|
|
209
|
+
one of those would let an optional, permission-sensitive section take the
|
|
210
|
+
rest of the report down with it -- a token without pull-request access would
|
|
211
|
+
lose its releases, issues and Dependabot posture too.
|
|
212
|
+
|
|
213
|
+
An error whose path names no alias cannot be attributed, so it implicates
|
|
214
|
+
every alias in the batch: with no way to tell which repositories it
|
|
215
|
+
touched, treating any of them as successfully read would be a guess.
|
|
216
|
+
|
|
217
|
+
An error *nested* inside an isolable field is classified by that field, and
|
|
218
|
+
deliberately so. ``reviewThreads`` is non-null in GitHub's schema
|
|
219
|
+
(``PullRequestReviewThreadConnection!``), so a resolver failure there does
|
|
220
|
+
not null the connection: it propagates up to the nearest nullable ancestor,
|
|
221
|
+
which is the pull-request node itself. The node arrives as ``null`` and
|
|
222
|
+
carries none of its facts, so ignoring the error would silently drop that
|
|
223
|
+
pull request from every column while ``totalCount`` still counted it --
|
|
224
|
+
understating the breakdown with nothing to say so. Failing the connection
|
|
225
|
+
reports the repository as unknown instead, which every table renders
|
|
226
|
+
correctly.
|
|
227
|
+
"""
|
|
228
|
+
all_aliases = {f"r{i}" for i in range(alias_count)}
|
|
229
|
+
if not isinstance(errors, list):
|
|
230
|
+
return set(), set()
|
|
231
|
+
unreadable: set[str] = set()
|
|
232
|
+
isolated: set[str] = set()
|
|
233
|
+
for entry in errors:
|
|
234
|
+
path = entry.get("path") if isinstance(entry, dict) else None
|
|
235
|
+
if not isinstance(path, list) or not path:
|
|
236
|
+
return all_aliases, set()
|
|
237
|
+
head = path[0]
|
|
238
|
+
if not isinstance(head, str) or head not in all_aliases:
|
|
239
|
+
return all_aliases, set()
|
|
240
|
+
field = path[1] if len(path) > 1 else None
|
|
241
|
+
if isinstance(field, str) and field in _ISOLABLE_FIELDS:
|
|
242
|
+
isolated.add(head)
|
|
243
|
+
else:
|
|
244
|
+
unreadable.add(head)
|
|
245
|
+
# An alias with failures on both sides is unreadable: the isolable one is
|
|
246
|
+
# the lesser problem, and the other still poisons the rest of the node.
|
|
247
|
+
return unreadable, isolated - unreadable
|