Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,8 @@ Pages output. The valid category keys are: `codeql`,
`dependabot_cooldown`, `releases`, `mutable_releases`,
`private_vulnerability_reporting`, `auto_merge`, `malware_alerts`,
`code_quality`, `workflow_failures`, `workflow_failures_detail`, `github_issues`,
`pull_requests`, `pull_requests_assigned`. Like the other
`github_issues_assigned`, `pull_requests`, `pull_requests_assigned`. Like the
other
`report` settings, `categories` can be set globally and overridden per
organisation (overrides merge key-by-key, so flipping one output leaves the
rest untouched). The
Expand Down Expand Up @@ -711,7 +712,7 @@ By default the report sorts its sections into three bands:
| ---- | -------- | --- |
| **Priority** | Secret Scanning, Dependabot: Malware Alerts, Dependabot: Security Alerts, CodeQL, Mutable Releases | Findings worth acting on today |
| **Middle** | Everything else | Neither urgent nor constant |
| **BAU** | OpenSSF Scorecard, GitHub Issues, Pull Requests, Assigned to Me | Carry data every run, so none of it is news |
| **BAU** | OpenSSF Scorecard, GitHub Issues, Issues: Assigned to Me, Pull Requests, Assigned to Me | Carry data every run, so none of it is news |

The bands are not fixed slots. **A band member with nothing to report moves
into the middle**: a clean priority category is noise at the top of a page, and
Expand Down Expand Up @@ -1210,6 +1211,35 @@ name the category as well: `--hide` outranks this toggle and cannot be
countermanded by it. The input is empty by default, so this only matters if you
have set it.

### Issues: Assigned to Me

The `github_issues_assigned` category does the same for issues: the GitHub
Issues table narrowed to the open issues assigned to the account the report ran
as, with the same columns. Everything above applies unchanged: "mine" follows
the token, not a configured name; a bot or App token leaves the section absent;
and it is **terminal-only by default**, so publishing it takes the same
deliberate opt-in:

```json
{
"report": {
"categories": {
"github_issues_assigned": { "outputs": { "html": true } }
}
},
"organizations": [{ "name": "lfreleng-actions" }]
}
```

The table reads the same window of each repository's oldest 25 open issues as
GitHub Issues, so an issue assigned to you beyond that window, in a repository
with a larger backlog, is not counted. Total is therefore the count of your
issues *within* the window rather than GitHub's exact total, and a repository
whose backlog exceeds the window with none of yours inside it counts as unknown
rather than clean. `Oldest` reads `unknown` when an issue the run could not read
precedes your oldest one in the window, since the unread issue may be older and
yours.

### Hiding a category for one run

