Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,31 @@ Native stacks appear in the default PR overview with bottom-to-top ordering, the

Checks are paginated and tied to the PR head. Workflow names and run links distinguish same-named jobs; the display keeps the latest reported run per workflow and event. For stacks, required checks come from the stack target's classic branch protection and rulesets, with missing contexts shown as `EXPECTED`. Required checks pinned to an app must match that app; optional failures alone do not block a stack merge. If branch rules are inaccessible, the output reports that required-check coverage is incomplete. `pr checks --all` also shows historical head checks for closed and merged PRs.

Stack mergeability describes the unmerged layers from the bottom through the selected PR and their blockers, then links to GitHub's native stack merge controls. GitHub makes the final readiness decision. Diff and `review-start` remain scoped to the selected PR and its direct base; cumulative stack diffs are not included. Base branch changes appear in the timeline. Historical stack join/leave events are not reconstructed: the API does not reliably expose their historical stack IDs.
Stack mergeability describes the unmerged layers from the bottom through the selected PR and their blockers, then offers `gh-llm pr merge` commands pinned to the inspected head. GitHub makes the final readiness decision. Diff and `review-start` remain scoped to the selected PR and its direct base; cumulative stack diffs are not included. Base branch changes appear in the timeline. Historical stack join/leave events are not reconstructed: the API does not reliably expose their historical stack IDs.

### Merging PRs and Native Stacks

`pr merge` uses GitHub's [asynchronous merge API](https://docs.github.com/en/rest/pulls/pulls?apiVersion=2026-03-10#merge-a-pull-request-asynchronously) for both ordinary and stacked PRs. It requires neither a browser nor the `gh-stack` extension. For a stack, selecting a PR also merges its open downstack PRs into the stack target; upper layers are excluded. The command prints this scope before submitting one request to GitHub.

```bash
# Use the head SHA from `pr view` to guard against changes since inspection
gh-llm pr merge <pr_number> --repo yutto-dev/yutto --squash --head <head_sha>

# Customize a direct merge's commit message
gh-llm pr merge <pr_number> --repo yutto-dev/yutto --merge \
--subject 'Feature title' --body-file merge-message.md

# Request the merge queue explicitly; its configuration determines the merge method
gh-llm pr merge <pr_number> --repo yutto-dev/yutto --merge-action merge_queue

# Return immediately and retain the request UUID for a later status check
gh-llm pr merge <pr_number> --repo yutto-dev/yutto --squash --timeout 0
gh-llm pr merge-status <uuid> --pr <pr_number> --repo yutto-dev/yutto --timeout 60
```

By default, `pr merge` follows the target branch's merge queue configuration and polls for up to 60 seconds. `--merge-action direct_merge` requests a direct merge; rules are enforced unless you explicitly pass `--bypass-rules` and have the necessary permission. `--merge`, `--squash`, and `--rebase` select the direct merge method. Omitted commit titles, messages, and methods use GitHub's defaults. `--body-file -` reads a message from standard input. Without `--head`, the freshly fetched PR head is sent as the expected SHA.

Output distinguishes `pending`, `merged`, `enqueued`, and `failed`. Exit codes are **0** for merged or enqueued, **1** for failure, and **2** for a request still pending after the polling timeout. `enqueued` means only that the PR entered the merge queue; inspect the PR to confirm its eventual merge. The request UUID and a `merge-status` command are printed before polling, so interrupted waits can be resumed without another write. An existing pending request is followed with its original options, which are shown in the output. Merge writes are never automatically retried after a network error. GitHub retains request results for 24 hours after their last update; if a result has expired, inspect the PR's current state.

### PR Body Scaffold

Expand Down
15 changes: 13 additions & 2 deletions skills/github-conversation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ metadata:

## Tool split

1. Use `gh-llm` for reading context (timeline, collapsed items, review threads, checks) and structured review actions (reply, submit, resolve).
2. Use `gh` for simple write actions (comment, reactions, labels, assignees, reviewers, close/reopen, merge).
1. Use `gh-llm` for reading context (timeline, collapsed items, review threads, checks), structured review actions (reply, submit, resolve), and asynchronous PR or stack merges.
2. Use `gh` for simple write actions (comment, reactions, labels, assignees, reviewers, close/reopen).
3. If context is incomplete, do not reply yet; expand first.
4. Apply the current task's publication mode and authorized scope to every GitHub write, including reactions and resolving threads. In read-only or dry-run mode, keep proposed updates local. `viewerCanReact` indicates account capability, not operator authorization.

Expand Down Expand Up @@ -133,6 +133,17 @@ gh-llm pr body-template \

Use this before `gh pr create` when you need to load a repo PR template, append required sections, and produce a ready-to-edit body file.

### Merge a PR or native stack

After checking the current PR and receiving authorization to merge, use the inspected head SHA:

```bash
gh-llm pr merge <pr_number> --repo <owner/repo> --squash --head <head_sha>
gh-llm pr merge-status <uuid> --pr <pr_number> --repo <owner/repo> --timeout 60
```

For a native stack, the merge includes the selected PR and its open downstack PRs. Verify that this entire scope is authorized. The async API honors the target branch's merge queue by default. `enqueued` is not merged; check the PR again for the eventual outcome. A pending request returns exit code 2 after the timeout and prints its UUID and resume command. Resume with `merge-status` instead of resubmitting. When an existing request is returned, its original options remain in effect. Do not retry a merge write blindly after a network error; inspect the PR first.

### Read an issue

```bash
Expand Down
2 changes: 2 additions & 0 deletions src/gh_llm/commands/pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
resolve_file_or_inline_text,
resolve_subject,
)
from gh_llm.commands.pr_merge import register_merge_parsers
from gh_llm.github_api import GitHubClient
from gh_llm.invocation import display_command_with
from gh_llm.models import CheckResults, PullRequestDiffPage
Expand Down Expand Up @@ -74,6 +75,7 @@ class _ShowOptions:
def register_pr_parser(subparsers: Any) -> None:
pr_parser = subparsers.add_parser("pr", help="pull request reading, review, and actions")
pr_subparsers = pr_parser.add_subparsers(dest="pr_command")
register_merge_parsers(pr_subparsers)

view_parser = pr_subparsers.add_parser(
"view",
Expand Down
158 changes: 158 additions & 0 deletions src/gh_llm/commands/pr_merge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import time
from typing import TYPE_CHECKING, Any
from uuid import UUID

from gh_llm.commands.options import add_body_input_arguments, resolve_file_or_inline_text, resolve_subject
from gh_llm.github_api import GitHubClient
from gh_llm.invocation import display_command_with

if TYPE_CHECKING:
from gh_llm.models import AsyncMergeResult, PullRequestRef


def register_merge_parsers(subparsers: Any) -> None:
parser = subparsers.add_parser("merge", help="merge a PR and its open downstack PRs through the async merge API")
parser.add_argument("pr", nargs="?", help="PR number/url/branch")
parser.add_argument("--repo", help="repository in OWNER/REPO format")
methods = parser.add_mutually_exclusive_group()
for method in ("merge", "squash", "rebase"):
methods.add_argument(
f"--{method}",
dest="merge_method",
action="store_const",
const=method,
help=f"use {method} for a direct merge",
)
parser.add_argument("--head", help="expected PR head SHA; defaults to the freshly fetched head")
parser.add_argument("--subject", help="commit title for a direct merge; defaults to GitHub's title")
add_body_input_arguments(
parser,
required=False,
body_help="commit message for a direct merge",
file_help="read commit message from file (use `-` for standard input)",
)
parser.add_argument(
"--merge-action",
choices=("default", "direct_merge", "merge_queue"),
default="default",
help="default honors the target branch's merge queue configuration",
)
parser.add_argument(
"--bypass-rules", action="store_true", help="explicitly request bypass of rules that your account may bypass"
)
parser.add_argument(
"--timeout", type=int, default=60, help="seconds to poll for a result (default: 60; 0 returns immediately)"
)
parser.set_defaults(handler=cmd_pr_merge)

status = subparsers.add_parser(
"merge-status", help="inspect or wait for an existing async merge request without resubmitting"
)
status.add_argument("request_id", help="merge request UUID")
status.add_argument("--pr", help="PR number/url/branch")
status.add_argument("--repo", help="repository in OWNER/REPO format")
status.add_argument("--timeout", type=int, default=0, help="seconds to poll while pending (default: 0)")
status.set_defaults(handler=cmd_pr_merge_status)


def cmd_pr_merge(args: Any) -> int:
_validate_timeout(args.timeout)
body = None
if args.body is not None or args.body_file is not None:
body = resolve_file_or_inline_text(args, text_attr="body", file_attr="body_file")
if args.merge_action == "merge_queue" and any(
value is not None for value in (args.merge_method, args.subject, body)
):
raise RuntimeError("merge method, subject, and body are only supported for direct merges")
client = GitHubClient()
meta = resolve_subject(selector=args.pr, repo=args.repo, selector_flag="PR", resolver=client.resolve_pull_request)
if meta.is_draft or meta.state not in {"OPEN", "MERGED"}:
raise RuntimeError("cannot merge a draft, closed, or unknown-state pull request")
if not meta.stack_supported:
raise RuntimeError("native stack metadata is unavailable; cannot determine the merge scope")
head = meta.head_ref_oid
if args.head is not None and args.head != head:
raise RuntimeError("PR head changed; refresh the PR before merging")
if meta.state == "OPEN" and not head:
raise RuntimeError("PR head SHA is unavailable; cannot submit a merge request")
meta = client.fetch_pull_request_stack(meta)
if meta.stack:
if not meta.stack.complete or not any(
entry.number == meta.ref.number and entry.position == meta.stack.position for entry in meta.stack.entries
):
raise RuntimeError("stack entries are incomplete; refresh the PR before merging")
scope = [
entry for entry in meta.stack.entries if entry.position <= meta.stack.position and entry.state != "MERGED"
]
if any(entry.is_draft or entry.state != "OPEN" for entry in scope):
raise RuntimeError("merge scope includes a draft or closed downstack PR")
print(
f"Stack merge scope into `{meta.stack.base_ref_name}`: "
+ " → ".join(f"#{entry.number}" for entry in scope),
flush=True,
)
result = client.request_pull_request_merge(
meta.ref,
sha=head,
merge_method=args.merge_method,
merge_action=args.merge_action,
subject=args.subject,
body=body,
bypass_rules=args.bypass_rules,
)
return _wait_for_merge(client, meta.ref, result, timeout=args.timeout)


def cmd_pr_merge_status(args: Any) -> int:
_validate_timeout(args.timeout)
request_id = str(UUID(args.request_id))
client = GitHubClient()
meta = resolve_subject(selector=args.pr, repo=args.repo, selector_flag="--pr", resolver=client.resolve_pull_request)
result = client.fetch_pull_request_merge(meta.ref, request_id)
return _wait_for_merge(client, meta.ref, result, timeout=args.timeout)


def _validate_timeout(timeout: int) -> None:
if timeout < 0:
raise RuntimeError("--timeout must be zero or greater")


def _wait_for_merge(client: GitHubClient, ref: PullRequestRef, result: AsyncMergeResult, *, timeout: int) -> int:
print("## Merge")
print(f"repo: {ref.owner}/{ref.name}")
print(f"pr: #{ref.number}")
if result.existing_request:
print("existing_request: true (following the existing request; new merge options were not applied)")
if result.request_id:
print(f"request_id: {result.request_id}")
for key in ("merge_method", "merge_action", "expected_head_sha", "bypass_rules"):
value = getattr(result, key)
if value is not None:
print(f"{key}: {str(value).lower() if isinstance(value, bool) else value}")
if result.status == "pending":
assert result.request_id is not None
request_id = result.request_id
print("status: pending")
resume = display_command_with(
f"pr merge-status {result.request_id} --pr {ref.number} --repo {ref.owner}/{ref.name} --timeout 60"
)
print(f"⏎ resume: `{resume}`", flush=True)
deadline = time.monotonic() + timeout
while result.status == "pending":
remaining = deadline - time.monotonic()
if remaining <= 0:
print("Merge request is still pending; use the request ID above to check again.")
return 2
time.sleep(min(2, remaining))
result = client.fetch_pull_request_merge(ref, request_id)
print(f"status: {result.status}")
if result.message:
print(f"message: {result.message}")
if result.sha:
print(f"merge_commit: {result.sha}")
if result.status == "enqueued":
print("Added to the merge queue; the PR has not necessarily merged.")
print(
f"⏎ check PR: `{display_command_with(f'pr view {ref.number} --repo {ref.owner}/{ref.name} --show meta,mergeability')}`"
)
return 1 if result.status == "failed" else 0
86 changes: 86 additions & 0 deletions src/gh_llm/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
from fnmatch import fnmatchcase
from typing import TYPE_CHECKING, cast
from urllib.parse import quote, urlparse
from uuid import UUID

from gh_llm.diagnostics import GhCommandError, looks_like_transport_error
from gh_llm.invocation import display_command, display_command_with
from gh_llm.models import (
AsyncMergeResult,
CheckItem,
CheckResults,
PageInfo,
Expand Down Expand Up @@ -1715,6 +1717,53 @@ def _get_viewer_login(self) -> str:
login = _as_optional_str(payload.get("login"))
return login or ""

def request_pull_request_merge(
self,
ref: PullRequestRef,
*,
sha: str | None,
merge_method: str | None = None,
merge_action: str = "default",
subject: str | None = None,
body: str | None = None,
bypass_rules: bool = False,
) -> AsyncMergeResult:
cmd = _async_merge_command(ref)
cmd.extend(
["--method", "PUT", "-f", f"merge_action={merge_action}", "-F", f"bypass_rules={str(bypass_rules).lower()}"]
)
for key, value in (
("sha", sha),
("merge_method", merge_method),
("commit_title", subject),
("commit_message", body),
):
if value is not None:
cmd.extend(["-f", f"{key}={value}"])
try:
# A transport failure may occur after acceptance. Never retry a merge write automatically.
payload = _run_command_json(cmd)
except GhCommandError as error:
if "HTTP 409" not in error.stderr:
raise
try:
payload = _as_dict(json.loads(error.stdout), context="existing merge request")
result = _parse_async_merge_result(payload)
except ValueError, RuntimeError:
raise error from None
if result.status != "pending":
raise
return replace(result, existing_request=True)
return _parse_async_merge_result(payload)

def fetch_pull_request_merge(self, ref: PullRequestRef, request_id: str) -> AsyncMergeResult:
request_id = str(UUID(request_id))
payload = _run_command_json(_async_merge_command(ref, request_id))
result = _parse_async_merge_result(payload)
if result.request_id and result.request_id != request_id:
raise RuntimeError("merge response does not match the requested UUID")
return replace(result, request_id=request_id)

def fetch_checks(self, ref: PullRequestRef, *, meta: PullRequestMeta | None = None) -> CheckResults:
nodes: list[dict[str, object]] = []
head_oid = meta.head_ref_oid if meta else None
Expand Down Expand Up @@ -2444,6 +2493,43 @@ def _graphql_query_max_attempts(query: str) -> int:
return GRAPHQL_MAX_ATTEMPTS


def _async_merge_command(ref: PullRequestRef, request_id: str | None = None) -> list[str]:
endpoint = f"repos/{ref.owner}/{ref.name}/pulls/{ref.number}/merge-async"
if request_id:
endpoint += f"/{request_id}"
return [
"gh",
"api",
endpoint,
"-H",
"Accept: application/vnd.github+json",
"-H",
"X-GitHub-Api-Version: 2026-03-10",
]


def _parse_async_merge_result(payload: dict[str, object]) -> AsyncMergeResult:
status = _as_optional_str(payload.get("status"))
if status not in {"pending", "merged", "enqueued", "failed"}:
raise RuntimeError(f"unexpected asynchronous merge status: {status!r}")
details = _as_dict(payload.get("details"), context="merge result details")
request_id = _as_optional_str(details.get("uuid"))
if request_id:
request_id = str(UUID(request_id))
if status == "pending" and not request_id:
raise RuntimeError("pending merge response is missing its request UUID")
return AsyncMergeResult(
status=status,
message=_as_optional_str(details.get("message")) or "",
request_id=request_id,
sha=_as_optional_str(details.get("sha")),
merge_method=_as_optional_str(details.get("merge_method")),
merge_action=_as_optional_str(details.get("merge_action")),
expected_head_sha=_as_optional_str(details.get("expected_head_sha")),
bypass_rules=_as_optional_bool(details.get("bypass_rules")),
)


def _run_command_json(
cmd: list[str],
*,
Expand Down
Loading
Loading