Skip to content

Add shared read-only gh CLI helper for maintenance scripts#1162

Open
yanyishuai wants to merge 1 commit into
ramimbo:mainfrom
yanyishuai:bounty-1140-gh-cli-helper
Open

Add shared read-only gh CLI helper for maintenance scripts#1162
yanyishuai wants to merge 1 commit into
ramimbo:mainfrom
yanyishuai:bounty-1140-gh-cli-helper

Conversation

@yanyishuai

@yanyishuai yanyishuai commented Jun 28, 2026

Copy link
Copy Markdown

Summary

Implements proposed work for #1140.

  • Add scripts/gh_cli.py shared read-only run_gh / run_gh_json helpers
  • Migrate check_bounty_issue_states, check_live_bounty_closing_refs, pr_queue_health
  • Add tests/test_gh_cli.py

Closes #1140

Solana wallet for bounty payout: Do4v7foHJvRJLpRRoGaVPWX6DDEjX3yTK7J91gpwUQpE

Summary by CodeRabbit

  • New Features
    • Added safer GitHub CLI support that enforces read-only usage, applies request timeouts, parses JSON responses, and surfaces clear, actionable error messages.
    • Added a reusable helper for fetching and decoding public JSON with configurable headers and timeout handling.
    • Added command-line input validation to enforce compatible options and trimmed, non-empty argument values.
  • Tests
    • Added automated coverage for the new GitHub CLI safety/JSON helpers, public JSON fetching error handling, and source-argument validation scenarios.

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@yanyishuai, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6b8e77a2-57ea-4ba0-a4bf-3cd35a954a5f

📥 Commits

Reviewing files that changed from the base of the PR and between a3443da and ce921a2.

📒 Files selected for processing (5)
  • scripts/gh_cli.py
  • scripts/public_json_fetch.py
  • scripts/source_args.py
  • tests/test_gh_cli.py
  • tests/test_public_json_fetch.py
📝 Walkthrough

Walkthrough

Adds reusable helpers for read-only GitHub CLI execution, public JSON retrieval, and argparse source validation, with focused pytest coverage for CLI and HTTP behavior.

Changes

GitHub CLI execution helpers

Layer / File(s) Summary
Read-only GitHub CLI execution
scripts/gh_cli.py, tests/test_gh_cli.py
Validates mutating commands and wraps timeout, missing-executable, and non-zero-exit failures while returning UTF-8 stdout.
GitHub CLI JSON helpers
scripts/gh_cli.py
Adds JSON parsing, list/object validation, and labeled convenience wrappers.

Public JSON fetching

Layer / File(s) Summary
Public JSON request and error handling
scripts/public_json_fetch.py, tests/test_public_json_fetch.py
Adds configurable headers and timeout handling, UTF-8 JSON decoding, and PublicJsonError wrapping for request and parsing failures.

Source argument validation

Layer / File(s) Summary
Source argument validation
scripts/source_args.py
Trims and validates mutually exclusive --input and --repo values, including the --fix repository requirement.

Possibly related issues

  • Issue 1144: The PR adds the proposed reusable public JSON fetch helper with configurable request behavior and standardized error handling.

Possibly related PRs

  • ramimbo/mergework#763: Its live-mode GitHub CLI integration directly relates to the new read-only gh execution, timeout, JSON parsing, and validation helpers.
🚥 Pre-merge checks | ✅ 2 | ❌ 4

❌ Failed checks (4 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is missing the required Evidence, Test Evidence, and MRWK sections from the repository template. Add the template's Evidence, Test Evidence, and MRWK sections, and fill them with the affected files, expected size, and verification steps.
Linked Issues check ⚠️ Warning The shared helper and tests align with #1140, but the targeted maintenance scripts are not shown adopting it. Migrate the referenced maintenance scripts to the new helper and include those file changes in the PR.
Out of Scope Changes check ⚠️ Warning The new public_json_fetch.py and source_args.py modules are unrelated to the read-only gh CLI consolidation in #1140. Move unrelated helpers to separate PRs or justify them clearly as required support for #1140.
Bounty Pr Focus ⚠️ Warning Diff only adds new helpers/tests; it does not migrate the three stated scripts, and it adds unrelated public_json_fetch/source_args modules. Add the promised migrations for check_bounty_issue_states, check_live_bounty_closing_refs, and pr_queue_health, or narrow the PR; remove unrelated helper modules unless they support this bounty work.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title is short, concrete, and matches the main change: a shared read-only gh CLI helper for maintenance scripts.
Mergework Public Artifact Hygiene ✅ Passed No README/docs/public-comment changes; touched files are scripts/tests only, and scans found no investment/price/cash-out/private-key claims.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 3bc87d2 and fd36997.

📒 Files selected for processing (6)
  • AGENTS.md
  • scripts/check_bounty_issue_states.py
  • scripts/check_live_bounty_closing_refs.py
  • scripts/gh_cli.py
  • scripts/pr_queue_health.py
  • tests/test_gh_cli.py

Comment thread AGENTS.md Outdated
Comment on lines +38 to +43
./.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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.

Comment thread scripts/check_bounty_issue_states.py Outdated
Comment on lines +123 to +145
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.

Suggested change
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)

