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.
uv tool install git+https://github.com/ultimatile/gh-postRequires Python 3.11+ and the GitHub CLI (gh) on PATH. The gh-post
executable is placed on whichever directory uv tool install configures.
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-stdinBatched 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."}
JSONLEach 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.
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.mdAccepted 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.mdProviding --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 hardeningOn 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.
--body-file <path>(alias-F) — read body from a file.--body-stdin— read body from stdin.--no-view— skip the post-actiongh viewsummary. The marker line still fires as long as the wrappedghcall printed the target URL on stdout (the standard behavior ofgh create|edit|comment).--no-viewdoes not issue any additional network call to recover a URLghdid 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).
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:
- 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.
- 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.
- 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.
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 (
\ntrailing-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 to1., GFM table columns visually aligned, inline> [!NOTE] bodyalerts 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.
The reflow pass is opt-out on the wrapper boundary:
--no-formaton 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 of1,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-formatwins over the environment when both are set.
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.
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
Bashtool 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.
Issues track every deferred capability (additional subcommands, validator extensions, the allowlist-vs-passthrough redesign, hook coverage extensions). See the issue tracker.