Skip to content

ultimatile/gh-post

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gh-post

Single-entry wrapper around gh issue|pr create|edit|comment that funnels every GitHub body through a validator before forwarding to gh. Designed to be paired with a Claude Code (or equivalent) PreToolUse hook that blocks any direct gh ... --body* call, so every body posted to GitHub passes through the wrapper's validation stack.

Install

uv tool install git+https://github.com/ultimatile/gh-post

Requires Python 3.11+ and the GitHub CLI (gh) on PATH. The gh-post executable is placed on whichever directory uv tool install configures.

Usage

gh-post issue create --repo OWNER/REPO --title T --body-file PATH [--label L]...
gh-post issue edit <number> --repo OWNER/REPO [--title T] [--body-file PATH]
gh-post issue comment <number> --repo OWNER/REPO --body-file PATH

gh-post pr create --repo OWNER/REPO --title T --body-file PATH [--base B] [--head H]
gh-post pr edit <number> --repo OWNER/REPO [--title T] [--body-file PATH]
gh-post pr comment <number> --repo OWNER/REPO --body-file PATH

gh-post reply-inline OWNER/REPO PR < replies.jsonl

gh-post comment-edit <url-or-id> [--repo OWNER/REPO --kind <kind>] (--body-file PATH | --body-stdin)

Body input modes:

# file
gh-post issue comment <number> --repo OWNER/REPO --body-file body.md

# stdin
echo "Short comment text" | gh-post issue comment <number> --repo OWNER/REPO --body-stdin

reply-inline

Batched POSTs to the inline review reply endpoint (POST /repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies). The typical use case is Copilot inline review reply: after a review pass, a caller produces JSON Lines on stdin where each line is one reply:

gh-post reply-inline owner/repo 42 <<'JSONL'
{"id": 3203809570, "body": "Fixed: added @assert n >= 1 to pauli_matrix."}
{"id": 3203809605, "body": "Fixed: removed stale ComplexF64 claim."}
JSONL

Each line is validated through the same body validator stack as other subcommands before any send. On a validation failure the wrapper halts before opening any network connection, prints the offending line number, and sends nothing. On an API failure mid-batch the wrapper prints the indices of un-sent replies (the failing index and everything after it) and exits with gh's returncode. Empty input is a no-op success.

Each reply is forwarded via gh api ... --method POST --input <jsonfile>, where the JSON file contains {"body": "<reply text>"}. The --input path is used instead of -F body=@<file> because -F applies magic type conversion (a body equal to the literal "42" or "true" would be coerced to int / bool and silently corrupt the payload).

Unlike the other subcommands, reply-inline does not accept passthrough flags. gh api flags (--hostname, --jq, --silent, etc.) either conflict with the wrapper's own --method POST --input or apply at batch granularity that does not compose with per-reply un-sent accounting. If you need those flags, invoke gh api one reply at a time directly.

comment-edit

PATCH an existing issue or PR comment via gh api repos/{owner}/{repo}/{issues,pulls}/comments/{id} --method PATCH. The target is either a GitHub comment URL or a numeric comment id.

URL form (the wrapper auto-detects repo and kind):

gh-post comment-edit https://github.com/owner/repo/issues/42#issuecomment-1234567 \
  --body-file new-body.md

Accepted URL fragments:

  • #issuecomment-<id> on /issues/N — issue comment
  • #issuecomment-<id> on /pull/N — PR top-level comment (stored as an issue comment on the API side)
  • #discussion_r<id> on /pull/N — PR review-thread (inline) comment

Numeric-id form (both --repo and --kind are required so the endpoint can be disambiguated; --kind is one of issue, pull-thread, pull-toplevel):

gh-post comment-edit 1234567 --repo owner/repo --kind issue \
  --body-file new-body.md

Providing --repo or --kind alongside a URL is rejected to keep one source of truth for the endpoint. Bodies are validated through the same stack as other subcommands; on validation failure, no PATCH is issued.

