github-security-report 0.17.0__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {github_security_report-0.17.0 → github_security_report-0.18.0}/PKG-INFO +36 -2
  2. {github_security_report-0.17.0 → github_security_report-0.18.0}/README.md +34 -0
  3. {github_security_report-0.17.0 → github_security_report-0.18.0}/pyproject.toml +2 -2
  4. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/_version.py +2 -2
  5. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/collect/extras.py +2 -0
  6. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/config/loader.py +27 -16
  7. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/config/models.py +6 -0
  8. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/config/schema.py +6 -0
  9. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/issues.py +73 -3
  10. {github_security_report-0.17.0 → github_security_report-0.18.0}/.gitignore +0 -0
  11. {github_security_report-0.17.0 → github_security_report-0.18.0}/LICENSE +0 -0
  12. {github_security_report-0.17.0 → github_security_report-0.18.0}/LICENSES/Apache-2.0.txt +0 -0
  13. {github_security_report-0.17.0 → github_security_report-0.18.0}/scripts/README.md +0 -0
  14. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/__init__.py +0 -0
  15. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/authors.py +0 -0
  16. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/categories/__init__.py +0 -0
  17. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/categories/keys.py +0 -0
  18. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/categories/signals.py +0 -0
  19. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/categories/tables.py +0 -0
  20. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/classify.py +0 -0
  21. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/cli/__init__.py +0 -0
  22. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/cli/__main__.py +0 -0
  23. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/cli/app.py +0 -0
  24. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/cli/boundary.py +0 -0
  25. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/cli/modes.py +0 -0
  26. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/cli/options.py +0 -0
  27. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/cli/outputs.py +0 -0
  28. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/cli/publish.py +0 -0
  29. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/cli/serialise.py +0 -0
  30. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/__init__.py +0 -0
  31. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/alerts.py +0 -0
  32. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/batch_errors.py +0 -0
  33. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/codeql_parsers.py +0 -0
  34. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/copilot.py +0 -0
  35. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/endpoints.py +0 -0
  36. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/errors.py +0 -0
  37. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/org_reads.py +0 -0
  38. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/parsers.py +0 -0
  39. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/queries.py +0 -0
  40. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/reads.py +0 -0
  41. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/reviews.py +0 -0
  42. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/transport.py +0 -0
  43. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/client/writes.py +0 -0
  44. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/codeql/__init__.py +0 -0
  45. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/codeql/cleanup.py +0 -0
  46. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/codeql/facts.py +0 -0
  47. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/codeql/tables.py +0 -0
  48. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/collect/__init__.py +0 -0
  49. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/collect/codeql.py +0 -0
  50. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/collect/context.py +0 -0
  51. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/collect/org.py +0 -0
  52. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/collect/protocols.py +0 -0
  53. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/collect/repo.py +0 -0
  54. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/collect/scoping.py +0 -0
  55. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/config/__init__.py +0 -0
  56. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/config/order.py +0 -0
  57. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/gating.py +0 -0
  58. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/gitctx.py +0 -0
  59. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/layout.py +0 -0
  60. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/models.py +0 -0
  61. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/ordering.py +0 -0
  62. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/posture/__init__.py +0 -0
  63. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/posture/enablement.py +0 -0
  64. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/posture/facts.py +0 -0
  65. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/posture/releases.py +0 -0
  66. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/pulls/__init__.py +0 -0
  67. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/pulls/columns.py +0 -0
  68. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/pulls/counting.py +0 -0
  69. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/pulls/presentation.py +0 -0
  70. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/pulls/table.py +0 -0
  71. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/py.typed +0 -0
  72. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/ranking.py +0 -0
  73. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/remediate/__init__.py +0 -0
  74. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/remediate/codeql.py +0 -0
  75. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/remediate/model.py +0 -0
  76. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/render/__init__.py +0 -0
  77. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/render/html.py +0 -0
  78. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/render/markdown.py +0 -0
  79. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/render/slack.py +0 -0
  80. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/render/slack_limits.py +0 -0
  81. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/render/terminal.py +0 -0
  82. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/report/__init__.py +0 -0
  83. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/report/aggregate.py +0 -0
  84. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/report/display.py +0 -0
  85. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/report/signals.py +0 -0
  86. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/report/tables.py +0 -0
  87. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/rulesets.py +0 -0
  88. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/runner.py +0 -0
  89. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/scope.py +0 -0
  90. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/secret_patterns.py +0 -0
  91. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/severity.py +0 -0
  92. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/summary.py +0 -0
  93. {github_security_report-0.17.0 → github_security_report-0.18.0}/src/github_security_report/templates/index.html.j2 +0 -0
  94. {github_security_report-0.17.0 → github_security_report-0.18.0}/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.17.0
