Skip to content

Commit fa5c45d

Browse files
committed
Refactor towards a service type interface.
- Drop the mapping from env to URLs - Rearrange the package names from "promotion" it's about more than just that. - Provide a mock cache implementation and test the promotion logic. - Flesh out the README.md - Allow providing the branch-name from the command-line. Rework.
1 parent b76fbcd commit fa5c45d

21 files changed

+392
-226
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
./services

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1-
# promote
1+
# services
22

33
A tool for promoting between GitHub repositories.
4+
5+
This is a pre-alpha PoC for promoting versions of files between environments, represented as repositories.
6+
7+
## Building
8+
9+
```shell
10+
$ go build ./cmd/services
11+
```
12+
13+
## Running
14+
15+
You'll need a GitHub token to test this out.
16+
17+
```shell
18+
$ export GITHUB_TOKEN=<paste in GitHub access token>
19+
$ ./services promote --from https://github.com/organisation/first-environment.git --to https://github.com/organisation/second-environment.git --service service-a --commit-name <User to commit as> --commit-email <Email to commit as>
20+
```
21+
22+
If the `commit-name` and `commit-email` are not provided, it will attempt to find them in `~/.gitconfig`, otherwise it will fail.
23+
24+
This will _copy_ a single file `deployment.txt` from `service-a` in `first-environment` to `service-a` in `second-environment`, commit and push, and open a PR for the change.
25+
26+
## Testing
27+
28+
```shell
29+
$ go test ./...
30+
```

cmd/services/main.go

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@ import (
66
"log"
77
"os"
88

9-
"github.com/bigkevmcd/promote/pkg/cache"
10-
"github.com/bigkevmcd/promote/pkg/promote"
9+
"github.com/bigkevmcd/services/pkg/avancement"
10+
"github.com/bigkevmcd/services/pkg/git"
1111
"github.com/mitchellh/go-homedir"
1212
"github.com/tcnksm/go-gitconfig"
1313
"github.com/urfave/cli/v2"
1414
)
1515

