Skip to content

Commit 2cbd9bb

Browse files
authored
Merge pull request #182 from netresearch/fix/pr-status-gate-path
fix(hooks): recommend pr-status.sh by its resolved absolute path
2 parents 1e82c93 + 642c49a commit 2cbd9bb

2 files changed

Lines changed: 63 additions & 3 deletions

File tree

scripts/validate_git_command.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,29 @@ def reply_path_without_pr(cmd: str) -> str | None:
198198
return None
199199

200200

201+
# The plugin's scripts are not on PATH: recommending a bare `pr-status.sh`
202+
# sends the reader into `command not found` (exit 127) and a hunt through the
203+
# plugin cache before the gate's advice becomes followable (2026-08-13). The
204+
# script ships in this plugin, so the message can name the invocable path;
205+
# the bare name stays as fallback for layouts where the sibling is absent.
206+
_PR_STATUS_SH = os.path.join(
207+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
208+
"skills",
209+
"git-workflow",
210+
"scripts",
211+
"pr-status.sh",
212+
)
213+
PR_STATUS = _PR_STATUS_SH if os.path.isfile(_PR_STATUS_SH) else "pr-status.sh"
214+
215+
201216
def handrolled_pr_poll(cmd: str) -> str | None:
202217
if "--watch" in cmd or not POLLS_PR.search(cmd):
203218
return None
204219
if not (POLL_LOOP.search(cmd) or FOR_LOOP_POLL.search(cmd)):
205220
return None
206221
return (
207222
"Hand-rolled poll over pull-request state. Use "
208-
"`pr-status.sh -R <owner/repo> <pr> --watch` instead: it returns at the "
223+
f"`{PR_STATUS} -R <owner/repo> <pr> --watch` instead: it returns at the "
209224
"FIRST actionable event — a check that failed, a review that arrived, a "
210225
"thread that needs an answer — where a loop written here waits for the "
211226
"one outcome it was told about and sleeps through the rest. A loop that "
@@ -243,13 +258,13 @@ def merge_readiness_without_pr_status(cmd: str) -> str | None:
243258
if QUERIES_FORGE.match(segment) and MERGE_READINESS_FIELD.search(segment):
244259
return (
245260
"Merge readiness asked of gh directly. Use "
246-
"`pr-status.sh -R <owner/repo> <pr>` instead: it reports checks, "
261+
f"`{PR_STATUS} -R <owner/repo> <pr>` instead: it reports checks, "
247262
"reviews, rulesets and unresolved threads together and ends with "
248263
"a NEXT: line naming the action. mergeable_state and "
249264
"mergeStateStatus answer only 'blocked' and never say why, so an "
250265
"unresolved thread or a red check stays invisible and the pull "
251266
"request looks like it is merely waiting. To find out which "
252-
"required context is unmet, run pr-status.sh first, then the "
267+
f"required context is unmet, run {PR_STATUS} first, then the "
253268
"GraphQL query with `isRequired` — that one is not gated."
254269
)
255270
return None

tests/test_validate_git_command.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import json
1212
import os
13+
import re
1314
import subprocess
1415
import sys
1516
import tempfile
@@ -136,6 +137,33 @@ def fifo_case() -> bool:
136137
os.rmdir(tmp)
137138

138139

140+
def pr_status_path_case(command: str) -> bool:
141+
"""The deny must name an invocable pr-status.sh, not a bare command name.
142+
143+
The plugin's scripts are not on PATH: a bare `pr-status.sh` produced
144+
`command not found` (exit 127) and a hunt through the plugin cache
145+
(2026-08-13). The recommendation must carry the absolute path of the
146+
script shipped in this plugin, and that path must exist. Both gates
147+
that recommend the script are exercised, so a later message edit
148+
cannot drop the interpolation from one of them unnoticed.
149+
"""
150+
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": command}})
151+
proc = subprocess.run(
152+
[sys.executable, HOOK],
153+
input=payload,
154+
capture_output=True,
155+
text=True,
156+
timeout=10,
157+
check=False,
158+
)
159+
out = proc.stdout.strip()
160+
if not out.startswith("{"):
161+
return False
162+
reason = json.loads(out)["hookSpecificOutput"].get("permissionDecisionReason", "")
163+
match = re.search(r"`(/[^`\s]+/pr-status\.sh)", reason)
164+
return bool(match and os.path.isfile(match.group(1)))
165+
166+
139167
def main() -> int:
140168
fails = 0
141169
for name, expected, command in CASES:
@@ -151,6 +179,23 @@ def main() -> int:
151179
f"got={'no-hang' if ok else 'HUNG'}"
152180
)
153181

182+
for name, command in [
183+
(
184+
"merge-readiness deny names pr-status.sh path",
185+
"gh pr view 6651 --json mergeStateStatus",
186+
),
187+
(
188+
"poll deny names pr-status.sh path",
189+
"until [ x = y ]; do gh pr view 1 --json state; sleep 30; done",
190+
),
191+
]:
192+
ok = pr_status_path_case(command)
193+
fails += 0 if ok else 1
194+
print(
195+
f" {'OK ' if ok else 'FAIL'} {name:<44} "
196+
f"want=abs-path got={'abs-path' if ok else 'bare-name'}"
197+
)
198+
154199
print(f" ---- failures: {fails}")
155200
return 1 if fails else 0
156201

0 commit comments

Comments
 (0)