Skip to content
Open
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
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,16 @@ jobs:
- name: Check goreleaser file
uses: goreleaser/goreleaser-action@v6.2.1
with:
version: latest
# Pinned: goreleaser v2.16.0 (2026-05-24) started treating the
# deprecated `brews:` key in .goreleaser.yml as a hard error,
# breaking CI on every push. v2.15.4 (2026-04-21, the last
# release before the strict check) still accepts `brews:` with
# only a warning AND understands the current `formats:` syntax
# under `archives.format_overrides`. Unpin once .goreleaser.yml
# is updated (likely either drop the brews block or migrate it
# to whatever goreleaser eventually settles on as the homebrew
# successor).
version: 'v2.15.4'
args: check

#- name: Upload coverage
Expand Down
4 changes: 4 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ type UserConfig struct {

CreateDraftPRs bool `default:"false" yaml:"createDraftPRs"`
PreserveTitleAndBody bool `default:"false" yaml:"preserveTitleAndBody"`
// MaintainerCanModify is passed to GitHub when opening cross-fork PRs.
// When true (the default) upstream maintainers can push to the PR head
// branch on the fork. Has no effect for same-fork PRs.
MaintainerCanModify bool `default:"true" yaml:"maintainerCanModify"`
NoRebase bool `default:"false" yaml:"noRebase"`
NoFetch bool `default:"false" yaml:"noFetch"`
DeleteMergedBranches bool `default:"false" yaml:"deleteMergedBranches"`
Expand Down
28 changes: 8 additions & 20 deletions config/config_parser/remote_source.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package config_parser

import (
"fmt"
"regexp"
"strings"

Expand Down Expand Up @@ -38,27 +37,16 @@ func (s *remoteSource) Load(_ interface{}) {
}
}

// originPushURLRegex captures the URL portion of an `origin ... (push)` line
// from `git remote -v` output. The URL itself is parsed by git.ParseRepoURL.
var originPushURLRegex = regexp.MustCompile(`^origin\s+(\S+)\s+\(push\)`)

func getRepoDetailsFromRemote(remote string) (string, string, string, bool) {
// Allows "https://", "ssh://" or no protocol at all (this means ssh)
protocolFormat := `(?:(https://)|(ssh://))?`
// This may or may not be present in the address
userFormat := `(git@)?`
// "/" is expected in "http://" or "ssh://" protocol, when no protocol given
// it should be ":"
repoFormat := `(?P<githubHost>[a-z0-9._\-]+)(/|:)(?P<repoOwner>[\w-]+)/(?P<repoName>[\w-]+)`
// This is neither required in https access nor in ssh one
suffixFormat := `(.git)?`
regexFormat := fmt.Sprintf(`^origin\s+%s%s%s%s \(push\)`,
protocolFormat, userFormat, repoFormat, suffixFormat)
regex := regexp.MustCompile(regexFormat)
matches := regex.FindStringSubmatch(remote)
if matches != nil {
githubHostIndex := regex.SubexpIndex("githubHost")
repoOwnerIndex := regex.SubexpIndex("repoOwner")
repoNameIndex := regex.SubexpIndex("repoName")
return matches[githubHostIndex], matches[repoOwnerIndex], matches[repoNameIndex], true
matches := originPushURLRegex.FindStringSubmatch(remote)
if matches == nil {
return "", "", "", false
}
return "", "", "", false
return git.ParseRepoURL(matches[1])
}

func check(err error) {
Expand Down
13 changes: 7 additions & 6 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,13 @@ func TestDefaultConfig(t *testing.T) {
ShowPrTitlesInStack: false,
},
User: &UserConfig{
ShowPRLink: true,
LogGitCommands: false,
LogGitHubCalls: false,
StatusBitsHeader: true,
StatusBitsEmojis: true,
BranchPrefix: "spr",
ShowPRLink: true,
LogGitCommands: false,
LogGitHubCalls: false,
StatusBitsHeader: true,
StatusBitsEmojis: true,
MaintainerCanModify: true,
BranchPrefix: "spr",
},
State: &InternalState{
MergeCheckCommit: map[string]string{},
Expand Down
41 changes: 41 additions & 0 deletions git/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,47 @@ func BranchNameRegex(branchPrefix string) *regexp.Regexp {
return regexp.MustCompile(regexp.QuoteMeta(branchPrefix) + `/([a-zA-Z0-9_\-/\.]+)/([a-f0-9]{8})$`)
}

// repoURLRegex matches the supported forms of a GitHub-style remote URL and
// captures the host, owner, and name. Used by ParseRepoURL.
//
// Forms accepted:
// - https://host/owner/name[.git]
// - ssh://[git@]host/owner/name[.git]
// - [git@]host:owner/name[.git]
var repoURLRegex = regexp.MustCompile(
`^` +
`(?:https://|ssh://)?` +
`(?:git@)?` +
`(?P<host>[a-z0-9._\-]+)` +
`(?:/|:)` +
`(?P<owner>[\w-]+)/(?P<name>[\w-]+)` +
`(?:\.git)?` +
`/?$`,
)

// ParseRepoURL extracts (host, owner, name) from a git remote URL.
// Returns ok=false if the URL is not recognizable as a GitHub-style repo URL.
func ParseRepoURL(remoteURL string) (host, owner, name string, ok bool) {
matches := repoURLRegex.FindStringSubmatch(strings.TrimSpace(remoteURL))
if matches == nil {
return "", "", "", false
}
return matches[repoURLRegex.SubexpIndex("host")],
matches[repoURLRegex.SubexpIndex("owner")],
matches[repoURLRegex.SubexpIndex("name")],
true
}

// GetRemoteURL returns the push URL for the given git remote, or an error if
// the remote is not configured.
func GetRemoteURL(gitcmd GitInterface, remoteName string) (string, error) {
var output string
if err := gitcmd.Git("remote get-url "+remoteName, &output); err != nil {
return "", err
}
return strings.TrimSpace(output), nil
}

// GetLocalTopCommit returns the top unmerged commit in the stack
//
// return nil if there are no unmerged commits in the stack
Expand Down
35 changes: 35 additions & 0 deletions git/helpers_external_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Tests in package git_test rather than git so they can use git/mockgit
// without an import cycle.
package git_test

import (
"testing"

"github.com/ejoffe/spr/git"
"github.com/ejoffe/spr/git/mockgit"
"github.com/stretchr/testify/require"
)

func TestGetRemoteURL(t *testing.T) {
t.Run("returns trimmed URL on success", func(t *testing.T) {
mock := mockgit.NewMockGit(t)
mock.ExpectRemoteGetURL("origin", "git@github.com:r2/d2.git")

url, err := git.GetRemoteURL(mock, "origin")

require.NoError(t, err)
require.Equal(t, "git@github.com:r2/d2.git", url)
mock.ExpectationsMet()
})

t.Run("passes the remote name through verbatim", func(t *testing.T) {
mock := mockgit.NewMockGit(t)
mock.ExpectRemoteGetURL("upstream", "https://github.com/ejoffe/spr.git")

url, err := git.GetRemoteURL(mock, "upstream")

require.NoError(t, err)
require.Equal(t, "https://github.com/ejoffe/spr.git", url)
mock.ExpectationsMet()
})
}
36 changes: 36 additions & 0 deletions git/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@ func TestBranchNameRegexNoMatch(t *testing.T) {
}
}

func TestParseRepoURL(t *testing.T) {
tests := []struct {
name string
url string
host string
owner string
repoName string
ok bool
}{
{name: "https with .git", url: "https://github.com/r2/d2.git", host: "github.com", owner: "r2", repoName: "d2", ok: true},
{name: "https without .git", url: "https://github.com/r2/d2", host: "github.com", owner: "r2", repoName: "d2", ok: true},
{name: "ssh form", url: "git@github.com:r2/d2.git", host: "github.com", owner: "r2", repoName: "d2", ok: true},
{name: "ssh form no suffix", url: "git@github.com:r2/d2", host: "github.com", owner: "r2", repoName: "d2", ok: true},
{name: "ssh:// protocol", url: "ssh://git@github.com/r2/d2.git", host: "github.com", owner: "r2", repoName: "d2", ok: true},
{name: "enterprise host", url: "git@gh.enterprise.com:r2/d2.git", host: "gh.enterprise.com", owner: "r2", repoName: "d2", ok: true},
{name: "dashes in owner and name", url: "https://github.com/r-2/d-2.git", host: "github.com", owner: "r-2", repoName: "d-2", ok: true},
{name: "underscores in name", url: "https://github.com/r2/d2_a.git", host: "github.com", owner: "r2", repoName: "d2_a", ok: true},
{name: "case sensitive", url: "https://github.com/R2/D2.git", host: "github.com", owner: "R2", repoName: "D2", ok: true},
{name: "trailing slash", url: "https://github.com/r2/d2/", host: "github.com", owner: "r2", repoName: "d2", ok: true},
{name: "leading and trailing whitespace", url: " https://github.com/r2/d2.git\n", host: "github.com", owner: "r2", repoName: "d2", ok: true},

{name: "empty", url: "", ok: false},
{name: "garbage", url: "not a url", ok: false},
{name: "missing owner", url: "https://github.com//d2.git", ok: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host, owner, name, ok := ParseRepoURL(tc.url)
assert.Equal(t, tc.ok, ok)
assert.Equal(t, tc.host, host)
assert.Equal(t, tc.owner, owner)
assert.Equal(t, tc.repoName, name)
})
}
}

func TestBranchNameFromCommit(t *testing.T) {
tests := []struct {
name string
Expand Down
6 changes: 6 additions & 0 deletions git/mockgit/mockgit.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ func (m *Mock) ExpectRemote(remote string) {
m.expect("git remote -v").respond(response)
}

// ExpectRemoteGetURL queues an expectation for `git remote get-url <name>`
// and a canned URL response.
func (m *Mock) ExpectRemoteGetURL(remoteName, url string) {
m.expect("git remote get-url " + remoteName).respond(url + "\n")
}

func (m *Mock) ExpectFixup(commitHash string) {
m.expect("git commit --fixup " + commitHash)
m.expect("git rebase -i --autosquash --autostash origin/master")
Expand Down
92 changes: 80 additions & 12 deletions github/githubclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,69 @@ func (c *client) GetInfo(ctx context.Context, gitcmd git.GitInterface) *github.G
}

info := &github.GitHubInfo{
UserName: loginName,
RepositoryID: repoID,
LocalBranch: git.GetLocalBranchName(gitcmd),
PullRequests: pullRequests,
UserName: loginName,
RepositoryID: repoID,
HeadRepositoryID: resolveHeadRepositoryID(ctx, gitcmd, c.config.Repo, repoID, c.starGetRepoLookup),
LocalBranch: git.GetLocalBranchName(gitcmd),
PullRequests: pullRequests,
}

log.Debug().Interface("Info", info).Msg("GetInfo")
return info
}

// repoIDLookup resolves an (owner, name) pair to the repo's GitHub node ID.
// Extracted as a separate type so resolveHeadRepositoryID is testable without
// stubbing the full genclient.Client interface.
type repoIDLookup func(ctx context.Context, owner, name string) (string, error)

// resolveHeadRepositoryID returns the fork's GitHub node ID when spr is
// configured for cross-fork PRs (i.e. the URL of GitHubRemote points at a
// different (owner, name) than the configured GitHubRepoOwner/Name target).
// Returns "" for same-fork operation or when fork detection fails — in the
// latter case spr falls back to same-fork behavior and GitHub will surface
// any resulting error on the CreatePullRequest call.
func resolveHeadRepositoryID(ctx context.Context, gitcmd git.GitInterface,
repoCfg *config.RepoConfig, upstreamRepoID string, lookup repoIDLookup) string {
remoteURL, err := git.GetRemoteURL(gitcmd, repoCfg.GitHubRemote)
if err != nil {
log.Warn().Err(err).Str("remote", repoCfg.GitHubRemote).
Msg("cross-fork detection: failed to read remote URL")
return ""
}
_, headOwner, headName, ok := git.ParseRepoURL(remoteURL)
if !ok {
log.Warn().Str("remote", repoCfg.GitHubRemote).Str("url", remoteURL).
Msg("cross-fork detection: unparseable remote URL")
return ""
}
if headOwner == repoCfg.GitHubRepoOwner && headName == repoCfg.GitHubRepoName {
return ""
}
headRepoID, err := lookup(ctx, headOwner, headName)
if err != nil {
log.Warn().Err(err).Str("owner", headOwner).Str("name", headName).
Msg("cross-fork detection: failed to resolve fork repo ID")
return ""
}
if headRepoID == "" || headRepoID == upstreamRepoID {
return ""
}
return headRepoID
}

// starGetRepoLookup adapts the genclient StarGetRepo call to a repoIDLookup.
func (c *client) starGetRepoLookup(ctx context.Context, owner, name string) (string, error) {
resp, err := c.api.StarGetRepo(ctx, owner, name)
if err != nil {
return "", err
}
if resp == nil || resp.Repository == nil {
return "", nil
}
return resp.Repository.Id, nil
}

func matchPullRequestStack(
repoConfig *config.RepoConfig,
branchPrefix string,
Expand Down Expand Up @@ -409,6 +462,26 @@ func (c *client) GetAssignableUsers(ctx context.Context) []github.RepoAssignee {
return users
}

// buildCreatePullRequestInput assembles the GraphQL input for opening a PR.
// Routes through the fork via HeadRepositoryId when GitHubInfo signals
// cross-fork mode (HeadRepositoryID set and different from RepositoryID).
func buildCreatePullRequestInput(info *github.GitHubInfo, user *config.UserConfig,
headRefName, baseRefName, title, body string) genclient.CreatePullRequestInput {
input := genclient.CreatePullRequestInput{
RepositoryId: info.RepositoryID,
BaseRefName: baseRefName,
HeadRefName: headRefName,
Title: title,
Body: &body,
Draft: &user.CreateDraftPRs,
}
if info.HeadRepositoryID != "" && info.HeadRepositoryID != info.RepositoryID {
input.HeadRepositoryId = &info.HeadRepositoryID
input.MaintainerCanModify = &user.MaintainerCanModify
}
return input
}

func (c *client) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface,
info *github.GitHubInfo, commit git.Commit, prevCommit *git.Commit) *github.PullRequest {

Expand All @@ -425,14 +498,9 @@ func (c *client) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface,
templatizer := config_fetcher.PRTemplatizer(c.config, gitcmd)

body := templatizer.Body(info, commit, nil)
resp, err := c.api.CreatePullRequest(ctx, genclient.CreatePullRequestInput{
RepositoryId: info.RepositoryID,
BaseRefName: baseRefName,
HeadRefName: headRefName,
Title: templatizer.Title(info, commit),
Body: &body,
Draft: &c.config.User.CreateDraftPRs,
})
input := buildCreatePullRequestInput(info, c.config.User, headRefName, baseRefName,
templatizer.Title(info, commit), body)
resp, err := c.api.CreatePullRequest(ctx, input)
check(err)

pr := &github.PullRequest{
Expand Down
Loading