1616
const (
1717
githubTokenFlag = "github-token"
18+
branchNameFlag = "branch-name"
1819
fromFlag = "from"
1920
toFlag = "to"
2021
serviceFlag = "service"
@@ -37,25 +38,23 @@ var (
3738
promoteFlags = []cli.Flag{
3839
&cli.StringFlag{
3940
Name: fromFlag,
40-
Usage: "source environment name",
41+
Usage: "source Git repository",
4142
Required: true,
4243
},
4344
&cli.StringFlag{
4445
Name: toFlag,
45-
Usage: "destination environment name",
46+
Usage: "destination Git repository",
4647
Required: true,
4748
},
4849
&cli.StringFlag{
4950
Name: serviceFlag,
5051
Usage: "service name to promote",
5152
Required: true,
5253
},
53-
5454
&cli.StringFlag{
55-
Name: mappingFileFlag,
56-
Value: "~/.env-mapping",
57-
Usage: "mapping from environment to repository",
58-
Required: false,
55+
Name: branchNameFlag,
56+
Usage: "the name to use for the newly created branch",
57+
Value: "test-branch",
5958
},
6059
&cli.StringFlag{
6160
Name: cacheDirFlag,
@@ -81,8 +80,8 @@ var (
8180

8281
func main() {
8382
app := &cli.App{
84-
Name: "promotion-tool",
85-
Usage: "promote a file between two git repositories",
83+
Name: "services",
84+
Description: "manage services lifecycle via GitOps",
8685
Commands: []*cli.Command{
8786
{
8887
Name: "promote",
@@ -105,7 +104,7 @@ func promoteAction(c *cli.Context) error {
105104
toRepo := c.String(toFlag)
106105
service := c.String(serviceFlag)
107106
token := c.String(githubTokenFlag)
108-
newBranchName := "testing"
107+
newBranchName := c.String(branchNameFlag)
109108

110109
cacheDir, err := homedir.Expand(c.String(cacheDirFlag))
111110
if err != nil {
@@ -114,23 +113,13 @@ func promoteAction(c *cli.Context) error {
114113

115114
user, email, err := credentials(c)
116115
if err != nil {
117-
return fmt.Errorf("unable to establish: %w", err)
116+
return fmt.Errorf("unable to establish credentials: %w", err)
118117
}
119-
cache, err := cache.NewLocalCache(cacheDir, user, email)
118+
cache, err := git.NewLocalCache(cacheDir, user, email)
120119
if err != nil {
121120
return fmt.Errorf("failed to create a local cache in %v: %w", cacheDir, err)
122121
}
123-
124-
mappingFilename, err := homedir.Expand(c.String(mappingFileFlag))
125-
if err != nil {
126-
return fmt.Errorf("failed to expand mapping file path: %w", err)
127-
}
128-
mapping, err := promote.LoadMappingFromFile(mappingFilename)
129-
if err != nil {
130-
return fmt.Errorf("failed to load the mappingfile %s: %w", mappingFilename, err)
131-
}
132-
133-
return promote.PromoteService(cache, token, service, fromRepo, toRepo, newBranchName, mapping)
122+
return avancement.New(cache, token).Promote(service, fromRepo, toRepo, newBranchName)
134123
}
135124

136125
func credentials(c *cli.Context) (string, string, error) {

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
module github.com/bigkevmcd/promote
1+
module github.com/bigkevmcd/services
22

33
go 1.13
44

55
require (
66
github.com/golang/protobuf v1.3.2 // indirect
77
github.com/google/go-cmp v0.3.0
8-
github.com/jenkins-x/go-scm v1.5.71
8+
github.com/jenkins-x/go-scm v1.5.77
99
github.com/mitchellh/go-homedir v1.1.0
1010
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 // indirect
1111
github.com/tcnksm/go-gitconfig v0.1.2

go.sum

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
4242
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
4343
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
4444
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
45-
github.com/jenkins-x/go-scm v1.5.71 h1:NWbwd9s5vrxnn0vCJIYhRsUYHY1OZa9WsMx+nTHyfP0=
46-
github.com/jenkins-x/go-scm v1.5.71/go.mod h1:PCT338UhP/pQ0IeEeMEf/hoLTYKcH7qjGEKd7jPkeYg=
45+
github.com/jenkins-x/go-scm v1.5.77 h1:7P2PFzYpEIr5xeeqPevfBtpLrgAdEL+ZLQofQ2tU+7s=
46+
github.com/jenkins-x/go-scm v1.5.77/go.mod h1:PCT338UhP/pQ0IeEeMEf/hoLTYKcH7qjGEKd7jPkeYg=
4747
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
4848
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
4949
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY=
@@ -156,6 +156,7 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
156156
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
157157
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
158158
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
159+
k8s.io/apimachinery v0.0.0-20190703205208-4cfb76a8bf76 h1:vxMYBaJgczGAIpJAOBco2eHuFYIyDdNIebt60jxLauA=
159160
k8s.io/apimachinery v0.0.0-20190703205208-4cfb76a8bf76/go.mod h1:M2fZgZL9DbLfeJaPBCDqSqNsdsmLN+V29knYJnIXlMA=
160161
k8s.io/klog v0.3.1/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
161162
k8s.io/kube-openapi v0.0.0-20190228160746-b3a7cee44a30/go.mod h1:BXM9ceUBTj2QnfH2MK1odQs778ajze1RxcmP6S8RVVc=

pkg/avancement/pull_request.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package avancement
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/bigkevmcd/services/pkg/util"
7+
"github.com/jenkins-x/go-scm/scm"
8+
)
9+
10+
// TODO: OptionFuncs for Base and Title?
11+
// TODO: For the Head, should this try and determine whether or not this is a
12+
// fork ("user" of both repoURLs) and if so, simplify the Head?
13+
func makePullRequestInput(fromURL, toURL, branchName string) (*scm.PullRequestInput, error) {
14+
fromUser, fromRepo, err := util.ExtractUserAndRepo(fromURL)
15+
if err != nil {
16+
return nil, err
17+
}
18+
_, toRepo, err := util.ExtractUserAndRepo(toURL)
19+
if err != nil {
20+
return nil, err
21+
}
22+
title := fmt.Sprintf("promotion from %s to %s", fromRepo, toRepo)
23+
return &scm.PullRequestInput{
24+
Title: title,
25+
Head: fmt.Sprintf("%s:%s", fromUser, branchName),
26+
Base: "master",
27+
Body: "this is a test body",
28+
}, nil
29+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package avancement
2+
3+
import (
4+
"testing"
5+
6+
"github.com/jenkins-x/go-scm/scm"
7+
8+
"github.com/google/go-cmp/cmp"
9+
)
10+
11+
func TestMakePullRequestInput(t *testing.T) {
12+
pr, err := makePullRequestInput("https://example.com/project/dev-env.git", "https://example.com/project/prod-env.git", "my-test-branch")
13+
if err != nil {
14+
t.Fatal(err)
15+
}
16+
17+
want := &scm.PullRequestInput{
18+
Title: "promotion from dev-env to prod-env",
19+
Head: "project:my-test-branch",
20+
Base: "master",
21+
Body: "this is a test body",
22+
}
23+
24+
if diff := cmp.Diff(want, pr); diff != "" {
25+
t.Fatalf("pull request input is different from expected: %s", diff)
26+
}
27+
}

pkg/avancement/service_manager.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package avancement
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
8+
"github.com/jenkins-x/go-scm/scm"
9+
10+
"github.com/bigkevmcd/services/pkg/git"
11+
"github.com/bigkevmcd/services/pkg/util"
12+
)
13+
14+
type ServiceManager struct {
15+
cache git.Cache
16+
token string
17+
clientFactory scmClientFactory
18+
}
19+
20+
type scmClientFactory func(token string) *scm.Client
21+
22+
// New creates and returns a new ServiceManager.
23+
//
24+
// The token is used to create a Git token
25+
func New(c git.Cache, token string) *ServiceManager {
26+
return &ServiceManager{cache: c, token: token, clientFactory: git.CreateGitHubClient}
27+
}
28+
29+
// Promote is the main driver for promoting files between two
30+
// repositories.
31+
//
32+
// It uses a Git cache to checkout the code to, and will copy the environment
33+
// configuration for the `fromURL` to the `toURL` in a named branch.
34+
func (s *ServiceManager) Promote(service, fromURL, toURL, newBranchName string) error {
35+
ctx := context.Background()
36+
err := s.cache.CreateAndCheckoutBranch(ctx, toURL, "master", newBranchName)
37+
if err != nil {
38+
return fmt.Errorf("failed to create and checkout the new branch %v for the %v environment: %w", newBranchName, toURL, err)
39+
}
40+
41+
fileToUpdate := pathForService(service)
42+
newBody, err := s.cache.ReadFileFromBranch(ctx, fromURL, fileToUpdate, "master")
43+
if err != nil {
44+
return fmt.Errorf("failed to read the file %v from the %v environment: %w", fileToUpdate, fromURL, err)
45+
}
46+
err = s.cache.WriteFileToBranchAndStage(ctx, toURL, newBranchName, fileToUpdate, newBody)
47+
if err != nil {
48+
return fmt.Errorf("failed to write the updated file to %v: %w", fileToUpdate, err)
49+
}
50+
51+
err = s.cache.CommitAndPushBranch(ctx, toURL, newBranchName, "this is a test commit", s.token)
52+
if err != nil {
53+
return fmt.Errorf("failed to commit and push branch for environment %v: %w", toURL, err)
54+
}
55+
56+
pr, err := createPullRequest(ctx, fromURL, toURL, newBranchName, s.clientFactory(s.token))
57+
if err != nil {
58+
return fmt.Errorf("failed to create a pull-request for branch %s in %v: %w", newBranchName, toURL, err)
59+
}
60+
log.Printf("created PR %d", pr.Number)
61+
return nil
62+
}
63+
64+
func pathForService(s string) string {
65+
return fmt.Sprintf("%s/deployment.txt", s)
66+
}
67+
68+
func createPullRequest(ctx context.Context, fromURL, toURL, newBranchName string, client *scm.Client) (*scm.PullRequest, error) {
69+
prInput, err := makePullRequestInput(fromURL, toURL, newBranchName)
70+
if err != nil {
71+
// TODO: improve this error message
72+
return nil, err
73+
}
74+
75+
user, repo, err := util.ExtractUserAndRepo(toURL)
76+
if err != nil {
77+
// TODO: improve this error message
78+
return nil, err
79+
}
80+
// TODO: come up with a better way of generating the repo URL (this
81+
// only works for GitHub)
82+
pr, _, err := client.PullRequests.Create(ctx, fmt.Sprintf("%s/%s", user, repo), prInput)
83+
return pr, err
84+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package avancement
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/jenkins-x/go-scm/scm"
8+
fakescm "github.com/jenkins-x/go-scm/scm/driver/fake"
9+
10+
"github.com/bigkevmcd/services/pkg/git/mock"
11+
)
12+
13+
const (
14+
dev = "https://example.com/testing/dev-env"
15+
staging = "https://example.com/testing/staging-env"
16+
)
17+
18+
var testBody = []byte("this is the body")
19+
20+
func TestPromoteWithSuccess(t *testing.T) {
21+
filePath := pathForService("my-service")
22+
client, data := fakescm.NewDefault()
23+
fakeClientFactory := func(s string) *scm.Client {
24+
return client
25+
}
26+
27+
mc := mock.New()
28+
mc.AddRepoFile(dev, "master", filePath, testBody)
29+
30+
sm := New(mc, "testing")
31+
sm.clientFactory = fakeClientFactory
32+
33+
err := sm.Promote("my-service", dev, staging, "my-branch")
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
38+
mc.AssertBranchCreated(t, staging, "master", "my-branch")
39+
mc.AssertFileWrittenToBranch(t, staging, "my-branch", filePath, testBody)
40+
mc.AssertCommitAndPush(t, staging, "my-branch", "this is a test commit", "testing")
41+
}

pkg/git/client.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package git
2+
3+
import (
4+
"context"
5+
6+
"github.com/jenkins-x/go-scm/scm"
7+
"github.com/jenkins-x/go-scm/scm/driver/github"
8+
"golang.org/x/oauth2"
9+
)
10+
11+
// CreateGitHubClient creates and returns a go-scm GitHub client, using the provided
12+
// oauth2 token.
13+
func CreateGitHubClient(token string) *scm.Client {
14+
client := github.NewDefault()
15+
ts := oauth2.StaticTokenSource(
16+
&oauth2.Token{AccessToken: token},
17+
)
18+
client.Client = oauth2.NewClient(context.Background(), ts)
19+
return client
20+
}

0 commit comments

Comments
 (0)