3
+ Version: 0.18.0
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
@@ -36,7 +36,7 @@ Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
36
36
  Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
37
37
  Requires-Dist: pytest==9.1.1; extra == 'dev'
38
38
  Requires-Dist: respx==0.23.1; extra == 'dev'
39
- Requires-Dist: ruff==0.16.8; extra == 'dev'
39
+ Requires-Dist: ruff==0.16.9; extra == 'dev'
40
40
  Requires-Dist: syrupy==6.1.1; extra == 'dev'
41
41
  Requires-Dist: types-jsonschema==4.26.0.20260518; extra == 'dev'
42
42
  Requires-Dist: types-pyyaml==6.0.12.20250915; extra == 'dev'
@@ -345,6 +345,8 @@ environment-variable name, never embedded.
345
345
  "repo_min_age_days": 28,
346
346
  "release_max_age_days": 60,
347
347
  "codeql_stale_days": 30,
348
+ "issue_age_warn_days": 30,
349
+ "issue_age_error_days": 60,
348
350
  "graph_batch": 10,
349
351
  "order": { "style": "auto" }
350
352
  },
@@ -837,6 +839,38 @@ with HTTP 200 and this one field null, so affected repositories are reported as
837
839
  `❓ Unknown` rather than counted as having no open issues — an unreadable backlog
838
840
  is never presented as a clean one.
839
841
 
842
+ #### Issue colours and age thresholds
843
+
844
+ On the terminal the counts are coloured so the table reads at a glance: `Bug`
845
+ and `Untriaged` **red**, `Docs` **green**. As in the Pull Requests table, only
846
+ **non-zero** counts are coloured, so the eye lands on the rows with something
847
+ in them. The colours follow the column **name** (case-insensitively), so they
848
+ apply to a custom `issue_labels` that keeps a `Bug` or `Docs` column and simply
849
+ do not appear for one that renames them.
850
+
851
+ `Oldest` is coloured against two age thresholds, in days:
852
+
853
+ ```json
854
+ {
855
+ "report": {
856
+ "issue_age_warn_days": 30,
857
+ "issue_age_error_days": 60
858
+ },
859
+ "organizations": [{ "name": "lfreleng-actions" }]
860
+ }
861
+ ```
862
+
863
+ - **Green** while the oldest open issue is at most `issue_age_warn_days`
864
+ (default `30`) old.
865
+ - **Yellow** once it is older than `issue_age_warn_days`.
866
+ - **Red** once it is older than `issue_age_error_days` (default `60`).
867
+
868
+ An `unknown` age is never coloured. Either threshold may be set to `0` to
869
+ switch that level off; with both off the column is left uncoloured.
870
+ `issue_age_error_days` must be **strictly greater** than `issue_age_warn_days`
871
+ (or `0`), otherwise the yellow level could never be reached, so it is rejected
872
+ at load. Both are settable per organisation.
873
+
840
874
  ### Pull Requests
841
875
 
842
876
  The `pull_requests` category counts each repository's **open pull requests**,
@@ -301,6 +301,8 @@ environment-variable name, never embedded.
301
301
  "repo_min_age_days": 28,
302
302
  "release_max_age_days": 60,
303
303
  "codeql_stale_days": 30,
304
+ "issue_age_warn_days": 30,
305
+ "issue_age_error_days": 60,
304
306
  "graph_batch": 10,
305
307
  "order": { "style": "auto" }
306
308
  },
@@ -793,6 +795,38 @@ with HTTP 200 and this one field null, so affected repositories are reported as
793
795
  `❓ Unknown` rather than counted as having no open issues — an unreadable backlog
794
796
  is never presented as a clean one.
795
797
 
