github-security-report 0.14.2__tar.gz → 0.15.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 (82) hide show
  1. {github_security_report-0.14.2 → github_security_report-0.15.0}/PKG-INFO +78 -34
  2. {github_security_report-0.14.2 → github_security_report-0.15.0}/README.md +77 -33
  3. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/_version.py +2 -2
  4. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/categories.py +5 -3
  5. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/parsers.py +2 -0
  6. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/queries.py +16 -0
  7. github_security_report-0.15.0/src/github_security_report/client/reviews.py +76 -0
  8. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/models.py +22 -71
  9. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/ordering.py +2 -2
  10. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/pulls/__init__.py +4 -0
  11. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/pulls/columns.py +10 -3
  12. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/pulls/counting.py +24 -4
  13. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/pulls/presentation.py +21 -1
  14. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/pulls/table.py +4 -0
  15. github_security_report-0.15.0/src/github_security_report/ranking.py +86 -0
  16. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/report/__init__.py +1 -1
  17. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/report/aggregate.py +1 -1
  18. {github_security_report-0.14.2 → github_security_report-0.15.0}/.gitignore +0 -0
  19. {github_security_report-0.14.2 → github_security_report-0.15.0}/LICENSE +0 -0
  20. {github_security_report-0.14.2 → github_security_report-0.15.0}/LICENSES/Apache-2.0.txt +0 -0
  21. {github_security_report-0.14.2 → github_security_report-0.15.0}/pyproject.toml +0 -0
  22. {github_security_report-0.14.2 → github_security_report-0.15.0}/scripts/README.md +0 -0
  23. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/__init__.py +0 -0
  24. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/authors.py +0 -0
  25. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/classify.py +0 -0
  26. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/cli/__init__.py +0 -0
  27. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/cli/__main__.py +0 -0
  28. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/cli/app.py +0 -0
  29. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/cli/boundary.py +0 -0
  30. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/cli/modes.py +0 -0
  31. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/cli/options.py +0 -0
  32. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/cli/outputs.py +0 -0
  33. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/cli/publish.py +0 -0
  34. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/cli/serialise.py +0 -0
  35. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/__init__.py +0 -0
  36. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/alerts.py +0 -0
  37. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/batch_errors.py +0 -0
  38. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/copilot.py +0 -0
  39. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/endpoints.py +0 -0
  40. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/errors.py +0 -0
  41. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/org_reads.py +0 -0
  42. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/reads.py +0 -0
  43. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/transport.py +0 -0
  44. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/client/writes.py +0 -0
  45. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/collect/__init__.py +0 -0
  46. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/collect/context.py +0 -0
  47. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/collect/extras.py +0 -0
  48. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/collect/org.py +0 -0
  49. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/collect/protocols.py +0 -0
  50. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/collect/repo.py +0 -0
  51. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/config/__init__.py +0 -0
  52. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/config/loader.py +0 -0
  53. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/config/models.py +0 -0
  54. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/config/order.py +0 -0
  55. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/config/schema.py +0 -0
  56. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/gating.py +0 -0
  57. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/gitctx.py +0 -0
  58. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/issues.py +0 -0
  59. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/layout.py +0 -0
  60. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/posture/__init__.py +0 -0
  61. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/posture/enablement.py +0 -0
  62. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/posture/facts.py +0 -0
  63. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/posture/releases.py +0 -0
  64. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/py.typed +0 -0
  65. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/remediate.py +0 -0
  66. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/render/__init__.py +0 -0
  67. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/render/html.py +0 -0
  68. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/render/markdown.py +0 -0
  69. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/render/slack.py +0 -0
  70. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/render/slack_limits.py +0 -0
  71. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/render/terminal.py +0 -0
  72. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/report/display.py +0 -0
  73. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/report/signals.py +0 -0
  74. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/report/tables.py +0 -0
  75. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/rulesets.py +0 -0
  76. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/runner.py +0 -0
  77. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/scope.py +0 -0
  78. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/secret_patterns.py +0 -0
  79. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/severity.py +0 -0
  80. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/summary.py +0 -0
  81. {github_security_report-0.14.2 → github_security_report-0.15.0}/src/github_security_report/templates/index.html.j2 +0 -0
  82. {github_security_report-0.14.2 → github_security_report-0.15.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.14.2
3
+ Version: 0.15.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
@@ -736,17 +736,17 @@ organisation](#inside-or-outside-the-organisation):
736
736
 
737
737
  ```text
738
738
  Pull Requests
739
- ┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━━┳━━━━━━━━━━┳━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━┓
740
- ┃ Repository ┃ Human ┃ Ext ┃ Auto ┃ Conflict ┃ Fail ┃ Copilot ┃ Draft ┃ Total ┃
741
- ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━╇━━━━━━╇━━━━━━━━━━╇━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━┩
742
- │ lftools-uv │ 8 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 8 │
743
- │ dependamerge │ 3 │ 0 │ 0 │ 0 │ 0 │ 2 │ 0 │ 3 │
744
- │ gha-workflow-linter │ 2 │ 0 │ 0 │ 0 │ 1 │ 0 │ 1 │ 2 │
745
- │ harden-runner-block- │ 1 │ 0 │ 0 │ 1 │ 0 │ 0 │ 1 │ 1 │
746
- │ action │ │ │ │ │ │ │ │ │
747
- ├─────────────────────┼───────┼─────┼──────┼──────────┼──────┼─────────┼───────┼───────┤
748
- │ Total │ 14 │ 0 │ 0 │ 1 │ 1 │ 2 │ 2 │ 14 │
749
- └─────────────────────┴───────┴─────┴──────┴──────────┴──────┴─────────┴───────┴───────┘
739
+ ┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━━┳━━━━━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━┓
740
+ ┃ Repository ┃ Human ┃ Ext ┃ Auto ┃ Conflict ┃ Fail ┃ Review ┃ Copilot ┃ Draft ┃ Total ┃
741
+ ┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━╇━━━━━━╇━━━━━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━┩
742
+ │ lftools-uv │ 8 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 8 │
743
+ │ dependamerge │ 3 │ 0 │ 0 │ 0 │ 0 │ 1 │ 2 │ 0 │ 3 │
744
+ │ gha-workflow-linter │ 2 │ 0 │ 0 │ 0 │ 1 │ 0 │ 0 │ 1 │ 2 │
745
+ │ harden-runner-block- │ 1 │ 0 │ 0 │ 1 │ 0 │ 0 │ 0 │ 1 │ 1 │
746
+ │ action │ │ │ │ │ │ │ │ │ │
747
+ ├──────────────────────┼───────┼─────┼──────┼──────────┼──────┼────────┼─────────┼───────┼───────┤
748
+ │ Total │ 14 │ 0 │ 0 │ 1 │ 1 │ 1 │ 2 │ 2 │ 14 │
749
+ └──────────────────────┴───────┴─────┴──────┴──────────┴──────┴────────┴─────────┴───────┴───────┘
750
750
  … and 9 more
751
751
  ❌ 13 With open pull requests
752
752
  ✅ 104 No open pull requests
@@ -760,34 +760,73 @@ The columns form **two independent groupings**:
760
760
  | `Ext` | Human pull requests raised from outside the organisation — a **subset of Human**, which is why it sits beside it. |
761
761
  | `Conflict` | Blocked on a merge conflict. |
762
762
  | `Fail` | Latest checks did not pass. The rollup includes optional checks, so this is not by itself proof that the merge is blocked. |
763
+ | `Review` | A reviewer has **requested changes** — a person waiting on the author. |
763
764
  | `Copilot` | Carries at least one **unresolved review thread** opened by GitHub's automated code reviewer. |
764
765
  | `Draft` | Marked as a draft. |
765
766
 
766
- `Conflict`, `Fail`, `Copilot` and `Draft` **overlap** each other and the author
767
- split, so they do not sum to `Total` and are not meant to: one pull request that
768
- is conflicting, failing *and* a draft is counted once in each of those three
769
- columns, and once under `Human` or `Auto`. Only `Human` + `Auto` reconciles with
770
- the collected total. They are ordered worst-first — a conflict needs a human to
771
- rebase, a failing check may only need a re-run, unresolved review feedback needs
772
- a human but does not hold the merge button down, and a draft is not blocked at
773
- all.
767
+ `Conflict`, `Fail`, `Review`, `Copilot` and `Draft` **overlap** each other and
768
+ the author split, so they do not sum to `Total` and are not meant to: one pull
769
+ request that is conflicting, failing *and* a draft is counted once in each of
770
+ those three columns, and once under `Human` or `Auto`. Only `Human` + `Auto`
771
+ reconciles with the collected total. They are ordered worst-first — a conflict
772
+ needs a human to rebase, a failing check may only need a re-run, requested
773
+ changes need the author to act, unresolved automated feedback needs somebody but
774
+ does not hold the merge button down, and a draft is not blocked at all.
775
+
776
+ `Review` and `Copilot` sit together inside the blocker group, and deliberately
777
+ not beside `Human`: that column names who **raised** a pull request, whereas
778
+ these two name who is **waiting on** it. The grouping is what keeps those two
779
+ readings of "human" apart.
780
+
781
+ #### Requested changes
782
+
783
+ `Review` counts pull requests where **a person** has asked for changes and not
784
+ withdrawn it.
785
+
786
+ Two fields answer that, each covering the other's blind spot. `reviewDecision` is
787
+ GitHub's own verdict, computed over every review and accounting for dismissals,
788
+ so it is **exact at any review count** — but it names nobody, and a GitHub App
789
+ holding pull-request write permission can request changes exactly as a person
790
+ can. `latestOpinionatedReviews` names the reviewers, but is a bounded window of
791
+ five (one review per reviewer, so five reviewers rather than five reviews).
792
+
793
+ So the decision **gates** and the window **attributes**. Anything other than
794
+ `CHANGES_REQUESTED` is a definite zero whatever the window holds, and only once
795
+ GitHub says changes are outstanding does the reviewer's identity matter — which
796
+ keeps the cheap exact answer for almost every pull request and pays the window's
797
+ uncertainty on the few where it changes the reading. Automated reviewers are
798
+ recognised by the same rule as the `Auto` column.
799
+
800
+ Only `CHANGES_REQUESTED` gates. **`REVIEW_REQUIRED` is deliberately excluded**:
801
+ it reports that a branch rule demands a review, not that anybody objected, so on
802
+ an organisation that requires review by default it would mark nearly every human
803
+ pull request and distinguish none of them. The rule is written as an allow-list
804
+ of the one blocking state rather than as "anything that is not `APPROVED`", so a
805
+ state GitHub adds later arrives uncounted rather than pre-counted as a blocker.
806
+
807
+ Where a request for changes cannot be attributed — the reviews were unreadable,
808
+ the author is gone, or the window held only automated requests without covering
809
+ every reviewer — the pull request is left **uncounted rather than credited to a
810
+ person**, and the table says so in its description.
774
811
 
775
812
  #### Unresolved Copilot feedback
776
813
 
777
- `Copilot` counts the pull requests **waiting on a human to answer a review**. A
778
- review thread counts when it was opened by GitHub's automated code reviewer (the
779
- `copilot-pull-request-reviewer` bot) and nobody has resolved it. The reviewer is
780
- identified from each thread's **opening** comment, since later replies are
781
- usually the human answering the review.
814
+ `Copilot` counts the pull requests **waiting on somebody to answer an automated
815
+ review**. A review thread counts when it was opened by GitHub's automated code
816
+ reviewer (the `copilot-pull-request-reviewer` bot) and nobody has resolved it.
817
+ The reviewer is identified from each thread's **opening** comment, since later
818
+ replies are usually the human answering the review.
782
819
 
783
820
  An **outdated** thread still counts: GitHub marks a thread outdated when the
784
821
  code beneath it changes, but that does not resolve it, so the feedback remains
785
822
  unanswered.
786
823
 
787
- Any non-zero count is coloured **red**, like `Conflict` and `Fail` — it marks a
788
- pull request that cannot progress until somebody responds. The count also feeds
789
- the row ranking, so a backlog awaiting review outranks an untouched one of the
790
- same size.
824
+ Any non-zero count is coloured **amber**, one step below the red of `Conflict`,
825
+ `Fail` and `Review`. Automated feedback is worth answering, but nobody is
826
+ waiting on it, and there is far more of it — colouring the two alike would let
827
+ the bot's threads mask the rarer case that actually holds a person up. The count
828
+ still feeds the row ranking, so a backlog awaiting review outranks an untouched
829
+ one of the same size.
791
830
 
792
831
  The scan reads the **newest 20 review threads per pull request**, which is the
793
832
  single most expensive part of the prefetch (see the cost figures above). The
@@ -802,8 +841,8 @@ clear — see below.
802
841
  #### Automation backlog thresholds
803
842
 
804
843
  On the terminal the counts are coloured so the table reads at a glance: `Human`
805
- and `Ext` **green**, `Conflict`, `Fail` and `Copilot` **red**, and `Auto`
806
- coloured against two configured thresholds.
844
+ and `Ext` **green**, `Conflict`, `Fail` and `Review` **red**, `Copilot`
845
+ **amber**, and `Auto` coloured against two configured thresholds.
807
846
 
808
847
  Dependabot stops raising pull requests once a repository reaches its
809
848
  `open-pull-requests-limit`, so the repository quietly stops receiving dependency
@@ -1025,7 +1064,7 @@ reports as an App and any unrecognised login carrying the `[bot]` marker — so
1025
1064
  future bot is classified as automation rather than mistaken for an outside
1026
1065
  contributor.
1027
1066
 
1028
- `Fail`, `Conflict` and `Copilot` count only **established** states. GitHub
1067
+ `Fail`, `Conflict`, `Review` and `Copilot` count only **established** states. GitHub
1029
1068
  computes mergeability lazily and answers `UNKNOWN` until it settles, and reports
1030
1069
  no check rollup at all when no checks have run; neither absence is evidence that
1031
1070
  a pull request is ready, so neither is counted either way. `Copilot` follows the
@@ -1039,10 +1078,15 @@ holding any such reading says so in its description: `Copilot` then
1039
1078
  **undercounts and should be read as a lower bound**, exactly as `Ext` does when
1040
1079
  membership cannot be read.
1041
1080
 
1081
+ `Review` carries a weaker version of the same hedge, and earns it far less
1082
+ often: `reviewDecision` is exact, so only a pull request GitHub *does* report
1083
+ changes requested on can leave the question open, and then only when the
1084
+ opinionated-review window fails to attribute it to anybody.
1085
+
1042
1086
  Repositories with no open pull requests are counted in the footer rather than
1043
1087
  listed. Rows rank by total open pull requests, then by those failing,
1044
- conflicting or awaiting Copilot feedback (counted once each, since the columns
1045
- overlap), so two repositories with equal backlogs surface the more
1088
+ conflicting or awaiting review — automated or human (counted once each, since
1089
+ the columns overlap) — so two repositories with equal backlogs surface the more
1046
1090
  stuck one first.
1047
1091
 
1048
1092
  > **Accuracy note.** As with the issues table, `Total` is exact at any size,
@@ -692,17 +692,17 @@ organisation](#inside-or-outside-the-organisation):
692
692
 
693
693
  ```text
694
694
  Pull Requests
695
- ┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━━┳━━━━━━━━━━┳━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━┓
696
- ┃ Repository ┃ Human ┃ Ext ┃ Auto ┃ Conflict ┃ Fail ┃ Copilot ┃ Draft ┃ Total ┃
697
- ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━╇━━━━━━╇━━━━━━━━━━╇━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━┩
698
- │ lftools-uv │ 8 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 8 │
699
- │ dependamerge │ 3 │ 0 │ 0 │ 0 │ 0 │ 2 │ 0 │ 3 │
700
- │ gha-workflow-linter │ 2 │ 0 │ 0 │ 0 │ 1 │ 0 │ 1 │ 2 │
701
- │ harden-runner-block- │ 1 │ 0 │ 0 │ 1 │ 0 │ 0 │ 1 │ 1 │
702
- │ action │ │ │ │ │ │ │ │ │
703
- ├─────────────────────┼───────┼─────┼──────┼──────────┼──────┼─────────┼───────┼───────┤
704
- │ Total │ 14 │ 0 │ 0 │ 1 │ 1 │ 2 │ 2 │ 14 │
705
- └─────────────────────┴───────┴─────┴──────┴──────────┴──────┴─────────┴───────┴───────┘
695
+ ┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━━┳━━━━━━━━━━┳━━━━━━┳━━━━━━━━┳━━━━━━━━━┳━━━━━━━┳━━━━━━━┓
696
+ ┃ Repository ┃ Human ┃ Ext ┃ Auto ┃ Conflict ┃ Fail ┃ Review ┃ Copilot ┃ Draft ┃ Total ┃
697
+ ┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━╇━━━━━━╇━━━━━━━━━━╇━━━━━━╇━━━━━━━━╇━━━━━━━━━╇━━━━━━━╇━━━━━━━┩
698
+ │ lftools-uv │ 8 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 8 │
699
+ │ dependamerge │ 3 │ 0 │ 0 │ 0 │ 0 │ 1 │ 2 │ 0 │ 3 │
700
+ │ gha-workflow-linter │ 2 │ 0 │ 0 │ 0 │ 1 │ 0 │ 0 │ 1 │ 2 │
701
+ │ harden-runner-block- │ 1 │ 0 │ 0 │ 1 │ 0 │ 0 │ 0 │ 1 │ 1 │
702
+ │ action │ │ │ │ │ │ │ │ │ │
703
+ ├──────────────────────┼───────┼─────┼──────┼──────────┼──────┼────────┼─────────┼───────┼───────┤
704
+ │ Total │ 14 │ 0 │ 0 │ 1 │ 1 │ 1 │ 2 │ 2 │ 14 │
705
+ └──────────────────────┴───────┴─────┴──────┴──────────┴──────┴────────┴─────────┴───────┴───────┘
706
706
  … and 9 more
707
707
  ❌ 13 With open pull requests
708
708
  ✅ 104 No open pull requests
@@ -716,34 +716,73 @@ The columns form **two independent groupings**:
716
716
  | `Ext` | Human pull requests raised from outside the organisation — a **subset of Human**, which is why it sits beside it. |
717
717
  | `Conflict` | Blocked on a merge conflict. |
718
718
  | `Fail` | Latest checks did not pass. The rollup includes optional checks, so this is not by itself proof that the merge is blocked. |
719
+ | `Review` | A reviewer has **requested changes** — a person waiting on the author. |
719
720
  | `Copilot` | Carries at least one **unresolved review thread** opened by GitHub's automated code reviewer. |
720
721
  | `Draft` | Marked as a draft. |
721
722
 
722
- `Conflict`, `Fail`, `Copilot` and `Draft` **overlap** each other and the author
723
- split, so they do not sum to `Total` and are not meant to: one pull request that
724
- is conflicting, failing *and* a draft is counted once in each of those three
725
- columns, and once under `Human` or `Auto`. Only `Human` + `Auto` reconciles with
726
- the collected total. They are ordered worst-first — a conflict needs a human to
727
- rebase, a failing check may only need a re-run, unresolved review feedback needs
728
- a human but does not hold the merge button down, and a draft is not blocked at
729
- all.
723
+ `Conflict`, `Fail`, `Review`, `Copilot` and `Draft` **overlap** each other and
724
+ the author split, so they do not sum to `Total` and are not meant to: one pull
725
+ request that is conflicting, failing *and* a draft is counted once in each of
726
+ those three columns, and once under `Human` or `Auto`. Only `Human` + `Auto`
727
+ reconciles with the collected total. They are ordered worst-first — a conflict
728
+ needs a human to rebase, a failing check may only need a re-run, requested
729
+ changes need the author to act, unresolved automated feedback needs somebody but
730
+ does not hold the merge button down, and a draft is not blocked at all.
731
+
732
+ `Review` and `Copilot` sit together inside the blocker group, and deliberately
733
+ not beside `Human`: that column names who **raised** a pull request, whereas
734
+ these two name who is **waiting on** it. The grouping is what keeps those two
735
+ readings of "human" apart.
736
+
737
+ #### Requested changes
738
+
739
+ `Review` counts pull requests where **a person** has asked for changes and not
740
+ withdrawn it.
741
+
742
+ Two fields answer that, each covering the other's blind spot. `reviewDecision` is
743
+ GitHub's own verdict, computed over every review and accounting for dismissals,
744
+ so it is **exact at any review count** — but it names nobody, and a GitHub App
745
+ holding pull-request write permission can request changes exactly as a person
746
+ can. `latestOpinionatedReviews` names the reviewers, but is a bounded window of
747
+ five (one review per reviewer, so five reviewers rather than five reviews).
748
+
749
+ So the decision **gates** and the window **attributes**. Anything other than
750
+ `CHANGES_REQUESTED` is a definite zero whatever the window holds, and only once
751
+ GitHub says changes are outstanding does the reviewer's identity matter — which
752
+ keeps the cheap exact answer for almost every pull request and pays the window's
753
+ uncertainty on the few where it changes the reading. Automated reviewers are
754
+ recognised by the same rule as the `Auto` column.
755
+
756
+ Only `CHANGES_REQUESTED` gates. **`REVIEW_REQUIRED` is deliberately excluded**:
757
+ it reports that a branch rule demands a review, not that anybody objected, so on
758
+ an organisation that requires review by default it would mark nearly every human
759
+ pull request and distinguish none of them. The rule is written as an allow-list
760
+ of the one blocking state rather than as "anything that is not `APPROVED`", so a
761
+ state GitHub adds later arrives uncounted rather than pre-counted as a blocker.
762
+
763
+ Where a request for changes cannot be attributed — the reviews were unreadable,
764
+ the author is gone, or the window held only automated requests without covering
765
+ every reviewer — the pull request is left **uncounted rather than credited to a
766
+ person**, and the table says so in its description.
730
767
 
731
768
  #### Unresolved Copilot feedback
732
769
 
733
- `Copilot` counts the pull requests **waiting on a human to answer a review**. A
734
- review thread counts when it was opened by GitHub's automated code reviewer (the
735
- `copilot-pull-request-reviewer` bot) and nobody has resolved it. The reviewer is
736
- identified from each thread's **opening** comment, since later replies are
737
- usually the human answering the review.
770
+ `Copilot` counts the pull requests **waiting on somebody to answer an automated
771
+ review**. A review thread counts when it was opened by GitHub's automated code
772
+ reviewer (the `copilot-pull-request-reviewer` bot) and nobody has resolved it.
773
+ The reviewer is identified from each thread's **opening** comment, since later
774
+ replies are usually the human answering the review.
738
775
 