Like reply-inline, comment-edit does not accept passthrough flags — the endpoint and method are fully determined per call, and gh api flags would either conflict with the wrapper's own --method PATCH --input <jsonfile> or apply at a granularity that does not compose with per-comment accounting.

Pass-through flags (anything the wrapper does not recognize) forward to gh verbatim:

gh-post pr create --repo OWNER/REPO --title T --body-file body.md \
  --reviewer @copilot --label hardening

Post-action output

On success, issue|pr create|edit|comment prints the auto-gh view summary (create / edit only) followed by a single marker line as the final stdout line:

[gh-post] created: https://github.com/OWNER/REPO/issues/123
[gh-post] edited: https://github.com/OWNER/REPO/pull/45
[gh-post] commented: https://github.com/OWNER/REPO/issues/7#issuecomment-1234567

The marker prefix is greppable and the line is always last, so agent-style callers can extract the URL with either grep '^\[gh-post\] ' or tail -1 / tail -3. The early per-gh URL echo is suppressed on the success path to keep this contract — without that, a long view body would push the URL out of tail -N range and produce duplicate-create regressions when callers retry on a perceived URL-less success.

On gh failure the marker is not emitted; gh's own stdout and stderr are mirrored verbatim and the wrapper exits with gh's return code.

Options

  • --body-file <path> (alias -F) — read body from a file.
  • --body-stdin — read body from stdin.
  • --no-view — skip the post-action gh view summary. The marker line still fires as long as the wrapped gh call printed the target URL on stdout (the standard behavior of gh create|edit|comment). --no-view does not issue any additional network call to recover a URL gh did not print.
  • --verbose — print the full body in the summary instead of the first 50 lines.
  • --no-format — opt out of the default auto-format paragraph reflow (see "Auto-format" below). The body is forwarded byte-for-byte and the hardwrap detector rejects column-wrapped bodies instead of reflowing them.
  • --version, --help — standard.

Rejected on the wrapper boundary (these defeat the funneling guarantee):

  • Inline --body <string> and gh's short alias -b, including pflag forms (-b=str, -bstr, -b--str).
  • Interactive body-authoring flags --editor / -e, --web / -w, --fill / -f, including pflag cluster forms (-wtrue, -eat).

Validators

Hard-wrap detection runs at paragraph granularity. Block recognition is delegated to markdown-it-py's CommonMark tokenizer: only top-level paragraph tokens feed the detector, and fenced code blocks, lists, blockquotes, headings, HTML blocks, link reference definitions, indented code, and tables are themselves exempt. Paragraphs nested inside list items, blockquotes, or table cells are excluded from the detector as well.

Each paragraph is evaluated against three hard-reject invariants:

  1. Semantic-run. Three or more consecutive in-band (50-85 UTF-8 bytes) lines whose normalized endings are non-terminal. Multiple independent runs in the same paragraph each emit a flag.
  2. Width-control. A sliding window of three in-band lines whose widths span at most 11 bytes, even when each line ends in a clause terminator. Suppressed when semantic-run already fired in the paragraph.
  3. Remainder-tail. The paragraph's final line is below the band and clause-terminator-ending, immediately preceded by two or more in-band non-terminal-normalized lines. Catches the fat-fat-thin column-wrap shape (two long lines plus a short remainder). Suppressed when semantic-run fired.

Normalized termination strips trailing whitespace and any run of trailing structural closers (`, ), ], }, >, ", ') before checking the line's last character against the clause-terminator set (.!?:;,). A line ending in ...code` is mid-clause (the backtick is a markup close, not a sentence end), while foo.) and foo." are correctly classified as clause-terminated.

Use semantic line breaks — one sentence or clause per line, varying in length with the content — instead of column wrapping. CJK-dominant lines fall outside the byte band and are not currently caught by the byte-band heuristic; that is the legacy detector's posture as well.