`--hide <category>` (repeatable) suppresses a category on **every** output for
Expand All @@ -1228,9 +1258,10 @@ The Action exposes the same control through a `hide` input:
```

It is **empty by default**, and does not need setting to keep the personal
queue out of shared artifacts — `pull_requests_assigned` already defaults to the
terminal only, so Pages, Markdown and Slack never render it, and `report.json`
omits it for the same reason (see below). Leaving the input empty also keeps the
queues out of shared artifacts — `pull_requests_assigned` and
`github_issues_assigned` already default to the terminal only, so Pages,
Markdown and Slack never render them, and `report.json` omits them for the same
reason (see below). Leaving the input empty also keeps the
Action compatible with whichever published version the runtime pin installs: a
non-empty default would be passed to releases predating the flag, which reject
it outright.
Expand Down
1 change: 1 addition & 0 deletions src/github_security_report/categories/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class CategoryKey(str, Enum):
GITHUB_ISSUES = "github_issues"
PULL_REQUESTS = "pull_requests"
PULL_REQUESTS_ASSIGNED = "pull_requests_assigned"
GITHUB_ISSUES_ASSIGNED = "github_issues_assigned"


@dataclass(frozen=True)
Expand Down
23 changes: 23 additions & 0 deletions src/github_security_report/categories/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,4 +333,27 @@
"omits this table rather than reporting an empty one."
),
),
CategoryKey.GITHUB_ISSUES_ASSIGNED: CategoryMeta(
key=CategoryKey.GITHUB_ISSUES_ASSIGNED,
title="Issues: Assigned to Me",
pass_label="None assigned",
pass_all_label="Clean",
fail_label="With assigned issues",
url="https://docs.github.com/en/issues",
description=(
"The GitHub Issues table narrowed to the issues assigned to the "
"account this report ran as, so it changes with the token used "
"rather than with any configured name. Columns carry the same "
"meaning as the GitHub Issues table, but every count, Total "
"included, covers only the oldest open issues the run collects "
"per repository: an issue assigned to the account beyond that "
"window, in a repository with a larger backlog, is not counted, "
"and such a repository with none of the account's issues inside "
"the window counts as unknown rather than clean. "
"A repository with open issues but none assigned to the account "
"is clean rather than a row of zeros; a run that authenticated "
"as a bot or App has no personal queue at all, and omits this "
"table rather than reporting an empty one."
),
),
}
1 change: 1 addition & 0 deletions src/github_security_report/cli/serialise.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ def table(section: TableSection | None) -> dict | None:
if (entry := table(t)) is not None
],
"issues": table(org.issues),
"assigned_issues": table(org.assigned_issues),
"pull_requests": table(org.pull_requests),
"assigned_pull_requests": table(org.assigned_pull_requests),
}
31 changes: 17 additions & 14 deletions src/github_security_report/client/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,13 @@ def _issue_ref(node: object) -> IssueRef | None:
labels_truncated=labels_truncated,
created_at=_parse_iso(node.get("createdAt")),
author=_author_ref(node),
assignees=_assignee_logins(node),
)


def _issue_refs(issues: object) -> tuple[int | None, tuple[IssueRef, ...], bool]:
def _issue_refs(
issues: object,
) -> tuple[int | None, tuple[IssueRef, ...], int | None]:
"""Open-issue total and window from an ``issues`` connection node.

Returns the authoritative ``totalCount`` alongside the bounded window of
Expand All @@ -241,12 +244,13 @@ def _issue_refs(issues: object) -> tuple[int | None, tuple[IssueRef, ...], bool]
reading at all rather than a reading of zero, so it is preserved as
indeterminate too -- a repository whose nodes came back cannot be clean.

The third element reports whether the leading (oldest) node was dropped.
Skipping it silently would promote a newer issue to entry 0, whose age the
report would then present as the oldest -- plausible, and wrong.
The third element is how many parsed issues precede the first dropped node
(None when none was dropped). Skipping it silently would promote a newer
issue to the oldest of its kind, whose age the report would then present
as the oldest -- plausible, and wrong.
"""
if not isinstance(issues, dict):
return None, (), False
return None, (), None
raw_total = issues.get("totalCount")
# ``bool`` is an ``int`` subclass; a boolean here would be a schema
# violation, so reject it rather than silently counting it as 0/1.
Expand All @@ -256,17 +260,16 @@ def _issue_refs(issues: object) -> tuple[int | None, tuple[IssueRef, ...], bool]
else None
)
refs: list[IssueRef] = []
lead_unreadable = False
gap: int | None = None
for node in issues.get("nodes") or []:
ref = _issue_ref(node)
if ref is None:
# Only a gap *before* the first survivor changes which issue ends
# up at entry 0; a later gap costs detail, not the oldest-issue
# answer.
lead_unreadable = lead_unreadable or not refs
# Only the first gap matters: it bounds which survivors are known
# to be older than everything unread.
gap = len(refs) if gap is None else gap
continue
refs.append(ref)
return total, tuple(refs), lead_unreadable
return total, tuple(refs), gap


def _check_rollup_failed(node: dict) -> bool | None:
Expand Down Expand Up @@ -304,7 +307,7 @@ def _check_rollup_failed(node: dict) -> bool | None:


def _assignee_logins(node: dict) -> tuple[str, ...]:
"""Lower-cased logins a pull request is assigned to.
"""Lower-cased logins an issue or pull request is assigned to.

Lower-cased at the boundary so the "is this mine?" comparison never has to
worry about the casing GitHub happened to return for either side.
Expand Down Expand Up @@ -403,7 +406,7 @@ def _parse_repo_node(node: dict) -> RepoGraphData:
candidates = list(window)
if latest is not None and all(r.tag != latest.tag for r in candidates):
candidates.append(latest)
open_issues, issues, oldest_unreadable = _issue_refs(node.get("issues"))
open_issues, issues, issue_gap = _issue_refs(node.get("issues"))
open_pulls, pulls = _pull_request_refs(node.get("pullRequests"))
head_target = (node.get("defaultBranchRef") or {}).get("target") or {}
return RepoGraphData(
Expand All @@ -418,7 +421,7 @@ def _parse_repo_node(node: dict) -> RepoGraphData:
dependabot_config=config_text,
open_issues=open_issues,
issues=issues,
oldest_issue_unreadable=oldest_unreadable,
issue_gap=issue_gap,
open_pull_requests=open_pulls,
pull_requests=pulls,
)
8 changes: 6 additions & 2 deletions src/github_security_report/client/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,15 @@
# five-repository batch, adding this connection together with the head commit's
# check rollup and the assignee list moved the query cost from 1 point to 4, and
# adds no extra HTTP
# request at all, since it rides a query the run already makes.
# request at all, since it rides a query the run already makes. The issues'
# assignee list, for the Issues: Assigned to Me table, adds one connection per
# collected issue: 25 per repository, or 125 for a five-repository batch, which
# GitHub's formula (connection requests / 100) puts at about one more point.
_PULL_REQUEST_WINDOW = 25
# GitHub caps an issue or pull request at 10 assignees, so this window is
# exhaustive by construction: unlike the issue-label window, it can never
# truncate, and the assignment breakdown is therefore exact for every collected
# pull request.
# issue and pull request.
_ASSIGNEE_WINDOW = 10
# Opinionated reviews inspected per pull request, to tell whose request for
# changes is outstanding. ``reviewDecision`` says *that* changes are requested
Expand Down Expand Up @@ -199,6 +202,7 @@
authorAssociation
author {{ __typename login }}
labels(first: {_ISSUE_LABEL_WINDOW}) {{ totalCount nodes {{ name }} }}
assignees(first: {_ASSIGNEE_WINDOW}) {{ nodes {{ login }} }}
}}
}}
pullRequests(states: OPEN, first: {_PULL_REQUEST_WINDOW},
Expand Down
2 changes: 1 addition & 1 deletion src/github_security_report/collect/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
class CollectExtras:
"""What an org-mode run collects beyond the signals and posture tables.

``author_tables`` off skips the Issues, Pull Requests and Assigned to Me
``author_tables`` off skips the Issues, Pull Requests and both Assigned to Me
tables and the membership and viewer reads behind them; remediation acts
on none of them. ``run_store`` is the workflow-run cache the Workflow
Failures tables sync against, or ``None`` to leave them uncollected;
Expand Down
23 changes: 18 additions & 5 deletions src/github_security_report/collect/extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
)
from github_security_report.collect.workflow_runs import attach_workflow_failures
from github_security_report.config import OrgConfig, ReportConfig
from github_security_report.issues import build_issues_table
from github_security_report.issues import (
build_assigned_issues_table,
build_issues_table,
)
from github_security_report.models import Repo, SignalType
from github_security_report.ordering import (
apply_configured_order,
Expand Down Expand Up @@ -125,7 +128,7 @@ async def _attach_author_tables(
ctx: OrgCollectContext,
report_cfg: ReportConfig,
) -> None:
"""Attach Issues, Pull Requests and Assigned to Me, and their identity reads."""
"""Attach Issues, Pull Requests, both Assigned to Me tables and their reads."""
# Organisation membership is collected once and reused by both author-aware
# tables, so classifying contributions costs one query rather than a probe
# per author. It is the token-independent basis for "outside the
Expand Down Expand Up @@ -160,11 +163,21 @@ async def _attach_author_tables(
error_threshold=report_cfg.dependabot_error_threshold,
viewer=viewer,
)
# A personal review queue needs a person. Without one the table would be
# A personal queue needs a person. Without one both tables would be
# unconditionally empty, and an "Assigned to Me" section reporting every
# repository clean reassures the reader about an inbox that does not exist,
# so the category is left uncollected and no surface renders it.
# so both categories are left uncollected and no surface renders them.
if viewer:
report.assigned_issues = build_assigned_issues_table(
ctx.graph,
in_scope,
generated_at=report.generated_at,
label_columns=report_cfg.issue_labels,
age_warn_days=report_cfg.issue_age_warn_days,
age_error_days=report_cfg.issue_age_error_days,
members=members,
viewer=viewer,
)
report.assigned_pull_requests = build_assigned_pull_requests_table(
ctx.graph,
in_scope,
Expand All @@ -187,7 +200,7 @@ async def attach_extra_tables(
"""Probe posture for every repository and attach the derived tables.

``extras`` (see :class:`CollectExtras`) switches the author tables off,
which leaves the Issues, Pull Requests and Assigned to Me tables
which leaves the Issues, Pull Requests and both Assigned to Me tables
uncollected and with them the membership and viewer reads they alone
need, and names the run store the Workflow Failures tables sync against.
"""
Expand Down
15 changes: 9 additions & 6 deletions src/github_security_report/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,12 @@ def shows_on(self, output: str) -> bool:
return self.enabled and getattr(self.outputs, output)


# Surfaces a personal, reader-specific category belongs on. "Assigned to Me"
# is one account's review queue -- whichever account the run authenticated as --
# so it belongs on that reader's own terminal and nowhere that is published. A
# Pages site, a Markdown artifact and a Slack digest are all read by the whole
# organisation, where one person's inbox is at best noise and at worst a
# statement about an individual's workload.
# Surfaces a personal, reader-specific category belongs on. The two "Assigned to
# Me" tables are one account's queue -- whichever account the run authenticated
# as -- so they belong on that reader's own terminal and nowhere that is
# published. A Pages site, a Markdown artifact and a Slack digest are all read
# by the whole organisation, where one person's inbox is at best noise and at
# worst a statement about an individual's workload.
_TERMINAL_ONLY = OutputToggles(cli=True, slack=False, markdown=False, html=False)

# Surfaces for a table too wide and too long for a chat digest: the per-workflow
Expand All @@ -139,6 +139,9 @@ def shows_on(self, output: str) -> bool:
CategoryKey.PULL_REQUESTS_ASSIGNED.value: CategoryToggle(
outputs=_TERMINAL_ONLY
),
CategoryKey.GITHUB_ISSUES_ASSIGNED.value: CategoryToggle(
outputs=_TERMINAL_ONLY
),
CategoryKey.WORKFLOW_FAILURES_DETAIL.value: CategoryToggle(
enabled=False, outputs=_NO_SLACK
),
Expand Down
5 changes: 3 additions & 2 deletions src/github_security_report/config/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@ class OrderStyle(str, Enum):
# Business as usual: categories that carry data on essentially every run. A
# healthy organisation still has open issues, open pull requests and a Scorecard
# score, so none of them is news, and leading with them buries whatever is.
# "Assigned to Me" follows Pull Requests because it is the same backlog
# narrowed to one reader.
# "Assigned to Me" follows Pull Requests, and "Issues: Assigned to Me" follows
# GitHub Issues, because each is the same backlog narrowed to one reader.
DEFAULT_BAU: tuple[CategoryKey, ...] = (
CategoryKey.SCORECARD,
CategoryKey.GITHUB_ISSUES,
CategoryKey.GITHUB_ISSUES_ASSIGNED,
CategoryKey.PULL_REQUESTS,
CategoryKey.PULL_REQUESTS_ASSIGNED,
)
Expand Down
Loading
Loading