diff --git a/.spr.yml b/.spr.yml index 58b12a49..c6dd542e 100644 --- a/.spr.yml +++ b/.spr.yml @@ -1,10 +1,15 @@ -githubRepoOwner: ejoffe -githubRepoName: spr -githubHost: github.com -githubRemote: origin -githubBranch: master +repoOwner: ejoffe +repoName: spr +forgeHost: github.com +forgeType: github +remote: origin +branch: master requireChecks: true requireApproval: false +defaultReviewers: [] mergeMethod: rebase mergeQueue: false +prTemplateType: stack forceFetchTags: false +showPrTitlesInStack: false +branchPushIndividually: false diff --git a/cmd/reword/main.go b/cmd/reword/main.go index 1ef837a6..a2133652 100644 --- a/cmd/reword/main.go +++ b/cmd/reword/main.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "os" + "regexp" "strings" "github.com/ejoffe/spr/config" @@ -11,6 +12,8 @@ import ( "github.com/google/uuid" ) +var commitIDRegex = regexp.MustCompile(`commit-id\:\s*([a-f0-9]{8})`) + func main() { filename := os.Args[1] gitcmd := realgit.NewGitCmd(config.DefaultConfig()) @@ -35,7 +38,7 @@ func main() { res := strings.Split(line, " ") var out string gitcmd.Git("log --format=%B -n 1 "+res[1], &out) - if !strings.Contains(out, "commit-id") { + if !commitIDRegex.MatchString(out) { line = strings.Replace(line, "pick ", "reword ", 1) } } @@ -69,7 +72,7 @@ func shouldAppendCommitID(filename string) (missingCommitID bool, missingNewLine if !strings.HasPrefix(line, "#") { lineCount += 1 } - if strings.HasPrefix(line, "commit-id:") { + if commitIDRegex.MatchString(line) { missingCommitID = false return } diff --git a/cmd/spr/main.go b/cmd/spr/main.go index bb2e2642..7be71f2d 100644 --- a/cmd/spr/main.go +++ b/cmd/spr/main.go @@ -1,15 +1,19 @@ package main import ( + "bufio" "context" "fmt" "os" + "strings" "github.com/ejoffe/rake" "github.com/ejoffe/spr/config" "github.com/ejoffe/spr/config/config_parser" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git/realgit" "github.com/ejoffe/spr/github/githubclient" + "github.com/ejoffe/spr/gitlab/gitlabclient" "github.com/ejoffe/spr/spr" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -49,7 +53,49 @@ func main() { gitcmd = realgit.NewGitCmd(cfg) ctx := context.Background() - client := githubclient.NewGitHubClient(ctx, cfg) + + forgeType := strings.ToLower(cfg.Repo.ForgeType) + if forgeType == "" { + host := strings.ToLower(cfg.Repo.ForgeHost) + switch { + case strings.Contains(host, "github"): + forgeType = "github" + case strings.Contains(host, "gitlab"): + forgeType = "gitlab" + default: + fmt.Printf("Unable to detect forge type from host %q.\n", cfg.Repo.ForgeHost) + fmt.Println("Please select your forge:") + fmt.Println(" 1. GitHub") + fmt.Println(" 2. GitLab") + fmt.Print("Choice [1/2]: ") + reader := bufio.NewReader(os.Stdin) + line, _ := reader.ReadString('\n') + line = strings.TrimSpace(line) + switch line { + case "1": + forgeType = "github" + case "2": + forgeType = "gitlab" + default: + fmt.Println("Invalid choice.") + os.Exit(2) + } + } + cfg.Repo.ForgeType = forgeType + rake.LoadSources(cfg.Repo, + rake.YamlFileWriter(config_parser.RepoConfigFilePath(gitcmd))) + } + + var client forge.ForgeInterface + switch forgeType { + case "github": + client = githubclient.NewGitHubClient(ctx, cfg) + case "gitlab": + client = gitlabclient.NewGitLabClient(ctx, cfg) + default: + fmt.Printf("Unknown forge type %q. Valid values: github, gitlab.\n", forgeType) + os.Exit(2) + } stackedpr := spr.NewStackedPR(cfg, client, gitcmd) detailFlag := &cli.BoolFlag{ @@ -122,7 +168,12 @@ VERSION: fork of {{.Version}} cfg.User.LogGitCommands = true cfg.User.LogGitHubCalls = true } - client.MaybeStar(ctx, cfg) + type stargazer interface { + MaybeStar(ctx context.Context, cfg *config.Config) + } + if s, ok := client.(stargazer); ok { + s.MaybeStar(ctx, cfg) + } return nil }, Commands: []*cli.Command{ diff --git a/config/config.go b/config/config.go index 26183b4c..e9515942 100644 --- a/config/config.go +++ b/config/config.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/ejoffe/rake" - "github.com/ejoffe/spr/github/githubclient/gen/genclient" ) type Config struct { @@ -16,12 +15,13 @@ type Config struct { // Config object to hold spr configuration type RepoConfig struct { - GitHubRepoOwner string `yaml:"githubRepoOwner"` - GitHubRepoName string `yaml:"githubRepoName"` - GitHubHost string `default:"github.com" yaml:"githubHost"` + RepoOwner string `yaml:"repoOwner"` + RepoName string `yaml:"repoName"` + ForgeHost string `yaml:"forgeHost"` + ForgeType string `yaml:"forgeType,omitempty"` - GitHubRemote string `default:"origin" yaml:"githubRemote"` - GitHubBranch string `default:"main" yaml:"githubBranch"` + Remote string `default:"origin" yaml:"remote"` + Branch string `default:"main" yaml:"branch"` RequireChecks bool `default:"true" yaml:"requireChecks"` RequireApproval bool `default:"true" yaml:"requireApproval"` @@ -99,21 +99,26 @@ func (c *Config) Normalize() { } } -func (c Config) MergeMethod() (genclient.PullRequestMergeMethod, error) { - var mergeMethod genclient.PullRequestMergeMethod - var err error +type MergeMethod string + +const ( + MergeMethodMerge MergeMethod = "merge" + MergeMethodSquash MergeMethod = "squash" + MergeMethodRebase MergeMethod = "rebase" +) + +func (c Config) ParseMergeMethod() (MergeMethod, error) { switch strings.ToLower(c.Repo.MergeMethod) { case "merge": - mergeMethod = genclient.PullRequestMergeMethod_MERGE + return MergeMethodMerge, nil case "squash": - mergeMethod = genclient.PullRequestMergeMethod_SQUASH + return MergeMethodSquash, nil case "rebase", "": - mergeMethod = genclient.PullRequestMergeMethod_REBASE + return MergeMethodRebase, nil default: - err = fmt.Errorf( + return "", fmt.Errorf( `unknown merge method %q, choose from "merge", "squash", or "rebase"`, c.Repo.MergeMethod, ) } - return mergeMethod, err } diff --git a/config/config_parser/config_parser.go b/config/config_parser/config_parser.go index 2305b464..551adcea 100644 --- a/config/config_parser/config_parser.go +++ b/config/config_parser/config_parser.go @@ -11,27 +11,86 @@ import ( "github.com/ejoffe/rake" "github.com/ejoffe/spr/config" "github.com/ejoffe/spr/git" + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" ) +// migrateRepoConfigKeys migrates old GitHub-specific YAML keys to +// forge-agnostic names in the given .spr.yml file. If the file contains +// any legacy keys they are renamed in place and the file is rewritten. +func migrateRepoConfigKeys(cfgPath string) { + data, err := os.ReadFile(cfgPath) + if err != nil { + return // file doesn't exist yet or is unreadable; nothing to migrate + } + + var raw yaml.Node + if err := yaml.Unmarshal(data, &raw); err != nil { + return + } + + renames := map[string]string{ + "githubRepoOwner": "repoOwner", + "githubRepoName": "repoName", + "githubHost": "forgeHost", + "githubRemote": "remote", + "githubBranch": "branch", + } + + // The top-level node is a document; its first child is the mapping. + if raw.Kind != yaml.DocumentNode || len(raw.Content) == 0 { + return + } + mapping := raw.Content[0] + if mapping.Kind != yaml.MappingNode { + return + } + + migrated := false + for i := 0; i < len(mapping.Content)-1; i += 2 { + keyNode := mapping.Content[i] + if newKey, ok := renames[keyNode.Value]; ok { + keyNode.Value = newKey + migrated = true + } + } + + if !migrated { + return + } + + out, err := yaml.Marshal(&raw) + if err != nil { + log.Warn().Err(err).Msg("failed to marshal migrated config") + return + } + if err := os.WriteFile(cfgPath, out, 0644); err != nil { + log.Warn().Err(err).Msg("failed to write migrated config") + } +} + func ParseConfig(gitcmd git.GitInterface) *config.Config { cfg := config.EmptyConfig() + // Migrate legacy GitHub-specific config keys before loading. + migrateRepoConfigKeys(RepoConfigFilePath(gitcmd)) + rake.LoadSources(cfg.Repo, rake.DefaultSource(), - NewGitHubRemoteSource(cfg, gitcmd), + NewRemoteSource(cfg, gitcmd), rake.YamlFileSource(RepoConfigFilePath(gitcmd)), NewRemoteBranchSource(gitcmd), ) - if cfg.Repo.GitHubHost == "" { + if cfg.Repo.ForgeHost == "" { fmt.Println("unable to auto configure repository host - must be set manually in .spr.yml") os.Exit(2) } - if cfg.Repo.GitHubRepoOwner == "" { + if cfg.Repo.RepoOwner == "" { fmt.Println("unable to auto configure repository owner - must be set manually in .spr.yml") os.Exit(3) } - if cfg.Repo.GitHubRepoName == "" { + if cfg.Repo.RepoName == "" { fmt.Println("unable to auto configure repository name - must be set manually in .spr.yml") os.Exit(4) } @@ -69,7 +128,7 @@ func ParseConfig(gitcmd git.GitInterface) *config.Config { } func CheckConfig(cfg *config.Config) error { - if strings.Contains(cfg.Repo.GitHubBranch, "/") { + if strings.Contains(cfg.Repo.Branch, "/") { return errors.New("Remote branch name must not contain backslashes '/'") } return nil diff --git a/config/config_parser/config_parser_test.go b/config/config_parser/config_parser_test.go index 53a80d2a..8701dcd2 100644 --- a/config/config_parser/config_parser_test.go +++ b/config/config_parser/config_parser_test.go @@ -10,11 +10,11 @@ import ( func TestGetRepoDetailsFromRemote(t *testing.T) { type testCase struct { - remote string - githubHost string - repoOwner string - repoName string - match bool + remote string + forgeHost string + repoOwner string + repoName string + match bool } testCases := []testCase{ {"origin https://github.com/r2/d2.git (push)", "github.com", "r2", "d2", true}, @@ -44,12 +44,18 @@ func TestGetRepoDetailsFromRemote(t *testing.T) { // GitHub names are case-sensitive {"origin https://github.com/R2/D2.git (push)", "github.com", "R2", "D2", true}, + + // GitLab nested subgroups (multi-level paths) + {"origin https://gitlab.cfdata.org/cloudflare/rt/mobile/core.git (push)", "gitlab.cfdata.org", "cloudflare/rt/mobile", "core", true}, + {"origin git@gitlab.cfdata.org:cloudflare/rt/mobile/core.git (push)", "gitlab.cfdata.org", "cloudflare/rt/mobile", "core", true}, + {"origin ssh://git@gitlab.cfdata.org/cloudflare/rt/mobile/core.git (push)", "gitlab.cfdata.org", "cloudflare/rt/mobile", "core", true}, + {"origin https://gitlab.cfdata.org/cloudflare/rt/mobile/core (push)", "gitlab.cfdata.org", "cloudflare/rt/mobile", "core", true}, } for i, testCase := range testCases { t.Logf("Testing %v %q", i, testCase.remote) - githubHost, repoOwner, repoName, match := getRepoDetailsFromRemote(testCase.remote) - if githubHost != testCase.githubHost { - t.Fatalf("Wrong \"githubHost\" returned for test case %v, expected %q, got %q", i, testCase.githubHost, githubHost) + forgeHost, repoOwner, repoName, match := getRepoDetailsFromRemote(testCase.remote) + if forgeHost != testCase.forgeHost { + t.Fatalf("Wrong \"forgeHost\" returned for test case %v, expected %q, got %q", i, testCase.forgeHost, forgeHost) } if repoOwner != testCase.repoOwner { t.Fatalf("Wrong \"repoOwner\" returned for test case %v, expected %q, got %q", i, testCase.repoOwner, repoOwner) @@ -63,15 +69,15 @@ func TestGetRepoDetailsFromRemote(t *testing.T) { } } -func TestGitHubRemoteSource(t *testing.T) { +func TestRemoteSource(t *testing.T) { mock := mockgit.NewMockGit(t) mock.ExpectRemote("https://github.com/r2/d2.git") expect := config.Config{ Repo: &config.RepoConfig{ - GitHubRepoOwner: "r2", - GitHubRepoName: "d2", - GitHubHost: "github.com", + RepoOwner: "r2", + RepoName: "d2", + ForgeHost: "github.com", RequireChecks: false, RequireApproval: false, MergeMethod: "", @@ -88,7 +94,7 @@ func TestGitHubRemoteSource(t *testing.T) { Repo: &config.RepoConfig{}, User: &config.UserConfig{}, } - source := NewGitHubRemoteSource(&actual, mock) + source := NewRemoteSource(&actual, mock) source.Load(nil) assert.Equal(t, expect, actual) mock.ExpectationsMet() diff --git a/config/config_parser/remote_branch.go b/config/config_parser/remote_branch.go index 45b89f51..ed38350b 100644 --- a/config/config_parser/remote_branch.go +++ b/config/config_parser/remote_branch.go @@ -31,6 +31,6 @@ func (s *remoteBranch) Load(cfg interface{}) { repoCfg := cfg.(*config.RepoConfig) - repoCfg.GitHubRemote = matches[2] - repoCfg.GitHubBranch = matches[3] + repoCfg.Remote = matches[2] + repoCfg.Branch = matches[3] } diff --git a/config/config_parser/remote_source.go b/config/config_parser/remote_source.go index bacdb810..04d6552e 100644 --- a/config/config_parser/remote_source.go +++ b/config/config_parser/remote_source.go @@ -14,7 +14,7 @@ type remoteSource struct { config *config.Config } -func NewGitHubRemoteSource(config *config.Config, gitcmd git.GitInterface) *remoteSource { +func NewRemoteSource(config *config.Config, gitcmd git.GitInterface) *remoteSource { return &remoteSource{ gitcmd: gitcmd, config: config, @@ -28,11 +28,11 @@ func (s *remoteSource) Load(_ interface{}) { lines := strings.Split(output, "\n") for _, line := range lines { - githubHost, repoOwner, repoName, match := getRepoDetailsFromRemote(line) + forgeHost, repoOwner, repoName, match := getRepoDetailsFromRemote(line) if match { - s.config.Repo.GitHubHost = githubHost - s.config.Repo.GitHubRepoOwner = repoOwner - s.config.Repo.GitHubRepoName = repoName + s.config.Repo.ForgeHost = forgeHost + s.config.Repo.RepoOwner = repoOwner + s.config.Repo.RepoName = repoName break } } @@ -45,7 +45,7 @@ func getRepoDetailsFromRemote(remote string) (string, string, string, bool) { userFormat := `(git@)?` // "/" is expected in "http://" or "ssh://" protocol, when no protocol given // it should be ":" - repoFormat := `(?P[a-z0-9._\-]+)(/|:)(?P[\w-]+)/(?P[\w-]+)` + repoFormat := `(?P[a-z0-9._\-]+)(/|:)(?P[\w-]+(?:/[\w-]+)*)/(?P[\w-]+)` // This is neither required in https access nor in ssh one suffixFormat := `(.git)?` regexFormat := fmt.Sprintf(`^origin\s+%s%s%s%s \(push\)`, @@ -53,10 +53,10 @@ func getRepoDetailsFromRemote(remote string) (string, string, string, bool) { regex := regexp.MustCompile(regexFormat) matches := regex.FindStringSubmatch(remote) if matches != nil { - githubHostIndex := regex.SubexpIndex("githubHost") + forgeHostIndex := regex.SubexpIndex("forgeHost") repoOwnerIndex := regex.SubexpIndex("repoOwner") repoNameIndex := regex.SubexpIndex("repoName") - return matches[githubHostIndex], matches[repoOwnerIndex], matches[repoNameIndex], true + return matches[forgeHostIndex], matches[repoOwnerIndex], matches[repoNameIndex], true } return "", "", "", false } diff --git a/config/config_test.go b/config/config_test.go index b322c581..7a99858d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,7 +3,6 @@ package config import ( "testing" - "github.com/ejoffe/spr/github/githubclient/gen/genclient" "github.com/stretchr/testify/assert" ) @@ -22,11 +21,11 @@ func TestEmptyConfig(t *testing.T) { func TestDefaultConfig(t *testing.T) { expect := &Config{ Repo: &RepoConfig{ - GitHubRepoOwner: "", - GitHubRepoName: "", - GitHubRemote: "origin", - GitHubBranch: "main", - GitHubHost: "github.com", + RepoOwner: "", + RepoName: "", + Remote: "origin", + Branch: "main", + ForgeHost: "", RequireChecks: true, RequireApproval: true, MergeMethod: "rebase", @@ -54,23 +53,23 @@ func TestDefaultConfig(t *testing.T) { func TestMergeMethodHelper(t *testing.T) { for _, tc := range []struct { configValue string - expected genclient.PullRequestMergeMethod + expected MergeMethod }{ { configValue: "rebase", - expected: genclient.PullRequestMergeMethod_REBASE, + expected: MergeMethodRebase, }, { configValue: "", - expected: genclient.PullRequestMergeMethod_REBASE, + expected: MergeMethodRebase, }, { configValue: "Merge", - expected: genclient.PullRequestMergeMethod_MERGE, + expected: MergeMethodMerge, }, { configValue: "SQUASH", - expected: genclient.PullRequestMergeMethod_SQUASH, + expected: MergeMethodSquash, }, } { tcName := tc.configValue @@ -79,14 +78,14 @@ func TestMergeMethodHelper(t *testing.T) { } t.Run(tcName, func(t *testing.T) { config := &Config{Repo: &RepoConfig{MergeMethod: tc.configValue}} - actual, err := config.MergeMethod() + actual, err := config.ParseMergeMethod() assert.NoError(t, err) assert.Equal(t, tc.expected, actual) }) } t.Run("invalid", func(t *testing.T) { config := &Config{Repo: &RepoConfig{MergeMethod: "magic"}} - actual, err := config.MergeMethod() + actual, err := config.ParseMergeMethod() assert.Error(t, err) assert.Empty(t, actual) }) diff --git a/forge/forge.go b/forge/forge.go new file mode 100644 index 00000000..60864a6b --- /dev/null +++ b/forge/forge.go @@ -0,0 +1,107 @@ +package forge + +import ( + "context" + "fmt" + + "github.com/ejoffe/spr/config" + "github.com/ejoffe/spr/git" +) + +type ForgeInterface interface { + // GetInfo returns the list of pull requests from the forge which match the local stack of commits + GetInfo(ctx context.Context, gitcmd git.GitInterface) *ForgeInfo + + // GetAssignableUsers returns a list of valid users that can review the pull request + GetAssignableUsers(ctx context.Context) []RepoAssignee + + // CreatePullRequest creates a pull request + CreatePullRequest(ctx context.Context, gitcmd git.GitInterface, info *ForgeInfo, commit git.Commit, prevCommit *git.Commit) *PullRequest + + // UpdatePullRequest updates a pull request with current commit + UpdatePullRequest(ctx context.Context, gitcmd git.GitInterface, info *ForgeInfo, pullRequests []*PullRequest, pr *PullRequest, commit git.Commit, prevCommit *git.Commit) + + // AddReviewers adds a reviewer to the given pull request + AddReviewers(ctx context.Context, pr *PullRequest, userIDs []string) + + // CommentPullRequest add a comment to the given pull request + CommentPullRequest(ctx context.Context, pr *PullRequest, comment string) + + // MergePullRequest merged the given pull request + MergePullRequest(ctx context.Context, pr *PullRequest, mergeMethod config.MergeMethod) + + // ClosePullRequest closes the given pull request + ClosePullRequest(ctx context.Context, pr *PullRequest) + + // PullRequestURL returns the web URL for the given pull request number + PullRequestURL(number int) string +} + +type ForgeInfo struct { + UserName string + RepositoryID string + LocalBranch string + PullRequests []*PullRequest + PRNumberPrefix string // Used to format PR bodies with the right auto-linking format +} + +type RepoAssignee struct { + ID string + Login string + Name string +} + +func (i *ForgeInfo) Key() string { + return i.RepositoryID + "_" + i.LocalBranch +} + +// BuildPullRequestStack takes a pre-built map of commitID → PullRequest and assembles +// them into an ordered stack by walking the ToBranch chain from the top PR down to +// the targetBranch. Both GitHub and GitLab clients build their pullRequestMap from +// forge-specific API responses, then call this shared function. +func BuildPullRequestStack( + targetBranch string, + localCommitStack []git.Commit, + pullRequestMap map[string]*PullRequest, +) []*PullRequest { + if len(localCommitStack) == 0 || len(pullRequestMap) == 0 { + return []*PullRequest{} + } + + // find top pr by walking local commits from newest to oldest + var currpr *PullRequest + for i := len(localCommitStack) - 1; i >= 0; i-- { + if pr, found := pullRequestMap[localCommitStack[i].CommitID]; found { + currpr = pr + break + } + } + + // The list of commits from the command line actually starts at the + // most recent commit. In order to reverse the list we use a + // custom prepend function instead of append. + prepend := func(l []*PullRequest, pr *PullRequest) []*PullRequest { + l = append(l, &PullRequest{}) + copy(l[1:], l) + l[0] = pr + return l + } + + // build pr stack by walking ToBranch chain + var pullRequests []*PullRequest + for currpr != nil { + pullRequests = prepend(pullRequests, currpr) + if currpr.ToBranch == targetBranch { + break + } + + matches := git.BranchNameRegex.FindStringSubmatch(currpr.ToBranch) + if matches == nil { + panic(fmt.Errorf("invalid base branch for pull request: %s", currpr.ToBranch)) + } + nextCommitID := matches[2] + currpr = pullRequestMap[nextCommitID] + } + + return pullRequests +} diff --git a/github/pullrequest.go b/forge/pullrequest.go similarity index 91% rename from github/pullrequest.go rename to forge/pullrequest.go index 899811f9..2a05589d 100644 --- a/github/pullrequest.go +++ b/forge/pullrequest.go @@ -1,4 +1,4 @@ -package github +package forge import ( "fmt" @@ -9,7 +9,7 @@ import ( "github.com/ejoffe/spr/terminal" ) -// PullRequest has GitHub pull request data +// PullRequest has pull request data type PullRequest struct { ID string Number int @@ -30,28 +30,22 @@ type checkStatus int const ( // CheckStatusUnknown CheckStatusUnknown checkStatus = iota - // CheckStatusPending when checks are still running CheckStatusPending - // CheckStatusPass when all checks pass CheckStatusPass - - // CheckStatusFail when some chechs have failed + // CheckStatusFail when some checks have failed CheckStatusFail ) // PullRequestMergeStatus is the merge status of a pull request type PullRequestMergeStatus struct { - // ChecksPass is the status of GitHub checks + // ChecksPass is the status of pull request checks ChecksPass checkStatus - // ReviewApproved is true when a pull request is approved by a fellow reviewer ReviewApproved bool - // NoConflicts is true when there are no merge conflicts NoConflicts bool - // Stacked is true when all requests in the stack up to this one are ready to merge Stacked bool } @@ -136,7 +130,7 @@ func statusBitIcons(config *config.Config) map[string]string { } } -// StatusString returs a string representation of the merge status bits +// StatusString returns a string representation of the merge status bits func (pr *PullRequest) StatusString(config *config.Config) string { icons := statusBitIcons(config) statusString := "[" @@ -169,7 +163,7 @@ func (pr *PullRequest) StatusString(config *config.Config) string { return statusString } -func (pr *PullRequest) String(config *config.Config) string { +func (pr *PullRequest) String(config *config.Config, forgeClient ForgeInterface) string { prStatus := pr.StatusString(config) if pr.Merged { prStatus = "MERGED" @@ -177,8 +171,7 @@ func (pr *PullRequest) String(config *config.Config) string { prInfo := fmt.Sprintf("%3d", pr.Number) if config.User.ShowPRLink { - prInfo = fmt.Sprintf("https://%s/%s/%s/pull/%d", - config.Repo.GitHubHost, config.Repo.GitHubRepoOwner, config.Repo.GitHubRepoName, pr.Number) + prInfo = forgeClient.PullRequestURL(pr.Number) } var mq string diff --git a/github/pullrequest_test.go b/forge/pullrequest_test.go similarity index 83% rename from github/pullrequest_test.go rename to forge/pullrequest_test.go index 9a8a282b..3046dc5e 100644 --- a/github/pullrequest_test.go +++ b/forge/pullrequest_test.go @@ -1,6 +1,7 @@ -package github +package forge import ( + "context" "fmt" "testing" @@ -9,6 +10,23 @@ import ( "github.com/stretchr/testify/assert" ) +type stubForge struct{} + +func (stubForge) GetInfo(context.Context, git.GitInterface) *ForgeInfo { return nil } +func (stubForge) GetAssignableUsers(context.Context) []RepoAssignee { return nil } +func (stubForge) CreatePullRequest(context.Context, git.GitInterface, *ForgeInfo, git.Commit, *git.Commit) *PullRequest { + return nil +} +func (stubForge) UpdatePullRequest(context.Context, git.GitInterface, *ForgeInfo, []*PullRequest, *PullRequest, git.Commit, *git.Commit) { +} +func (stubForge) AddReviewers(context.Context, *PullRequest, []string) {} +func (stubForge) CommentPullRequest(context.Context, *PullRequest, string) {} +func (stubForge) MergePullRequest(context.Context, *PullRequest, config.MergeMethod) {} +func (stubForge) ClosePullRequest(context.Context, *PullRequest) {} +func (stubForge) PullRequestURL(number int) string { + return fmt.Sprintf("https://stub/pull/%d", number) +} + func TestMergable(t *testing.T) { type testcase struct { pr *PullRequest @@ -176,6 +194,6 @@ func TestString(t *testing.T) { {expect: "[?xxx] ! 0 : Title", pr: pr(false, 2), cfg: cfg}, } for i, test := range tests { - assert.Equal(t, test.expect, test.pr.String(test.cfg), fmt.Sprintf("case %d failed", i)) + assert.Equal(t, test.expect, test.pr.String(test.cfg, stubForge{}), fmt.Sprintf("case %d failed", i)) } } diff --git a/github/template/config_fetcher/config.go b/forge/template/config_fetcher/config.go similarity index 68% rename from github/template/config_fetcher/config.go rename to forge/template/config_fetcher/config.go index b6e6b423..5d952c1b 100644 --- a/github/template/config_fetcher/config.go +++ b/forge/template/config_fetcher/config.go @@ -2,12 +2,12 @@ package config_fetcher import ( "github.com/ejoffe/spr/config" + "github.com/ejoffe/spr/forge/template" + "github.com/ejoffe/spr/forge/template/template_basic" + "github.com/ejoffe/spr/forge/template/template_custom" + "github.com/ejoffe/spr/forge/template/template_stack" + "github.com/ejoffe/spr/forge/template/template_why_what" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github/template" - "github.com/ejoffe/spr/github/template/template_basic" - "github.com/ejoffe/spr/github/template/template_custom" - "github.com/ejoffe/spr/github/template/template_stack" - "github.com/ejoffe/spr/github/template/template_why_what" ) func PRTemplatizer(c *config.Config, gitcmd git.GitInterface) template.PRTemplatizer { diff --git a/github/template/helpers.go b/forge/template/helpers.go similarity index 77% rename from github/template/helpers.go rename to forge/template/helpers.go index 6926c2be..aeb449af 100644 --- a/github/template/helpers.go +++ b/forge/template/helpers.go @@ -4,8 +4,8 @@ import ( "bytes" "fmt" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" ) /* @@ -21,7 +21,7 @@ func ManualMergeNotice() string { "Do not merge manually using the UI - doing so may have unexpected results.*" } -func FormatStackMarkdown(commit git.Commit, stack []*github.PullRequest, showPrTitlesInStack bool) string { +func FormatStackMarkdown(commit git.Commit, stack []*forge.PullRequest, showPrTitlesInStack bool, prNumberPrefix string) string { var buf bytes.Buffer for i := len(stack) - 1; i >= 0; i-- { isCurrent := stack[i].Commit == commit @@ -38,7 +38,7 @@ func FormatStackMarkdown(commit git.Commit, stack []*github.PullRequest, showPrT prTitle = "" } - buf.WriteString(fmt.Sprintf("- %s#%d%s\n", prTitle, stack[i].Number, suffix)) + buf.WriteString(fmt.Sprintf("- %s%s%d%s\n", prTitle, prNumberPrefix, stack[i].Number, suffix)) } return buf.String() diff --git a/forge/template/interface.go b/forge/template/interface.go new file mode 100644 index 00000000..d9114fef --- /dev/null +++ b/forge/template/interface.go @@ -0,0 +1,11 @@ +package template + +import ( + "github.com/ejoffe/spr/forge" + "github.com/ejoffe/spr/git" +) + +type PRTemplatizer interface { + Title(info *forge.ForgeInfo, commit git.Commit) string + Body(info *forge.ForgeInfo, commit git.Commit, pr *forge.PullRequest) string +} diff --git a/github/template/template_basic/template.go b/forge/template/template_basic/template.go similarity index 52% rename from github/template/template_basic/template.go rename to forge/template/template_basic/template.go index d253df07..6e4ddbd7 100644 --- a/github/template/template_basic/template.go +++ b/forge/template/template_basic/template.go @@ -1,9 +1,9 @@ package template_basic import ( + "github.com/ejoffe/spr/forge" + "github.com/ejoffe/spr/forge/template" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" - "github.com/ejoffe/spr/github/template" ) type BasicTemplatizer struct{} @@ -12,11 +12,11 @@ func NewBasicTemplatizer() *BasicTemplatizer { return &BasicTemplatizer{} } -func (t *BasicTemplatizer) Title(info *github.GitHubInfo, commit git.Commit) string { +func (t *BasicTemplatizer) Title(info *forge.ForgeInfo, commit git.Commit) string { return commit.Subject } -func (t *BasicTemplatizer) Body(info *github.GitHubInfo, commit git.Commit, pr *github.PullRequest) string { +func (t *BasicTemplatizer) Body(info *forge.ForgeInfo, commit git.Commit, pr *forge.PullRequest) string { body := commit.Body body += "\n\n" body += template.ManualMergeNotice() diff --git a/github/template/template_basic/template_test.go b/forge/template/template_basic/template_test.go similarity index 98% rename from github/template/template_basic/template_test.go rename to forge/template/template_basic/template_test.go index 1e3fba1a..8fb4d1c2 100644 --- a/github/template/template_basic/template_test.go +++ b/forge/template/template_basic/template_test.go @@ -4,14 +4,14 @@ import ( "strings" "testing" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" "github.com/stretchr/testify/assert" ) func TestTitle(t *testing.T) { templatizer := &BasicTemplatizer{} - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} tests := []struct { name string @@ -70,7 +70,7 @@ func TestTitle(t *testing.T) { func TestBody(t *testing.T) { templatizer := &BasicTemplatizer{} - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} tests := []struct { name string @@ -222,7 +222,7 @@ func TestBody(t *testing.T) { func TestBodyManualMergeNoticeFormat(t *testing.T) { templatizer := &BasicTemplatizer{} - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} commit := git.Commit{ Subject: "Test commit", @@ -249,7 +249,7 @@ func TestBodyManualMergeNoticeFormat(t *testing.T) { func TestBodyPreservesOriginalContent(t *testing.T) { templatizer := &BasicTemplatizer{} - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} testCases := []struct { name string @@ -302,7 +302,7 @@ func TestBodyPreservesOriginalContent(t *testing.T) { func TestBodyWithRealWorldExamples(t *testing.T) { templatizer := &BasicTemplatizer{} - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} tests := []struct { name string diff --git a/github/template/template_custom/template.go b/forge/template/template_custom/template.go similarity index 93% rename from github/template/template_custom/template.go rename to forge/template/template_custom/template.go index c6453bea..6bf2eb91 100644 --- a/github/template/template_custom/template.go +++ b/forge/template/template_custom/template.go @@ -11,9 +11,9 @@ import ( "strings" "github.com/ejoffe/spr/config" + "github.com/ejoffe/spr/forge" + "github.com/ejoffe/spr/forge/template" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" - "github.com/ejoffe/spr/github/template" "github.com/rs/zerolog/log" ) @@ -32,12 +32,12 @@ func NewCustomTemplatizer( } } -func (t *CustomTemplatizer) Title(info *github.GitHubInfo, commit git.Commit) string { +func (t *CustomTemplatizer) Title(info *forge.ForgeInfo, commit git.Commit) string { return commit.Subject } -func (t *CustomTemplatizer) Body(info *github.GitHubInfo, commit git.Commit, pr *github.PullRequest) string { - body := t.formatBody(commit, info.PullRequests) +func (t *CustomTemplatizer) Body(info *forge.ForgeInfo, commit git.Commit, pr *forge.PullRequest) string { + body := t.formatBody(commit, info.PullRequests, info.PRNumberPrefix) pullRequestTemplate, err := t.readPRTemplate() if err != nil { log.Fatal().Err(err).Msg("failed to read PR template") @@ -133,7 +133,7 @@ func EditWithEditor(initialContent string) (string, error) { return string(editedBytes), nil } -func (t *CustomTemplatizer) formatBody(commit git.Commit, stack []*github.PullRequest) string { +func (t *CustomTemplatizer) formatBody(commit git.Commit, stack []*forge.PullRequest, prNumberPrefix string) string { if len(stack) <= 1 { return strings.TrimSpace(commit.Body) } @@ -141,14 +141,14 @@ func (t *CustomTemplatizer) formatBody(commit git.Commit, stack []*github.PullRe if commit.Body == "" { return fmt.Sprintf( "**Stack**:\n%s\n%s", - template.FormatStackMarkdown(commit, stack, t.repoConfig.ShowPrTitlesInStack), + template.FormatStackMarkdown(commit, stack, t.repoConfig.ShowPrTitlesInStack, prNumberPrefix), template.ManualMergeNotice(), ) } return fmt.Sprintf("%s\n\n---\n\n**Stack**:\n%s\n%s", commit.Body, - template.FormatStackMarkdown(commit, stack, t.repoConfig.ShowPrTitlesInStack), + template.FormatStackMarkdown(commit, stack, t.repoConfig.ShowPrTitlesInStack, prNumberPrefix), template.ManualMergeNotice(), ) } @@ -181,7 +181,7 @@ const ( // // NOTE: on PR update, rather than using the PR template, it will use the existing PR body, which should have // the PR template from the initial PR create. -func (t *CustomTemplatizer) insertBodyIntoPRTemplate(body, prTemplate string, pr *github.PullRequest) (string, error) { +func (t *CustomTemplatizer) insertBodyIntoPRTemplate(body, prTemplate string, pr *forge.PullRequest) (string, error) { templateOrExistingPRBody := prTemplate if pr != nil && pr.Body != "" { templateOrExistingPRBody = pr.Body diff --git a/github/template/template_custom/template_test.go b/forge/template/template_custom/template_test.go similarity index 95% rename from github/template/template_custom/template_test.go rename to forge/template/template_custom/template_test.go index 5394d0ce..2b873d76 100644 --- a/github/template/template_custom/template_test.go +++ b/forge/template/template_custom/template_test.go @@ -9,8 +9,8 @@ import ( "testing" "github.com/ejoffe/spr/config" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -43,7 +43,7 @@ func TestTitle(t *testing.T) { repoConfig := &config.RepoConfig{} gitcmd := &mockGit{rootDir: "/tmp"} templatizer := NewCustomTemplatizer(repoConfig, gitcmd) - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} tests := []struct { name string @@ -94,7 +94,7 @@ func TestFormatBody(t *testing.T) { tests := []struct { name string commit git.Commit - stack []*github.PullRequest + stack []*forge.PullRequest contains []string }{ { @@ -103,7 +103,7 @@ func TestFormatBody(t *testing.T) { Subject: "Test commit", Body: "Commit body", }, - stack: []*github.PullRequest{ + stack: []*forge.PullRequest{ {Number: 1, Commit: git.Commit{CommitID: "commit1"}}, }, contains: []string{"Commit body"}, @@ -114,7 +114,7 @@ func TestFormatBody(t *testing.T) { Subject: "Test commit", Body: "Commit body", }, - stack: []*github.PullRequest{}, + stack: []*forge.PullRequest{}, contains: []string{"Commit body"}, }, { @@ -123,7 +123,7 @@ func TestFormatBody(t *testing.T) { Subject: "Test commit", Body: "Commit body text", }, - stack: []*github.PullRequest{ + stack: []*forge.PullRequest{ {Number: 1, Commit: git.Commit{CommitID: "commit1"}}, {Number: 2, Commit: git.Commit{CommitID: "commit2"}}, }, @@ -143,7 +143,7 @@ func TestFormatBody(t *testing.T) { Subject: "Test commit", Body: "", }, - stack: []*github.PullRequest{ + stack: []*forge.PullRequest{ {Number: 1, Commit: git.Commit{CommitID: "commit1"}}, {Number: 2, Commit: git.Commit{CommitID: "commit2"}}, }, @@ -160,7 +160,7 @@ func TestFormatBody(t *testing.T) { Subject: "Test commit", Body: "Commit body", }, - stack: []*github.PullRequest{ + stack: []*forge.PullRequest{ {Number: 1, Title: "First PR", Commit: git.Commit{CommitID: "commit1"}}, {Number: 2, Title: "Second PR", Commit: git.Commit{CommitID: "commit2"}}, }, @@ -169,7 +169,7 @@ func TestFormatBody(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := templatizer.formatBody(tt.commit, tt.stack) + result := templatizer.formatBody(tt.commit, tt.stack, "#") for _, wantStr := range tt.contains { assert.Contains(t, result, wantStr, "Expected output to contain: %s", wantStr) @@ -194,12 +194,12 @@ func TestFormatBodyWithPRTitles(t *testing.T) { Subject: "Test commit", Body: "Commit body", } - stack := []*github.PullRequest{ + stack := []*forge.PullRequest{ {Number: 1, Title: "First PR", Commit: git.Commit{CommitID: "commit1"}}, {Number: 2, Title: "Second PR", Commit: git.Commit{CommitID: "commit2"}}, } - result := templatizer.formatBody(commit, stack) + result := templatizer.formatBody(commit, stack, "#") assert.Contains(t, result, "First PR #1") assert.Contains(t, result, "Second PR #2") @@ -383,7 +383,7 @@ func TestInsertBodyIntoPRTemplateWithExistingPR(t *testing.T) { body := "Updated commit body" existingPRBody := "# PR Template\n\n\nOld body\n\n\n" - result, err := templatizer.insertBodyIntoPRTemplate(body, prTemplate, &github.PullRequest{ + result, err := templatizer.insertBodyIntoPRTemplate(body, prTemplate, &forge.PullRequest{ Body: existingPRBody, }) require.NoError(t, err) @@ -473,13 +473,13 @@ func TestFormatBodyStackOrder(t *testing.T) { Subject: "Test", Body: "Body text", } - stack := []*github.PullRequest{ + stack := []*forge.PullRequest{ {Number: 1, Commit: commit1}, {Number: 2, Commit: commit2}, {Number: 3, Commit: commit3}, } - result := templatizer.formatBody(commit, stack) + result := templatizer.formatBody(commit, stack, "#") // Stack should be in reverse order (3, 2, 1) idx3 := strings.Index(result, "#3") @@ -504,12 +504,12 @@ func TestFormatBodyCurrentCommitIndicator(t *testing.T) { commit2 := git.Commit{CommitID: "commit2", Subject: "Second"} commit := commit2 - stack := []*github.PullRequest{ + stack := []*forge.PullRequest{ {Number: 1, Commit: commit1}, {Number: 2, Commit: commit2}, } - result := templatizer.formatBody(commit, stack) + result := templatizer.formatBody(commit, stack, "#") // Current commit should have arrow indicator assert.Contains(t, result, "#2 ⬅") @@ -522,7 +522,7 @@ func TestInsertBodyIntoPRTemplateDefaultAnchors(t *testing.T) { name string prTemplate string body string - pr *github.PullRequest + pr *forge.PullRequest expectedError error expected string }{ @@ -580,7 +580,7 @@ New commit body Initial description `, body: "Updated commit body", - pr: &github.PullRequest{ + pr: &forge.PullRequest{ Body: `# PR Template Initial description @@ -609,7 +609,7 @@ Updated commit body description `, body: "New body", - pr: &github.PullRequest{ + pr: &forge.PullRequest{ Body: `# PR Template description @@ -628,7 +628,7 @@ Old content description `, body: "New body", - pr: &github.PullRequest{ + pr: &forge.PullRequest{ Body: `# PR Template @@ -648,7 +648,7 @@ Content 2 description `, body: "New body", - pr: &github.PullRequest{ + pr: &forge.PullRequest{ Body: `# PR Template diff --git a/github/template/template_stack/template.go b/forge/template/template_stack/template.go similarity index 56% rename from github/template/template_stack/template.go rename to forge/template/template_stack/template.go index 960a1941..3976154e 100644 --- a/github/template/template_stack/template.go +++ b/forge/template/template_stack/template.go @@ -1,9 +1,9 @@ package template_stack import ( + "github.com/ejoffe/spr/forge" + "github.com/ejoffe/spr/forge/template" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" - "github.com/ejoffe/spr/github/template" ) type StackTemplatizer struct { @@ -14,18 +14,18 @@ func NewStackTemplatizer(showPrTitlesInStack bool) *StackTemplatizer { return &StackTemplatizer{showPrTitlesInStack: showPrTitlesInStack} } -func (t *StackTemplatizer) Title(info *github.GitHubInfo, commit git.Commit) string { +func (t *StackTemplatizer) Title(info *forge.ForgeInfo, commit git.Commit) string { return commit.Subject } -func (t *StackTemplatizer) Body(info *github.GitHubInfo, commit git.Commit, pr *github.PullRequest) string { +func (t *StackTemplatizer) Body(info *forge.ForgeInfo, commit git.Commit, pr *forge.PullRequest) string { body := commit.Body - // Always show stack section and notice - body += "\n" - body += "---\n" + if body != "" { + body += "\n\n---\n" + } body += "**Stack**:\n" - body += template.FormatStackMarkdown(commit, info.PullRequests, t.showPrTitlesInStack) + body += template.FormatStackMarkdown(commit, info.PullRequests, t.showPrTitlesInStack, info.PRNumberPrefix) body += "---\n" body += template.ManualMergeNotice() return body diff --git a/github/template/template_stack/template_test.go b/forge/template/template_stack/template_test.go similarity index 90% rename from github/template/template_stack/template_test.go rename to forge/template/template_stack/template_test.go index bd72e372..401f337b 100644 --- a/github/template/template_stack/template_test.go +++ b/forge/template/template_stack/template_test.go @@ -4,14 +4,14 @@ import ( "strings" "testing" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" "github.com/stretchr/testify/assert" ) func TestTitle(t *testing.T) { templatizer := NewStackTemplatizer(false) - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{PRNumberPrefix: "#"} tests := []struct { name string @@ -70,8 +70,9 @@ func TestTitle(t *testing.T) { func TestBody_EmptyStack(t *testing.T) { templatizer := NewStackTemplatizer(false) - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{}, + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{}, } commit := git.Commit{ @@ -112,8 +113,9 @@ func TestBody_WithStack_NoTitles(t *testing.T) { Body: "Third body", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ { Number: 1, Title: "First commit", @@ -189,8 +191,9 @@ func TestBody_WithStack_WithTitles(t *testing.T) { Body: "Third body", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ { Number: 1, Title: "First commit", @@ -246,8 +249,9 @@ func TestBody_StackOrder(t *testing.T) { Body: "Body 3", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ {Number: 1, Commit: commit1}, {Number: 2, Commit: commit2}, {Number: 3, Commit: commit3}, @@ -296,8 +300,9 @@ func TestBody_CurrentCommitAtStart(t *testing.T) { Body: "Body 3", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ {Number: 1, Commit: commit1}, {Number: 2, Commit: commit2}, {Number: 3, Commit: commit3}, @@ -334,8 +339,9 @@ func TestBody_CurrentCommitAtEnd(t *testing.T) { Body: "Body 3", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ {Number: 1, Commit: commit1}, {Number: 2, Commit: commit2}, {Number: 3, Commit: commit3}, @@ -362,8 +368,9 @@ func TestBody_EmptyBody(t *testing.T) { Body: "", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ {Number: 1, Commit: commit}, }, } @@ -374,6 +381,12 @@ func TestBody_EmptyBody(t *testing.T) { assert.Contains(t, result, "#1") assert.Contains(t, result, "⚠️") assert.Contains(t, result, "Part of a stack created by [spr]") + + // Body must not start with "---" (even after trimming whitespace), + // because Markdown renderers (especially GitLab) interpret leading + // "---" as YAML frontmatter, swallowing everything until the next "---". + assert.False(t, strings.HasPrefix(strings.TrimSpace(result), "---"), + "empty commit body must not produce output starting with --- (YAML frontmatter)") } func TestBody_SinglePRInStack(t *testing.T) { @@ -385,8 +398,9 @@ func TestBody_SinglePRInStack(t *testing.T) { Body: "Single body", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ {Number: 42, Commit: commit}, }, } @@ -418,8 +432,9 @@ func TestBody_WithTitlesVsWithoutTitles(t *testing.T) { Body: "Body 2", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ {Number: 1, Title: "First PR", Commit: commit1}, {Number: 2, Title: "Second PR", Commit: commit2}, }, @@ -454,8 +469,9 @@ func TestBody_Structure(t *testing.T) { Body: "Commit body content", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ {Number: 1, Commit: commit}, }, } @@ -492,8 +508,9 @@ func TestBody_RealWorldExample(t *testing.T) { Body: "Created POST /register endpoint", } - info := &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info := &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ { Number: 10, Title: "Add authentication middleware", @@ -554,31 +571,32 @@ It even includes some **markdown** formatting.`} tests := []struct { name string commit git.Commit - info *github.GitHubInfo + info *forge.ForgeInfo expected string }{ { name: "EmptyStack", commit: git.Commit{}, - info: &github.GitHubInfo{ - PullRequests: []*github.PullRequest{}, + info: &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{}, }, - expected: ` ---- -**Stack**: + expected: `**Stack**: --- ⚠️ *Part of a stack created by [spr](https://github.com/ejoffe/spr). Do not merge manually using the UI - doing so may have unexpected results.*`, }, { name: "SinglePRStack", commit: descriptiveCommit, - info: &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info: &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ {Number: 2, Commit: descriptiveCommit}, }, }, expected: `This body describes my nice PR. It even includes some **markdown** formatting. + --- **Stack**: - #2 ⬅ @@ -587,14 +605,16 @@ It even includes some **markdown** formatting. }, { name: "TwoPRStack", - info: &github.GitHubInfo{ - PullRequests: []*github.PullRequest{ + info: &forge.ForgeInfo{ + PRNumberPrefix: "#", + PullRequests: []*forge.PullRequest{ {Number: 1, Commit: simpleCommit}, {Number: 2, Commit: descriptiveCommit}, }, }, expected: `This body describes my nice PR. It even includes some **markdown** formatting. + --- **Stack**: - #2 ⬅ @@ -631,18 +651,18 @@ It even includes some **markdown** formatting.`} tests := []struct { description string commit git.Commit - stack []*github.PullRequest + stack []*forge.PullRequest }{ { description: "", commit: git.Commit{}, - stack: []*github.PullRequest{}, + stack: []*forge.PullRequest{}, }, { description: `This body describes my nice PR. It even includes some **markdown** formatting.`, commit: descriptiveCommit, - stack: []*github.PullRequest{ + stack: []*forge.PullRequest{ {Number: 2, Commit: descriptiveCommit}, }, }, @@ -659,7 +679,7 @@ It even includes some **markdown** formatting. ⚠️ *Part of a stack created by [spr](https://github.com/ejoffe/spr). Do not merge manually using the UI - doing so may have unexpected results.*`, commit: descriptiveCommit, - stack: []*github.PullRequest{ + stack: []*forge.PullRequest{ {Number: 1, Commit: simpleCommit, Title: "Title A"}, {Number: 2, Commit: descriptiveCommit, Title: "Title B"}, }, @@ -680,7 +700,7 @@ func TestInsertBodyIntoPRTemplateHappyPath(t *testing.T) { body string pullRequestTemplate string repo *config.RepoConfig - pr *github.PullRequest + pr *forge.PullRequest expected string }{ { @@ -726,7 +746,7 @@ inserted body PRTemplateInsertStart: "## Description", PRTemplateInsertEnd: "## Checklist", }, - pr: &github.PullRequest{ + pr: &forge.PullRequest{ Body: ` ## Related Issues * Issue #1234 @@ -765,7 +785,7 @@ func TestInsertBodyIntoPRTemplateErrors(t *testing.T) { body string pullRequestTemplate string repo *config.RepoConfig - pr *github.PullRequest + pr *forge.PullRequest expected string }{ { diff --git a/github/template/template_why_what/template.go b/forge/template/template_why_what/template.go similarity index 91% rename from github/template/template_why_what/template.go rename to forge/template/template_why_what/template.go index c81f49ad..236d0769 100644 --- a/github/template/template_why_what/template.go +++ b/forge/template/template_why_what/template.go @@ -5,9 +5,9 @@ import ( "strings" go_template "text/template" + "github.com/ejoffe/spr/forge" + "github.com/ejoffe/spr/forge/template" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" - "github.com/ejoffe/spr/github/template" ) type WhyWhatTemplatizer struct{} @@ -16,11 +16,11 @@ func NewWhyWhatTemplatizer() *WhyWhatTemplatizer { return &WhyWhatTemplatizer{} } -func (t *WhyWhatTemplatizer) Title(info *github.GitHubInfo, commit git.Commit) string { +func (t *WhyWhatTemplatizer) Title(info *forge.ForgeInfo, commit git.Commit) string { return commit.Subject } -func (t *WhyWhatTemplatizer) Body(info *github.GitHubInfo, commit git.Commit, pr *github.PullRequest) string { +func (t *WhyWhatTemplatizer) Body(info *forge.ForgeInfo, commit git.Commit, pr *forge.PullRequest) string { // Split commit body by empty lines and filter out empty sections sections := splitByEmptyLines(commit.Body) @@ -64,10 +64,10 @@ func (t *WhyWhatTemplatizer) Body(info *github.GitHubInfo, commit git.Commit, pr body := buf.String() // Always show stack section and notice - body += "\n" + body += "\n\n" body += "---\n" body += "**Stack**:\n" - body += template.FormatStackMarkdown(commit, info.PullRequests, true) + body += template.FormatStackMarkdown(commit, info.PullRequests, true, info.PRNumberPrefix) body += "---\n" body += template.ManualMergeNotice() return body diff --git a/github/template/template_why_what/template_test.go b/forge/template/template_why_what/template_test.go similarity index 98% rename from github/template/template_why_what/template_test.go rename to forge/template/template_why_what/template_test.go index 7a9179a4..01ed9d82 100644 --- a/github/template/template_why_what/template_test.go +++ b/forge/template/template_why_what/template_test.go @@ -4,14 +4,14 @@ import ( "strings" "testing" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" "github.com/stretchr/testify/assert" ) func TestTitle(t *testing.T) { templatizer := &WhyWhatTemplatizer{} - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} tests := []struct { name string @@ -54,7 +54,7 @@ func TestTitle(t *testing.T) { func TestBody(t *testing.T) { templatizer := &WhyWhatTemplatizer{} - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} tests := []struct { name string @@ -313,7 +313,7 @@ func TestSplitByEmptyLines(t *testing.T) { func TestBodyTemplateStructure(t *testing.T) { // This test ensures the template always produces the correct structure templatizer := &WhyWhatTemplatizer{} - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} commit := git.Commit{ Subject: "Test", @@ -336,7 +336,7 @@ func TestBodyTemplateStructure(t *testing.T) { func TestBodyWithRealWorldExamples(t *testing.T) { templatizer := &WhyWhatTemplatizer{} - info := &github.GitHubInfo{} + info := &forge.ForgeInfo{} tests := []struct { name string diff --git a/git/helpers.go b/git/helpers.go index 5b7c4c2c..7f14409a 100644 --- a/git/helpers.go +++ b/git/helpers.go @@ -25,7 +25,7 @@ func GetLocalBranchName(gitcmd GitInterface) string { } func BranchNameFromCommit(cfg *config.Config, commit Commit) string { - remoteBranchName := cfg.Repo.GitHubBranch + remoteBranchName := cfg.Repo.Branch return "spr/" + remoteBranchName + "/" + commit.CommitID } @@ -48,7 +48,7 @@ func GetLocalTopCommit(cfg *config.Config, gitcmd GitInterface) *Commit { func GetLocalCommitStack(cfg *config.Config, gitcmd GitInterface) []Commit { var commitLog string logCommand := fmt.Sprintf("log --format=medium --no-color %s/%s..HEAD", - cfg.Repo.GitHubRemote, cfg.Repo.GitHubBranch) + cfg.Repo.Remote, cfg.Repo.Branch) gitcmd.MustGit(logCommand, &commitLog) commits, valid := parseLocalCommitStack(commitLog) if !valid { @@ -56,7 +56,7 @@ func GetLocalCommitStack(cfg *config.Config, gitcmd GitInterface) []Commit { rewordPath, err := exec.LookPath("spr_reword_helper") check(err) rebaseCommand := fmt.Sprintf("rebase %s/%s -i --autosquash --autostash", - cfg.Repo.GitHubRemote, cfg.Repo.GitHubBranch) + cfg.Repo.Remote, cfg.Repo.Branch) gitcmd.GitWithEditor(rebaseCommand, nil, rewordPath) gitcmd.MustGit(logCommand, &commitLog) diff --git a/git/mockgit/mockgit.go b/git/mockgit/mockgit.go index 1316d071..4930eaab 100644 --- a/git/mockgit/mockgit.go +++ b/git/mockgit/mockgit.go @@ -80,7 +80,7 @@ func (m *Mock) ExpectFetch() { } func (m *Mock) ExpectDeleteBranch(branchName string) { - m.expect(fmt.Sprintf("git DeleteRemoteBranch(%s)", branchName)) + m.expect("git DeleteRemoteBranch(%s)", branchName) } func (m *Mock) ExpectLogAndRespond(commits []*git.Commit) { @@ -99,7 +99,7 @@ func (m *Mock) ExpectPushCommits(commits []*git.Commit) { branchName := "spr/master/" + c.CommitID refNames = append(refNames, c.CommitHash+":refs/heads/"+branchName) } - m.expect("git push --force --atomic origin " + strings.Join(refNames, " ")) + m.expect("git push --force --atomic origin %s", strings.Join(refNames, " ")) } func (m *Mock) ExpectRemote(remote string) { @@ -109,7 +109,7 @@ func (m *Mock) ExpectRemote(remote string) { } func (m *Mock) ExpectFixup(commitHash string) { - m.expect("git commit --fixup " + commitHash) + m.expect("git commit --fixup %s", commitHash) m.expect("git rebase -i --autosquash --autostash origin/master") } diff --git a/git/realgit/realcmd.go b/git/realgit/realcmd.go index 5bae1531..de23469b 100644 --- a/git/realgit/realcmd.go +++ b/git/realgit/realcmd.go @@ -124,7 +124,7 @@ func (c *gitcmd) RootDir() string { } func (c *gitcmd) DeleteRemoteBranch(ctx context.Context, branch string) error { - remoteName := c.config.Repo.GitHubRemote + remoteName := c.config.Repo.Remote remote, err := c.repo.Remote(remoteName) if err != nil { diff --git a/github/githubclient/client.go b/github/githubclient/client.go index edb790da..8876cd98 100644 --- a/github/githubclient/client.go +++ b/github/githubclient/client.go @@ -11,11 +11,11 @@ import ( "gopkg.in/yaml.v3" "github.com/ejoffe/spr/config" + "github.com/ejoffe/spr/forge" + "github.com/ejoffe/spr/forge/template/config_fetcher" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" "github.com/ejoffe/spr/github/githubclient/fezzik_types" "github.com/ejoffe/spr/github/githubclient/gen/genclient" - "github.com/ejoffe/spr/github/template/config_fetcher" "github.com/rs/zerolog/log" "golang.org/x/oauth2" ) @@ -136,9 +136,9 @@ so if you already use that, spr will automatically pick up your token. ` func NewGitHubClient(ctx context.Context, config *config.Config) *client { - token := findToken(config.Repo.GitHubHost) + token := findToken(config.Repo.ForgeHost) if token == "" { - fmt.Printf(tokenHelpText, config.Repo.GitHubHost) + fmt.Printf(tokenHelpText, config.Repo.ForgeHost) os.Exit(3) } ts := oauth2.StaticTokenSource( @@ -147,14 +147,14 @@ func NewGitHubClient(ctx context.Context, config *config.Config) *client { tc := oauth2.NewClient(ctx, ts) var api genclient.Client - if strings.HasSuffix(config.Repo.GitHubHost, "github.com") { + if strings.HasSuffix(config.Repo.ForgeHost, "github.com") { api = genclient.NewClient("https://api.github.com/graphql", tc) } else { var scheme, host string - gitHubRemoteUrl, err := url.Parse(config.Repo.GitHubHost) + gitHubRemoteUrl, err := url.Parse(config.Repo.ForgeHost) check(err) if gitHubRemoteUrl.Host == "" { - host = config.Repo.GitHubHost + host = config.Repo.ForgeHost scheme = "https" } else { host = gitHubRemoteUrl.Host @@ -173,7 +173,7 @@ type client struct { api genclient.Client } -func (c *client) GetInfo(ctx context.Context, gitcmd git.GitInterface) *github.GitHubInfo { +func (c *client) GetInfo(ctx context.Context, gitcmd git.GitInterface) *forge.ForgeInfo { if c.config.User.LogGitHubCalls { fmt.Printf("> github fetch pull requests\n") } @@ -183,23 +183,23 @@ func (c *client) GetInfo(ctx context.Context, gitcmd git.GitInterface) *github.G var repoID string if c.config.Repo.MergeQueue { resp, err := c.api.PullRequestsWithMergeQueue(ctx, - c.config.Repo.GitHubRepoOwner, - c.config.Repo.GitHubRepoName) + c.config.Repo.RepoOwner, + c.config.Repo.RepoName) check(err) pullRequestConnection = resp.Viewer.PullRequests loginName = resp.Viewer.Login repoID = resp.Repository.Id } else { resp, err := c.api.PullRequests(ctx, - c.config.Repo.GitHubRepoOwner, - c.config.Repo.GitHubRepoName) + c.config.Repo.RepoOwner, + c.config.Repo.RepoName) check(err) pullRequestConnection = resp.Viewer.PullRequests loginName = resp.Viewer.Login repoID = resp.Repository.Id } - targetBranch := c.config.Repo.GitHubBranch + targetBranch := c.config.Repo.Branch localCommitStack := git.GetLocalCommitStack(c.config, gitcmd) pullRequests := matchPullRequestStack(c.config.Repo, targetBranch, localCommitStack, pullRequestConnection) @@ -211,11 +211,12 @@ 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, + info := &forge.ForgeInfo{ + UserName: loginName, + RepositoryID: repoID, + LocalBranch: git.GetLocalBranchName(gitcmd), + PullRequests: pullRequests, + PRNumberPrefix: "#", } log.Debug().Interface("Info", info).Msg("GetInfo") @@ -226,14 +227,14 @@ func matchPullRequestStack( repoConfig *config.RepoConfig, targetBranch string, localCommitStack []git.Commit, - allPullRequests fezzik_types.PullRequestConnection) []*github.PullRequest { + allPullRequests fezzik_types.PullRequestConnection) []*forge.PullRequest { if len(localCommitStack) == 0 || allPullRequests.Nodes == nil { - return []*github.PullRequest{} + return []*forge.PullRequest{} } // pullRequestMap is a map from commit-id to pull request - pullRequestMap := make(map[string]*github.PullRequest) + pullRequestMap := make(map[string]*forge.PullRequest) for _, node := range *allPullRequests.Nodes { var commits []git.Commit for _, v := range *node.Commits.Nodes { @@ -249,7 +250,7 @@ func matchPullRequestStack( } } - pullRequest := &github.PullRequest{ + pullRequest := &forge.PullRequest{ ID: node.Id, Number: node.Number, Title: node.Title, @@ -270,19 +271,19 @@ func matchPullRequestStack( Body: commit.MessageBody, } - checkStatus := github.CheckStatusPass + checkStatus := forge.CheckStatusPass if commit.StatusCheckRollup != nil { switch commit.StatusCheckRollup.State { case "SUCCESS": - checkStatus = github.CheckStatusPass + checkStatus = forge.CheckStatusPass case "PENDING": - checkStatus = github.CheckStatusPending + checkStatus = forge.CheckStatusPending default: - checkStatus = github.CheckStatusFail + checkStatus = forge.CheckStatusFail } } - pullRequest.MergeStatus = github.PullRequestMergeStatus{ + pullRequest.MergeStatus = forge.PullRequestMergeStatus{ ChecksPass: checkStatus, ReviewApproved: node.ReviewDecision != nil && *node.ReviewDecision == "APPROVED", NoConflicts: node.Mergeable == "MERGEABLE", @@ -292,67 +293,29 @@ func matchPullRequestStack( } } - var pullRequests []*github.PullRequest - - // find top pr - var currpr *github.PullRequest - var found bool - for i := len(localCommitStack) - 1; i >= 0; i-- { - currpr, found = pullRequestMap[localCommitStack[i].CommitID] - if found { - break - } - } - - // The list of commits from the command line actually starts at the - // most recent commit. In order to reverse the list we use a - // custom prepend function instead of append - prepend := func(l []*github.PullRequest, pr *github.PullRequest) []*github.PullRequest { - l = append(l, &github.PullRequest{}) - copy(l[1:], l) - l[0] = pr - return l - } - - // build pr stack - for currpr != nil { - pullRequests = prepend(pullRequests, currpr) - if currpr.ToBranch == targetBranch { - break - } - - matches := git.BranchNameRegex.FindStringSubmatch(currpr.ToBranch) - if matches == nil { - panic(fmt.Errorf("invalid base branch for pull request:%s", currpr.ToBranch)) - } - nextCommitID := matches[2] - - currpr = pullRequestMap[nextCommitID] - } - - return pullRequests + return forge.BuildPullRequestStack(targetBranch, localCommitStack, pullRequestMap) } // GetAssignableUsers is taken from github.com/cli/cli/api and is the approach used by the official gh // client to resolve user IDs to "ID" values for the update PR API calls. See api.RepoAssignableUsers. -func (c *client) GetAssignableUsers(ctx context.Context) []github.RepoAssignee { +func (c *client) GetAssignableUsers(ctx context.Context) []forge.RepoAssignee { if c.config.User.LogGitHubCalls { fmt.Printf("> github get assignable users\n") } - users := []github.RepoAssignee{} + users := []forge.RepoAssignee{} var endCursor *string for { resp, err := c.api.AssignableUsers(ctx, - c.config.Repo.GitHubRepoOwner, - c.config.Repo.GitHubRepoName, endCursor) + c.config.Repo.RepoOwner, + c.config.Repo.RepoName, endCursor) if err != nil { log.Fatal().Err(err).Msg("get assignable users failed") return nil } for _, node := range *resp.Repository.AssignableUsers.Nodes { - user := github.RepoAssignee{ + user := forge.RepoAssignee{ ID: node.Id, Login: node.Login, } @@ -371,9 +334,9 @@ func (c *client) GetAssignableUsers(ctx context.Context) []github.RepoAssignee { } func (c *client) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface, - info *github.GitHubInfo, commit git.Commit, prevCommit *git.Commit) *github.PullRequest { + info *forge.ForgeInfo, commit git.Commit, prevCommit *git.Commit) *forge.PullRequest { - baseRefName := c.config.Repo.GitHubBranch + baseRefName := c.config.Repo.Branch if prevCommit != nil { baseRefName = git.BranchNameFromCommit(c.config, *prevCommit) } @@ -396,7 +359,7 @@ func (c *client) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface, }) check(err) - pr := &github.PullRequest{ + pr := &forge.PullRequest{ ID: resp.CreatePullRequest.PullRequest.Id, Number: resp.CreatePullRequest.PullRequest.Number, FromBranch: headRefName, @@ -404,8 +367,8 @@ func (c *client) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface, Commit: commit, Title: commit.Subject, Body: resp.CreatePullRequest.PullRequest.Body, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusUnknown, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusUnknown, ReviewApproved: false, NoConflicts: false, Stacked: false, @@ -420,14 +383,14 @@ func (c *client) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface, } func (c *client) UpdatePullRequest(ctx context.Context, gitcmd git.GitInterface, - info *github.GitHubInfo, pullRequests []*github.PullRequest, pr *github.PullRequest, + info *forge.ForgeInfo, pullRequests []*forge.PullRequest, pr *forge.PullRequest, commit git.Commit, prevCommit *git.Commit) { if c.config.User.LogGitHubCalls { fmt.Printf("> github update %d : %s\n", pr.Number, pr.Title) } - baseRefName := c.config.Repo.GitHubBranch + baseRefName := c.config.Repo.Branch if prevCommit != nil { baseRefName = git.BranchNameFromCommit(c.config, *prevCommit) } @@ -468,7 +431,7 @@ func (c *client) UpdatePullRequest(ctx context.Context, gitcmd git.GitInterface, // AddReviewers adds reviewers to the provided pull request using the requestReviews() API call. It // takes github user IDs (ID type) as its input. These can be found by first querying the AssignableUsers // for the repo, and then mapping login name to ID. -func (c *client) AddReviewers(ctx context.Context, pr *github.PullRequest, userIDs []string) { +func (c *client) AddReviewers(ctx context.Context, pr *forge.PullRequest, userIDs []string) { log.Debug().Strs("userIDs", userIDs).Msg("AddReviewers") if c.config.User.LogGitHubCalls { fmt.Printf("> github add reviewers %d : %s - %+v\n", pr.Number, pr.Title, userIDs) @@ -490,7 +453,7 @@ func (c *client) AddReviewers(ctx context.Context, pr *github.PullRequest, userI } } -func (c *client) CommentPullRequest(ctx context.Context, pr *github.PullRequest, comment string) { +func (c *client) CommentPullRequest(ctx context.Context, pr *forge.PullRequest, comment string) { _, err := c.api.CommentPullRequest(ctx, genclient.AddCommentInput{ SubjectId: pr.ID, Body: comment, @@ -510,22 +473,23 @@ func (c *client) CommentPullRequest(ctx context.Context, pr *github.PullRequest, } func (c *client) MergePullRequest(ctx context.Context, - pr *github.PullRequest, mergeMethod genclient.PullRequestMergeMethod) { + pr *forge.PullRequest, mergeMethod config.MergeMethod) { log.Debug(). Interface("PR", pr). Str("mergeMethod", string(mergeMethod)). Msg("MergePullRequest") + apiMergeMethod := toGitHubMergeMethod(mergeMethod) var err error if c.config.Repo.MergeQueue { _, err = c.api.AutoMergePullRequest(ctx, genclient.EnablePullRequestAutoMergeInput{ PullRequestId: pr.ID, - MergeMethod: &mergeMethod, + MergeMethod: &apiMergeMethod, }) } else { _, err = c.api.MergePullRequest(ctx, genclient.MergePullRequestInput{ PullRequestId: pr.ID, - MergeMethod: &mergeMethod, + MergeMethod: &apiMergeMethod, }) } if err != nil { @@ -543,7 +507,25 @@ func (c *client) MergePullRequest(ctx context.Context, } } -func (c *client) ClosePullRequest(ctx context.Context, pr *github.PullRequest) { +func toGitHubMergeMethod(m config.MergeMethod) genclient.PullRequestMergeMethod { + switch m { + case config.MergeMethodMerge: + return genclient.PullRequestMergeMethod_MERGE + case config.MergeMethodSquash: + return genclient.PullRequestMergeMethod_SQUASH + case config.MergeMethodRebase: + return genclient.PullRequestMergeMethod_REBASE + default: + return genclient.PullRequestMergeMethod_REBASE + } +} + +func (c *client) PullRequestURL(number int) string { + return fmt.Sprintf("https://%s/%s/%s/pull/%d", + c.config.Repo.ForgeHost, c.config.Repo.RepoOwner, c.config.Repo.RepoName, number) +} + +func (c *client) ClosePullRequest(ctx context.Context, pr *forge.PullRequest) { log.Debug().Interface("PR", pr).Msg("ClosePullRequest") _, err := c.api.ClosePullRequest(ctx, genclient.ClosePullRequestInput{ PullRequestId: pr.ID, diff --git a/github/githubclient/client_test.go b/github/githubclient/client_test.go index c2cbd588..8f60cdd6 100644 --- a/github/githubclient/client_test.go +++ b/github/githubclient/client_test.go @@ -4,8 +4,8 @@ import ( "testing" "github.com/ejoffe/spr/config" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" "github.com/ejoffe/spr/github/githubclient/fezzik_types" "github.com/stretchr/testify/require" ) @@ -15,7 +15,7 @@ func TestMatchPullRequestStack(t *testing.T) { name string commits []git.Commit prs fezzik_types.PullRequestConnection - expect []*github.PullRequest + expect []*forge.PullRequest }{ { name: "ThirdCommitQueue", @@ -28,6 +28,7 @@ func TestMatchPullRequestStack(t *testing.T) { Nodes: &fezzik_types.PullRequestsViewerPullRequestsNodes{ { Id: "2", + Number: 2, HeadRefName: "spr/master/00000002", BaseRefName: "master", MergeQueueEntry: &fezzik_types.PullRequestsViewerPullRequestsNodesMergeQueueEntry{Id: "020"}, @@ -44,9 +45,10 @@ func TestMatchPullRequestStack(t *testing.T) { }, }, }, - expect: []*github.PullRequest{ + expect: []*forge.PullRequest{ { ID: "2", + Number: 2, FromBranch: "spr/master/00000002", ToBranch: "master", Commit: git.Commit{ @@ -59,8 +61,8 @@ func TestMatchPullRequestStack(t *testing.T) { {CommitID: "1", CommitHash: "1", Body: "commit-id:1"}, {CommitID: "2", CommitHash: "2", Body: "commit-id:2"}, }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, }, @@ -77,6 +79,7 @@ func TestMatchPullRequestStack(t *testing.T) { Nodes: &fezzik_types.PullRequestsViewerPullRequestsNodes{ { Id: "2", + Number: 2, HeadRefName: "spr/master/00000002", BaseRefName: "master", MergeQueueEntry: &fezzik_types.PullRequestsViewerPullRequestsNodesMergeQueueEntry{Id: "020"}, @@ -93,6 +96,7 @@ func TestMatchPullRequestStack(t *testing.T) { }, { Id: "3", + Number: 3, HeadRefName: "spr/master/00000003", BaseRefName: "spr/master/00000002", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -105,9 +109,10 @@ func TestMatchPullRequestStack(t *testing.T) { }, }, }, - expect: []*github.PullRequest{ + expect: []*forge.PullRequest{ { ID: "2", + Number: 2, FromBranch: "spr/master/00000002", ToBranch: "master", Commit: git.Commit{ @@ -120,12 +125,13 @@ func TestMatchPullRequestStack(t *testing.T) { {CommitID: "1", CommitHash: "1", Body: "commit-id:1"}, {CommitID: "2", CommitHash: "2", Body: "commit-id:2"}, }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, { ID: "3", + Number: 3, FromBranch: "spr/master/00000003", ToBranch: "spr/master/00000002", Commit: git.Commit{ @@ -136,8 +142,8 @@ func TestMatchPullRequestStack(t *testing.T) { Commits: []git.Commit{ {CommitID: "3", CommitHash: "3", Body: "commit-id:3"}, }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, }, @@ -146,13 +152,13 @@ func TestMatchPullRequestStack(t *testing.T) { name: "Empty", commits: []git.Commit{}, prs: fezzik_types.PullRequestConnection{}, - expect: []*github.PullRequest{}, + expect: []*forge.PullRequest{}, }, { name: "FirstCommit", commits: []git.Commit{{CommitID: "00000001"}}, prs: fezzik_types.PullRequestConnection{}, - expect: []*github.PullRequest{}, + expect: []*forge.PullRequest{}, }, { name: "SecondCommit", @@ -164,6 +170,7 @@ func TestMatchPullRequestStack(t *testing.T) { Nodes: &fezzik_types.PullRequestsViewerPullRequestsNodes{ { Id: "1", + Number: 1, HeadRefName: "spr/master/00000001", BaseRefName: "master", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -176,17 +183,18 @@ func TestMatchPullRequestStack(t *testing.T) { }, }, }, - expect: []*github.PullRequest{ + expect: []*forge.PullRequest{ { ID: "1", + Number: 1, FromBranch: "spr/master/00000001", ToBranch: "master", Commit: git.Commit{ CommitID: "00000001", CommitHash: "1", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, }, @@ -202,6 +210,7 @@ func TestMatchPullRequestStack(t *testing.T) { Nodes: &fezzik_types.PullRequestsViewerPullRequestsNodes{ { Id: "1", + Number: 1, HeadRefName: "spr/master/00000001", BaseRefName: "master", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -214,6 +223,7 @@ func TestMatchPullRequestStack(t *testing.T) { }, { Id: "2", + Number: 2, HeadRefName: "spr/master/00000002", BaseRefName: "spr/master/00000001", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -226,29 +236,31 @@ func TestMatchPullRequestStack(t *testing.T) { }, }, }, - expect: []*github.PullRequest{ + expect: []*forge.PullRequest{ { ID: "1", + Number: 1, FromBranch: "spr/master/00000001", ToBranch: "master", Commit: git.Commit{ CommitID: "00000001", CommitHash: "1", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, { ID: "2", + Number: 2, FromBranch: "spr/master/00000002", ToBranch: "spr/master/00000001", Commit: git.Commit{ CommitID: "00000002", CommitHash: "2", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, }, @@ -260,6 +272,7 @@ func TestMatchPullRequestStack(t *testing.T) { Nodes: &fezzik_types.PullRequestsViewerPullRequestsNodes{ { Id: "1", + Number: 1, HeadRefName: "spr/master/00000001", BaseRefName: "master", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -272,7 +285,7 @@ func TestMatchPullRequestStack(t *testing.T) { }, }, }, - expect: []*github.PullRequest{}, + expect: []*forge.PullRequest{}, }, { name: "RemoveTopCommit", @@ -284,6 +297,7 @@ func TestMatchPullRequestStack(t *testing.T) { Nodes: &fezzik_types.PullRequestsViewerPullRequestsNodes{ { Id: "1", + Number: 1, HeadRefName: "spr/master/00000001", BaseRefName: "master", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -296,6 +310,7 @@ func TestMatchPullRequestStack(t *testing.T) { }, { Id: "3", + Number: 3, HeadRefName: "spr/master/00000003", BaseRefName: "spr/master/00000002", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -308,6 +323,7 @@ func TestMatchPullRequestStack(t *testing.T) { }, { Id: "2", + Number: 2, HeadRefName: "spr/master/00000002", BaseRefName: "spr/master/00000001", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -320,29 +336,31 @@ func TestMatchPullRequestStack(t *testing.T) { }, }, }, - expect: []*github.PullRequest{ + expect: []*forge.PullRequest{ { ID: "1", + Number: 1, FromBranch: "spr/master/00000001", ToBranch: "master", Commit: git.Commit{ CommitID: "00000001", CommitHash: "1", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, { ID: "2", + Number: 2, FromBranch: "spr/master/00000002", ToBranch: "spr/master/00000001", Commit: git.Commit{ CommitID: "00000002", CommitHash: "2", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, }, @@ -357,6 +375,7 @@ func TestMatchPullRequestStack(t *testing.T) { Nodes: &fezzik_types.PullRequestsViewerPullRequestsNodes{ { Id: "1", + Number: 1, HeadRefName: "spr/master/00000001", BaseRefName: "master", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -369,6 +388,7 @@ func TestMatchPullRequestStack(t *testing.T) { }, { Id: "2", + Number: 2, HeadRefName: "spr/master/00000002", BaseRefName: "spr/master/00000001", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -381,6 +401,7 @@ func TestMatchPullRequestStack(t *testing.T) { }, { Id: "3", + Number: 3, HeadRefName: "spr/master/00000003", BaseRefName: "spr/master/00000002", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -393,41 +414,44 @@ func TestMatchPullRequestStack(t *testing.T) { }, }, }, - expect: []*github.PullRequest{ + expect: []*forge.PullRequest{ { ID: "1", + Number: 1, FromBranch: "spr/master/00000001", ToBranch: "master", Commit: git.Commit{ CommitID: "00000001", CommitHash: "1", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, { ID: "2", + Number: 2, FromBranch: "spr/master/00000002", ToBranch: "spr/master/00000001", Commit: git.Commit{ CommitID: "00000002", CommitHash: "2", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, { ID: "3", + Number: 3, FromBranch: "spr/master/00000003", ToBranch: "spr/master/00000002", Commit: git.Commit{ CommitID: "00000003", CommitHash: "3", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, }, @@ -442,6 +466,7 @@ func TestMatchPullRequestStack(t *testing.T) { Nodes: &fezzik_types.PullRequestsViewerPullRequestsNodes{ { Id: "1", + Number: 1, HeadRefName: "spr/master/00000001", BaseRefName: "master", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -454,6 +479,7 @@ func TestMatchPullRequestStack(t *testing.T) { }, { Id: "2", + Number: 2, HeadRefName: "spr/master/00000002", BaseRefName: "spr/master/00000001", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -466,6 +492,7 @@ func TestMatchPullRequestStack(t *testing.T) { }, { Id: "3", + Number: 3, HeadRefName: "spr/master/00000003", BaseRefName: "spr/master/00000002", Commits: fezzik_types.PullRequestsViewerPullRequestsNodesCommits{ @@ -478,42 +505,45 @@ func TestMatchPullRequestStack(t *testing.T) { }, }, }, - expect: []*github.PullRequest{ + expect: []*forge.PullRequest{ { ID: "1", + Number: 1, FromBranch: "spr/master/00000001", ToBranch: "master", Commit: git.Commit{ CommitID: "00000001", CommitHash: "1", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, { ID: "2", + Number: 2, FromBranch: "spr/master/00000002", ToBranch: "spr/master/00000001", Commit: git.Commit{ CommitID: "00000002", CommitHash: "2", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, { ID: "3", + Number: 3, FromBranch: "spr/master/00000003", ToBranch: "spr/master/00000002", Commit: git.Commit{ CommitID: "00000003", CommitHash: "3", }, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, }, }, }, @@ -521,7 +551,11 @@ func TestMatchPullRequestStack(t *testing.T) { } for _, tc := range tests { - repoConfig := &config.RepoConfig{} + repoConfig := &config.RepoConfig{ + ForgeHost: "github.com", + RepoOwner: "ejoffe", + RepoName: "spr", + } t.Run(tc.name, func(t *testing.T) { actual := matchPullRequestStack(repoConfig, "master", tc.commits, tc.prs) require.Equal(t, tc.expect, actual) diff --git a/github/interface.go b/github/interface.go deleted file mode 100644 index 18c25da6..00000000 --- a/github/interface.go +++ /dev/null @@ -1,51 +0,0 @@ -package github - -import ( - "context" - - "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github/githubclient/gen/genclient" -) - -type GitHubInterface interface { - // GetInfo returns the list of pull requests from GitHub which match the local stack of commits - GetInfo(ctx context.Context, gitcmd git.GitInterface) *GitHubInfo - - // GetAssignableUsers returns a list of valid GitHub users that can review the pull request - GetAssignableUsers(ctx context.Context) []RepoAssignee - - // CreatePullRequest creates a pull request - CreatePullRequest(ctx context.Context, gitcmd git.GitInterface, info *GitHubInfo, commit git.Commit, prevCommit *git.Commit) *PullRequest - - // UpdatePullRequest updates a pull request with current commit - UpdatePullRequest(ctx context.Context, gitcmd git.GitInterface, info *GitHubInfo, pullRequests []*PullRequest, pr *PullRequest, commit git.Commit, prevCommit *git.Commit) - - // AddReviewers adds a reviewer to the given pull request - AddReviewers(ctx context.Context, pr *PullRequest, userIDs []string) - - // CommentPullRequest add a comment to the given pull request - CommentPullRequest(ctx context.Context, pr *PullRequest, comment string) - - // MergePullRequest merged the given pull request - MergePullRequest(ctx context.Context, pr *PullRequest, mergeMethod genclient.PullRequestMergeMethod) - - // ClosePullRequest closes the given pull request - ClosePullRequest(ctx context.Context, pr *PullRequest) -} - -type GitHubInfo struct { - UserName string - RepositoryID string - LocalBranch string - PullRequests []*PullRequest -} - -type RepoAssignee struct { - ID string - Login string - Name string -} - -func (i *GitHubInfo) Key() string { - return i.RepositoryID + "_" + i.LocalBranch -} diff --git a/github/mockclient/mockclient.go b/github/mockclient/mockclient.go index bda9399d..22facaa8 100644 --- a/github/mockclient/mockclient.go +++ b/github/mockclient/mockclient.go @@ -7,9 +7,9 @@ import ( "sync" "testing" + "github.com/ejoffe/spr/config" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" - "github.com/ejoffe/spr/github/githubclient/gen/genclient" "github.com/stretchr/testify/require" ) @@ -27,13 +27,13 @@ func NewMockClient(t *testing.T) *MockClient { type MockClient struct { assert *require.Assertions - Info *github.GitHubInfo + Info *forge.ForgeInfo expect []expectation expectMutex sync.Mutex Synchronized bool // When true code is executed without goroutines. Allows test to be deterministic } -func (c *MockClient) GetInfo(ctx context.Context, gitcmd git.GitInterface) *github.GitHubInfo { +func (c *MockClient) GetInfo(ctx context.Context, gitcmd git.GitInterface) *forge.ForgeInfo { fmt.Printf("HUB: GetInfo\n") c.verifyExpectation(expectation{ op: getInfoOP, @@ -41,12 +41,12 @@ func (c *MockClient) GetInfo(ctx context.Context, gitcmd git.GitInterface) *gith return c.Info } -func (c *MockClient) GetAssignableUsers(ctx context.Context) []github.RepoAssignee { +func (c *MockClient) GetAssignableUsers(ctx context.Context) []forge.RepoAssignee { fmt.Printf("HUB: GetAssignableUsers\n") c.verifyExpectation(expectation{ op: getAssignableUsersOP, }) - return []github.RepoAssignee{ + return []forge.RepoAssignee{ { ID: NobodyUserID, Login: NobodyLogin, @@ -55,8 +55,8 @@ func (c *MockClient) GetAssignableUsers(ctx context.Context) []github.RepoAssign } } -func (c *MockClient) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface, info *github.GitHubInfo, - commit git.Commit, prevCommit *git.Commit) *github.PullRequest { +func (c *MockClient) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface, info *forge.ForgeInfo, + commit git.Commit, prevCommit *git.Commit) *forge.PullRequest { fmt.Printf("HUB: CreatePullRequest\n") c.verifyExpectation(expectation{ op: createPullRequestOP, @@ -66,15 +66,15 @@ func (c *MockClient) CreatePullRequest(ctx context.Context, gitcmd git.GitInterf // TODO - don't hardcode ID and Number // TODO - set FromBranch and ToBranch correctly - return &github.PullRequest{ + return &forge.PullRequest{ ID: "001", Number: 1, FromBranch: "from_branch", ToBranch: "to_branch", Commit: commit, Title: commit.Subject, - MergeStatus: github.PullRequestMergeStatus{ - ChecksPass: github.CheckStatusPass, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusPass, ReviewApproved: true, NoConflicts: true, Stacked: true, @@ -82,7 +82,7 @@ func (c *MockClient) CreatePullRequest(ctx context.Context, gitcmd git.GitInterf } } -func (c *MockClient) UpdatePullRequest(ctx context.Context, gitcmd git.GitInterface, info *github.GitHubInfo, pullRequests []*github.PullRequest, pr *github.PullRequest, commit git.Commit, prevCommit *git.Commit) { +func (c *MockClient) UpdatePullRequest(ctx context.Context, gitcmd git.GitInterface, info *forge.ForgeInfo, pullRequests []*forge.PullRequest, pr *forge.PullRequest, commit git.Commit, prevCommit *git.Commit) { fmt.Printf("HUB: UpdatePullRequest\n") c.verifyExpectation(expectation{ op: updatePullRequestOP, @@ -91,14 +91,14 @@ func (c *MockClient) UpdatePullRequest(ctx context.Context, gitcmd git.GitInterf }) } -func (c *MockClient) AddReviewers(ctx context.Context, pr *github.PullRequest, userIDs []string) { +func (c *MockClient) AddReviewers(ctx context.Context, pr *forge.PullRequest, userIDs []string) { c.verifyExpectation(expectation{ op: addReviewersOP, userIDs: userIDs, }) } -func (c *MockClient) CommentPullRequest(ctx context.Context, pr *github.PullRequest, comment string) { +func (c *MockClient) CommentPullRequest(ctx context.Context, pr *forge.PullRequest, comment string) { fmt.Printf("HUB: CommentPullRequest\n") c.verifyExpectation(expectation{ op: commentPullRequestOP, @@ -107,7 +107,7 @@ func (c *MockClient) CommentPullRequest(ctx context.Context, pr *github.PullRequ } func (c *MockClient) MergePullRequest(ctx context.Context, - pr *github.PullRequest, mergeMethod genclient.PullRequestMergeMethod) { + pr *forge.PullRequest, mergeMethod config.MergeMethod) { fmt.Printf("HUB: MergePullRequest, method=%q\n", mergeMethod) c.verifyExpectation(expectation{ op: mergePullRequestOP, @@ -116,7 +116,11 @@ func (c *MockClient) MergePullRequest(ctx context.Context, }) } -func (c *MockClient) ClosePullRequest(ctx context.Context, pr *github.PullRequest) { +func (c *MockClient) PullRequestURL(number int) string { + return fmt.Sprintf("https://github.com/test/repo/pull/%d", number) +} + +func (c *MockClient) ClosePullRequest(ctx context.Context, pr *forge.PullRequest) { fmt.Printf("HUB: ClosePullRequest\n") c.verifyExpectation(expectation{ op: closePullRequestOP, @@ -184,7 +188,7 @@ func (c *MockClient) ExpectCommentPullRequest(commit git.Commit) { }) } -func (c *MockClient) ExpectMergePullRequest(commit git.Commit, mergeMethod genclient.PullRequestMergeMethod) { +func (c *MockClient) ExpectMergePullRequest(commit git.Commit, mergeMethod config.MergeMethod) { c.expectMutex.Lock() defer c.expectMutex.Unlock() @@ -257,6 +261,6 @@ type expectation struct { op operation commit git.Commit prev *git.Commit - mergeMethod genclient.PullRequestMergeMethod + mergeMethod config.MergeMethod userIDs []string } diff --git a/github/template/interface.go b/github/template/interface.go deleted file mode 100644 index cc32525a..00000000 --- a/github/template/interface.go +++ /dev/null @@ -1,11 +0,0 @@ -package template - -import ( - "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" -) - -type PRTemplatizer interface { - Title(info *github.GitHubInfo, commit git.Commit) string - Body(info *github.GitHubInfo, commit git.Commit, pr *github.PullRequest) string -} diff --git a/gitlab/gitlabclient/client.go b/gitlab/gitlabclient/client.go new file mode 100644 index 00000000..5d1cb62f --- /dev/null +++ b/gitlab/gitlabclient/client.go @@ -0,0 +1,529 @@ +package gitlabclient + +import ( + "context" + "fmt" + "net/http" + "os" + "path" + "strconv" + "strings" + "time" + + "github.com/ejoffe/spr/config" + "github.com/ejoffe/spr/forge" + "github.com/ejoffe/spr/forge/template/config_fetcher" + "github.com/ejoffe/spr/git" + "github.com/rs/zerolog/log" + gitlab "gitlab.com/gitlab-org/api/client-go" + "gopkg.in/yaml.v3" +) + +// glab cli config (https://gitlab.com/gitlab-org/cli) +type glabCLIConfig struct { + Host string `yaml:"host"` + Hosts map[string]struct { + Token string `yaml:"token"` + APIHost string `yaml:"api_host"` + GitProtocol string `yaml:"git_protocol"` + APIProtocol string `yaml:"api_protocol"` + } `yaml:"hosts"` +} + +func readGlabCLIConfig() (*glabCLIConfig, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + + f, err := os.Open(path.Join(homeDir, ".config", "glab-cli", "config.yml")) + if err != nil { + return nil, fmt.Errorf("failed to open glab cli config file: %w", err) + } + + var cfg glabCLIConfig + if err := yaml.NewDecoder(f).Decode(&cfg); err != nil { + return nil, fmt.Errorf("failed to parse glab cli config file: %w", err) + } + + return &cfg, nil +} + +func findToken(gitlabHost string) string { + // Try environment variable first + token := os.Getenv("GITLAB_TOKEN") + if token != "" { + return token + } + + token = os.Getenv("GITLAB_PRIVATE_TOKEN") + if token != "" { + return token + } + + // Try ~/.config/glab-cli/config.yml + cfg, err := readGlabCLIConfig() + if err != nil { + log.Warn().Err(err).Msg("failed to read glab cli config file") + } else { + for host, hostCfg := range cfg.Hosts { + if host == gitlabHost { + return hostCfg.Token + } + } + } + + return "" +} + +const tokenHelpText = ` +No GitLab API token found! Create a personal access token +at https://%s/-/user_settings/personal_access_tokens +with the "api" scope, then either set the GITLAB_TOKEN environment variable: + + $ export GITLAB_TOKEN= + +or use the official "glab" CLI (https://gitlab.com/gitlab-org/cli) to log in: + + $ glab auth login +` + +func NewGitLabClient(ctx context.Context, cfg *config.Config) *client { + token := findToken(cfg.Repo.ForgeHost) + if token == "" { + fmt.Printf(tokenHelpText, cfg.Repo.ForgeHost) + os.Exit(3) + } + + var baseURL string + if strings.Contains(cfg.Repo.ForgeHost, "://") { + baseURL = cfg.Repo.ForgeHost + } else { + baseURL = "https://" + cfg.Repo.ForgeHost + } + + api, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL+"/api/v4")) + if err != nil { + fmt.Printf("error: failed to create GitLab client: %s\n", err) + os.Exit(3) + } + + return &client{ + config: cfg, + api: api, + projectID: cfg.Repo.RepoOwner + "/" + cfg.Repo.RepoName, + } +} + +type client struct { + config *config.Config + api *gitlab.Client + projectID string +} + +func (c *client) GetInfo(ctx context.Context, gitcmd git.GitInterface) *forge.ForgeInfo { + if c.config.User.LogGitHubCalls { + fmt.Printf("> gitlab fetch merge requests\n") + } + + user, _, err := c.api.Users.CurrentUser() + check(err) + + project, _, err := c.api.Projects.GetProject(c.projectID, nil) + check(err) + + targetBranch := c.config.Repo.Branch + localCommitStack := git.GetLocalCommitStack(c.config, gitcmd) + + state := "opened" + scope := "all" + authorID := int64(user.ID) + opts := &gitlab.ListProjectMergeRequestsOptions{ + State: &state, + Scope: &scope, + AuthorID: &authorID, + } + mrs, _, err := c.api.MergeRequests.ListProjectMergeRequests(c.projectID, opts) + check(err) + + pullRequests := matchMergeRequestStack(c.config.Repo, targetBranch, localCommitStack, mrs) + for _, pr := range pullRequests { + approvals, _, err := c.api.MergeRequestApprovals.GetConfiguration(c.projectID, int64(pr.Number)) + if err != nil { + log.Warn().Err(err).Int("mr", pr.Number).Msg("failed to get merge request approvals") + } else { + pr.MergeStatus.ReviewApproved = len(approvals.ApprovedBy) > 0 + } + } + for _, pr := range pullRequests { + commits, _, err := c.api.MergeRequests.GetMergeRequestCommits(c.projectID, int64(pr.Number), nil) + if err != nil { + log.Warn().Err(err).Int("mr", pr.Number).Msg("failed to get merge request commits") + } else { + var prCommits []git.Commit + for _, commit := range commits { + for _, line := range strings.Split(commit.Message, "\n") { + if strings.HasPrefix(line, "commit-id:") { + prCommits = append(prCommits, git.Commit{ + CommitID: strings.Split(line, ":")[1], + CommitHash: commit.ID, + Subject: commit.Title, + Body: commit.Message, + }) + } + } + } + pr.Commits = prCommits + } + } + for _, pr := range pullRequests { + if pr.Ready(c.config) { + pr.MergeStatus.Stacked = true + } else { + break + } + } + + info := &forge.ForgeInfo{ + UserName: user.Username, + RepositoryID: fmt.Sprintf("%d", project.ID), + LocalBranch: git.GetLocalBranchName(gitcmd), + PullRequests: pullRequests, + PRNumberPrefix: "!", + } + + log.Debug().Interface("Info", info).Msg("GetInfo") + return info +} + +func matchMergeRequestStack( + repoConfig *config.RepoConfig, + targetBranch string, + localCommitStack []git.Commit, + allMergeRequests []*gitlab.BasicMergeRequest) []*forge.PullRequest { + + if len(localCommitStack) == 0 || len(allMergeRequests) == 0 { + return []*forge.PullRequest{} + } + + pullRequestMap := make(map[string]*forge.PullRequest) + for _, mr := range allMergeRequests { + matches := git.BranchNameRegex.FindStringSubmatch(mr.SourceBranch) + if matches != nil { + commitID := matches[2] + + checkStatus := forge.CheckStatusUnknown + switch mr.DetailedMergeStatus { + case "mergeable", "ci_must_pass": + checkStatus = forge.CheckStatusPass + case "ci_still_running", "checking", "preparing": + checkStatus = forge.CheckStatusPending + case "broken_status", "not_approved", "blocked_status": + checkStatus = forge.CheckStatusFail + } + + pr := &forge.PullRequest{ + ID: fmt.Sprintf("%d", mr.IID), + Number: int(mr.IID), + Title: mr.Title, + Body: mr.Description, + FromBranch: mr.SourceBranch, + ToBranch: mr.TargetBranch, + Commit: git.Commit{ + CommitID: commitID, + Subject: mr.Title, + }, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: checkStatus, + NoConflicts: !mr.HasConflicts, + }, + } + + pullRequestMap[commitID] = pr + } + } + + return forge.BuildPullRequestStack(targetBranch, localCommitStack, pullRequestMap) +} + +func (c *client) GetAssignableUsers(ctx context.Context) []forge.RepoAssignee { + if c.config.User.LogGitHubCalls { + fmt.Printf("> gitlab get project members\n") + } + + users := []forge.RepoAssignee{} + opts := &gitlab.ListProjectMembersOptions{} + members, _, err := c.api.ProjectMembers.ListAllProjectMembers(c.projectID, opts) + if err != nil { + log.Fatal().Err(err).Msg("get project members failed") + return nil + } + + for _, member := range members { + users = append(users, forge.RepoAssignee{ + ID: fmt.Sprintf("%d", member.ID), + Login: member.Username, + Name: member.Name, + }) + } + + return users +} + +func (c *client) CreatePullRequest(ctx context.Context, gitcmd git.GitInterface, + info *forge.ForgeInfo, commit git.Commit, prevCommit *git.Commit) *forge.PullRequest { + + baseRefName := c.config.Repo.Branch + if prevCommit != nil { + baseRefName = git.BranchNameFromCommit(c.config, *prevCommit) + } + headRefName := git.BranchNameFromCommit(c.config, commit) + + log.Debug().Interface("Commit", commit). + Str("FromBranch", headRefName).Str("ToBranch", baseRefName). + Msg("CreatePullRequest") + + templatizer := config_fetcher.PRTemplatizer(c.config, gitcmd) + title := templatizer.Title(info, commit) + if c.config.User.CreateDraftPRs { + title = "Draft: " + title + } + body := templatizer.Body(info, commit, nil) + removeSourceBranch := true + opts := &gitlab.CreateMergeRequestOptions{ + SourceBranch: &headRefName, + TargetBranch: &baseRefName, + Title: &title, + Description: &body, + RemoveSourceBranch: &removeSourceBranch, + } + mr, _, err := c.api.MergeRequests.CreateMergeRequest(c.projectID, opts) + check(err) + + pr := &forge.PullRequest{ + ID: fmt.Sprintf("%d", mr.IID), + Number: int(mr.IID), + FromBranch: headRefName, + ToBranch: baseRefName, + Commit: commit, + Title: commit.Subject, + Body: mr.Description, + MergeStatus: forge.PullRequestMergeStatus{ + ChecksPass: forge.CheckStatusUnknown, + ReviewApproved: false, + NoConflicts: false, + Stacked: false, + }, + } + + if c.config.User.LogGitHubCalls { + fmt.Printf("> gitlab create !%d : %s\n", pr.Number, pr.Title) + } + + return pr +} + +func (c *client) UpdatePullRequest(ctx context.Context, gitcmd git.GitInterface, + info *forge.ForgeInfo, pullRequests []*forge.PullRequest, pr *forge.PullRequest, + commit git.Commit, prevCommit *git.Commit) { + + if c.config.User.LogGitHubCalls { + fmt.Printf("> gitlab update !%d : %s\n", pr.Number, pr.Title) + } + + baseRefName := c.config.Repo.Branch + if prevCommit != nil { + baseRefName = git.BranchNameFromCommit(c.config, *prevCommit) + } + + log.Debug().Interface("Commit", commit). + Str("FromBranch", pr.FromBranch).Str("ToBranch", baseRefName). + Interface("MR", pr).Msg("UpdatePullRequest") + + templatizer := config_fetcher.PRTemplatizer(c.config, gitcmd) + title := templatizer.Title(info, commit) + body := templatizer.Body(info, commit, pr) + opts := &gitlab.UpdateMergeRequestOptions{ + Title: &title, + Description: &body, + } + if c.config.User.PreserveTitleAndBody { + opts.Title = nil + opts.Description = nil + } + + if !pr.InQueue { + opts.TargetBranch = &baseRefName + } + + _, _, err := c.api.MergeRequests.UpdateMergeRequest(c.projectID, int64(pr.Number), opts) + if err != nil { + log.Fatal(). + Str("id", pr.ID). + Int("number", pr.Number). + Str("title", pr.Title). + Err(err). + Msg("merge request update failed") + } +} + +func (c *client) AddReviewers(ctx context.Context, pr *forge.PullRequest, userIDs []string) { + log.Debug().Strs("userIDs", userIDs).Msg("AddReviewers") + if c.config.User.LogGitHubCalls { + fmt.Printf("> gitlab add reviewers !%d : %s - %+v\n", pr.Number, pr.Title, userIDs) + } + + reviewerIDs := make([]int64, 0, len(userIDs)) + for _, id := range userIDs { + intID, err := strconv.ParseInt(id, 10, 64) + if err != nil { + log.Warn().Str("id", id).Err(err).Msg("invalid reviewer ID") + continue + } + reviewerIDs = append(reviewerIDs, intID) + } + + opts := &gitlab.UpdateMergeRequestOptions{ + ReviewerIDs: &reviewerIDs, + } + _, _, err := c.api.MergeRequests.UpdateMergeRequest(c.projectID, int64(pr.Number), opts) + if err != nil { + log.Fatal(). + Str("id", pr.ID). + Int("number", pr.Number). + Str("title", pr.Title). + Strs("userIDs", userIDs). + Err(err). + Msg("add reviewers failed") + } +} + +func (c *client) CommentPullRequest(ctx context.Context, pr *forge.PullRequest, comment string) { + opts := &gitlab.CreateMergeRequestNoteOptions{ + Body: &comment, + } + _, _, err := c.api.Notes.CreateMergeRequestNote(c.projectID, int64(pr.Number), opts) + if err != nil { + log.Fatal(). + Str("id", pr.ID). + Int("number", pr.Number). + Str("title", pr.Title). + Err(err). + Msg("merge request comment failed") + } + + if c.config.User.LogGitHubCalls { + fmt.Printf("> gitlab add comment !%d : %s\n", pr.Number, pr.Title) + } +} + +func (c *client) MergePullRequest(ctx context.Context, + pr *forge.PullRequest, mergeMethod config.MergeMethod) { + log.Debug(). + Interface("MR", pr). + Str("mergeMethod", string(mergeMethod)). + Msg("MergePullRequest") + + squash := mergeMethod == config.MergeMethodSquash + opts := &gitlab.AcceptMergeRequestOptions{ + Squash: &squash, + } + + if mergeMethod == config.MergeMethodRebase { + _, err := c.api.MergeRequests.RebaseMergeRequest(c.projectID, int64(pr.Number), nil) + if err != nil { + log.Warn().Err(err).Msg("rebase before merge failed, proceeding with merge") + } + } + + // Try a direct merge first. GitLab recalculates MR mergeability + // asynchronously after target branch changes, so this may fail with + // 405 while a new pipeline is running. + const maxDirectAttempts = 5 + const retryDelay = 2 * time.Second + + for attempt := 1; attempt <= maxDirectAttempts; attempt++ { + _, resp, err := c.api.MergeRequests.AcceptMergeRequest(c.projectID, int64(pr.Number), opts) + if err == nil { + if c.config.User.LogGitHubCalls { + fmt.Printf("> gitlab merge !%d : %s\n", pr.Number, pr.Title) + } + return + } + + if resp == nil || resp.StatusCode != http.StatusMethodNotAllowed { + log.Fatal(). + Str("id", pr.ID). + Int("number", pr.Number). + Str("title", pr.Title). + Err(err). + Msg("merge request merge failed") + } + + if attempt < maxDirectAttempts { + time.Sleep(retryDelay) + } + } + + // Direct merge is still blocked (likely a CI pipeline was triggered by + // the target branch change). Enable auto-merge so GitLab merges the MR + // as soon as the pipeline passes. + autoMerge := true + opts.AutoMerge = &autoMerge + _, _, err := c.api.MergeRequests.AcceptMergeRequest(c.projectID, int64(pr.Number), opts) + if err != nil { + log.Fatal(). + Str("id", pr.ID). + Int("number", pr.Number). + Str("title", pr.Title). + Err(err). + Msg("merge request merge failed") + } + + fmt.Printf("auto-merge enabled for !%d (waiting for CI pipeline)\n", pr.Number) + if c.config.User.LogGitHubCalls { + fmt.Printf("> gitlab auto-merge !%d : %s\n", pr.Number, pr.Title) + } +} + +func (c *client) PullRequestURL(number int) string { + return fmt.Sprintf("https://%s/%s/%s/-/merge_requests/%d", + c.config.Repo.ForgeHost, c.config.Repo.RepoOwner, c.config.Repo.RepoName, number) +} + +func (c *client) ClosePullRequest(ctx context.Context, pr *forge.PullRequest) { + log.Debug().Interface("MR", pr).Msg("ClosePullRequest") + stateEvent := "close" + opts := &gitlab.UpdateMergeRequestOptions{ + StateEvent: &stateEvent, + } + _, _, err := c.api.MergeRequests.UpdateMergeRequest(c.projectID, int64(pr.Number), opts) + if err != nil { + log.Fatal(). + Str("id", pr.ID). + Int("number", pr.Number). + Str("title", pr.Title). + Err(err). + Msg("merge request close failed") + } + + if c.config.User.LogGitHubCalls { + fmt.Printf("> gitlab close !%d : %s\n", pr.Number, pr.Title) + } +} + +func check(err error) { + if err != nil { + msg := err.Error() + if strings.Contains(msg, "401") { + errmsg := "error : 401 Unauthorized\n" + errmsg += " make sure GITLAB_TOKEN env variable is set with a valid token,\n" + errmsg += " or log in with: glab auth login\n" + errmsg += " to create a token manually goto your GitLab instance settings/access_tokens\n" + fmt.Fprint(os.Stderr, errmsg) + os.Exit(-1) + } else { + panic(err) + } + } +} diff --git a/go.mod b/go.mod index e5cd1001..5b34dee0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ejoffe/spr -go 1.21 +go 1.24.0 require ( github.com/ejoffe/profiletimer v0.1.0 @@ -10,11 +10,12 @@ require ( github.com/inigolabs/fezzik v0.4.10 github.com/jessevdk/go-flags v1.5.0 github.com/rs/zerolog v1.26.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/tidwall/pretty v1.2.0 github.com/urfave/cli/v2 v2.8.1 - golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 - golang.org/x/sys v0.29.0 + gitlab.com/gitlab-org/api/client-go v1.34.0 + golang.org/x/oauth2 v0.34.0 + golang.org/x/sys v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -35,7 +36,10 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hasura/go-graphql-client v0.9.3 // indirect github.com/iancoleman/strcase v0.2.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -63,10 +67,9 @@ require ( golang.org/x/mod v0.17.0 // indirect golang.org/x/net v0.34.0 // indirect golang.org/x/sync v0.10.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.28.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect nhooyr.io/websocket v1.8.7 // indirect diff --git a/go.sum b/go.sum index 4236d8a6..a784f012 100644 --- a/go.sum +++ b/go.sum @@ -166,6 +166,8 @@ github.com/evanphx/json-patch/v5 v5.1.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2Vvl github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= @@ -264,8 +266,11 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -299,8 +304,9 @@ github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8 github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/graph-gophers/graphql-go v1.5.0 h1:fDqblo50TEpD0LY7RXk/LFVYEVqo3+tXMNMPSVXA1yc= github.com/graph-gophers/graphql-go v1.5.0/go.mod h1:YtmJZDLbF1YYNrlNAuiO5zAStUWc3XZT07iGsVqe1Os= +github.com/graph-gophers/graphql-go v1.8.0 h1:NT05/H+PdH1/PONExlUycnhULYHBy98dxV63WYc0Ng8= +github.com/graph-gophers/graphql-go v1.8.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= github.com/graph-gophers/graphql-transport-ws v0.0.2 h1:DbmSkbIGzj8SvHei6n8Mh9eLQin8PtA8xY9eCzjRpvo= github.com/graph-gophers/graphql-transport-ws v0.0.2/go.mod h1:5BVKvFzOd2BalVIBFfnfmHjpJi/MZ5rOj8G55mXvZ8g= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= @@ -310,15 +316,20 @@ github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOj github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= @@ -398,14 +409,17 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= @@ -522,8 +536,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tidwall/gjson v1.11.0 h1:C16pk7tQNiH6VlCrtIXL1w8GaOsi1X3W8KDkE1BuYd4= github.com/tidwall/gjson v1.11.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -561,6 +575,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +gitlab.com/gitlab-org/api/client-go v1.34.0 h1:w/Zv3FmfrkZsVUJhzteAu0LsWsz2y7kv/XJ3pvRa+Eo= +gitlab.com/gitlab-org/api/client-go v1.34.0/go.mod h1:nsUbXSLfne+sl+j62f7S3LFNwQ5Ey96oG9QToJT4aTM= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= @@ -617,8 +633,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -716,8 +732,9 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -816,8 +833,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -833,12 +850,14 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -949,7 +968,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -1058,8 +1076,9 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/spr/spr.go b/spr/spr.go index b901bb26..778f96ad 100644 --- a/spr/spr.go +++ b/spr/spr.go @@ -18,15 +18,15 @@ import ( "github.com/ejoffe/rake" "github.com/ejoffe/spr/config" "github.com/ejoffe/spr/config/config_parser" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git" - "github.com/ejoffe/spr/github" ) // NewStackedPR constructs and returns a new stackediff instance. -func NewStackedPR(config *config.Config, github github.GitHubInterface, gitcmd git.GitInterface) *stackediff { +func NewStackedPR(config *config.Config, forgeClient forge.ForgeInterface, gitcmd git.GitInterface) *stackediff { return &stackediff{ config: config, - github: github, + forge: forgeClient, gitcmd: gitcmd, profiletimer: profiletimer.StartNoopTimer(), @@ -37,7 +37,7 @@ func NewStackedPR(config *config.Config, github github.GitHubInterface, gitcmd g type stackediff struct { config *config.Config - github github.GitHubInterface + forge forge.ForgeInterface gitcmd git.GitInterface profiletimer profiletimer.Timer DetailEnabled bool @@ -81,12 +81,12 @@ func (sd *stackediff) AmendCommit(ctx context.Context) { sd.gitcmd.MustGit("commit --fixup "+localCommits[commitIndex].CommitHash, nil) rebaseCmd := fmt.Sprintf("rebase -i --autosquash --autostash %s/%s", - sd.config.Repo.GitHubRemote, sd.config.Repo.GitHubBranch) + sd.config.Repo.Remote, sd.config.Repo.Branch) sd.gitcmd.MustGit(rebaseCmd, nil) } func (sd *stackediff) addReviewers(ctx context.Context, - pr *github.PullRequest, reviewers []string, assignable []github.RepoAssignee, + pr *forge.PullRequest, reviewers []string, assignable []forge.RepoAssignee, ) { userIDs := make([]string, 0, len(reviewers)) for _, r := range reviewers { @@ -102,10 +102,10 @@ func (sd *stackediff) addReviewers(ctx context.Context, check(fmt.Errorf("unable to add reviewer, user %q not found", r)) } } - sd.github.AddReviewers(ctx, pr, userIDs) + sd.forge.AddReviewers(ctx, pr, userIDs) } -func alignLocalCommits(commits []git.Commit, prs []*github.PullRequest) []git.Commit { +func alignLocalCommits(commits []git.Commit, prs []*forge.PullRequest) []git.Commit { remoteCommits := map[string]bool{} for _, pr := range prs { for _, c := range pr.Commits { @@ -145,15 +145,15 @@ func (sd *stackediff) UpdatePullRequests(ctx context.Context, reviewers []string sd.profiletimer.Step("UpdatePullRequests::GetLocalCommitStack") // close prs for deleted commits - var validPullRequests []*github.PullRequest + var validPullRequests []*forge.PullRequest localCommitMap := map[string]*git.Commit{} for _, commit := range localCommits { localCommitMap[commit.CommitID] = &commit } for _, pr := range githubInfo.PullRequests { if _, found := localCommitMap[pr.Commit.CommitID]; !found { - sd.github.CommentPullRequest(ctx, pr, "Closing pull request: commit has gone away") - sd.github.ClosePullRequest(ctx, pr) + sd.forge.CommentPullRequest(ctx, pr, "Closing pull request: commit has gone away") + sd.forge.ClosePullRequest(ctx, pr) } else { validPullRequests = append(validPullRequests, pr) } @@ -170,7 +170,7 @@ func (sd *stackediff) UpdatePullRequests(ctx context.Context, reviewers []string for i := range githubInfo.PullRequests { fn := func(i int) { pr := githubInfo.PullRequests[i] - sd.github.UpdatePullRequest(ctx, sd.gitcmd, githubInfo, githubInfo.PullRequests, pr, pr.Commit, nil) + sd.forge.UpdatePullRequest(ctx, sd.gitcmd, githubInfo, githubInfo.PullRequests, pr, pr.Commit, nil) wg.Done() } if sd.synchronized { @@ -190,13 +190,13 @@ func (sd *stackediff) UpdatePullRequests(ctx context.Context, reviewers []string sd.profiletimer.Step("UpdatePullRequests::SyncCommitStackToGithub") type prUpdate struct { - pr *github.PullRequest + pr *forge.PullRequest commit git.Commit prevCommit *git.Commit } updateQueue := make([]prUpdate, 0) - var assignable []github.RepoAssignee + var assignable []forge.RepoAssignee // iterate through local_commits and update pull_requests var prevCommit *git.Commit @@ -220,12 +220,12 @@ func (sd *stackediff) UpdatePullRequests(ctx context.Context, reviewers []string if !prFound { // if pull request is not found for this commit_id it means the commit // is new and we need to create a new pull request - pr := sd.github.CreatePullRequest(ctx, sd.gitcmd, githubInfo, c, prevCommit) + pr := sd.forge.CreatePullRequest(ctx, sd.gitcmd, githubInfo, c, prevCommit) githubInfo.PullRequests = append(githubInfo.PullRequests, pr) updateQueue = append(updateQueue, prUpdate{pr, c, prevCommit}) if len(reviewers) != 0 { if assignable == nil { - assignable = sd.github.GetAssignableUsers(ctx) + assignable = sd.forge.GetAssignableUsers(ctx) } sd.addReviewers(ctx, pr, reviewers, assignable) } @@ -246,7 +246,7 @@ func (sd *stackediff) UpdatePullRequests(ctx context.Context, reviewers []string for i := range updateQueue { fn := func(i int) { pr := updateQueue[i] - sd.github.UpdatePullRequest(ctx, sd.gitcmd, githubInfo, sortedPullRequests, pr.pr, pr.commit, pr.prevCommit) + sd.forge.UpdatePullRequest(ctx, sd.gitcmd, githubInfo, sortedPullRequests, pr.pr, pr.commit, pr.prevCommit) wg.Done() } if sd.synchronized { @@ -283,7 +283,7 @@ func (sd *stackediff) UpdatePullRequests(ctx context.Context, reviewers []string // their commits have already been merged. func (sd *stackediff) MergePullRequests(ctx context.Context, count *uint) { sd.profiletimer.Step("MergePullRequests::Start") - githubInfo := sd.github.GetInfo(ctx, sd.gitcmd) + githubInfo := sd.forge.GetInfo(ctx, sd.gitcmd) sd.profiletimer.Step("MergePullRequests::getGitHubInfo") // MergeCheck @@ -323,13 +323,13 @@ func (sd *stackediff) MergePullRequests(ctx context.Context, count *uint) { prToMerge := githubInfo.PullRequests[prIndex] // Update the base of the merging pr to target branch - sd.github.UpdatePullRequest(ctx, sd.gitcmd, githubInfo, githubInfo.PullRequests, prToMerge, prToMerge.Commit, nil) + sd.forge.UpdatePullRequest(ctx, sd.gitcmd, githubInfo, githubInfo.PullRequests, prToMerge, prToMerge.Commit, nil) sd.profiletimer.Step("MergePullRequests::update pr base") // Merge pull request - mergeMethod, err := sd.config.MergeMethod() + mergeMethod, err := sd.config.ParseMergeMethod() check(err) - sd.github.MergePullRequest(ctx, prToMerge, mergeMethod) + sd.forge.MergePullRequest(ctx, prToMerge, mergeMethod) if sd.config.User.DeleteMergedBranches { sd.gitcmd.DeleteRemoteBranch(ctx, prToMerge.FromBranch) } @@ -339,10 +339,10 @@ func (sd *stackediff) MergePullRequests(ctx context.Context, count *uint) { for i := 0; i < prIndex; i++ { pr := githubInfo.PullRequests[i] comment := fmt.Sprintf( - "✓ Commit merged in pull request [#%d](https://%s/%s/%s/pull/%d)", - prToMerge.Number, sd.config.Repo.GitHubHost, sd.config.Repo.GitHubRepoOwner, sd.config.Repo.GitHubRepoName, prToMerge.Number) - sd.github.CommentPullRequest(ctx, pr, comment) - sd.github.ClosePullRequest(ctx, pr) + "✓ Commit merged in pull request [#%d](%s)", + prToMerge.Number, sd.forge.PullRequestURL(prToMerge.Number)) + sd.forge.CommentPullRequest(ctx, pr, comment) + sd.forge.ClosePullRequest(ctx, pr) if sd.config.User.DeleteMergedBranches { sd.gitcmd.DeleteRemoteBranch(ctx, pr.FromBranch) } @@ -352,7 +352,7 @@ func (sd *stackediff) MergePullRequests(ctx context.Context, count *uint) { for i := 0; i <= prIndex; i++ { pr := githubInfo.PullRequests[i] pr.Merged = true - fmt.Fprintf(sd.output, "%s\n", pr.String(sd.config)) + fmt.Fprintf(sd.output, "%s\n", pr.String(sd.config, sd.forge)) } sd.profiletimer.Step("MergePullRequests::End") @@ -364,7 +364,7 @@ func (sd *stackediff) MergePullRequests(ctx context.Context, count *uint) { // remotely on github. func (sd *stackediff) StatusPullRequests(ctx context.Context) { sd.profiletimer.Step("StatusPullRequests::Start") - githubInfo := sd.github.GetInfo(ctx, sd.gitcmd) + githubInfo := sd.forge.GetInfo(ctx, sd.gitcmd) if len(githubInfo.PullRequests) == 0 { fmt.Fprintf(sd.output, "pull request stack is empty\n") @@ -374,7 +374,7 @@ func (sd *stackediff) StatusPullRequests(ctx context.Context) { } for i := len(githubInfo.PullRequests) - 1; i >= 0; i-- { pr := githubInfo.PullRequests[i] - fmt.Fprintf(sd.output, "%s\n", pr.String(sd.config)) + fmt.Fprintf(sd.output, "%s\n", pr.String(sd.config, sd.forge)) } } sd.profiletimer.Step("StatusPullRequests::End") @@ -385,7 +385,7 @@ func (sd *stackediff) SyncStack(ctx context.Context) { sd.profiletimer.Step("SyncStack::Start") defer sd.profiletimer.Step("SyncStack::End") - githubInfo := sd.github.GetInfo(ctx, sd.gitcmd) + githubInfo := sd.forge.GetInfo(ctx, sd.gitcmd) if len(githubInfo.PullRequests) == 0 { fmt.Fprintf(sd.output, "pull request stack is empty\n") @@ -413,7 +413,7 @@ func (sd *stackediff) RunMergeCheck(ctx context.Context) { return } - githubInfo := sd.github.GetInfo(ctx, sd.gitcmd) + githubInfo := sd.forge.GetInfo(ctx, sd.gitcmd) sigch := make(chan os.Signal, 1) signal.Notify(sigch, os.Interrupt, syscall.SIGTERM) @@ -468,7 +468,7 @@ func (sd *stackediff) ProfilingSummary() { check(err) } -func commitsReordered(localCommits []git.Commit, pullRequests []*github.PullRequest) bool { +func commitsReordered(localCommits []git.Commit, pullRequests []*forge.PullRequest) bool { for i := 0; i < len(pullRequests); i++ { if localCommits[i].CommitID != pullRequests[i].Commit.CommitID { return true @@ -477,13 +477,13 @@ func commitsReordered(localCommits []git.Commit, pullRequests []*github.PullRequ return false } -func sortPullRequestsByLocalCommitOrder(pullRequests []*github.PullRequest, localCommits []git.Commit) []*github.PullRequest { - pullRequestMap := map[string]*github.PullRequest{} +func sortPullRequestsByLocalCommitOrder(pullRequests []*forge.PullRequest, localCommits []git.Commit) []*forge.PullRequest { + pullRequestMap := map[string]*forge.PullRequest{} for _, pullRequest := range pullRequests { pullRequestMap[pullRequest.Commit.CommitID] = pullRequest } - var sortedPullRequests []*github.PullRequest + var sortedPullRequests []*forge.PullRequest for _, commit := range localCommits { if !commit.WIP && pullRequestMap[commit.CommitID] != nil { sortedPullRequests = append(sortedPullRequests, pullRequestMap[commit.CommitID]) @@ -492,19 +492,19 @@ func sortPullRequestsByLocalCommitOrder(pullRequests []*github.PullRequest, loca return sortedPullRequests } -func (sd *stackediff) fetchAndGetGitHubInfo(ctx context.Context) *github.GitHubInfo { +func (sd *stackediff) fetchAndGetGitHubInfo(ctx context.Context) *forge.ForgeInfo { if sd.config.Repo.ForceFetchTags { sd.gitcmd.MustGit("fetch --tags --force", nil) } else { sd.gitcmd.MustGit("fetch", nil) } rebaseCommand := fmt.Sprintf("rebase %s/%s --autostash", - sd.config.Repo.GitHubRemote, sd.config.Repo.GitHubBranch) + sd.config.Repo.Remote, sd.config.Repo.Branch) err := sd.gitcmd.Git(rebaseCommand, nil) if err != nil { return nil } - info := sd.github.GetInfo(ctx, sd.gitcmd) + info := sd.forge.GetInfo(ctx, sd.gitcmd) if git.BranchNameRegex.FindString(info.LocalBranch) != "" { fmt.Printf("error: don't run spr in a remote pr branch\n") fmt.Printf(" this could lead to weird duplicate pull requests getting created\n") @@ -523,7 +523,7 @@ func (sd *stackediff) fetchAndGetGitHubInfo(ctx context.Context) *github.GitHubI // which are new (on top of remote branch) and creates a corresponding // branch on github for each commit. func (sd *stackediff) syncCommitStackToGitHub(ctx context.Context, - commits []git.Commit, info *github.GitHubInfo, + commits []git.Commit, info *forge.ForgeInfo, ) bool { var output string sd.gitcmd.MustGit("status --porcelain --untracked-files=no", &output) @@ -535,7 +535,7 @@ func (sd *stackediff) syncCommitStackToGitHub(ctx context.Context, defer sd.gitcmd.MustGit("stash pop", nil) } - commitUpdated := func(c git.Commit, info *github.GitHubInfo) bool { + commitUpdated := func(c git.Commit, info *forge.ForgeInfo) bool { for _, pr := range info.PullRequests { if pr.Commit.CommitID == c.CommitID { return pr.Commit.CommitHash != c.CommitHash @@ -564,11 +564,11 @@ func (sd *stackediff) syncCommitStackToGitHub(ctx context.Context, if len(updatedCommits) > 0 { if sd.config.Repo.BranchPushIndividually { for _, refName := range refNames { - pushCommand := fmt.Sprintf("push --force %s %s", sd.config.Repo.GitHubRemote, refName) + pushCommand := fmt.Sprintf("push --force %s %s", sd.config.Repo.Remote, refName) sd.gitcmd.MustGit(pushCommand, nil) } } else { - pushCommand := fmt.Sprintf("push --force --atomic %s ", sd.config.Repo.GitHubRemote) + pushCommand := fmt.Sprintf("push --force --atomic %s ", sd.config.Repo.Remote) pushCommand += strings.Join(refNames, " ") sd.gitcmd.MustGit(pushCommand, nil) } @@ -590,7 +590,7 @@ func check(err error) { func header(config *config.Config) string { if config.User.StatusBitsEmojis { return ` - ┌─ github checks pass + ┌─ ci checks pass │ ┌── pull request approved │ │ ┌─── no merge conflicts │ │ │ ┌──── stack check @@ -598,7 +598,7 @@ func header(config *config.Config) string { ` } else { return ` - ┌─ github checks pass + ┌─ ci checks pass │┌── pull request approved ││┌─── no merge conflicts │││┌──── stack check diff --git a/spr/spr_test.go b/spr/spr_test.go index acddcc8a..786a7864 100644 --- a/spr/spr_test.go +++ b/spr/spr_test.go @@ -8,10 +8,9 @@ import ( "testing" "github.com/ejoffe/spr/config" + "github.com/ejoffe/spr/forge" "github.com/ejoffe/spr/git" "github.com/ejoffe/spr/git/mockgit" - "github.com/ejoffe/spr/github" - "github.com/ejoffe/spr/github/githubclient/gen/genclient" "github.com/ejoffe/spr/github/mockclient" "github.com/stretchr/testify/require" ) @@ -22,15 +21,16 @@ func makeTestObjects(t *testing.T, synchronized bool) ( cfg := config.EmptyConfig() cfg.Repo.RequireChecks = true cfg.Repo.RequireApproval = true - cfg.Repo.GitHubRemote = "origin" - cfg.Repo.GitHubBranch = "master" + cfg.Repo.Remote = "origin" + cfg.Repo.Branch = "master" cfg.Repo.MergeMethod = "rebase" gitmock = mockgit.NewMockGit(t) githubmock = mockclient.NewMockClient(t) - githubmock.Info = &github.GitHubInfo{ - UserName: "TestSPR", - RepositoryID: "RepoID", - LocalBranch: "master", + githubmock.Info = &forge.ForgeInfo{ + UserName: "TestSPR", + RepositoryID: "RepoID", + LocalBranch: "master", + PRNumberPrefix: "#", } s = NewStackedPR(cfg, githubmock, gitmock) output = &bytes.Buffer{} @@ -158,7 +158,7 @@ func testSPRBasicFlowFourCommitsQueue(t *testing.T, sync bool) { // 'git spr merge' :: MergePullRequest :: commits=[a1, a2] githubmock.ExpectGetInfo() githubmock.ExpectUpdatePullRequest(c2, nil) - githubmock.ExpectMergePullRequest(c2, genclient.PullRequestMergeMethod_REBASE) + githubmock.ExpectMergePullRequest(c2, config.MergeMethodRebase) githubmock.ExpectCommentPullRequest(c1) githubmock.ExpectClosePullRequest(c1) count := uint(2) @@ -202,7 +202,7 @@ func testSPRBasicFlowFourCommitsQueue(t *testing.T, sync bool) { // 'git spr merge' :: MergePullRequest :: commits=[a2, a3, a4] githubmock.ExpectGetInfo() githubmock.ExpectUpdatePullRequest(c4, nil) - githubmock.ExpectMergePullRequest(c4, genclient.PullRequestMergeMethod_REBASE) + githubmock.ExpectMergePullRequest(c4, config.MergeMethodRebase) githubmock.ExpectCommentPullRequest(c2) githubmock.ExpectClosePullRequest(c2) @@ -339,7 +339,7 @@ func testSPRBasicFlowFourCommits(t *testing.T, sync bool) { // 'git spr merge' :: MergePullRequest :: commits=[a1, a2, a3, a4] githubmock.ExpectGetInfo() githubmock.ExpectUpdatePullRequest(c4, nil) - githubmock.ExpectMergePullRequest(c4, genclient.PullRequestMergeMethod_REBASE) + githubmock.ExpectMergePullRequest(c4, config.MergeMethodRebase) githubmock.ExpectCommentPullRequest(c1) githubmock.ExpectClosePullRequest(c1) githubmock.ExpectCommentPullRequest(c2) @@ -423,7 +423,7 @@ func testSPRBasicFlowDeleteBranch(t *testing.T, sync bool) { // 'git spr merge' :: MergePullRequest :: commits=[a1, a2] githubmock.ExpectGetInfo() githubmock.ExpectUpdatePullRequest(c2, nil) - githubmock.ExpectMergePullRequest(c2, genclient.PullRequestMergeMethod_REBASE) + githubmock.ExpectMergePullRequest(c2, config.MergeMethodRebase) gitmock.ExpectDeleteBranch("from_branch") // <--- This is the key expectation of this test. githubmock.ExpectCommentPullRequest(c1) githubmock.ExpectClosePullRequest(c1) @@ -506,7 +506,7 @@ func testSPRMergeCount(t *testing.T, sync bool) { // 'git spr merge --count 2' :: MergePullRequest :: commits=[a1, a2, a3, a4] githubmock.ExpectGetInfo() githubmock.ExpectUpdatePullRequest(c2, nil) - githubmock.ExpectMergePullRequest(c2, genclient.PullRequestMergeMethod_REBASE) + githubmock.ExpectMergePullRequest(c2, config.MergeMethodRebase) githubmock.ExpectCommentPullRequest(c1) githubmock.ExpectClosePullRequest(c1) s.MergePullRequests(ctx, uintptr(2)) @@ -611,7 +611,7 @@ func testSPRAmendCommit(t *testing.T, sync bool) { // 'git spr merge' :: MergePullRequest :: commits=[a1, a2] githubmock.ExpectGetInfo() githubmock.ExpectUpdatePullRequest(c2, nil) - githubmock.ExpectMergePullRequest(c2, genclient.PullRequestMergeMethod_REBASE) + githubmock.ExpectMergePullRequest(c2, config.MergeMethodRebase) githubmock.ExpectCommentPullRequest(c1) githubmock.ExpectClosePullRequest(c1) s.MergePullRequests(ctx, nil)