798
+ #### Issue colours and age thresholds
799
+
800
+ On the terminal the counts are coloured so the table reads at a glance: `Bug`
801
+ and `Untriaged` **red**, `Docs` **green**. As in the Pull Requests table, only
802
+ **non-zero** counts are coloured, so the eye lands on the rows with something
803
+ in them. The colours follow the column **name** (case-insensitively), so they
804
+ apply to a custom `issue_labels` that keeps a `Bug` or `Docs` column and simply
805
+ do not appear for one that renames them.
806
+
807
+ `Oldest` is coloured against two age thresholds, in days:
808
+
809
+ ```json
810
+ {
811
+ "report": {
812
+ "issue_age_warn_days": 30,
813
+ "issue_age_error_days": 60
814
+ },
815
+ "organizations": [{ "name": "lfreleng-actions" }]
816
+ }
817
+ ```
818
+
819
+ - **Green** while the oldest open issue is at most `issue_age_warn_days`
820
+ (default `30`) old.
821
+ - **Yellow** once it is older than `issue_age_warn_days`.
822
+ - **Red** once it is older than `issue_age_error_days` (default `60`).
823
+
824
+ An `unknown` age is never coloured. Either threshold may be set to `0` to
825
+ switch that level off; with both off the column is left uncoloured.
826
+ `issue_age_error_days` must be **strictly greater** than `issue_age_warn_days`
827
+ (or `0`), otherwise the yellow level could never be reached, so it is rejected
828
+ at load. Both are settable per organisation.
829
+
796
830
  ### Pull Requests
797
831
 
798
832
  The `pull_requests` category counts each repository's **open pull requests**,
@@ -65,7 +65,7 @@ dev = [
65
65
  "respx==0.23.1",
66
66
  "syrupy==6.1.1",
67
67
  "mypy==2.3.1",
68
- "ruff==0.16.8",
68
+ "ruff==0.16.9",
69
69
  "types-jsonschema==4.26.0.20260518",
70
70
  "types-PyYAML==6.0.12.20250915",
71
71
  ]
@@ -97,7 +97,7 @@ dev = [
97
97
  "respx==0.23.1",
98
98
  "syrupy==6.1.1",
99
99
  "mypy==2.3.1",
100
- "ruff==0.16.8",
100
+ "ruff==0.16.9",
101
101
  "types-jsonschema==4.26.0.20260518",
102
102
  "types-PyYAML==6.0.12.20250915",
103
103
  ]