739
776
  An **outdated** thread still counts: GitHub marks a thread outdated when the
740
777
  code beneath it changes, but that does not resolve it, so the feedback remains
741
778
  unanswered.
742
779
 
743
- Any non-zero count is coloured **red**, like `Conflict` and `Fail` — it marks a
744
- pull request that cannot progress until somebody responds. The count also feeds
745
- the row ranking, so a backlog awaiting review outranks an untouched one of the
746
- same size.
780
+ Any non-zero count is coloured **amber**, one step below the red of `Conflict`,
781
+ `Fail` and `Review`. Automated feedback is worth answering, but nobody is
782
+ waiting on it, and there is far more of it — colouring the two alike would let
783
+ the bot's threads mask the rarer case that actually holds a person up. The count
784
+ still feeds the row ranking, so a backlog awaiting review outranks an untouched
785
+ one of the same size.
747
786
 
748
787
  The scan reads the **newest 20 review threads per pull request**, which is the
749
788
  single most expensive part of the prefetch (see the cost figures above). The
@@ -758,8 +797,8 @@ clear — see below.
758
797
  #### Automation backlog thresholds
759
798
 
760
799
  On the terminal the counts are coloured so the table reads at a glance: `Human`
761
- and `Ext` **green**, `Conflict`, `Fail` and `Copilot` **red**, and `Auto`
762
- coloured against two configured thresholds.
800
+ and `Ext` **green**, `Conflict`, `Fail` and `Review` **red**, `Copilot`
801
+ **amber**, and `Auto` coloured against two configured thresholds.
763
802
 
