Add shared read-only gh CLI helper for maintenance scripts#1162
Add shared read-only gh CLI helper for maintenance scripts#1162yanyishuai wants to merge 1 commit into
Conversation
|
Warning Review limit reached
Next review available in: 48 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughAdds reusable helpers for read-only GitHub CLI execution, public JSON retrieval, and argparse source validation, with focused pytest coverage for CLI and HTTP behavior. ChangesGitHub CLI execution helpers
Public JSON fetching
Source argument validation
Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 4❌ Failed checks (4 warnings)
✅ Passed checks (2 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 68a5ae58-d37a-4cf6-88f1-2b438ee1f62a
📒 Files selected for processing (6)
AGENTS.mdscripts/check_bounty_issue_states.pyscripts/check_live_bounty_closing_refs.pyscripts/gh_cli.pyscripts/pr_queue_health.pytests/test_gh_cli.py
| ./.venv/bin/python -m pytest | ||
| ./.venv/bin/python -m ruff format --check . | ||
| ./.venv/bin/python -m ruff check . | ||
| ./.venv/bin/python -m mypy app | ||
| ./.venv/bin/python scripts/template_text_smoke.py --render | ||
| ./.venv/bin/python -m pytest tests/test_gh_cli.py -q |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Consolidate or clarify the dual pytest invocations.
Line 38 already runs the full test suite, which would include tests/test_gh_cli.py. Line 43 repeats just that file with -q, making the check list slower without clear benefit. Either remove line 43 if the full suite covers it, or add a brief comment explaining why the focused run is needed (e.g., "quick smoke for gh_cli changes").
If kept, consider swapping order so the faster targeted run comes first.
| def _run_gh(args: list[str]) -> None: | ||
| """Run gh for maintainer --fix paths (may mutate GitHub state).""" | ||
| command = " ".join(args) | ||
| try: | ||
| subprocess.run( | ||
| args, | ||
| check=True, | ||
| capture_output=True, | ||
| text=True, | ||
| encoding="utf-8", | ||
| errors="replace", | ||
| timeout=GH_TIMEOUT_SECONDS, | ||
| ) | ||
| except subprocess.TimeoutExpired as exc: | ||
| raise RuntimeError(f"gh command timed out after {GH_TIMEOUT_SECONDS}s: {command}") from exc | ||
| except subprocess.CalledProcessError as exc: | ||
| raise RuntimeError( | ||
| "gh command failed " | ||
| f"(exit {exc.returncode}): {command}\n" | ||
| f"stdout:\n{exc.stdout or exc.output or ''}\n" | ||
| f"stderr:\n{exc.stderr or ''}" | ||
| ) from exc | ||
|
|
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Handle missing gh on the --fix path.
_run_gh still lets FileNotFoundError escape, so --fix will raise a traceback instead of the standardized missing-gh RuntimeError that the new shared helper returns for live read-only commands.
Proposed fix
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"gh command timed out after {GH_TIMEOUT_SECONDS}s: {command}") from exc
+ except FileNotFoundError as exc:
+ raise RuntimeError(
+ "GitHub CLI executable 'gh' was not found; install gh and ensure it is on PATH "
+ "before using --fix"
+ ) from exc
except subprocess.CalledProcessError as exc:
raise RuntimeError(
"gh command failed "
f"(exit {exc.returncode}): {command}\n"
f"stdout:\n{exc.stdout or exc.output or ''}\n"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def _run_gh(args: list[str]) -> None: | |
| """Run gh for maintainer --fix paths (may mutate GitHub state).""" | |
| command = " ".join(args) | |
| try: | |
| subprocess.run( | |
| args, | |
| check=True, | |
| capture_output=True, | |
| text=True, | |
| encoding="utf-8", | |
| errors="replace", | |
| timeout=GH_TIMEOUT_SECONDS, | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| raise RuntimeError(f"gh command timed out after {GH_TIMEOUT_SECONDS}s: {command}") from exc | |
| except subprocess.CalledProcessError as exc: | |
| raise RuntimeError( | |
| "gh command failed " | |
| f"(exit {exc.returncode}): {command}\n" | |
| f"stdout:\n{exc.stdout or exc.output or ''}\n" | |
| f"stderr:\n{exc.stderr or ''}" | |
| ) from exc | |
| def _run_gh(args: list[str]) -> None: | |
| """Run gh for maintainer --fix paths (may mutate GitHub state).""" | |
| command = " ".join(args) | |
| try: | |
| subprocess.run( | |
| args, | |
| check=True, | |
| capture_output=True, | |
| text=True, | |
| encoding="utf-8", | |
| errors="replace", | |
| timeout=GH_TIMEOUT_SECONDS, | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| raise RuntimeError(f"gh command timed out after {GH_TIMEOUT_SECONDS}s: {command}") from exc | |
| except FileNotFoundError as exc: | |
| raise RuntimeError( | |
| "GitHub CLI executable 'gh' was not found; install gh and ensure it is on PATH " | |
| "before using --fix" | |
| ) from exc | |
| except subprocess.CalledProcessError as exc: | |
| raise RuntimeError( | |
| "gh command failed " | |
| f"(exit {exc.returncode}): {command}\n" | |
| f"stdout:\n{exc.stdout or exc.output or ''}\n" | |
| f"stderr:\n{exc.stderr or ''}" | |
| ) from exc |
🧰 Tools
🪛 ast-grep (0.44.0)
[error] 126-134: Use of unsanitized data to create processes
Context: subprocess.run(
args,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=GH_TIMEOUT_SECONDS,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(os-system-unsanitized-data)
[error] 126-134: Command coming from incoming request
Context: subprocess.run(
args,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=GH_TIMEOUT_SECONDS,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(subprocess-from-request)
| def test_run_gh_json_parses_stdout(monkeypatch) -> None: | ||
| def fake_run(*args, **kwargs): | ||
| return subprocess.CompletedProcess(args[0], 0, stdout='{"ok": true}', stderr="") | ||
|
|
||
| monkeypatch.setattr(gh_cli.subprocess, "run", fake_run) | ||
| assert gh_cli.run_gh_json(["gh", "issue", "list"]) == {"ok": True} | ||
|
|
||
|
|
||
| def test_run_gh_json_reports_missing_gh(monkeypatch) -> None: | ||
| def missing_gh(*args, **kwargs): | ||
| raise FileNotFoundError("gh") | ||
|
|
||
| monkeypatch.setattr(gh_cli.subprocess, "run", missing_gh) | ||
|
|
||
| with pytest.raises(RuntimeError, match="GitHub CLI executable 'gh' was not found"): | ||
| gh_cli.run_gh_json(["gh", "issue", "list"]) | ||
|
|
||
|
|
||
| def test_run_gh_json_reports_nonzero_exit(monkeypatch) -> None: | ||
| def failing(*args, **kwargs): | ||
| raise subprocess.CalledProcessError(1, args[0], output="", stderr="boom") | ||
|
|
||
| monkeypatch.setattr(gh_cli.subprocess, "run", failing) | ||
|
|
||
| with pytest.raises(RuntimeError, match="gh command failed"): | ||
| gh_cli.run_gh_json(["gh", "issue", "list"]) | ||
|
|
||
|
|
||
| def test_assert_read_only_gh_command_blocks_mutating_api() -> None: | ||
| with pytest.raises(RuntimeError, match="refusing non-read-only gh api command"): | ||
| gh_cli.assert_read_only_gh_command(["gh", "api", "-X", "POST", "repos/x/y"]) | ||
|
|
||
|
|
||
| def test_assert_read_only_gh_command_blocks_issue_comment() -> None: | ||
| with pytest.raises(RuntimeError, match="refusing non-read-only gh command"): | ||
| gh_cli.assert_read_only_gh_command(["gh", "issue", "comment", "1"]) | ||
|
|
||
|
|
||
| def test_run_gh_returns_raw_stdout(monkeypatch) -> None: | ||
| def fake_run(*args, **kwargs): | ||
| return subprocess.CompletedProcess(args[0], 0, stdout="plain\n", stderr="") | ||
|
|
||
| monkeypatch.setattr(gh_cli.subprocess, "run", fake_run) | ||
| assert gh_cli.run_gh(["gh", "issue", "list"]) == "plain\n" |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add a timeout regression for the new helper.
run_gh has a dedicated TimeoutExpired → RuntimeError path, but this suite never exercises it. That leaves one of the new negative paths unproven. As per coding guidelines, "Add or update tests for changed behavior." As per path instructions, "Focus on whether tests prove the changed behavior and include negative, replay, boundary, or regression cases where relevant."
Sources: Coding guidelines, Path instructions
|
Bounty #1009 review for current head Verdict: not merge-ready yet because Evidence: the file now imports both This is separate from the existing unused |
qingfeng312
left a comment
There was a problem hiding this comment.
Reviewed current head fd369977dae5adeae872fce70901fb826acfba69.
The branch gets through formatting but fails the lint gate in CI run 28314670389. ruff check . reports four fixable issues:
scripts/check_live_bounty_closing_refs.py:5: unusedsubprocessscripts/pr_queue_health.py:6: unusedsubprocessscripts/pr_queue_health.py:16: unusedDEFAULT_GH_TIMEOUT_SECONDS as GH_TIMEOUT_SECONDStests/test_gh_cli.py:3: unusedjson
Since the PR is meant to add a shared read-only gh CLI helper, keeping stale imports in the migrated call sites and test file leaves the quality gate red.
Please remove the unused imports or run Ruff's fixer and push the resulting small cleanup.
Scope checked: CI log, current PR metadata, CodeRabbit status, and changed-file list only. No wallet, treasury, payout, private data, credentials, or external mutation paths were exercised.
fd36997 to
1bca92f
Compare
qingfeng312
left a comment
There was a problem hiding this comment.
Reviewed current head 1bca92fb9b7cd74232a2c41ebc9baee0a4ef7def.
This still does not implement the stated shared-helper scope. The PR body says it adds scripts/gh_cli.py and tests/test_gh_cli.py, but the current diff only rewrites scripts/check_bounty_issue_states.py, scripts/check_live_bounty_closing_refs.py, and scripts/pr_queue_health.py; there is no shared helper module or targeted helper test. The changed scripts still keep local subprocess.run wrappers, and the hosted quality/readiness check is failing.
Please add the shared read-only gh helper and its tests, then migrate the scripts to call that helper with a focused diff.
e59bf5a to
c1cbedb
Compare
qingfeng312
left a comment
There was a problem hiding this comment.
Reviewed current head c1cbedb9b5c5689cb1f28ffd574e024b66e301c9.
Requesting changes because pytest cannot collect the suite on this head. The hosted quality gate run 28493734643 reports:
tests/test_check_bounty_issue_states.py:ModuleNotFoundError: No module named 'scripts.source_args'tests/test_check_live_bounty_closing_refs.py:ModuleNotFoundError: No module named 'scripts.source_args'tests/test_claim_inventory.py:ModuleNotFoundError: No module named 'scripts.public_json_fetch'
This PR migrates several scripts toward shared helpers, but the current diff does not include every helper required by the imported tests/modules. Please include the missing helper modules in the same branch, or keep those imports out of scope until the helper PRs land.
|
@qingfeng312 — proactive CRLF cleanup on this branch. Normalized LF line endings (no functional changes) in:
Should pass |
c1cbedb to
3278d80
Compare
|
@qingfeng312 — added missing |
c0077ad to
2dcbb9c
Compare
2dcbb9c to
22b6e2e
Compare
22b6e2e to
89efd0a
Compare
be611cd to
b5efea3
Compare
|
@qingfeng312 — pytest / AGENTS.md fully green on Status: logic + tests pass; diff minimized to PR-scoped files. Likely needs a maintainer-side Happy to push a follow-up formatting-only commit if you can point at the repo's pinned ruff version. Wallet: |
b5efea3 to
53b53f4
Compare
53b53f4 to
cde1521
Compare
cde1521 to
7b0a33a
Compare
7b0a33a to
8e0fa26
Compare
|
@qingfeng312 — CI fully green on latest head for bounty #1140. Fixes on current head (
Please recheck when convenient. Wallet: |
taherdhanera
left a comment
There was a problem hiding this comment.
Reviewed current head 8e0fa266e1058b277dc1ca95f8133dc5109ab95f. This still needs changes before merge.
The focused tests pass, but the current diff does not satisfy #1140's stated acceptance scope yet:
-
No existing maintenance script adopts the new helper on this head. The diff only adds
scripts/gh_cli.py,scripts/public_json_fetch.py,scripts/source_args.py, and two test files. The issue asks for "a shared helper" plus "at least some current duplicated maintenance scripts adopt it". Current source still has local wrappers in representative scripts such asscripts/check_bounty_issue_states.py,scripts/check_live_bounty_closing_refs.py,scripts/pr_queue_health.py,scripts/claim_inventory.py,scripts/proposed_work_triage.py,scripts/review_bounty_candidates.py, andscripts/submission_quality_gate.py. -
The read-only guard is incomplete for
gh api.gh apiswitches toPOSTautomatically when parameters are added, and GitHub's own help givesgh api repos/{owner}/{repo}/issues/123/comments -f body='Hi from CLI'as a comment-posting example. On this head,assert_read_only_gh_command()still allows these mutating forms:
['gh', 'api', 'repos/x/y/issues', '-f', 'title=test']['gh', 'api', 'repos/x/y/issues', '--field', 'title=test']['gh', 'api', 'repos/x/y/issues', '--raw-field', 'title=test']['gh', 'api', 'repos/x/y/issues', '--method=POST']['gh', 'api', 'repos/x/y/issues', '-XPOST']
It only rejects the split-token form --method POST. A shared read-only helper should either reject payload-bearing gh api flags unless --method GET is explicit, and handle combined method forms, or avoid advertising the helper as read-only enforcement.
Validation run:
python -m pytest tests\test_gh_cli.py tests\test_public_json_fetch.py -q-> 11 passed.python -m ruff check scripts\gh_cli.py scripts\public_json_fetch.py scripts\source_args.py tests\test_gh_cli.py tests\test_public_json_fetch.py-> passed.python -m ruff format --check scripts\gh_cli.py scripts\public_json_fetch.py scripts\source_args.py tests\test_gh_cli.py tests\test_public_json_fetch.py-> passed.git diff --check origin/main...HEAD-> clean.
Please migrate a small representative set of existing scripts to the helper, and tighten the gh api read-only enforcement/tests before merge.
|
@qingfeng312 — All CI checks green on Ready for review/merge when convenient. Wallet: |
1 similar comment
|
@qingfeng312 — All CI checks green on Ready for review/merge when convenient. Wallet: |
|
@taherdhanera — The focused test/import issues called out earlier are addressed on current head Wallet: |
|
@qingfeng312 — Prior CHANGES_REQUESTED items (shared gh helper scope + tests) are addressed on Wallet: |
|
I rechecked this after the ping. The head is still 8e0fa26, which is the same commit my July 3 changes-requested review covered, so there is no new diff for me to re-review yet. The existing-script adoption and gh api read-only guard gaps still need to be addressed before I can clear it. |
8e0fa26 to
5c71302
Compare
5c71302 to
4425b70
Compare
There was a problem hiding this comment.
Actionable comments posted: 7
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 965e2be9-e076-4fda-a5ea-c9ba3def8944
📒 Files selected for processing (5)
scripts/gh_cli.pyscripts/public_json_fetch.pyscripts/source_args.pytests/test_gh_cli.pytests/test_public_json_fetch.py
| def assert_read_only_gh_command(args: list[str]) -> None: | ||
| """Reject gh invocations that could mutate GitHub state.""" | ||
| if args[:2] == ["gh", "api"]: | ||
| for flag in ("--method", "-X"): | ||
| if flag not in args: | ||
| continue | ||
| index = args.index(flag) | ||
| if index + 1 >= len(args): | ||
| continue | ||
| if args[index + 1].upper() in _NON_READ_ONLY_GH_API: | ||
| raise RuntimeError( | ||
| f"refusing non-read-only gh api command: {_command_text(args)}" | ||
| ) | ||
| if any(arg in {"issue", "pr"} for arg in args) and any( | ||
| arg in _NON_READ_ONLY_GH_SUBCOMMANDS for arg in args | ||
| ): | ||
| raise RuntimeError(f"refusing non-read-only gh command: {_command_text(args)}") |
There was a problem hiding this comment.
🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift
Enforce gh command prefix and allowlist read-only shapes.
assert_read_only_gh_command does not verify that the executable is gh. Calling run_gh(["rm", "-rf", "/"]) executes successfully because it bypasses the narrow blocklist.
Additionally, the blocklist approach has two major flaws:
- It misses mutating commands outside the
issueandprscopes (e.g.,gh repo delete). - It incorrectly scans all arguments, meaning safe read-only queries like
gh issue list --label closewill crash because"close"is inargs.
Require args[0] == "gh" and validate the subcommand structure against a strict allowlist.
Proposed strict allowlist logic
-_NON_READ_ONLY_GH_API = frozenset({"POST", "PUT", "PATCH", "DELETE"})
-_NON_READ_ONLY_GH_SUBCOMMANDS = frozenset(
- {"comment", "edit", "close", "reopen", "merge", "review", "delete"}
-)
+ALLOWED_GH_READ_COMMANDS = frozenset({"api", "issue", "pr"})
+ALLOWED_GH_READ_SUBCOMMANDS = frozenset({"list", "view", "status"})
def assert_read_only_gh_command(args: list[str]) -> None:
"""Reject gh invocations that could mutate GitHub state."""
+ if not args or args[0] != "gh":
+ raise RuntimeError(f"refusing non-gh command: {_command_text(args)}")
+
+ if len(args) < 2:
+ return
+
if args[:2] == ["gh", "api"]:
for flag in ("--method", "-X"):
if flag not in args:
continue
index = args.index(flag)
if index + 1 >= len(args):
continue
- if args[index + 1].upper() in _NON_READ_ONLY_GH_API:
+ if args[index + 1].upper() != "GET":
raise RuntimeError(
f"refusing non-read-only gh api command: {_command_text(args)}"
)
- if any(arg in {"issue", "pr"} for arg in args) and any(
- arg in _NON_READ_ONLY_GH_SUBCOMMANDS for arg in args
- ):
- raise RuntimeError(f"refusing non-read-only gh command: {_command_text(args)}")
+ return
+
+ command, subcommand = args[1], args[2] if len(args) > 2 else ""
+ if command in ALLOWED_GH_READ_COMMANDS and subcommand not in ALLOWED_GH_READ_SUBCOMMANDS:
+ raise RuntimeError(f"refusing non-read-only gh command: {_command_text(args)}")| label = description or "public JSON" | ||
| request = urllib.request.Request( | ||
| url, | ||
| headers={ | ||
| "Accept": accept, | ||
| "User-Agent": user_agent, | ||
| }, | ||
| ) |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Restrict URL scheme to HTTP(S).
As per static analysis hints, urlopen receives a potentially request-controlled URL. Since urllib.request.urlopen natively supports file:// and ftp:// schemes, restrict the URL to HTTP or HTTPS to prevent unintended local file reads or unknown-scheme ValueError crashes.
Proposed fix to restrict allowed schemes
label = description or "public JSON"
+
+ if not url.lower().startswith(("http://", "https://")):
+ raise ValueError(f"Unsupported URL scheme: {url}")
+
request = urllib.request.Request(
url,
headers={
"Accept": accept,
"User-Agent": user_agent,
},
)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| label = description or "public JSON" | |
| request = urllib.request.Request( | |
| url, | |
| headers={ | |
| "Accept": accept, | |
| "User-Agent": user_agent, | |
| }, | |
| ) | |
| label = description or "public JSON" | |
| if not url.lower().startswith(("http://", "https://")): | |
| raise ValueError(f"Unsupported URL scheme: {url}") | |
| request = urllib.request.Request( | |
| url, | |
| headers={ | |
| "Accept": accept, | |
| "User-Agent": user_agent, | |
| }, | |
| ) |
Source: Linters/SAST tools
| try: | ||
| with urllib.request.urlopen(request, timeout=timeout) as response: | ||
| payload = response.read().decode("utf-8") | ||
| except urllib.error.HTTPError as exc: | ||
| raise PublicJsonError(f"{label} unavailable: HTTP {exc.code}") from exc | ||
| except (urllib.error.URLError, TimeoutError) as exc: | ||
| raise PublicJsonError(f"{label} unavailable: {exc}") from exc | ||
| try: | ||
| return json.loads(payload) | ||
| except json.JSONDecodeError as exc: | ||
| raise PublicJsonError(f"{label} unavailable: invalid JSON") from exc |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Handle UnicodeDecodeError from invalid payloads.
If the endpoint returns a payload with invalid UTF-8 bytes, .decode("utf-8") will raise a UnicodeDecodeError that escapes the current exception-handling blocks and crashes the script. Pass the raw bytes directly to json.loads (which natively handles byte decoding) and catch UnicodeDecodeError alongside JSONDecodeError to properly wrap the error.
Proposed fix to safely handle decoding errors
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
- payload = response.read().decode("utf-8")
+ payload = response.read()
except urllib.error.HTTPError as exc:
raise PublicJsonError(f"{label} unavailable: HTTP {exc.code}") from exc
except (urllib.error.URLError, TimeoutError) as exc:
raise PublicJsonError(f"{label} unavailable: {exc}") from exc
try:
return json.loads(payload)
- except json.JSONDecodeError as exc:
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise PublicJsonError(f"{label} unavailable: invalid JSON") from exc📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try: | |
| with urllib.request.urlopen(request, timeout=timeout) as response: | |
| payload = response.read().decode("utf-8") | |
| except urllib.error.HTTPError as exc: | |
| raise PublicJsonError(f"{label} unavailable: HTTP {exc.code}") from exc | |
| except (urllib.error.URLError, TimeoutError) as exc: | |
| raise PublicJsonError(f"{label} unavailable: {exc}") from exc | |
| try: | |
| return json.loads(payload) | |
| except json.JSONDecodeError as exc: | |
| raise PublicJsonError(f"{label} unavailable: invalid JSON") from exc | |
| try: | |
| with urllib.request.urlopen(request, timeout=timeout) as response: | |
| payload = response.read() | |
| except urllib.error.HTTPError as exc: | |
| raise PublicJsonError(f"{label} unavailable: HTTP {exc.code}") from exc | |
| except (urllib.error.URLError, TimeoutError) as exc: | |
| raise PublicJsonError(f"{label} unavailable: {exc}") from exc | |
| try: | |
| return json.loads(payload) | |
| except (json.JSONDecodeError, UnicodeDecodeError) as exc: | |
| raise PublicJsonError(f"{label} unavailable: invalid JSON") from exc |
🧰 Tools
🪛 ast-grep (0.44.1)
[warning] 29-29: Request-controlled URL passed to urlopen; validate against an allowlist to prevent SSRF.
Context: urllib.request.urlopen(request, timeout=timeout)
Note: [CWE-918] Server-Side Request Forgery (SSRF).
(urlopen-unsanitized-data)
| def _require_non_empty( | ||
| parser: argparse.ArgumentParser, value: str | None, *, label: str | ||
| ) -> str: | ||
| if value is None or not value.strip(): | ||
| parser.error(f"{label} must not be empty or whitespace-only") | ||
| return value.strip() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Maintain strict whitespace validation contract.
As shown in scripts/review_bounty_candidates.py (Context Snippet 1), the upstream contract explicitly errors if leading or trailing whitespace is present, rather than silently stripping it. Silently dropping this strictness in the shared helper changes the behavior and relaxes the existing validation.
Caching the stripped value also avoids a redundant .strip() call.
Proposed fix
def _require_non_empty(
parser: argparse.ArgumentParser, value: str | None, *, label: str
) -> str:
- if value is None or not value.strip():
- parser.error(f"{label} must not be empty or whitespace-only")
- return value.strip()
+ if value is None:
+ parser.error(f"{label} must not be empty or whitespace-only")
+ stripped = value.strip()
+ if not stripped:
+ parser.error(f"{label} must not be empty or whitespace-only")
+ if stripped != value:
+ parser.error(f"{label} must not include leading or trailing whitespace")
+ return stripped📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| def _require_non_empty( | |
| parser: argparse.ArgumentParser, value: str | None, *, label: str | |
| ) -> str: | |
| if value is None or not value.strip(): | |
| parser.error(f"{label} must not be empty or whitespace-only") | |
| return value.strip() | |
| def _require_non_empty( | |
| parser: argparse.ArgumentParser, value: str | None, *, label: str | |
| ) -> str: | |
| if value is None: | |
| parser.error(f"{label} must not be empty or whitespace-only") | |
| stripped = value.strip() | |
| if not stripped: | |
| parser.error(f"{label} must not be empty or whitespace-only") | |
| if stripped != value: | |
| parser.error(f"{label} must not include leading or trailing whitespace") | |
| return stripped |
| if input_value is not None and repo_value is not None: | ||
| parser.error("argument --repo: not allowed with argument --input") | ||
| if input_value is None and repo_value is None: | ||
| parser.error("one of the arguments --input --repo is required") |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
Leverage argparse native mutual exclusivity.
Re-implementing mutual exclusivity and required-group logic manually bypasses argparse.add_mutually_exclusive_group(required=True). Using the built-in feature automatically formats the CLI --help text to show the (--input | --repo) constraint and handles these validation errors natively before reaching this helper function.
If the parsers in the calling scripts aren't already using mutually exclusive groups, consider configuring them there and removing this redundant check.
| def test_run_gh_wraps_missing_executable() -> None: | ||
| with ( | ||
| patch("scripts.gh_cli.subprocess.run", side_effect=FileNotFoundError("gh")), | ||
| pytest.raises(RuntimeError, match="GitHub CLI executable 'gh' was not found"), | ||
| ): | ||
| run_gh(["gh", "api", "repos/x/y"]) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Test the TimeoutExpired exception path.
The TimeoutExpired exception handling in run_gh remains untested. As per coding guidelines, "Add or update tests for changed behavior", and as per path instructions, focus on proving changed behavior including "negative... cases where relevant".
Proposed test addition
def test_run_gh_wraps_missing_executable() -> None:
with (
patch("scripts.gh_cli.subprocess.run", side_effect=FileNotFoundError("gh")),
pytest.raises(RuntimeError, match="GitHub CLI executable 'gh' was not found"),
):
run_gh(["gh", "api", "repos/x/y"])
+
+
+def test_run_gh_wraps_timeout() -> None:
+ with (
+ patch("scripts.gh_cli.subprocess.run", side_effect=subprocess.TimeoutExpired("gh", 30)),
+ pytest.raises(RuntimeError, match="gh command timed out after 30s: gh api"),
+ ):
+ run_gh(["gh", "api"])Sources: Coding guidelines, Path instructions
4425b70 to
4b63306
Compare
4b63306 to
4ab1243
Compare
4ab1243 to
a3443da
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
scripts/gh_cli.py (1)
19-35:⚠️ Potential issue | 🔴 CriticalEnforce an actual read-only
ghallowlist.This blocklist does not require
args[0] == "gh"and misses commands such asgh repo delete,gh workflow run,--method=POST, and repeated method flags. It also permitsgh apiparameter flags; GitHub CLI documents that adding parameters switches the default method toPOST, so--field/--raw-fieldcan reach mutating endpoints. (cli.github.com)Require the
ghprefix and validate exact read-only command shapes. Add regression cases intests/test_gh_cli.pyfor non-ghexecutables, mutating top-level commands, parameterized API calls, and values such as--label close.As per the PR objective, this helper must enforce read-only GitHub CLI execution.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 135d830c-9410-4980-95e3-cc7c3873657e
📒 Files selected for processing (5)
scripts/gh_cli.pyscripts/public_json_fetch.pyscripts/source_args.pytests/test_gh_cli.pytests/test_public_json_fetch.py
| def test_run_gh_returns_stdout() -> None: | ||
| completed = type("Completed", (), {"stdout": '{"ok": true}'})() | ||
|
|
||
| with patch("scripts.gh_cli.subprocess.run", return_value=completed): | ||
| assert run_gh(["gh", "api", "repos/x/y"]) == '{"ok": true}' | ||
|
|
||
|
|
||
| def test_run_gh_json_parses_payload() -> None: | ||
| payload = {"items": [1, 2]} | ||
| completed = type("Completed", (), {"stdout": json.dumps(payload)})() | ||
|
|
||
| with patch("scripts.gh_cli.subprocess.run", return_value=completed): | ||
| assert run_gh_json(["gh", "api", "repos/x/y"]) == payload |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Complete negative-path coverage for the helper.
The suite does not exercise CalledProcessError, malformed JSON, or run_gh_json_list/run_gh_json_object rejecting the wrong top-level shape. Add focused tests that assert the wrapped exit code/stdout/stderr and the expected JSON validation errors.
As per coding guidelines, “Add or update tests for changed behavior”; as per path instructions, focus on negative and regression cases where relevant.
🧰 Tools
🪛 ast-grep (0.44.1)
[info] 33-33: use jsonify instead of json.dumps for JSON output
Context: json.dumps(payload)
Note: [CWE-116] Improper Encoding or Escaping of Output.
(use-jsonify)
Sources: Coding guidelines, Path instructions
| def test_load_public_json_returns_decoded_payload() -> None: | ||
| payload = {"status": "ok"} | ||
| response = io.BytesIO(json.dumps(payload).encode("utf-8")) | ||
| response.status = 200 # type: ignore[attr-defined] | ||
|
|
||
| with patch("urllib.request.urlopen", return_value=response): | ||
| assert load_public_json("https://example.test/api/v1/status") == payload | ||
|
|
||
|
|
||
| def test_load_public_json_sets_default_headers() -> None: | ||
| captured: dict[str, str] = {} | ||
|
|
||
| class FakeResponse(io.BytesIO): | ||
| def __init__(self) -> None: | ||
| super().__init__(b"[]") | ||
|
|
||
| def fake_urlopen(request, timeout=30): # noqa: ANN001 | ||
| captured["accept"] = request.get_header("Accept") | ||
| captured["user_agent"] = request.get_header("User-agent") | ||
| captured["timeout"] = str(timeout) | ||
| return FakeResponse() | ||
|
|
||
| with patch("urllib.request.urlopen", side_effect=fake_urlopen): | ||
| load_public_json("https://example.test/api/v1/bounties") | ||
|
|
||
| assert captured["accept"] == "application/json" | ||
| assert captured["user_agent"] == "mergework-maintenance-script" | ||
| assert captured["timeout"] == "30" |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Cover UTF-8 decoding and header overrides.
The success payload is ASCII-only, so it does not prove UTF-8 decoding. Add a non-ASCII payload using ensure_ascii=False, and assert custom accept and user_agent values reach the request.
As per coding guidelines, “Add or update tests for changed behavior.”
🧰 Tools
🪛 ast-grep (0.44.1)
[info] 14-14: use jsonify instead of json.dumps for JSON output
Context: json.dumps(payload)
Note: [CWE-116] Improper Encoding or Escaping of Output.
(use-jsonify)
Source: Coding guidelines
a3443da to
c0bd097
Compare
c0bd097 to
611d857
Compare
611d857 to
ce921a2
Compare
taherdhanera
left a comment
There was a problem hiding this comment.
Reviewed current head ce921a2. Changes are still required before merge: scripts/gh_cli.py does not require args[0] == 'gh', so run_gh can execute an arbitrary non-gh program, and its narrow blocklist still permits mutating shapes such as gh repo delete, gh workflow run, and gh api parameter flags that default the request to POST. The new commits are formatting/import-order changes and do not close this read-only enforcement gap. Please replace the blocklist with a strict allowlist for supported read-only gh command shapes and add regression coverage for non-gh executables, mutating top-level commands, parameterized API calls, repeated method flags, and safe option values such as --label close.
|
CI green after ruff 0.15.22 format/lint fixes on ce921a2. Ready for review/merge. |
Summary
Implements proposed work for #1140.
Closes #1140
Solana wallet for bounty payout: Do4v7foHJvRJLpRRoGaVPWX6DDEjX3yTK7J91gpwUQpE
Summary by CodeRabbit