@@ -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.17.0'
22
- __version_tuple__ = version_tuple = (0, 17, 0)
21
+ __version__ = version = '0.18.0'
22
+ __version_tuple__ = version_tuple = (0, 18, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -129,6 +129,8 @@ async def attach_extra_tables(
129
129
  in_scope,
130
130
  generated_at=report.generated_at,
131
131
  label_columns=report_cfg.issue_labels,
132
+ age_warn_days=report_cfg.issue_age_warn_days,
133
+ age_error_days=report_cfg.issue_age_error_days,
132
134
  members=members,
133
135
  )
134
136
  report.pull_requests = build_pull_requests_table(
@@ -167,6 +167,8 @@ def _report_from(data: dict, base: ReportConfig) -> ReportConfig:
167
167
  "codeql_stale_days",
168
168
  "dependabot_warn_threshold",
169
169
  "dependabot_error_threshold",
170
+ "issue_age_warn_days",
171
+ "issue_age_error_days",
170
172
  "gating",
171
173
  "graph_batch",
172
174
  }
@@ -195,24 +197,33 @@ def _report_from(data: dict, base: ReportConfig) -> ReportConfig:
195
197
  result = replace(
196
198
  result, excluded_display=ExcludedDisplay(data["excluded_display"])
197
199
  )
198
- if (
199
- result.dependabot_error_threshold
200
- and result.dependabot_error_threshold <= result.dependabot_warn_threshold
201
- ):
202
- # The error level is checked first and inclusively (``>= error``), so a
203
- # warning threshold at or above it can never be reached: every value
204
- # that would warn has already errored. Rejecting beats silently
205
- # ignoring one of the two knobs the operator deliberately set. A zero
206
- # error threshold is exempt -- that is the documented way to turn the
207
- # error level off, leaving a warning-only configuration.
200
+ _require_reachable_warning(
201
+ result, "dependabot_warn_threshold", "dependabot_error_threshold"
202
+ )
203
+ _require_reachable_warning(result, "issue_age_warn_days", "issue_age_error_days")
204
+ return result
205
+
206
+
207
+ def _require_reachable_warning(
208
+ report: ReportConfig, warn_name: str, error_name: str
209
+ ) -> None:
210
+ """Reject an error threshold that leaves its warning level unreachable.
211
+
212
+ The error level is checked first, so a warning threshold at or above it
213
+ can never be reached: every value that would warn has already errored.
214
+ Rejecting beats silently ignoring one of the two knobs the operator
215
+ deliberately set. A zero error threshold is exempt -- that is the
216
+ documented way to turn the error level off, leaving a warning-only
217
+ configuration.
218
+ """
219
+ warn = getattr(report, warn_name)
220
+ error = getattr(report, error_name)
221
+ if error and error <= warn:
208
222
  raise ConfigError(
209
- "report.dependabot_error_threshold "
210
- f"({result.dependabot_error_threshold}) must be greater than "
211
- "report.dependabot_warn_threshold "
212
- f"({result.dependabot_warn_threshold}), or 0 to disable the error "
213
- "level; otherwise the warning level can never be reached"
223
+ f"report.{error_name} ({error}) must be greater than "
224
+ f"report.{warn_name} ({warn}), or 0 to disable the error level; "
225
+ "otherwise the warning level can never be reached"
214
226
  )
215
- return result
216
227
 
217
228
 
218
229
  def _categories_from(
@@ -178,6 +178,12 @@ class ReportConfig:
178
178
  # Error (red) at or above this many: GitHub's default open-pull-requests
179
179
  # limit, at which no further updates arrive for that ecosystem.
180
180
  dependabot_error_threshold: int = 5
181
+ # Age thresholds, in days, colouring the GitHub Issues table's Oldest
182
+ # column: green at or below the warn threshold, yellow once the oldest open
183
+ # issue is older than it, red once it is older than the error threshold.
184
+ # 0 switches that level off.
185
+ issue_age_warn_days: int = 30
186
+ issue_age_error_days: int = 60
181
187
  # Organisation feature gating for the workflow-driven signals (Scorecard,
182
188
  # zizmor, aislop): when true, each is collected only after a cheap check
183
189
  # finds organisation support (an org ruleset requiring the workflow,
@@ -147,6 +147,12 @@ CONFIG_SCHEMA: dict = {
147
147
  # ecosystem while Auto counts every automation author.
148
148
  "dependabot_warn_threshold": {"type": "integer", "minimum": 0},
149
149
  "dependabot_error_threshold": {"type": "integer", "minimum": 0},
150
+ # Oldest-issue age thresholds colouring the GitHub Issues
151
+ # table's Oldest column: yellow once the oldest open issue is
152
+ # older than the first, red once older than the second
153
+ # (0 switches that level off).
154
+ "issue_age_warn_days": {"type": "integer", "minimum": 0},
155
+ "issue_age_error_days": {"type": "integer", "minimum": 0},
150
156
  # Organisation feature gating: when true (the default) the
151
157
  # workflow-driven signals (Scorecard, zizmor, aislop) are
152
158
  # probed only after a cheap support check (org ruleset,
@@ -33,7 +33,13 @@ from collections.abc import Mapping, Set
33
33
  from github_security_report.authors import is_external_author
34
34
  from github_security_report.categories import CategoryKey, category_meta
35
35
  from github_security_report.models import IssueRef, Repo, RepoGraphData
36
- from github_security_report.report import TableRow, TableSection
36
+ from github_security_report.report import (
37
+ CELL_BAD,
38
+ CELL_GOOD,
39
+ CELL_WARN,
40
+ TableRow,
41
+ TableSection,
42
+ )
37
43
 
38
44
  # Columns appended after the configured label classes. ``Other`` is "labelled,
39
45
  # but not as anything we were asked about"; ``Untriaged`` is "not labelled at
@@ -68,6 +74,17 @@ TRUNCATED_MARKER = "+"
68
74
  # distinct from an absent backlog: the repository has open issues either way.
69
75
  UNKNOWN_AGE = "unknown"
70
76
 
77
+ # Emphasis for the count columns, keyed case-insensitively by header so it
78
+ # follows ``Bug`` and ``Docs`` into an ``issue_labels`` that keeps those names,
79
+ # and simply does not apply to one that renames them. Untriaged is the triage
80
+ # gap the table exists to surface, and Bug the class most likely to hurt a
81
+ # user, so both are red; documentation work is a healthy backlog, so green.
82
+ _COLUMN_LEVELS: Mapping[str, str] = {
83
+ "bug": CELL_BAD,
84
+ "docs": CELL_GOOD,
85
+ UNTRIAGED_COLUMN.casefold(): CELL_BAD,
86
+ }
87
+
71
88
 
72
89
  def classify_issue(
73
90
  issue: IssueRef, label_columns: Mapping[str, tuple[str, ...]]
@@ -160,6 +177,43 @@ def _oldest_cell(age: int | None, truncated: bool) -> str:
160
177
  return f"{text} {TRUNCATED_MARKER}" if truncated else text
161
178
 
162
179
 
180
+ def age_level(age: int | None, *, warn_days: int, error_days: int) -> str | None:
181
+ """Emphasis for an oldest open issue that is ``age`` days old.
182
+
183
+ Both thresholds read "older than": warning once the age exceeds
184
+ ``warn_days``, error once it exceeds ``error_days``, and good at or below
185
+ every active threshold. Either may be ``0`` to turn that level off, matching
186
+ the ``0 = no limit`` idiom used elsewhere; with both off the column is left
187
+ plain rather than painted uniformly green. An unknown age has nothing to
188
+ measure, so it is never coloured.
189
+ """
190
+ if age is None or not (warn_days or error_days):
191
+ return None
192
+ if error_days and age > error_days:
193
+ return CELL_BAD
194
+ if warn_days and age > warn_days:
195
+ return CELL_WARN
196
+ return CELL_GOOD
197
+
198
+
199
+ def _cell_levels(
200
+ columns: tuple[str, ...],
201
+ counts: Mapping[str, int],
202
+ age: str | None,
203
+ ) -> tuple[str | None, ...]:
204
+ """Semantic emphasis for one row's cells, parallel to its columns.
205
+
206
+ Only *non-zero* counts are emphasised, as in the Pull Requests table: a
207
+ column of red zeros trains the reader to ignore the colour. Total and Ext
208
+ carry none -- Total sums classes that disagree about what good looks like.
209
+ """
210
+ counted = (
211
+ _COLUMN_LEVELS.get(column.casefold()) if counts[column] else None
212
+ for column in columns
213
+ )
214
+ return (*counted, None, None, age)
215
+
216
+
163
217
  def _describe(base: str, age_cells: list[str], members: Set[str] | None) -> str:
164
218
  """Extend the category description with the caveats the table earned.
165
219
 
@@ -198,6 +252,8 @@ def build_issues_table(
198
252
  *,
199
253
  generated_at: dt.datetime,
200
254
  label_columns: Mapping[str, tuple[str, ...]],
255
+ age_warn_days: int,
256
+ age_error_days: int,
201
257
  members: Set[str] | None = frozenset(),
202
258
  ) -> TableSection:
203
259
  """The GitHub Issues table, largest backlog first.
@@ -209,7 +265,8 @@ def build_issues_table(
209
265
  with equal backlogs surface the less-triaged one first.
210
266
 
211
267
  ``members`` is the organisation's membership, used to count the issues
212
- raised from outside it (see :mod:`authors`).
268
+ raised from outside it (see :mod:`authors`). ``age_warn_days`` and
269
+ ``age_error_days`` colour the Oldest column (see :func:`age_level`).
213
270
  """
214
271
  columns = (*label_columns, OTHER_COLUMN, UNTRIAGED_COLUMN)
215
272
  rows: list[tuple[int, int, str, TableRow]] = []
@@ -266,7 +323,20 @@ def build_issues_table(
266
323
  data.open_issues,
267
324
  counts[UNTRIAGED_COLUMN],
268
325
  repo.name,
269
- TableRow(repo=repo, cells=cells, sort_values=sort_values),
326
+ TableRow(
327
+ repo=repo,
328
+ cells=cells,
329
+ sort_values=sort_values,
330
+ cell_levels=_cell_levels(
331
+ columns,
332
+ counts,
333
+ age_level(
334
+ oldest,
335
+ warn_days=age_warn_days,
336
+ error_days=age_error_days,
337
+ ),
338
+ ),
339
+ ),
270
340
  )
271
341
  )
272
342
  # Negated numerics so the whole sort runs ascending, keeping the name