764
803
  Dependabot stops raising pull requests once a repository reaches its
765
804
  `open-pull-requests-limit`, so the repository quietly stops receiving dependency
@@ -981,7 +1020,7 @@ reports as an App and any unrecognised login carrying the `[bot]` marker — so
981
1020
  future bot is classified as automation rather than mistaken for an outside
982
1021
  contributor.
983
1022
 
984
- `Fail`, `Conflict` and `Copilot` count only **established** states. GitHub
1023
+ `Fail`, `Conflict`, `Review` and `Copilot` count only **established** states. GitHub
985
1024
  computes mergeability lazily and answers `UNKNOWN` until it settles, and reports
986
1025
  no check rollup at all when no checks have run; neither absence is evidence that
987
1026
  a pull request is ready, so neither is counted either way. `Copilot` follows the
@@ -995,10 +1034,15 @@ holding any such reading says so in its description: `Copilot` then
995
1034
  **undercounts and should be read as a lower bound**, exactly as `Ext` does when
996
1035
  membership cannot be read.
997
1036
 
1037
+ `Review` carries a weaker version of the same hedge, and earns it far less
1038
+ often: `reviewDecision` is exact, so only a pull request GitHub *does* report
1039
+ changes requested on can leave the question open, and then only when the
1040
+ opinionated-review window fails to attribute it to anybody.
1041
+
998
1042
  Repositories with no open pull requests are counted in the footer rather than