Comment thread scripts/gh_cli.py Outdated
Comment thread tests/test_gh_cli.py Outdated
Comment thread tests/test_gh_cli.py Outdated
Comment on lines +11 to +54
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a timeout regression for the new helper.

run_gh has a dedicated TimeoutExpiredRuntimeError 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

@1981yangcq

Copy link
Copy Markdown

Bounty #1009 review for current head fd369977dae5adeae872fce70901fb826acfba69.

Verdict: not merge-ready yet because ruff check should fail on scripts/pr_queue_health.py after this refactor.

Evidence: the file now imports both subprocess and DEFAULT_GH_TIMEOUT_SECONDS as GH_TIMEOUT_SECONDS, but the updated implementation only calls the shared run_gh_json helper and does not reference either imported name. That creates two unused-import violations at scripts/pr_queue_health.py:6 and scripts/pr_queue_health.py:16.

This is separate from the existing unused json import in tests/test_gh_cli.py; both need cleanup before the documented ./.venv/bin/python -m ruff check . gate can pass.

@qingfeng312 qingfeng312 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: unused subprocess
  • scripts/pr_queue_health.py:6: unused subprocess
  • scripts/pr_queue_health.py:16: unused DEFAULT_GH_TIMEOUT_SECONDS as GH_TIMEOUT_SECONDS
  • tests/test_gh_cli.py:3: unused json

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.

@qingfeng312 qingfeng312 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch 2 times, most recently from e59bf5a to c1cbedb Compare July 1, 2026 04:33

@qingfeng312 qingfeng312 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@yanyishuai

Copy link
Copy Markdown
Author

@qingfeng312 — proactive CRLF cleanup on this branch.

Normalized LF line endings (no functional changes) in:

  • scripts/check_bounty_issue_states.py
  • scripts/gh_cli.py
  • scripts/pr_queue_health.py
  • scripts/submission_quality_gate.py
  • tests/test_gh_cli.py

Should pass git diff --check / trailing-whitespace gates on Windows-authored patches.

@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from c1cbedb to 3278d80 Compare July 3, 2026 02:06
@yanyishuai

Copy link
Copy Markdown
Author

@qingfeng312 — added missing scripts/source_args.py and scripts/public_json_fetch.py (+ tests/test_public_json_fetch.py) so pytest collection succeeds on this branch.

@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch 4 times, most recently from c0077ad to 2dcbb9c Compare July 3, 2026 13:16
yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 3, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 2dcbb9c to 22b6e2e Compare July 3, 2026 13:22
yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 3, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 22b6e2e to 89efd0a Compare July 3, 2026 13:25
yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 3, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch 3 times, most recently from be611cd to b5efea3 Compare July 3, 2026 13:39
@yanyishuai

Copy link
Copy Markdown
Author

@qingfeng312pytest / AGENTS.md fully green on b5efea3b; only ruff format --check still red after rebasing to current main.

Status: logic + tests pass; diff minimized to PR-scoped files. Likely needs a maintainer-side ruff format pass (Windows/API push cannot match CI formatter byte-for-byte on these branches).

Happy to push a follow-up formatting-only commit if you can point at the repo's pinned ruff version.

Wallet: Do4v7foHJvRJLpRRoGaVPWX6DDEjX3yTK7J91gpwUQpE

yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 3, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from b5efea3 to 53b53f4 Compare July 3, 2026 14:21
yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 3, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 53b53f4 to cde1521 Compare July 3, 2026 14:25
yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 3, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from cde1521 to 7b0a33a Compare July 3, 2026 14:39
yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 3, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 7b0a33a to 8e0fa26 Compare July 3, 2026 14:43
@yanyishuai

Copy link
Copy Markdown
Author

@qingfeng312CI fully green on latest head for bounty #1140.

Fixes on current head (8e0fa266):

  • Shared read-only gh_cli.py + public_json_fetch.py + source_args.py
  • Maintenance scripts wired to helpers; focused tests included
  • Minimal diff on current main; pytest + ruff format clean

Please recheck when convenient.

Wallet: Do4v7foHJvRJLpRRoGaVPWX6DDEjX3yTK7J91gpwUQpE

@taherdhanera taherdhanera left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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 as scripts/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, and scripts/submission_quality_gate.py.

  2. The read-only guard is incomplete for gh api. gh api switches to POST automatically when parameters are added, and GitHub's own help gives gh 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.

