scripts/discover_remediation_units.py is a read-only pre-flight pass, run
before any remediation. It:
- enumerates every open security alert in a profile's scope
- groups alerts into remediation units, keyed by the operating-model dedup key (see operating-model.md)
- assigns each unit a static
triage_status+triage_reason - surfaces profile coverage gaps (unlisted, archived, or missing repos)
It emits one JSON artifact that a remediation agent can consume as a
deterministic work list instead of doing LLM-driven discovery across every
repository, and that doubles as an audit trail. It supports both org and
user profiles.
The script never runs verification commands, queries CI/check status,
predicts merged/opened_pr outcomes, or mutates the profile. Merge-vs-PR
is a runtime review-gate decision, out of scope here. Wiring this script into
the weekly Codex automation prompt is a separate follow-up.
Fetch strategy is driven by profile.owner_type.
org— one paginated call per alert class to the org endpoint (/orgs/{owner}/{alert_class}/alerts). The result is not pre-filtered to profile repos:build_unitskeeps only in-scope repos, while the coverage sweep reuses the full set to detectunlisted_repositorygaps.- Org endpoint failure is a hard failure (non-zero exit, not a recorded issue) — a failed org call makes the whole discovery fundamentally incomplete.
user— the per-owner alert endpoint 404s for user accounts, so discovery iterates the full repo inventory (all non-archived repos, listed and unlisted in the profile) and calls the per-repo endpoint (/repos/{owner}/{repo}/{alert_class}/alerts) once per repo per alert class. This also makes unlisted user repos visible to the coverage sweep.- A non-2xx per-repo HTTP response (403/404 may mean a disabled
feature, an auth-scope gap, private-repo visibility, or endpoint
absence) does not crash the run. It is recorded as a
fetch_issuesentry —{repository, alert_class, http_status, detail}, withdetailthe shortghAPI error message — and counted insummary.fetch_issues. A repo with fetch issues is incomplete, not clean: consumers must treat its unit list as partial. fetch_issuesentries are HTTP-level failures only. Aghtransport failure for a per-repo call (no(HTTP n)status ingh's stderr — e.g. network unreachable, DNS failure) is a hard failure, same as an org endpoint failure: it aborts the run rather than being recorded as a fetch issue, for user profiles too.
- A non-2xx per-repo HTTP response (403/404 may mean a disabled
feature, an auth-scope gap, private-repo visibility, or endpoint
absence) does not crash the run. It is recorded as a
The repo inventory
(gh repo list <owner> --json name,isArchived,defaultBranchRef --limit 1000)
is fetched once per run and reused for both alert iteration (user) and the
coverage sweep (both owner types).
Base key: owner/repo|alert_class|base_branch|target_id
code_scanning— append the normalizedrule_id, so different allowlisted rules in one target do not share a key.secret_scanning— append the GitHub alert number, so distinct secret alerts in one target stay isolated.dependabot— base key only; multiple advisories against one target nest as alerts of a single unit.
Resolved before key composition (many profile entries use
default_base_branch: auto), in order — first non-auto, non-empty value
wins:
- the repository entry's own
default_base_branch - the profile's
defaults.default_base_branch - the repository's
defaultBranchReffrom the coverage-sweep inventory unknown, if none of the above resolve (defensive fallback; in practice repos with no GitHub default branch —profile_repository_missing_remote— also return no alerts, so no unit is ever built with this fallback)
The alert's file path is matched against each enabled target's path,
choosing the longest (most specific) match; the root target (path: "." or
"") is the fallback for repository-level alerts or when no target path
matches. secret_scanning uses a simplified mapping — see
Secret-Scanning Target Mapping. Paths are
used internally for mapping only — see Sanitization.
{
"generated_at": "2026-07-07T...Z",
"schema_version": 1,
"owner": "actiobio",
"owner_type": "org",
"profile_id": "actiobio-org",
"summary": {
"units_total": 0,
"alerts_total": 0,
"by_status": {"actionable": 0, "manual_only": 0, "ignored": 0, "unsupported": 0},
"by_alert_class": {"dependabot": 0, "code_scanning": 0, "secret_scanning": 0},
"coverage_gaps": {
"unlisted_with_alerts": 0,
"unlisted_no_alerts": 0,
"archived_in_profile": 0,
"profile_repository_missing_remote": 0
},
"fetch_issues": 0
},
"remediation_units": [
{
"remediation_key": "actiobio/actio-neo4j|dependabot|main|analysis",
"repository": "actio-neo4j",
"alert_class": "dependabot",
"base_branch": "main",
"target_id": "analysis",
"ecosystem": "pip",
"triage_status": "actionable",
"triage_reason": null,
"alert_count": 1,
"alerts": [
{"number": 29, "advisory_ids": ["GHSA-rrmf-rvhw-rf47"], "package": "torch", "severity": "high"}
]
}
],
"coverage_gaps": [
{"repository": "new-repo", "gap": "unlisted_repository", "open_alert_count": 2},
{"repository": "renamed-repo", "gap": "profile_repository_missing_remote"},
{"repository": "old-repo", "gap": "archived_in_profile"}
],
"fetch_issues": [
{"repository": "private-repo", "alert_class": "code_scanning", "http_status": 403, "detail": "Code security must be enabled for this repository to use code scanning."}
]
}- For
dependabotunits,ecosystemis GitHub'sdependency.package.ecosystemtaken from the unit's first alert (nullfor other alert classes) — it is not the profile-declaredecosystemslist on the target. summary.by_statusandsummary.by_alert_classcount remediation units, not alerts;summary.alerts_totalcounts alerts.owneris always emitted lowercased.- Records are sorted deterministically so reruns produce stable diffs:
remediation_unitsbyremediation_key;coverage_gapsbyrepository(case-insensitive), thengap;fetch_issuesbyrepository, thenalert_class. - The script writes JSON with
sort_keys=True, so real output has alphabetized object keys at every level — the ordering above is illustrative, not literal.
Four triage_status values. Discovery states what is worth attempting and
why the rest is not; the runtime review gate still decides merge-vs-PR.
triage_status |
Meaning |
|---|---|
ignored |
repo automation_mode: ignored |
manual_only |
repo automation_mode: manual_only, or any repo listed in defaults.protected_manual_repositories regardless of its automation_mode |
unsupported |
a static reason blocks automation for this unit |
actionable |
active repo, enabled target, supported ecosystem/rule, verification commands present |
secret_scanning units are actionable — the agent still opens a
review-required PR; that review gate is not discovery's concern.
triage_reason is null for actionable and otherwise one of a closed
vocabulary, evaluated in this order (first match wins):
ignored_repository— repoautomation_modeisignoredprotected_manual_repository— repo name is indefaults.protected_manual_repositories(checked ahead ofautomation_mode, so it overridesactive)manual_only_repository— repoautomation_modeismanual_onlyand the repo is not protected-manualtarget_alert_class_disabled— no enabled target owns this alert: either no target in the repo entry lists this alert class inalert_classes, or no class-enabled target's path matches the alert's pathunsupported_ecosystem—dependabotalert but the mapped target declares noecosystemsunsupported_rule—code_scanningrule_idnot indefaults.code_scanning.allowlisted_rulesverification_unavailable— mapped target has noverification_commands(defensive; the profile linter should already prevent this)
Reason codes 5 and 6 apply only to their respective alert class; a
code_scanning alert is never checked against unsupported_ecosystem, and a
dependabot alert is never checked against unsupported_rule.
Values that coincide with operating-model outcome reason codes
(unsupported_ecosystem, unsupported_rule, verification_unavailable) are
reused intentionally; the triage_reason field name keeps them distinct from
runtime outcome reason codes.
The coverage sweep fetches all repos, without pre-filtering archived
repos (so archived_in_profile stays detectable):
gh repo list <owner> --json name,isArchived,defaultBranchRef --limit 1000
That inventory is diffed against the profile into three gap kinds:
unlisted_repository— present on GitHub (non-archived), absent from the profile. Cross-referenced against fetched alerts:open_alert_countis included on every entry, and rolls up insummary.coverage_gapsasunlisted_with_alerts(count > 0) orunlisted_no_alerts(count 0).archived_in_profile— repo entry isactiveormanual_onlyin the profile but archived on GitHub (the operating model requires archived repos be excluded). A profile entry withautomation_mode: ignoredthat is archived on GitHub does not produce this gap — it is already excluded from automation.profile_repository_missing_remote— repo entry is in the profile but absent from the GitHub repo list (renamed or deleted); flag for profile cleanup. These repos have no GitHub default branch, but they also return no alerts, so no remediation units are built for them (see Base-branch resolution ladder).
Gaps are sorted by repository (case-insensitive), then gap.
Alert records carry only what dedup and prioritization need — never raw payloads, secret values, secret locations, or file paths:
dependabot:number,advisory_ids(GHSA ids only),package(name),severity. No advisory description text.code_scanning:number,rule_id,severity(security_severity_level, falling back toseverity). The alert's file path is used internally for target mapping but never emitted.secret_scanning:numberonly. No secret type, value, or location. The alert's file path is used internally for target mapping but never emitted.
This mirrors the weekly-report sanitization contract: no raw alert payloads, secret values, or raw secret types in output.
Documented simplification: discovery does not fetch per-alert secret
locations (locations_url), so it cannot map a secret alert to a target by
path the way it does for dependabot and code_scanning.
Instead, among the targets on the alert's repository that enable
secret_scanning:
- prefer the root-path target (
pathis""or".") - otherwise, fall back to the first enabled target in profile order
This is coarser than the path-based mapping used for the other two alert
classes and is a known approximation when a repository enables
secret_scanning on more than one non-root target.