999
1043
  listed. Rows rank by total open pull requests, then by those failing,
1000
- conflicting or awaiting Copilot feedback (counted once each, since the columns
1001
- overlap), so two repositories with equal backlogs surface the more
1044
+ conflicting or awaiting review — automated or human (counted once each, since
1045
+ the columns overlap) — so two repositories with equal backlogs surface the more
1002
1046
  stuck one first.
1003
1047
 
1004
1048
  > **Accuracy note.** As with the issues table, `Total` is exact at any size,
@@ -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.2'
22
- __version_tuple__ = version_tuple = (0, 14, 2)
21
+ __version__ = version = '0.15.0'
22
+ __version_tuple__ = version_tuple = (0, 15, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -311,9 +311,11 @@ _CATEGORIES: dict[CategoryKey, CategoryMeta] = {
311
311
  "Conflict counts pull requests blocked on a merge conflict; Fail "
312
312
  "counts those whose latest checks did not pass, which includes "
313
313
  "optional checks and so is not by itself proof that a merge is "
314
- "blocked; Copilot counts those still carrying an unresolved review "
315
- "thread opened by GitHub's automated code reviewer; Draft counts "
316
- "those still marked as drafts. Those four "
314
+ "blocked; Review counts those a reviewer has asked for changes on, "
315
+ "which is a person waiting on the author; Copilot counts those "
316
+ "still carrying an unresolved review thread opened by GitHub's "
317
+ "automated code reviewer; Draft counts "
318
+ "those still marked as drafts. Those five "
317
319
  "are independent of the author split and of each other, so one "
318
320
  "pull request can appear in more than one of them. Ranked by total "
319
321
  "open pull requests, then by those failing, conflicting or "
@@ -17,6 +17,7 @@ from typing import cast
17
17
  import httpx
18
18
 
19
19
  from github_security_report.client.copilot import _copilot_unresolved
20
+ from github_security_report.client.reviews import _changes_requested
20
21
  from github_security_report.models import (
21
22
  AuthorRef,
22
23
  IssueRef,
@@ -341,6 +342,7 @@ def _pull_request_ref(node: object) -> PullRequestRef | None:
341
342
  conflicting=conflicting,
342
343
  failing=_check_rollup_failed(node),
343
344
  copilot_unresolved=_copilot_unresolved(node),
345
+ changes_requested=_changes_requested(node),
344
346
  )
345
347
 
346
348
 
@@ -111,6 +111,17 @@ _PULL_REQUEST_WINDOW = 25
111
111
  # truncate, and the assignment breakdown is therefore exact for every collected
112
112
  # pull request.
113
113
  _ASSIGNEE_WINDOW = 10
114
+ # Opinionated reviews inspected per pull request, to tell whose request for
115
+ # changes is outstanding. ``reviewDecision`` says *that* changes are requested
116
+ # but names nobody, and a GitHub App with pull-request write permission can
117
+ # request them exactly as a person can -- so identity needs its own field.
118
+ #
119
+ # The connection returns at most one review per reviewer, so five covers a pull
120
+ # request with five opinionated reviewers rather than five reviews. Measured
121
+ # against a 25-repository batch this adds 6 points, which is the cheapest of the
122
+ # three windows here by an order of magnitude, and ``totalCount`` rides along
123
+ # free to keep it honest.
124
+ _OPINIONATED_REVIEW_WINDOW = 5
114
125
  # Review threads inspected per pull request, to spot outstanding Copilot review
115
126
  # feedback. This is the most expensive window in the fragment, because it
116
127
  # multiplies the pull-request window and then again by the one comment needed to
@@ -179,9 +190,14 @@ fragment RepoData on Repository {{
179
190
  number
180
191
  isDraft
181
192
  mergeable
193
+ reviewDecision
182
194
  authorAssociation
183
195
  author {{ __typename login }}
184
196
  assignees(first: {_ASSIGNEE_WINDOW}) {{ nodes {{ login }} }}
197
+ latestOpinionatedReviews(first: {_OPINIONATED_REVIEW_WINDOW}) {{
198
+ totalCount
199
+ nodes {{ state author {{ __typename login }} }}
200
+ }}
185
201
  reviewThreads(last: {_REVIEW_THREAD_WINDOW}) {{
186
202
  totalCount
187
203
  nodes {{
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Reading an outstanding request for changes from a pull-request node.
4
+
5
+ One question, asked of the two review fields the batched prefetch requests: is a
6
+ *person* waiting on this pull request's author? Kept apart from the general node
7
+ parsing for the same reason as the Copilot reading beside it -- it has to reason
8
+ about a bounded window, and so about what that window's edge does and does not
9
+ prove.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from github_security_report.authors import is_automation_author
15
+
16
+ # The one review decision that means somebody objected. An allow-list of that
17
+ # single state rather than "anything that is not APPROVED": REVIEW_REQUIRED says
18
+ # a branch rule demands a review, not that a reviewer objected, so counting it
19
+ # would mark nearly every pull request in an organisation that requires review by
20
+ # default. Writing the rule positively also means a state GitHub adds later
21
+ # arrives *uncounted* rather than pre-counted as a blocker.
22
+ _BLOCKING_DECISION = "CHANGES_REQUESTED"
23
+
24
+
25
+ def _changes_requested(node: dict) -> bool | None:
26
+ """Whether a *person* has asked for changes and not withdrawn it.
27
+
28
+ Two fields, each covering the other's blind spot. ``reviewDecision`` is
29
+ GitHub's own verdict, computed over every review and accounting for
30
+ dismissals, so it is exact however long the review ran -- but it names
31
+ nobody, and a GitHub App holding pull-request write permission can request
32
+ changes exactly as a person can. ``latestOpinionatedReviews`` names the
33
+ reviewers but is a bounded window.
34
+
35
+ So the decision *gates* and the window *attributes*. Anything other than
36
+ ``CHANGES_REQUESTED`` is a definite False whatever the window holds, and
37
+ only once GitHub says changes are outstanding does identity matter. That
38
+ keeps the cheap exact answer for the overwhelming majority of pull requests
39
+ and pays the window's uncertainty on the few where it changes the reading.
40
+
41
+ ``None`` is the open question: the fields could not be read, a requested
42
+ change carried no author to attribute it to, or the window held only
43
+ automated requests without covering every opinionated reviewer -- in which
44
+ case a person's may sit among the reviews this run never saw. As elsewhere,
45
+ None is "not established" rather than "nothing outstanding".
46
+ """
47
+ if "reviewDecision" not in node:
48
+ return None
49
+ decision = node["reviewDecision"]
50
+ if not isinstance(decision, str) or decision != _BLOCKING_DECISION:
51
+ return False
52
+ reviews = node.get("latestOpinionatedReviews")
53
+ if not isinstance(reviews, dict):
54
+ return None
55
+ nodes = reviews.get("nodes")
56
+ if not isinstance(nodes, list):
57
+ return None
58
+ seen = 0
59
+ for review in nodes:
60
+ if not isinstance(review, dict):
61
+ continue
62
+ seen += 1
63
+ if review.get("state") != _BLOCKING_DECISION:
64
+ continue
65
+ author = review.get("author")
66
+ if not isinstance(author, dict):
67
+ # A request for changes we cannot attribute -- a deleted account,
68
+ # say. Counting it would guess at a person; skipping it would let
69
+ # the loop fall through to a confident False.
70
+ return None
71
+ if not is_automation_author(author.get("login"), author.get("__typename")):
72
+ return True
73
+ total = reviews.get("totalCount")
74
+ if not isinstance(total, int) or isinstance(total, bool) or total > seen:
75
+ return None
76
+ return False
@@ -4,9 +4,9 @@
4
4
 
5
5
  Encodes the Phase 0 design (see ``docs/BRIEF.md`` and
6
6
  ``docs/phase0-findings.md``): the six ranked signals, the four-state per-report
7
- classification, severity counts with hierarchical worst-first ordering, and the
8
- ranking rules (alert tables sort by severity descending; Scorecard by its worst
9
- populated severity rung descending, then by score ascending).
7
+ classification, and severity counts with hierarchical worst-first ordering.
8
+ The rules for ordering offenders *between* repositories live in ``ranking.py``,
9
+ which reads these models rather than being part of them.
10
10
  """
11
11
 
12
12
  from __future__ import annotations
@@ -178,7 +178,7 @@ class IssueRef:
178
178
  class PullRequestRef:
179
179
  """One open pull request's review-load facts.
180
180
 
181
- ``draft`` and the three blocked flags are independent of the author and of
181
+ ``draft`` and the four blocked flags are independent of the author and of
182
182
  each other, so one pull request may be a draft *and* conflicting *and*
183
183
  awaiting review; the table counts each axis separately rather than bucketing
184
184
  rows.
@@ -207,6 +207,24 @@ class PullRequestRef:
207
207
  # the threads this run never saw. As with ``conflicting`` and ``failing``,
208
208
  # None is "not established" rather than "nothing outstanding".
209
209
  copilot_unresolved: bool | None = None
210
+ # True when a *person* has requested changes and not withdrawn it. Read
211
+ # from ``reviewDecision``, which GitHub computes over every review and so is
212
+ # exact at any review count, attributed through the bounded window of
213
+ # opinionated reviews -- the decision names nobody, and a GitHub App can
214
+ # request changes exactly as a person can.
215
+ #
216
+ # None where that attribution could not be settled: the fields were
217
+ # unreadable, a request carried no author, or the window held only automated
218
+ # requests without covering every reviewer. As with the flags above, None is
219
+ # "not established" rather than "nothing outstanding" -- though it is far
220
+ # rarer here, since it needs GitHub to report changes requested *and* the
221
+ # window to fall short.
222
+ #
223
+ # Only CHANGES_REQUESTED counts. REVIEW_REQUIRED is deliberately excluded:
224
+ # it reports that a branch rule demands a review, not that anyone objected,
225
+ # so on an organisation that requires review by default it would mark almost
226
+ # every human pull request and say nothing about any of them.
227
+ changes_requested: bool | None = None
210
228
 
211
229
 
212
230
  @dataclass
@@ -367,70 +385,3 @@ class RepoSignal:
367
385
  @property
368
386
  def is_offender(self) -> bool:
369
387
  return self.state is RepoState.OFFENDER
370
-
371
-
372
- # The rungs eligible to lead the Scorecard ordering, worst-first.
373
- # ``INFORMATIONAL`` is deliberately absent: it is the non-actionable rung, so it
374
- # never displaces the score as the primary key.
375
- LEAD_RUNGS: tuple[Severity, ...] = tuple(
376
- rung for rung in RUNGS_WORST_FIRST if rung is not Severity.INFORMATIONAL
377
- )
378
-
379
-
380
- def lead_rung(offenders: list[RepoSignal]) -> Severity | None:
381
- """The worst severity rung any offender actually carries.
382
-
383
- Returns ``None`` when no offender carries a finding at Low or above, in
384
- which case there is no severity tier worth leading on.
385
- """
386
- return next(
387
- (rung for rung in LEAD_RUNGS if any(s.counts.at(rung) for s in offenders)),
388
- None,
389
- )
390
-
391
-
392
- def rank_offenders(signals: list[RepoSignal]) -> list[RepoSignal]:
393
- """Sort offenders worst-first for a single signal.
394
-
395
- Alert-based signals sort by the hierarchical severity key descending, with
396
- total as a tiebreaker.
397
-
398
- Scorecard sorts on two tiers: the count at the worst severity rung present
399
- anywhere in the table (descending), then the aggregate score (ascending,
400
- lower == worse). The leading rung cascades -- Critical, else High, else
401
- Medium, else Low -- so the rung that actually discriminates between
402
- repositories leads, and a lone Critical can never be buried mid-table by a
403
- weaker repository with a lower score. When no offender carries a finding at
404
- Low or above, the score alone orders the table.
405
-
406
- Repo name breaks remaining ties, ascending.
407
-
408
- Numeric components are negated so the whole sort runs ascending (no
409
- ``reverse=True``); that keeps the name tiebreaker correctly ascending even
410
- when one name is a prefix of another.
411
- """
412
- offenders = [s for s in signals if s.is_offender]
413
- if not offenders:
414
- return []
415
- signal = offenders[0].signal
416
- if signal.sort_ascending:
417
- rung = lead_rung(offenders)
418
- return sorted(
419
- offenders,
420
- key=lambda s: (
421
- -s.counts.at(rung) if rung is not None else 0,
422
- s.score if s.score is not None else float("inf"),
423
- s.repo.name,
424
- ),
425
- )
426
- return sorted(
427
- offenders,
428
- key=lambda s: (
429
- -s.counts.critical,
430
- -s.counts.high,
431
- -s.counts.medium,
432
- -s.counts.low,
433
- -s.counts.total,
434
- s.repo.name,
435
- ),
436
- )
@@ -30,7 +30,7 @@ whose rendered columns vary by surface and by data (Slack abbreviates the
30
30
  headers and drops Total, and the Informational column appears only when
31
31
  populated), so resolving against a renderer's column list would make the
32
32
  ordering depend on which surface asked. Omitting ``sort`` keeps
33
- :func:`models.rank_offenders`, whose default encodes domain logic a column sort
33
+ :func:`ranking.rank_offenders`, whose default encodes domain logic a column sort
34
34
  cannot express -- notably Scorecard's cascade through the worst populated
35
35
  severity rung, which stops a lone Critical being buried by a weaker repository
36
36
  with a lower score.
@@ -417,7 +417,7 @@ def apply_configured_signal_order(
417
417
  ) -> None:
418
418
  """Reorder each signal's offenders in place using its configured ordering.
419
419
 
420
- A signal with no ``sort`` keeps :func:`models.rank_offenders`, whose default
420
+ A signal with no ``sort`` keeps :func:`ranking.rank_offenders`, whose default
421
421
  is not expressible as a column list: Scorecard cascades through the worst
422
422
  severity rung any offender actually carries, which is a property of the
423
423
  table as a whole rather than of any one row.
@@ -71,6 +71,7 @@ from github_security_report.pulls.columns import (
71
71
  OTHERS_ROW,
72
72
  PERSONAL_ASSIGNMENT_ROWS,
73
73
  REPOSITORY_COLUMN,
74
+ REVIEW_COLUMN,
74
75
  TOTAL_COLUMN,
75
76
  TRUNCATED_MARKER,
76
77
  UNASSIGNED_ROW,
@@ -84,6 +85,7 @@ from github_security_report.pulls.counting import (
84
85
  copilot_indeterminate,
85
86
  count_pull_requests,
86
87
  is_mine,
88
+ review_indeterminate,
87
89
  )
88
90
  from github_security_report.pulls.presentation import (
89
91
  _cell_levels,
@@ -131,6 +133,7 @@ __all__ = [
131
133
  "PERSONAL_ASSIGNMENT_ROWS",
132
134
  "PullRequestRef",
133
135
  "REPOSITORY_COLUMN",
136
+ "REVIEW_COLUMN",
134
137
  "Repo",
135
138
  "RepoGraphData",
136
139
  "Set",
@@ -151,4 +154,5 @@ __all__ = [
151
154
  "is_automation_author",
152
155
  "is_external_author",
153
156
  "is_mine",
157
+ "review_indeterminate",
154
158
  ]
@@ -15,6 +15,7 @@ DRAFT_COLUMN = "Draft"
15
15
  EXTERNAL_COLUMN = "Ext"
16
16
  FAILING_COLUMN = "Fail"
17
17
  CONFLICT_COLUMN = "Conflict"
18
+ REVIEW_COLUMN = "Review"
18
19
  COPILOT_COLUMN = "Copilot"
19
20
  TOTAL_COLUMN = "Total"
20
21
 
@@ -22,15 +23,21 @@ TOTAL_COLUMN = "Total"
22
23
  # Ordered so related columns read together: the author split first, with Ext
23
24
  # beside Human because it qualifies it (Ext is a subset of Human, never of
24
25
  # Auto), then the blockers, worst first -- a conflict needs a human to rebase,
25
- # a failing check may only need a re-run, unresolved Copilot feedback needs a
26
- # human but does not hold the merge button down, and a draft is not blocked at
27
- # all.
26
+ # a failing check may only need a re-run, a reviewer who requested changes needs
27
+ # the author to act, unresolved Copilot feedback needs somebody but does not
28
+ # hold the merge button down, and a draft is not blocked at all.
29
+ #
30
+ # Review sits beside Copilot because the two answer the same question about
31
+ # different reviewers, and both sit inside the blocker group rather than beside
32
+ # Human -- which names who *raised* a pull request, not who reviewed it. The
33
+ # grouping is what keeps those two readings of "human" apart.
28
34
  BREAKDOWN_COLUMNS = (
29
35
  HUMAN_COLUMN,
30
36
  EXTERNAL_COLUMN,
31
37
  AUTOMATION_COLUMN,
32
38
  CONFLICT_COLUMN,
33
39
  FAILING_COLUMN,
40
+ REVIEW_COLUMN,
34
41
  COPILOT_COLUMN,
35
42
  DRAFT_COLUMN,
36
43
  )
@@ -25,6 +25,7 @@ from github_security_report.pulls.columns import (
25
25
  HUMAN_COLUMN,
26
26
  MINE_ROW,
27
27
  OTHERS_ROW,
28
+ REVIEW_COLUMN,
28
29
  UNASSIGNED_ROW,
29
30
  )
30
31
 
@@ -86,6 +87,12 @@ def count_pull_requests(
86
87
  counts[CONFLICT_COLUMN] += 1
87
88
  if pull.copilot_unresolved is True:
88
89
  counts[COPILOT_COLUMN] += 1
90
+ # Review needs the same hedge, though it earns it far less often: the
91
+ # decision is exact, so only a pull request GitHub *does* report changes
92
+ # requested on can leave the question open, and then only if the window
93
+ # failed to attribute it.
94
+ if pull.changes_requested is True:
95
+ counts[REVIEW_COLUMN] += 1
89
96
  return counts
90
97
 
91
98
 
@@ -148,6 +155,17 @@ def copilot_indeterminate(pulls: tuple[PullRequestRef, ...]) -> bool:
148
155
  return any(pull.copilot_unresolved is None for pull in pulls)
149
156
 
150
157
 
158
+ def review_indeterminate(pulls: tuple[PullRequestRef, ...]) -> bool:
159
+ """Whether any of these pull requests left the Review question unsettled.
160
+
161
+ Same rule as :func:`copilot_indeterminate` and for the same reason -- a
162
+ bounded window, not a moment that will settle itself -- but reached far
163
+ less often, since it needs GitHub to report changes requested *and* the
164
+ window of opinionated reviews to fail to attribute them to anybody.
165
+ """
166
+ return any(pull.changes_requested is None for pull in pulls)
167
+
168
+
151
169
  def _blocked_count(pulls: tuple[PullRequestRef, ...]) -> int:
152
170
  """Pull requests that are failing, conflicting *or* awaiting Copilot, once each.
153
171
 
@@ -157,10 +175,11 @@ def _blocked_count(pulls: tuple[PullRequestRef, ...]) -> int:
157
175
  stuck ones. The table already says the columns overlap; the ranking has
158
176
  to agree with it.
159
177
 
160
- Unresolved Copilot feedback joins the union because it is the same kind of
161
- fact as the other two -- work the pull request is waiting on a human for --
162
- and the table already colours it as blocking. Leaving it out would rank a
163
- repository whose whole backlog is awaiting review below an untouched one.
178
+ Unresolved Copilot feedback and a reviewer's requested changes join the
179
+ union because they are the same kind of fact as the other two -- work the
180
+ pull request is waiting on somebody for -- and the table already colours
181
+ both as blocking. Leaving them out would rank a repository whose whole
182
+ backlog is awaiting review below an untouched one.
164
183
  """
165
184
  return sum(
166
185
  1
@@ -168,4 +187,5 @@ def _blocked_count(pulls: tuple[PullRequestRef, ...]) -> int:
168
187
  if pull.failing is True
169
188
  or pull.conflicting is True
170
189
  or pull.copilot_unresolved is True
190
+ or pull.changes_requested is True
171
191
  )
@@ -19,6 +19,7 @@ from github_security_report.pulls.columns import (
19
19
  EXTERNAL_COLUMN,
20
20
  FAILING_COLUMN,
21
21
  HUMAN_COLUMN,
22
+ REVIEW_COLUMN,
22
23
  TRUNCATED_MARKER,
23
24
  )
24
25
  from github_security_report.report import CELL_BAD, CELL_GOOD, CELL_WARN
@@ -68,6 +69,13 @@ def _cell_levels(
68
69
 
69
70
  The trailing Total is never emphasised: it is the sum of columns that
70
71
  disagree about what good looks like, so no one colour is true of it.
72
+
73
+ The blocked columns are not all one severity. Conflict, Fail and Review are
74
+ red: each needs a person to do something before the pull request can move.
75
+ Copilot is amber, a deliberate step down -- automated review feedback is
76
+ worth answering, but it is not a human waiting on a reply, and colouring the
77
+ two identically would let the louder, more numerous bot threads mask the
78
+ rarer case that actually holds somebody up.
71
79
  """
72
80
  levels: list[str | None] = []
73
81
  for column in BREAKDOWN_COLUMNS:
@@ -82,8 +90,10 @@ def _cell_levels(
82
90
  )
83
91
  elif column in (HUMAN_COLUMN, EXTERNAL_COLUMN):
84
92
  levels.append(CELL_GOOD if value else None)
85
- elif column in (CONFLICT_COLUMN, FAILING_COLUMN, COPILOT_COLUMN):
93
+ elif column in (CONFLICT_COLUMN, FAILING_COLUMN, REVIEW_COLUMN):
86
94
  levels.append(CELL_BAD if value else None)
95
+ elif column == COPILOT_COLUMN:
96
+ levels.append(CELL_WARN if value else None)
87
97
  else:
88
98
  # Draft is neither good nor bad: a draft is not blocked, it is
89
99
  # simply not finished, so it carries no emphasis.
@@ -98,6 +108,7 @@ def _describe(
98
108
  *,
99
109
  filtered: bool,
100
110
  copilot_partial: bool = False,
111
+ review_partial: bool = False,
101
112
  ) -> str:
102
113
  """Extend the category description with the caveats the table earned.
103
114
 
@@ -146,4 +157,13 @@ def _describe(
146
157
  "answered, so Copilot undercounts and should be read as a lower "
147
158
  "bound."
148
159
  )
160
+ if review_partial:
161
+ description += (
162
+ " Who asked for changes could not be settled for at least one pull "
163
+ "request, whose opinionated reviews were unreadable, carried no "
164
+ "author to attribute a request to, or held only automated requests "
165
+ "without covering every reviewer. Such a pull request is left "
166
+ "uncounted rather than credited to a person, so Review undercounts "
167
+ "and should be read as a lower bound."
168
+ )
149
169
  return description
@@ -24,6 +24,7 @@ from github_security_report.pulls.counting import (
24
24
  copilot_indeterminate,
25
25
  count_pull_requests,
26
26
  is_mine,
27
+ review_indeterminate,
27
28
  )
28
29
  from github_security_report.pulls.presentation import (
29
30
  _cell_levels,
@@ -57,6 +58,7 @@ def _build_table(
57
58
  clean_count = 0
58
59
  unknown_count = 0
59
60
  copilot_partial = False
61
+ review_partial = False
60
62
  footer_labels = assignment_rows(viewer) if footer else ()
61
63
  for repo in repos:
62
64
  data = graph.get(repo.name, RepoGraphData())
@@ -91,6 +93,7 @@ def _build_table(
91
93
  counts = count_pull_requests(selected, members)
92
94
  blocked = _blocked_count(selected)
93
95
  copilot_partial = copilot_partial or copilot_indeterminate(selected)
96
+ review_partial = review_partial or review_indeterminate(selected)
94
97
  cells = (
95
98
  *(str(counts[column]) for column in BREAKDOWN_COLUMNS),
96
99
  _total_cell(total, truncated),
@@ -129,6 +132,7 @@ def _build_table(
129
132
  members,
130
133
  filtered=select is not None,
131
134
  copilot_partial=copilot_partial,
135
+ review_partial=review_partial,
132
136
  )
133
137
  return TableSection(
134
138
  category=meta,
@@ -0,0 +1,86 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Worst-first ordering of the repositories a signal found something in.
4
+
5
+ The domain default behind every offender table: which repository a reader meets
6
+ first. ``ordering.py`` owns the *configurable* orderings a report may ask for
7
+ and defers to :func:`rank_offenders` when a signal names none, so the two are
8
+ separate concerns -- what the reader chose, and what the domain says when they
9
+ chose nothing.
10
+
11
+ Kept out of ``models.py`` because these are decisions taken *about* the models
12
+ rather than part of them, and out of ``severity.py`` because they read a
13
+ ``RepoSignal``, which would make that module import the models that already
14
+ import it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from github_security_report.models import RepoSignal
20
+ from github_security_report.severity import RUNGS_WORST_FIRST, Severity
21
+
22
+ # The rungs eligible to lead the Scorecard ordering, worst-first.
23
+ # ``INFORMATIONAL`` is deliberately absent: it is the non-actionable rung, so it
24
+ # never displaces the score as the primary key.
25
+ LEAD_RUNGS: tuple[Severity, ...] = tuple(
26
+ rung for rung in RUNGS_WORST_FIRST if rung is not Severity.INFORMATIONAL
27
+ )
28
+
29
+
30
+ def lead_rung(offenders: list[RepoSignal]) -> Severity | None:
31
+ """The worst severity rung any offender actually carries.
32
+
33
+ Returns ``None`` when no offender carries a finding at Low or above, in
34
+ which case there is no severity tier worth leading on.
35
+ """
36
+ return next(
37
+ (rung for rung in LEAD_RUNGS if any(s.counts.at(rung) for s in offenders)),
38
+ None,
39
+ )
40
+
41
+
42
+ def rank_offenders(signals: list[RepoSignal]) -> list[RepoSignal]:
43
+ """Sort offenders worst-first for a single signal.
44
+
45
+ Alert-based signals sort by the hierarchical severity key descending, with
46
+ total as a tiebreaker.
47
+
48
+ Scorecard sorts on two tiers: the count at the worst severity rung present
49
+ anywhere in the table (descending), then the aggregate score (ascending,
50
+ lower == worse). The leading rung cascades -- Critical, else High, else
51
+ Medium, else Low -- so the rung that actually discriminates between
52
+ repositories leads, and a lone Critical can never be buried mid-table by a
53
+ weaker repository with a lower score. When no offender carries a finding at
54
+ Low or above, the score alone orders the table.
55
+
56
+ Repo name breaks remaining ties, ascending.
57
+
58
+ Numeric components are negated so the whole sort runs ascending (no
59
+ ``reverse=True``); that keeps the name tiebreaker correctly ascending even
60
+ when one name is a prefix of another.
61
+ """
62
+ offenders = [s for s in signals if s.is_offender]
63
+ if not offenders:
64
+ return []
65
+ signal = offenders[0].signal
66
+ if signal.sort_ascending:
67
+ rung = lead_rung(offenders)
68
+ return sorted(
69
+ offenders,
70
+ key=lambda s: (
71
+ -s.counts.at(rung) if rung is not None else 0,
72
+ s.score if s.score is not None else float("inf"),
73
+ s.repo.name,
74
+ ),
75
+ )
76
+ return sorted(
77
+ offenders,
78
+ key=lambda s: (
79
+ -s.counts.critical,
80
+ -s.counts.high,
81
+ -s.counts.medium,
82
+ -s.counts.low,
83
+ -s.counts.total,
84
+ s.repo.name,
85
+ ),
86
+ )
@@ -37,7 +37,7 @@ from github_security_report.models import RepoSignal as RepoSignal
37
37
  from github_security_report.models import RepoState as RepoState
38
38
  from github_security_report.models import SeverityCounts as SeverityCounts
39
39
  from github_security_report.models import SignalType as SignalType
40
- from github_security_report.models import rank_offenders as rank_offenders
40
+ from github_security_report.ranking import rank_offenders as rank_offenders
41
41
  from github_security_report.report.aggregate import (
42
42
  SIGNAL_ORDER,
43
43
  OrgReport,
@@ -22,8 +22,8 @@ from github_security_report.models import (
22
22
  RepoSignal,
23
23
  RepoState,
24
24
  SignalType,
25
- rank_offenders,
26
25
  )
26
+ from github_security_report.ranking import rank_offenders
27
27
  from github_security_report.report.signals import SignalSection
28
28
  from github_security_report.report.tables import TableSection
29
29