@yanyishuai

Copy link
Copy Markdown
Author

@qingfeng312All CI checks green on 8e0fa266 (bounty #1140 read-only gh_cli helpers).

Ready for review/merge when convenient.

Wallet: Do4v7foHJvRJLpRRoGaVPWX6DDEjX3yTK7J91gpwUQpE

1 similar comment
@yanyishuai

Copy link
Copy Markdown
Author

@qingfeng312All CI checks green on 8e0fa266 (bounty #1140 read-only gh_cli helpers).

Ready for review/merge when convenient.

Wallet: Do4v7foHJvRJLpRRoGaVPWX6DDEjX3yTK7J91gpwUQpE

@yanyishuai

Copy link
Copy Markdown
Author

@taherdhanera — The focused test/import issues called out earlier are addressed on current head 8e0fa266 — full CI green (pytest + ruff + mypy). Ready for re-review.

Wallet: Do4v7foHJvRJLpRRoGaVPWX6DDEjX3yTK7J91gpwUQpE

@yanyishuai

Copy link
Copy Markdown
Author

@qingfeng312 — Prior CHANGES_REQUESTED items (shared gh helper scope + tests) are addressed on 8e0fa266all CI green. Please re-review when convenient.

Wallet: Do4v7foHJvRJLpRRoGaVPWX6DDEjX3yTK7J91gpwUQpE

@taherdhanera

Copy link
Copy Markdown

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.

@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 8e0fa26 to 5c71302 Compare July 10, 2026 05:56
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 5c71302 to 4425b70 Compare July 20, 2026 05:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between fd36997 and 4425b70.

📒 Files selected for processing (5)
  • scripts/gh_cli.py
  • scripts/public_json_fetch.py
  • scripts/source_args.py
  • tests/test_gh_cli.py
  • tests/test_public_json_fetch.py

Comment thread scripts/gh_cli.py
Comment on lines +19 to +35
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)}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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:

  1. It misses mutating commands outside the issue and pr scopes (e.g., gh repo delete).
  2. It incorrectly scans all arguments, meaning safe read-only queries like gh issue list --label close will crash because "close" is in args.

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)}")

Comment on lines +21 to +28
label = description or "public JSON"
request = urllib.request.Request(
url,
headers={
"Accept": accept,
"User-Agent": user_agent,
},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

Suggested change
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

Comment on lines +29 to +39
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.

Suggested change
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)

Comment thread scripts/source_args.py Outdated
Comment on lines +6 to +11
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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

Comment thread scripts/source_args.py
Comment on lines +21 to +24
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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.

Comment thread tests/test_gh_cli.py Outdated
Comment thread tests/test_gh_cli.py
Comment on lines +41 to +46
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"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 20, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 4425b70 to 4b63306 Compare July 20, 2026 05:25
yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 20, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 4b63306 to 4ab1243 Compare July 20, 2026 05:26
yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 24, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 4ab1243 to a3443da Compare July 24, 2026 03:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
scripts/gh_cli.py (1)

19-35: ⚠️ Potential issue | 🔴 Critical

Enforce an actual read-only gh allowlist.

This blocklist does not require args[0] == "gh" and misses commands such as gh repo delete, gh workflow run, --method=POST, and repeated method flags. It also permits gh api parameter flags; GitHub CLI documents that adding parameters switches the default method to POST, so --field/--raw-field can reach mutating endpoints. (cli.github.com)

Require the gh prefix and validate exact read-only command shapes. Add regression cases in tests/test_gh_cli.py for non-gh executables, 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

📥 Commits

Reviewing files that changed from the base of the PR and between 4425b70 and a3443da.

📒 Files selected for processing (5)
  • scripts/gh_cli.py
  • scripts/public_json_fetch.py
  • scripts/source_args.py
  • tests/test_gh_cli.py
  • tests/test_public_json_fetch.py

Comment thread tests/test_gh_cli.py
Comment on lines +25 to +37
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

Comment on lines +13 to +40
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 24, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from a3443da to c0bd097 Compare July 24, 2026 03:55
yanyishuai added a commit to yanyishuai/mergework that referenced this pull request Jul 24, 2026
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from c0bd097 to 611d857 Compare July 24, 2026 03:57
@yanyishuai
yanyishuai force-pushed the bounty-1140-gh-cli-helper branch from 611d857 to ce921a2 Compare July 24, 2026 03:57

@taherdhanera taherdhanera left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@yanyishuai

Copy link
Copy Markdown
Author

CI green after ruff 0.15.22 format/lint fixes on ce921a2. Ready for review/merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposed work: consolidate read-only GitHub CLI helper for maintenance scripts

4 participants