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
2 changes: 2 additions & 0 deletions gitea/prs.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ type giteaPRService struct {
client *gitea.Client
}

func (*giteaPRService) SupportsQualifiedPRHeads() bool { return true }

func (f *giteaForge) PullRequests() forge.PullRequestService {
return &giteaPRService{client: f.client}
}
Expand Down
2 changes: 2 additions & 0 deletions github/prs.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ type gitHubPRService struct {
client *github.Client
}

func (*gitHubPRService) SupportsQualifiedPRHeads() bool { return true }

func (f *gitHubForge) PullRequests() forge.PullRequestService {
return &gitHubPRService{client: f.client}
}
Expand Down
72 changes: 68 additions & 4 deletions internal/cli/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ func prCreateCmd() *cobra.Command {
flagHead string
flagBase string
flagDraft bool
flagPush bool
flagReviewers []string
flagAssignees []string
flagLabels []string
Expand All @@ -295,10 +296,31 @@ func prCreateCmd() *cobra.Command {
return fmt.Errorf("--head is required")
}

forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType)
forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType)
if err != nil {
return err
}
prService := forge.PullRequests()
qualifiedHeadProvider, ok := prService.(forges.QualifiedPRHeadProvider)
supportsQualifiedHeads := ok && qualifiedHeadProvider.SupportsQualifiedPRHeads()

localHead := flagHead
if flagPush {
headOwner, localBranch, err := splitPRHead(flagHead)
if err != nil {
return err
}
if headOwner != "" && !supportsQualifiedHeads {
return fmt.Errorf("qualified head %q is not supported by this forge; fork pushes require GitHub or Gitea", flagHead)
}
if err := validatePushRemote(cmd.Context(), domain, owner, repoName, headOwner, localBranch, supportsQualifiedHeads); err != nil {
return err
}
if err := git.PushBranch(cmd.Context(), "", resolve.RemoteName(), localBranch); err != nil {
return fmt.Errorf("pushing head branch: %w", err)
}
localHead = localBranch
}

opts := forges.CreatePROpts{
Title: flagTitle,
Expand All @@ -312,13 +334,13 @@ func prCreateCmd() *cobra.Command {
Milestone: flagMilestone,
}

pr, err := forge.PullRequests().Create(cmd.Context(), owner, repoName, opts)
pr, err := prService.Create(cmd.Context(), owner, repoName, opts)
if err != nil {
return fmt.Errorf("creating pull request: %w", err)
}

if flagHead != "" && pr.Base.Ref != "" {
_ = git.SetBaseBranch(cmd.Context(), "", flagHead, pr.Base.Ref)
if localHead != "" && pr.Base.Ref != "" {
_ = git.SetBaseBranch(cmd.Context(), "", localHead, pr.Base.Ref)
}

p := printer()
Expand All @@ -336,6 +358,7 @@ func prCreateCmd() *cobra.Command {
cmd.Flags().StringVarP(&flagHead, "head", "H", "", "Head branch")
cmd.Flags().StringVarP(&flagBase, "base", "B", "", "Base branch")
cmd.Flags().BoolVarP(&flagDraft, "draft", "d", false, "Create as draft")
cmd.Flags().BoolVar(&flagPush, "push", false, "Push the head branch before creating the PR")
cmd.Flags().StringSliceVarP(&flagReviewers, "reviewer", "r", nil, "Request a reviewer")
cmd.Flags().StringSliceVarP(&flagAssignees, "assignee", "a", nil, "Assign to a user")
cmd.Flags().StringSliceVarP(&flagLabels, "label", "l", nil, "Add a label")
Expand All @@ -345,6 +368,47 @@ func prCreateCmd() *cobra.Command {
return cmd
}

func splitPRHead(head string) (owner, branch string, err error) {
owner, branch, qualified := strings.Cut(head, ":")
if !qualified {
return "", head, nil
}
if owner == "" || branch == "" {
return "", "", fmt.Errorf("invalid qualified head %q, expected OWNER:BRANCH", head)
}
return owner, branch, nil
}

func validatePushRemote(ctx context.Context, domain, owner, repo, headOwner, branch string, supportsQualifiedHeads bool) error {
remote := resolve.RemoteName()
pushDomain, pushOwner, pushRepo, err := resolve.PushRemoteRepo(ctx, remote)
if err != nil {
// Local-path remotes cannot be mapped to a forge repository, but Git can
// still push to them. Let the push itself determine whether they work.
return nil
}

if !strings.EqualFold(pushDomain, domain) {
return fmt.Errorf("push remote %q points to %s/%s/%s, not %s/%s/%s", remote, pushDomain, pushOwner, pushRepo, domain, owner, repo)
}
if headOwner != "" {
if !strings.EqualFold(headOwner, pushOwner) {
return fmt.Errorf("head owner %q does not match push remote %q owner %q", headOwner, remote, pushOwner)
}
return nil
}
if !strings.EqualFold(pushOwner, owner) {
if !supportsQualifiedHeads {
return fmt.Errorf("push remote %q belongs to %q; fork pushes are not supported by this forge", remote, pushOwner)
}
return fmt.Errorf("push remote %q belongs to %q; use --head %s:%s for a fork pull request", remote, pushOwner, pushOwner, branch)
}
if !strings.EqualFold(pushRepo, repo) {
return fmt.Errorf("push remote %q repository %q does not match target repository %q", remote, pushRepo, repo)
}
return nil
}

func prCloseCmd() *cobra.Command {
return &cobra.Command{
Use: "close <number>",
Expand Down
21 changes: 15 additions & 6 deletions internal/cli/pr_checkout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@ import (

// mockPRService implements forges.PullRequestService for testing.
type mockPRService struct {
pr *forges.PullRequest
err error
listResult []forges.PullRequest
listErr error
pr *forges.PullRequest
err error
listResult []forges.PullRequest
listErr error
createResult *forges.PullRequest
createErr error
createOpts forges.CreatePROpts
createCalls int
qualifiedHeads bool
}

func (m *mockPRService) SupportsQualifiedPRHeads() bool { return m.qualifiedHeads }

func (m *mockPRService) Get(_ context.Context, _, _ string, _ int) (*forges.PullRequest, error) {
return m.pr, m.err
}
Expand All @@ -29,8 +36,10 @@ func (m *mockPRService) List(_ context.Context, _, _ string, _ forges.ListPROpts
return m.listResult, m.listErr
}

func (m *mockPRService) Create(_ context.Context, _, _ string, _ forges.CreatePROpts) (*forges.PullRequest, error) {
return nil, nil
func (m *mockPRService) Create(_ context.Context, _, _ string, opts forges.CreatePROpts) (*forges.PullRequest, error) {
m.createCalls++
m.createOpts = opts
return m.createResult, m.createErr
}

func (m *mockPRService) Update(_ context.Context, _, _ string, _ int, _ forges.UpdatePROpts) (*forges.PullRequest, error) {
Expand Down
Loading