Auto-format

Bodies submitted via every body-bearing subcommand (issue create, issue edit, issue comment, pr create, pr edit, pr comment, comment-edit, reply-inline) are auto-formatted before being forwarded to gh. The pass is delegated to mdformat (with the gfm, gfm_alerts, and footnote extensions) and operates on the following contract:

  • Within each CommonMark paragraph — top-level, inside list items, and inside blockquotes alike — soft-break newlines collapse to single spaces. Multi-line paragraphs become single lines on the wire.
  • Hard breaks ( \n trailing-two-space or unescaped trailing \) are preserved semantically. mdformat canonicalizes the two-space form to the backslash form (\\\n); both are CommonMark- equivalent and render identically on GitHub.
  • Fenced code-block contents (both ``` and ~~~, including fence-char collision cases) and HTML blocks (including <!-- comments -->) are forwarded with their inner bytes intact. Other block regions — lists, blockquotes, tables, headings, horizontal rules, indented code, link reference definitions — are not guaranteed byte-identical: input already in mdformat's canonical form (e.g., ATX headings, - bullets, 1. ordered-list digits) round-trips unchanged, while non-canonical spellings are rewritten. All such rewrites are HTML-equivalent on GitHub's renderer; representative cases include setext headings rewritten to ATX, --- horizontal rules to underscore runs, indented code to fenced code, tilde fences to backtick fences, ordered-list digits canonicalized to 1., GFM table columns visually aligned, inline > [!NOTE] body alerts expanded to the two-line marker form, and unused link reference definitions stripped.
  • Paragraphs nested inside table cells are NOT reflowed.
  • Bullet markers (* / +-), fence characters (~~~ → backtick), heading styles (setext → ATX), and similar spelling variants ARE normalized by mdformat to its canonical CommonMark form. All such normalizations are HTML-equivalent on GitHub's renderer; consumers viewing the post-format body should treat the spelling as the formatter's choice, not the author's.

Opt-out

The reflow pass is opt-out on the wrapper boundary:

  • --no-format on any subcommand opts out for that single invocation. The body is forwarded byte-for-byte and the hardwrap detector reverts to its hard-rejector behavior.
  • GH_POST_NO_FORMAT=true (or any of 1, t, T, TRUE, True) as an environment variable opts out for the entire process invocation. Falsy literals (0, f, F, FALSE, false, False) and unset behave like the default (format-on). Any other value exits non-zero with a clear error before the body is read.
  • --no-format wins over the environment when both are set.

Why a wrapper

A naive PreToolUse hook that parsed gh ... --body ... invocations to extract the body could only support a handful of shapes (--body-file, --body "$(cat <<TAG ... TAG)", gh api ... -F body=@<path>) and would fail-open silently on everything else. Shell-substitution forms such as gh issue create --body "$(cat file.md)" reach GitHub unvalidated because the detector does not recognize the shape.

Detector-per-form is whack-a-mole. The wrapper instead funnels all body input through --body-file or --body-stdin, then a companion hook denies any direct gh (issue|pr) (create|edit|comment) ... --body* outside the wrapper. The bypass surface is reduced to "did you call gh directly?", which a hook can check.

Companion hook

The funneling guarantee is only complete with a PreToolUse hook that blocks any direct gh ... --body* call. The hook is environment-specific (Claude Code settings, dotfiles, team-shared MCP, etc.) and is not distributed with this repo. A working hook needs to:

  • match Bash tool calls,
  • deny when the command contains gh (issue|pr) (create|edit|comment) together with --body[\s=], --body-file, -b[\s=], or -F\s,
  • skip when the top-level command starts with git, since git commit messages routinely contain literal text mentioning gh subcommands without invoking them.

Roadmap

Issues track every deferred capability (additional subcommands, validator extensions, the allowlist-vs-passthrough redesign, hook coverage extensions). See the issue tracker.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages