Skip to content

feat(github): support cross-fork PRs by passing HeadRepositoryId#515

Open
jucor wants to merge 2 commits into
ejoffe:masterfrom
jucor:cross-fork-prs
Open

feat(github): support cross-fork PRs by passing HeadRepositoryId#515
jucor wants to merge 2 commits into
ejoffe:masterfrom
jucor:cross-fork-prs

Conversation

@jucor

@jucor jucor commented Jun 10, 2026

Copy link
Copy Markdown

Stacking note: this PR sits on top of the goreleaser CI pin PR (PR 1 of 3 in this batch). Until that PR lands, the diff here shows two commits — the CI pin plus the cross-fork support. Once PR 1 merges into master, this PR's diff will collapse to the single cross-fork commit on rebase.

Before this change spr could only open PRs whose head branch lived in the same repository as the PR target. That collapsed the (fork, upstream) distinction: GetInfo resolved a single RepositoryID and CreatePullRequest passed only HeadRefName, so GitHub looked for the head branch inside the upstream repo even when the user had pushed it to a fork.

We now detect cross-fork operation from the URL of cfg.Repo.GitHubRemote (the actual push target) vs cfg.Repo.GitHubRepoOwner/Name (the PR target). When they differ, spr resolves the fork's GitHub node ID via StarGetRepo and threads it through info.HeadRepositoryID. CreatePullRequest sets the GraphQL HeadRepositoryId field, which is GitHub's documented way to route a same-named branch through the fork.

When the URL is unparseable, the remote is missing, or StarGetRepo fails, detection silently falls back to same-fork behavior and GitHub surfaces the resulting error on the create call, matching pre-change semantics.

MaintainerCanModify is now a UserConfig option (default true) and is forwarded only in cross-fork mode; it has no meaning for same-fork PRs.

URL parsing is factored out as git.ParseRepoURL and shared with the existing config_parser remote source, which previously had its own inline regex.

Tests

  • ParseRepoURL: HTTPS / SSH / no-suffix / enterprise host / dashes / underscores / case / trailing slash / whitespace / malformed.
  • buildCreatePullRequestInput: same-fork omits HeadRepositoryId and MaintainerCanModify; cross-fork sets both; MaintainerCanModify=false is honoured; HeadRepositoryID equal to RepositoryID is treated as same-fork (defensive).

No behavior change for users whose origin is the PR target.

Known trade-off: cross-fork stacked PRs lose the per-commit diff view

A cross-fork PR's base branch must live in the upstream repo — that's a GitHub constraint, not a spr one. For a stack of PRs that means every PR in the stack must base on master (the only branch in upstream the contributor can reference) rather than on the previous PR's head (which lives in the fork). Consequence: each subsequent PR's "Files changed" tab shows the cumulative diff from all predecessors, not just its own commit.

In spr's native same-fork stacked PRs (where origin is the PR target), PR N's base is PR N-1's head branch — same repo, so it works — and each PR's diff is exactly one commit. Cross-fork sacrifices that.

For reviewers:

  • The commits tab still shows each commit individually; that's the per-commit view to use.
  • Compare URLs (/compare/SHA1...SHA2) pasted into the PR body can point at the per-commit diff.
  • Code-review bots (e.g. GitHub Copilot) review the cumulative diff. Expect them to re-flag earlier-PR issues on each subsequent PR — roughly N× the noise for an N-deep stack. Practical workaround: only request the bot on the top PR.
  • Once each predecessor lands at upstream, rebasing the rest of the stack collapses each remaining PR's diff back to single-commit, so the cumulative-diff window is bounded by the merge cadence.

The only way to recover proper stacked PRs across fork boundaries is for the upstream maintainer to grant the contributor write access to the upstream repo (collaborator role with Write or Maintain permission). That lets the contributor push feature branches directly to upstream, which can then be valid PR bases. This is unrelated to the maintainerCanModify flag this PR adds — that goes the opposite direction (maintainer can push to contributor's PR branch on the fork). GitHub has no in-between for cross-fork base permissions.


Full transparency: I made heavy use of Claude Code (Opus 4.7, high effort) for this PR, as I do for my own work these days.

goreleaser v2.16.0 (2026-05-24) turned the deprecated `brews:` key in
.goreleaser.yml into a hard error. With `version: latest`, every push
since fails CI's `goreleaser check` step regardless of the code change.

v2.15.4 (2026-04-21) is the latest release that still accepts `brews:`
with only a warning and also understands the current
`archives.format_overrides.formats` syntax. Pinning to it restores green
CI without touching .goreleaser.yml.

## Migration path (for whoever unpins)

The goreleaser deprecation page recommends migrating `brews:` →
`homebrew_casks:`. For simple cases this is a key rename, plus a
`hooks.post.install` block to strip the macOS quarantine xattr (because
the binaries aren't signed/notarized).

End-user impact:

- macOS: transparent. The binary still lands at
  `/opt/homebrew/bin/git-spr` (via cask `binary` stanza) — same PATH,
  same usage. The install command may need `--cask` explicitly in the
  readme.
- Linux: regression. Casks are macOS-only; Linux brew users currently
  installing via `brew install ejoffe/tap/spr` would have to switch to
  another channel (binary release, apt, Nix, source).
- Tap repo: `Formula/spr.rb` becomes `Casks/spr.rb`. The
  `ejoffe/homebrew-tap` repo would need a transition plan.

Alternative: drop `brews:` entirely and remove the brew install path
from the readme. Simpler but breaks the channel outright.

Either way it's a deliberate distribution decision, not a mechanical CI
fix, which is why this PR pins rather than migrates.

Unpin once the `brews:` block is migrated or removed.
Before this change spr could only open PRs whose head branch lived in the
same repository as the PR target. That collapsed the (fork, upstream)
distinction: GetInfo resolved a single RepositoryID and CreatePullRequest
passed only HeadRefName, so GitHub looked for the head branch inside the
upstream repo even when the user had pushed it to a fork.

We now detect cross-fork operation from the URL of cfg.Repo.GitHubRemote
(the actual push target) vs cfg.Repo.GitHubRepoOwner/Name (the PR target).
When they differ, spr resolves the fork's GitHub node ID via StarGetRepo
and threads it through info.HeadRepositoryID. CreatePullRequest sets the
GraphQL HeadRepositoryId field, which is GitHub's documented way to route
a same-named branch through the fork.

When the URL is unparseable, the remote is missing, or StarGetRepo fails,
detection silently falls back to same-fork behavior and GitHub surfaces
the resulting error on the create call, matching pre-change semantics.

MaintainerCanModify is now a UserConfig option (default true) and is
forwarded only in cross-fork mode; it has no meaning for same-fork PRs.

URL parsing is factored out as git.ParseRepoURL and shared with the
existing config_parser remote source, which previously had its own
inline regex.

Tests cover:
  - ParseRepoURL: HTTPS / SSH / no-suffix / enterprise host / dashes /
    underscores / case / trailing slash / whitespace / malformed.
  - buildCreatePullRequestInput: same-fork omits HeadRepositoryId and
    MaintainerCanModify; cross-fork sets both; MaintainerCanModify=false
    is honoured; HeadRepositoryID equal to RepositoryID is treated as
    same-fork (defensive).

No behavior change for users whose origin is the PR target.
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.

1 participant