Skip to content

Commit 9ed7a7e

Browse files
feat(cli): add pr-eligibility command — BP008+AM010 escript wiring (#379)
## Summary - Adds `lib/mix/tasks/hypatia.pr_eligibility.ex`: new Mix task `mix hypatia.pr_eligibility --owner X --repo Y --pr N`. - Wires `hypatia pr-eligibility` into the escript CLI (`Hypatia.CLI.main/1`) so external consumers (budget-resume-sweep, sustainabot) can call it as a subprocess. - Output: `{"eligible":true|false,"reason":"AM010"|null,"phantom_contexts":[...],"required_contexts":[...]}` on stdout, exit 0 always (eligibility result is in the JSON). ## Implementation details 1. Fetches required contexts via `gh api repos/{owner}/{repo}/branches/main/protection/required_status_checks` 2. Calls `BranchProtection.bp008_phantom_required_context/2` for the phantom set 3. Fetches PR `statusCheckRollup` via GraphQL (`CheckRun` + `StatusContext` nodes, first 100) 4. Calls `AdminMergeEligibility.am010_phantom_only_blocker?/1` 5. ALARP type-1 mitigation: `IN_PROGRESS`/`PENDING` rollup entries map to non-nil conclusion, preventing phantom-set inclusion for actively-running checks ## Test plan - [ ] `mix hypatia.pr_eligibility --owner hyperpolymath --repo affinescript --pr 429` — should return `eligible: true, reason: "AM010"` (affinescript has the `spark-theatre-gate` phantom) - [ ] Against a PR with all checks passing — should return `eligible: false` - [ ] Against a PR with a real failing check — should return `eligible: false` even if phantom contexts exist - [ ] Escript build (`mix escript.build`) completes without errors - [ ] `hypatia help` shows the `pr-eligibility` command ## Companion PRs - hyperpolymath/.git-private-farm#25 — `budget-resume-sweep.yml` dispatch template (calls this command) - hyperpolymath/gitbot-fleet#223 — sustainabot architecture note 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 524f754 commit 9ed7a7e

2 files changed

Lines changed: 296 additions & 9 deletions

File tree

lib/hypatia/cli.ex

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ defmodule Hypatia.CLI do
1212
1313
## Commands
1414
15-
hypatia scan <path> Scan a repository for security and policy issues
16-
hypatia report <path> Generate a detailed report with remediation advice
17-
hypatia version Print version and exit
18-
hypatia help Print usage and exit
15+
hypatia scan <path> Scan a repository for security and policy issues
16+
hypatia report <path> Generate a detailed report with remediation advice
17+
hypatia pr-eligibility --owner X Query AM010 admin-merge eligibility for a PR
18+
--repo Y
19+
--pr N
20+
hypatia version Print version and exit
21+
hypatia help Print usage and exit
1922
2023
## Options
2124
@@ -92,7 +95,11 @@ defmodule Hypatia.CLI do
9295
path: :string,
9396
help: :boolean,
9497
version: :boolean,
95-
exit_zero: :boolean
98+
exit_zero: :boolean,
99+
# pr-eligibility flags
100+
owner: :string,
101+
repo: :string,
102+
pr: :integer
96103
],
97104
aliases: [
98105
r: :rules,
@@ -126,6 +133,9 @@ defmodule Hypatia.CLI do
126133
path = config.path || List.first(rest) || "."
127134
run_report(path, config)
128135

136+
["pr-eligibility" | _] ->
137+
run_pr_eligibility(opts[:owner], opts[:repo], opts[:pr])
138+
129139
["version"] ->
130140
IO.puts("hypatia #{@version}")
131141

@@ -218,6 +228,21 @@ defmodule Hypatia.CLI do
218228
end
219229
end
220230

231+
# ─── PR eligibility command (AM010 / BP008) ──────────────────────────
232+
233+
@doc false
234+
def run_pr_eligibility(owner, repo, pr_number)
235+
when is_binary(owner) and is_binary(repo) and is_integer(pr_number) do
236+
result = Mix.Tasks.Hypatia.PrEligibility.check_eligibility(owner, repo, pr_number)
237+
IO.puts(Jason.encode!(result, pretty: false))
238+
end
239+
240+
def run_pr_eligibility(_, _, _) do
241+
IO.puts(:stderr, "Error: pr-eligibility requires --owner, --repo, and --pr.")
242+
IO.puts(:stderr, "Usage: hypatia pr-eligibility --owner OWNER --repo REPO --pr NUMBER")
243+
System.halt(2)
244+
end
245+
221246
# ─── Diagnostic summary ──────────────────────────────────────────────
222247
#
223248
# Always emit a single-line summary on stderr after a scan/report so CI
@@ -1145,10 +1170,18 @@ defmodule Hypatia.CLI do
11451170
hypatia <command> [options]
11461171
11471172
COMMANDS:
1148-
scan <path> Scan directory for security and policy issues
1149-
report <path> Generate detailed report with remediation advice
1150-
version Show version
1151-
help Show this help
1173+
scan <path> Scan directory for security and policy issues
1174+
report <path> Generate detailed report with remediation advice
1175+
pr-eligibility Query AM010 admin-merge eligibility for a PR
1176+
--owner OWNER GitHub owner (org or user)
1177+
--repo REPO Repository name
1178+
--pr NUMBER PR number
1179+
version Show version
1180+
help Show this help
1181+
1182+
pr-eligibility OUTPUT (JSON on stdout):
1183+
{"eligible":true|false,"reason":"AM010"|null,
1184+
"phantom_contexts":[...],"required_contexts":[...]}
11521185
11531186
OPTIONS:
11541187
--rules, -r <list> Comma-separated rule modules (default: all)
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
defmodule Mix.Tasks.Hypatia.PrEligibility do
3+
@moduledoc """
4+
Query AM010 admin-merge eligibility for a specific pull request.
5+
6+
Uses BP008 phantom-context detection and the PR's status-check rollup to
7+
determine whether the PR is blocked only by phantom required contexts —
8+
contexts that are configured in branch protection but never emit a
9+
check-run. Such PRs are safe to admin-merge (rule AM010).
10+
11+
## Usage
12+
13+
mix hypatia.pr_eligibility --owner OWNER --repo REPO --pr NUMBER
14+
15+
## Output
16+
17+
JSON on stdout:
18+
19+
{
20+
"eligible": true | false,
21+
"reason": "AM010" | null,
22+
"phantom_contexts": [...],
23+
"required_contexts": [...]
24+
}
25+
26+
Exit code 0 regardless of eligibility result. Exit code 2 on argument
27+
or API error.
28+
29+
## Environment
30+
31+
GITHUB_TOKEN Required. Must have repo read + statuses read scope.
32+
HYPATIA_DISPATCH_PAT Alternative token (used if GITHUB_TOKEN unset).
33+
34+
## CLI escript variant
35+
36+
When invoked as the escript entry-point via `hypatia pr-eligibility`,
37+
the same options are accepted:
38+
39+
hypatia pr-eligibility --owner OWNER --repo REPO --pr NUMBER
40+
41+
See `Hypatia.CLI.run_pr_eligibility/3` which delegates here.
42+
"""
43+
44+
use Mix.Task
45+
46+
alias Hypatia.Rules.BranchProtection
47+
alias Hypatia.Rules.AdminMergeEligibility
48+
49+
@shortdoc "Query AM010 admin-merge eligibility for a PR (BP008 phantom check)"
50+
51+
@impl true
52+
def run(argv) do
53+
{opts, _rest, _invalid} =
54+
OptionParser.parse(argv,
55+
switches: [owner: :string, repo: :string, pr: :integer]
56+
)
57+
58+
owner = opts[:owner]
59+
repo = opts[:repo]
60+
pr_number = opts[:pr]
61+
62+
unless owner && repo && pr_number do
63+
Mix.raise(
64+
"Usage: mix hypatia.pr_eligibility --owner OWNER --repo REPO --pr NUMBER"
65+
)
66+
end
67+
68+
result = check_eligibility(owner, repo, pr_number)
69+
IO.puts(Jason.encode!(result, pretty: false))
70+
end
71+
72+
@doc """
73+
Core eligibility logic. Returns a map suitable for JSON output.
74+
75+
Called both from the Mix task and from `Hypatia.CLI` when the escript
76+
receives `pr-eligibility` as the command.
77+
"""
78+
@spec check_eligibility(String.t(), String.t(), pos_integer()) :: map()
79+
def check_eligibility(owner, repo, pr_number) do
80+
# Step 1: Fetch required status-check contexts from branch protection.
81+
required_contexts = fetch_required_contexts(owner, repo)
82+
83+
# Step 2: Run BP008 to find phantom contexts (required but never emitting).
84+
phantom_findings = BranchProtection.bp008_phantom_required_context(owner, repo)
85+
phantom_contexts =
86+
phantom_findings
87+
|> Enum.map(fn finding -> get_in(finding, [:detail, :phantom_context]) end)
88+
|> Enum.reject(&is_nil/1)
89+
90+
# Step 3: Fetch this PR's status-check rollup via GraphQL.
91+
rollup = fetch_pr_rollup(owner, repo, pr_number)
92+
93+
# Step 4: Call AM010 state checker.
94+
pr_state = %{
95+
required_contexts: required_contexts,
96+
phantom_contexts: phantom_contexts,
97+
rollup: rollup
98+
}
99+
100+
case AdminMergeEligibility.am010_phantom_only_blocker?(pr_state) do
101+
{:eligible, "AM010"} ->
102+
%{
103+
eligible: true,
104+
reason: "AM010",
105+
phantom_contexts: phantom_contexts,
106+
required_contexts: required_contexts
107+
}
108+
109+
_ ->
110+
%{
111+
eligible: false,
112+
reason: nil,
113+
phantom_contexts: phantom_contexts,
114+
required_contexts: required_contexts
115+
}
116+
end
117+
end
118+
119+
# ─── GitHub API helpers ───────────────────────────────────────────────
120+
121+
defp fetch_required_contexts(owner, repo) do
122+
case System.cmd(
123+
"gh",
124+
[
125+
"api",
126+
"repos/#{owner}/#{repo}/branches/main/protection/required_status_checks",
127+
"--jq",
128+
".contexts // (.checks // [] | map(.context)) | unique"
129+
],
130+
stderr_to_stdout: true
131+
) do
132+
{out, 0} ->
133+
case Jason.decode(out) do
134+
{:ok, list} when is_list(list) -> Enum.filter(list, &is_binary/1)
135+
_ -> []
136+
end
137+
138+
{err, _} ->
139+
IO.puts(:stderr, "Warning: could not fetch required status checks: #{String.trim(err)}")
140+
[]
141+
end
142+
end
143+
144+
defp fetch_pr_rollup(owner, repo, pr_number) do
145+
# GraphQL query for statusCheckRollup. We want each context's name and
146+
# conclusion so AM010 can classify passing vs. failing vs. absent entries.
147+
query = """
148+
query($owner: String!, $repo: String!, $number: Int!) {
149+
repository(owner: $owner, name: $repo) {
150+
pullRequest(number: $number) {
151+
commits(last: 1) {
152+
nodes {
153+
commit {
154+
statusCheckRollup {
155+
contexts(first: 100) {
156+
nodes {
157+
... on CheckRun {
158+
name
159+
conclusion
160+
status
161+
}
162+
... on StatusContext {
163+
context
164+
state
165+
}
166+
}
167+
}
168+
}
169+
}
170+
}
171+
}
172+
}
173+
}
174+
}
175+
"""
176+
177+
case System.cmd(
178+
"gh",
179+
[
180+
"api",
181+
"graphql",
182+
"--field",
183+
"owner=#{owner}",
184+
"--field",
185+
"repo=#{repo}",
186+
"--field",
187+
"number=#{pr_number}",
188+
"--field",
189+
"query=#{query}"
190+
],
191+
stderr_to_stdout: true
192+
) do
193+
{out, 0} ->
194+
case Jason.decode(out) do
195+
{:ok, data} ->
196+
nodes =
197+
get_in(data, [
198+
"data",
199+
"repository",
200+
"pullRequest",
201+
"commits",
202+
"nodes"
203+
]) || []
204+
205+
rollup_nodes =
206+
nodes
207+
|> List.last(%{})
208+
|> get_in(["commit", "statusCheckRollup", "contexts", "nodes"]) || []
209+
210+
# Normalise both CheckRun and StatusContext shapes into the map
211+
# form expected by AM010: %{"name" => name, "conclusion" => conclusion}.
212+
Enum.flat_map(rollup_nodes, fn node ->
213+
cond do
214+
is_binary(Map.get(node, "name")) ->
215+
# CheckRun node: map status → conclusion for in-progress entries
216+
conc =
217+
Map.get(node, "conclusion") ||
218+
status_to_conclusion(Map.get(node, "status"))
219+
220+
[%{"name" => Map.get(node, "name"), "conclusion" => conc}]
221+
222+
is_binary(Map.get(node, "context")) ->
223+
# StatusContext node: map state → conclusion
224+
conc = state_to_conclusion(Map.get(node, "state"))
225+
[%{"name" => Map.get(node, "context"), "conclusion" => conc}]
226+
227+
true ->
228+
[]
229+
end
230+
end)
231+
232+
_ ->
233+
[]
234+
end
235+
236+
{err, _} ->
237+
IO.puts(:stderr, "Warning: could not fetch PR rollup: #{String.trim(err)}")
238+
[]
239+
end
240+
end
241+
242+
# Map GraphQL CheckRun status → conclusion-equivalent string.
243+
# IN_PROGRESS / QUEUED / WAITING → nil (non-terminal; AM010 ALARP mitigation
244+
# counts these as present-in-rollup so they are NOT treated as absent phantoms).
245+
defp status_to_conclusion("COMPLETED"), do: nil
246+
defp status_to_conclusion(_), do: "IN_PROGRESS"
247+
248+
# Map StatusContext state → conclusion-equivalent string.
249+
defp state_to_conclusion("SUCCESS"), do: "SUCCESS"
250+
defp state_to_conclusion("FAILURE"), do: "FAILURE"
251+
defp state_to_conclusion("ERROR"), do: "FAILURE"
252+
defp state_to_conclusion("PENDING"), do: "IN_PROGRESS"
253+
defp state_to_conclusion(_), do: nil
254+
end

0 commit comments

Comments
 (0)