feat: emit URL on marked final line after create/edit/comment#18
Conversation
After a successful `gh-post issue|pr create|edit|comment`, the wrapper
now suppresses the early `gh` stdout URL echo, runs the auto-`gh view`
summary first, and emits a final marker line of the form
`[gh-post] {created|edited|commented}: <URL>`. The marker is greppable
and is always the last stdout line, so callers using `tail -N` recover
the URL regardless of view body length — closing the duplicate-create
regression where a long view body pushed the URL out of tail range.
On `gh` failure or on a clean exit without a recognizable github.com
URL, the marker is suppressed and stdout is mirrored verbatim so no
information is silently dropped.
`gh issue|pr edit` can exit 0 without printing the target URL on stdout under some versions / configurations. The earlier URL-only gate dropped both the view summary and the `[gh-post] edited:` marker line in that case even though the wrapper already knows the target number and repo from argv. On the edit path, synthesize the URL from --repo + number when extraction from gh stdout returns None, so the view + marker contract still holds. Create and comment retain the URL-required behavior — the wrapper has no other source of truth for the issue / PR number on create or the comment id on comment.
The previous edit-fallback synthesis hardcoded `https://github.com` as the host. That breaks against GitHub Enterprise repositories (`--repo HOST/OWNER/REPO`) and also failed for current-repo edits invoked without `--repo` at all, since the synthesizer required the caller to have supplied `--repo`. Make `view_short` return its JSON-reported URL alongside the rc, and let `cmd_post` pick `stdout_url or view_url` for the marker. The follow-up `gh view` already runs for create/edit (target number is known either from argv or from the host-agnostic stdout regex), so its JSON URL is a host-aware fallback that handles both GHE and the silent-edit case without any hardcoded host.
`_URL_RE` was anchored to `github.com`, so URLs printed by `gh` on Enterprise hosts (`--repo HOST/OWNER/REPO`) slipped past extraction. For comments — which run no auto-view fallback — that meant no marker line at all on Enterprise. Re-anchor on the issue / PR path (`/issues/N` or `/pull/N`) instead, which is host-agnostic but still rejects stray help URLs (e.g. `https://docs.github.com/manual`) in warning text. Also tighten the `--no-view` README note: the marker fires as long as `gh` printed the target URL on stdout (the standard behavior); the wrapper does not issue an additional network call to recover a URL `gh` did not print, which would contradict the spirit of `--no-view`.
`gh pr create --dry-run` succeeds without creating anything and prints the would-be body to stdout. If the body contains an issue / PR URL — a common case for `Closes #N` style references — the new marker contract would emit `[gh-post] created: <body URL>` for a non-event. Detect `--dry-run` in the passthrough args and fall back to verbatim gh output mirroring for that case. No marker, no auto-view.
`gh` uses pflag, which also accepts the boolean-form `--dry-run=true` (and `1`, `t`, `T`, `TRUE`, `True`). The earlier exact-token `--dry-run` check missed those, allowing a dry-run invocation to fall through to the marker / view path and emit a spurious `[gh-post] created:` line for a body-embedded URL. Promote the detection to `_is_dry_run`, which accepts the bare form and any pflag truthy literal in the `--dry-run=` form, and rejects the explicit falsy literals (`--dry-run=false` and friends) so a caller can selectively re-enable the marker contract.
pflag processes repeated flags in order and uses the final value, so `--dry-run --dry-run=false` ends up with dry-run disabled and gh performs the real action. The earlier first-match-wins detection would incorrectly suppress the success marker in that case. Scan every occurrence and let the last one decide. Both `--dry-run=false` followed by bare `--dry-run` and bare `--dry-run` followed by `--dry-run=false` are now correctly resolved.
There was a problem hiding this comment.
Pull request overview
Implements issue #17 by reordering the success-path output of gh-post issue|pr create|edit|comment so the auto-gh view summary prints first and a single [gh-post] {created|edited|commented}: <URL> marker line is always last on stdout. This makes the URL reliably recoverable via tail -N or grep, fixing a duplicate-create regression where long view bodies pushed the URL out of tail -N range.
Changes:
- Add
extract_url(path-anchored, host-agnostic regex supporting GHE + comment fragments) and_is_dry_run(pflag-aware, last-occurrence semantics) helpers; changeview_shortto return(rc, url)so the marker URL can fall back to the JSON-reported URL on silentgh edit. - Reorder
cmd_postsuccess path to suppress gh stdout, run the view, and emit the marker line last; suppress marker on--dry-runand onghfailure. - Re-export new helpers in
gh_post/__init__.py; document the contract inREADME.md; add extensive tests covering ordering,--no-view, failure, GHE hosts, dry-run pflag forms, and silent-edit fallback.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| gh_post/subcommands/post.py | Adds extract_url, _is_dry_run, _MARKER_VERB; updates view_short to return URL; rewrites cmd_post success path to emit the marker line last. |
| gh_post/init.py | Re-exports new helpers and constants for test/public access. |
| README.md | Documents post-action output contract and --no-view interaction. |
| test_gh_post.py | Adds tests for extract_url, _is_dry_run, marker ordering, GHE hosts, failure/no-view/dry-run handling, and silent-edit fallback. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Remove the "even though `extract_url` is github.com-only" clause from `cmd_post`'s target_number comment; after the path-anchored refactor, both `extract_url` and `extract_created_number` are host-agnostic. - Document the shared scan limitation in `_is_dry_run`: a literal `--dry-run` passed as another flag's value (`--title --dry-run`) is mis-detected. The same shape applies to `extract_repo`, `extract_url`, and `extract_created_number`. The recommended workaround is the single-argument `--title=--dry-run` form.
| # Success path. Mirror stderr verbatim (warnings, deprecation | ||
| # notices) but suppress gh's stdout — the URL it prints is | ||
| # reordered to the trailing marker line below so a `tail -N` | ||
| # heuristic surfaces the URL rather than the tail of the view | ||
| # body. | ||
| if result.stderr: | ||
| sys.stderr.write(result.stderr) | ||
| if not result.stderr.endswith("\n"): | ||
| sys.stderr.write("\n") | ||
|
|
||
| # Extracted URL from gh's stdout, when present, is the | ||
| # authoritative marker source. For create/comment it is the | ||
| # only source — the wrapper has no other way to learn the | ||
| # newly-allocated issue / PR number or the new comment id. | ||
| # For edit, gh may exit cleanly without printing the URL on | ||
| # some versions / configurations; the view call below then | ||
| # supplies the URL from its JSON response. | ||
| stdout_url = extract_url(result.stdout) | ||
|
|
||
| # Determine target_number for the optional auto-followup view. | ||
| # edit / comment: already in argv. create: parse from stdout | ||
| # using a host-agnostic regex so GitHub Enterprise URLs are | ||
| # handled. | ||
| target_number: Optional[str] = None | ||
| if spec["number"]: | ||
| target_number = args.number | ||
| else: | ||
| target_number = extract_created_number(result.stdout) | ||
|
|
||
| view_url: Optional[str] = None | ||
| if not args.no_view and args.action != "comment" and target_number is not None: | ||
| _, view_url = view_short( | ||
| args.kind, | ||
| target_number, | ||
| extract_repo(args.passthrough), | ||
| args.verbose, | ||
| ) | ||
|
|
||
| marker_url = stdout_url or view_url | ||
|
|
||
| if marker_url is None: | ||
| # Clean exit without any recoverable URL — neither gh's | ||
| # stdout nor a view call yielded one (or view was skipped | ||
| # for comment / --no-view). Mirror stdout so the | ||
| # information is not silently dropped, and skip the | ||
| # marker. | ||
| if result.stdout: | ||
| sys.stdout.write(result.stdout) | ||
| if not result.stdout.endswith("\n"): | ||
| sys.stdout.write("\n") | ||
| return 0 | ||
|
|
||
| # Final marker line so callers can `tail -N` or grep | ||
| # `[gh-post] ` to extract the URL regardless of view body | ||
| # length. The marker is the last stdout line on the success | ||
| # path by construction; anything appended after this would | ||
| # break that contract and is caught by the marker-ordering | ||
| # test. | ||
| print(f"[gh-post] {_MARKER_VERB[args.action]}: {marker_url}") |
There was a problem hiding this comment.
Acknowledged and documented in the next commit. For the wrapped subcommands gh currently prints only the target URL on stdout (informational content goes to stderr), so suppressing all of stdout is safe today. The comment now flags this as an assumption that needs a per-line URL scrub if upstream starts emitting non-URL stdout content.
- Extract `_emit_stream(text, stream)` from `_emit_subprocess_output` so the success path in `cmd_post` (which mirrors only stderr, or only stdout in the no-URL fallback) shares the single-newline write shape with the existing combined helper instead of open-coding it. - Strengthen the stdout-suppression comment in `cmd_post` to make the assumption explicit: the wrapped `gh` subcommands currently print only the target URL on stdout; if upstream ever starts emitting informational stdout content, the suppression branch will need to scrub only the URL line. - Make `_is_dry_run` stop at the POSIX-style `--` end-of-options separator so a literal `--dry-run` token passed as a positional value is not mis-detected as a flag.
Summary
Closes #17.
Reorders post-action output on a successful
gh-post issue|pr create|edit|commentso the auto-gh viewbody is printed first and a single marker line carries the URL last.The marker has the form
[gh-post] {created|edited|commented}: <URL>, making the URL recoverable bytail -Norgrep '[gh-post] 'regardless of view body length.This closes the duplicate-create regression where a long view body pushed
gh's early URL echo out oftail -Nrange and led callers to retry the action.Changes
gh_post/subcommands/post.py: addextract_url(path-anchored, host-agnostic) and_is_dry_run(pflag-aware, last-occurrence semantics, POSIX--honored); reordercmd_postsuccess path; return the JSON URL fromview_shortfor use as the marker URL whengh editexits silently; emit the marker line as the last stdout line.gh_post/_subprocess.py: factor_emit_stream(text, stream)from_emit_subprocess_outputso the single-stream mirror in the success path shares the trailing-newline write shape.gh_post/__init__.py: re-export the new helpers.README.md: document the post-action output contract and the precise--no-viewinteraction.test_gh_post.py: marker-line, ordering,--no-view, failure, GitHub Enterprise, dry-run, pflag-form, and--separator tests.Test plan
uv run pytest test_gh_post.py -q— 172 passed.uv run ruff check/uv run ruff format --check— clean.Behavior coverage: marker placement under create / edit / comment; ordering vs. view body;
--no-view;ghfailure (no marker);ghsilent-stdout edit (marker recovered via view JSON); current-repo edit without--repo; GitHub Enterprise create / comment;--dry-run/--dry-run=true/--dry-run=false; pflag last-occurrence override; POSIX--end-of-options separator.Out of scope
The marker convention is intentionally not applied to
comment-edit(PATCH on existing comments) orreply-inline(batched inline review replies).Those subcommands have different output contracts —
comment-editdoes not run an auto-followup view, andreply-inlinealready emits per-reply un-sent indices on failure.Extending the marker convention to them is left to a separate change when a caller surfaces a concrete need.
Notes
The marker URL extractor is host-agnostic.
It path-anchors on
/issues/Nor/pull/N, so GitHub Enterprise hosts work without the wrapper learning the host out-of-band.--no-viewis precise.The marker fires when
ghprinted the target URL on stdout (the standard behavior ofgh create|edit|comment).The wrapper does not issue an additional network call to recover an unusually silent invocation.