From d39e48f007a5ff132d8aee02c107ca43e66a0700 Mon Sep 17 00:00:00 2001 From: Chris Sidi Date: Tue, 22 Sep 2020 13:52:54 -0400 Subject: [PATCH] Create org if necessary --- README.md | 9 +++- script/test-build | 21 ++++++++ src/push.go | 67 ++++++++++++++++++-------- test/github.go | 119 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9cd140e..af071f7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ When there are machines which have access to both the public internet and the GH - `destination-url` _(required)_ The URL of the GHES instance to sync repositories onto. - `destination-token` _(required)_ - A personal access token to authenticate against the GHES instance when uploading repositories. + A personal access token to authenticate against the GHES instance when uploading repositories. See [Destination token scopes](#destination-token-scopes) below. - `repo-name` _(optional)_ A single repository to be synced. In the format of `owner/repo`. Optionally if you wish the repository to be named different on your GHES instance you can provide an alias in the format: `upstream_owner/upstream_repo:destination_owner/destination_repo` - `repo-name-list` _(optional)_ @@ -89,7 +89,9 @@ When no machine has access to both the public internet and the GHES instance: - `destination-url` _(required)_ The URL of the GHES instance to sync repositories onto. - `destination-token` _(required)_ - A personal access token to authenticate against the GHES instance when uploading repositories. + A personal access token to authenticate against the GHES instance when uploading repositories. See [Destination token scopes](#destination-token-scopes) below. +- `repo-name`, `repo-name-list` or `repo-name-list-file` _(optional)_ + Limit push to specific repositories in the cache directory. **Example Usage:** @@ -100,6 +102,9 @@ When no machine has access to both the public internet and the GHES instance: --destination-url "https://www.example.com" ``` +## Destination token scopes + +When creating a personal access token include the `repo` scope. Include the `site_admin` scope (optional) if you want organizations to be created as necessary. ## Contributing diff --git a/script/test-build b/script/test-build index 4b3385c..7a94fe7 100755 --- a/script/test-build +++ b/script/test-build @@ -102,6 +102,27 @@ function test_push() { assert_dest_sha "org/repo1" "heads/main" "e9009d51dd6da2c363d1d14779c53dd27fcb0c52" "updating org/repo1 passed in repo flag" assert_dest_sha "org/repo2" "heads/main" "a5984bb887dd2fcdc2892cd906d6f004844d1142" "org/repo2 not updated despite cache" + # Push to pre-existing org + setup_cache "org-already-exists/new-repo:heads/main:e9009d51dd6da2c363d1d14779c53dd27fcb0c52" + setup_dest "org-already-exists/new-repo:heads/main:a5984bb887dd2fcdc2892cd906d6f004844d1142" + + push "pushing to existing org" + assert_dest_sha "org-already-exists/new-repo" "heads/main" "e9009d51dd6da2c363d1d14779c53dd27fcb0c52" "updating org-already-exists/new-repo" + + # Push to pre-existing repo + setup_cache "org-already-exists/repo-already-exists:heads/main:e9009d51dd6da2c363d1d14779c53dd27fcb0c52" + setup_dest "org-already-exists/repo-already-exists:heads/main:a5984bb887dd2fcdc2892cd906d6f004844d1142" + + push "pushing to existing repo" + assert_dest_sha "org-already-exists/repo-already-exists" "heads/main" "e9009d51dd6da2c363d1d14779c53dd27fcb0c52" "updating org-already-exists/repo-already-exists" + + # Push to repo in user's account + setup_cache "monalisa/new-repo:heads/main:e9009d51dd6da2c363d1d14779c53dd27fcb0c52" + setup_dest "monalisa/new-repo:heads/main:a5984bb887dd2fcdc2892cd906d6f004844d1142" + + push "pushing to authenticated user's account" + assert_dest_sha "monalisa/new-repo" "heads/main" "e9009d51dd6da2c363d1d14779c53dd27fcb0c52" "updating monalisa/new-repo" + echo "all push tests passed successfully" } diff --git a/src/push.go b/src/push.go index 50f6cc3..c744d53 100644 --- a/src/push.go +++ b/src/push.go @@ -103,7 +103,7 @@ func PushWithGitImpl(ctx context.Context, flags *PushFlags, repoName string, ghC } fmt.Printf("syncing `%s`\n", nwo) - ghRepo, err := getOrCreateGitHubRepo(ctx, flags, ghClient, bareRepoName, ownerName) + ghRepo, err := getOrCreateGitHubRepo(ctx, ghClient, bareRepoName, ownerName) if err != nil { return errors.Wrapf(err, "error creating github repository `%s`", nwo) } @@ -115,7 +115,7 @@ func PushWithGitImpl(ctx context.Context, flags *PushFlags, repoName string, ghC return nil } -func getOrCreateGitHubRepo(ctx context.Context, flags *PushFlags, client *github.Client, repoName, ownerName string) (*github.Repository, error) { +func getOrCreateGitHubRepo(ctx context.Context, client *github.Client, repoName, ownerName string) (*github.Repository, error) { repo := &github.Repository{ Name: github.String(repoName), HasIssues: github.Bool(false), @@ -124,32 +124,36 @@ func getOrCreateGitHubRepo(ctx context.Context, flags *PushFlags, client *github HasProjects: github.Bool(false), } - orgName := ownerName + currentUser, _, err := client.Users.Get(ctx, "") + if err != nil { + return nil, errors.Wrap(err, "error retrieving authenticated user") + } + if currentUser == nil || currentUser.Login == nil { + return nil, errors.New("error retrieving authenticated user's login name") + } - // Confirm the org exists - _, resp, err := client.Organizations.Get(ctx, orgName) - if resp != nil && resp.StatusCode == 404 { - // Check if the destination owner matches the authenticated user. (best effort) - currentUser, _, _ := client.Users.Get(ctx, "") - if currentUser != nil && strings.EqualFold(*currentUser.Login, ownerName) { - // create the new repo under the authenticated user's account. - orgName = "" - err = nil - } else { - return nil, errors.Errorf("Organization `%s` doesn't exist at %s. You must create it first.", ownerName, flags.BaseURL) + // check if the owner refers to the authenticated user or an organization. + var createRepoOrgName string + if strings.EqualFold(*currentUser.Login, ownerName) { + // we'll create the repo under the authenticated user's account. + createRepoOrgName = "" + } else { + // ensure the org exists. + createRepoOrgName = ownerName + _, err := getOrCreateGitHubOrg(ctx, client, ownerName, *currentUser.Login) + if err != nil { + return nil, err } } - if err != nil { - return nil, errors.Wrapf(err, "error retrieving organization %s", ownerName) - } - // Create the repo if necessary - ghRepo, resp, err := client.Repositories.Create(ctx, orgName, repo) - if resp != nil && resp.StatusCode == 422 { + ghRepo, resp, err := client.Repositories.Create(ctx, createRepoOrgName, repo) + if err == nil { + fmt.Printf("Created repo `%s/%s`\n", ownerName, repoName) + } else if resp != nil && resp.StatusCode == 422 { ghRepo, _, err = client.Repositories.Get(ctx, ownerName, repoName) } if err != nil { - return nil, errors.Wrap(err, "error creating repository") + return nil, errors.Wrapf(err, "error creating repository %s/%s", ownerName, repoName) } if ghRepo == nil { return nil, errors.New("error repository is nil") @@ -157,6 +161,27 @@ func getOrCreateGitHubRepo(ctx context.Context, flags *PushFlags, client *github return ghRepo, nil } +func getOrCreateGitHubOrg(ctx context.Context, client *github.Client, orgName, admin string) (*github.Organization, error) { + org := &github.Organization{Login: &orgName} + + var getErr error + ghOrg, _, createErr := client.Admin.CreateOrg(ctx, org, admin) + if createErr == nil { + fmt.Printf("Created organization `%s` (admin: %s)\n", orgName, admin) + } else { + // Regardless of why create failed, see if we can retrieve the org + ghOrg, _, getErr = client.Organizations.Get(ctx, orgName) + } + if createErr != nil && getErr != nil { + return nil, errors.Wrapf(createErr, "error creating organization %s", orgName) + } + if ghOrg == nil { + return nil, errors.New("error organization is nil") + } + + return ghOrg, nil +} + func syncWithCachedRepository(ctx context.Context, flags *PushFlags, ghRepo *github.Repository, repoDir string, gitimpl GitImplementation) error { gitRepo, err := gitimpl.NewGitRepository(repoDir) if err != nil { diff --git a/test/github.go b/test/github.go index 37fd9c0..c881fe9 100644 --- a/test/github.go +++ b/test/github.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "flag" + "fmt" "io/ioutil" "net/http" "path" @@ -11,6 +12,10 @@ import ( "github.com/gorilla/mux" ) +var authenticatedLogin string = "monalisa" +var existingOrg string = "org-already-exists" +var existingRepo string = "repo-already-exists" + func main() { var port, gitDaemonURL string flag.StringVar(&port, "p", "", "") @@ -20,9 +25,64 @@ func main() { r := mux.NewRouter() r.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {}) + r.HandleFunc("/api/v3/user", func(w http.ResponseWriter, r *http.Request) { + currentUser := github.User{Login: &authenticatedLogin} + b, _ := json.Marshal(currentUser) + _, err := w.Write(b) + if err != nil { + panic(err) + } + }) + + r.HandleFunc("/api/v3/admin/organizations", func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + if err != nil { + panic(err) + } + var orgReq struct { + Login string `json:"login,omitempty"` + Admin string `json:"admin,omitempty"` + } + err = json.Unmarshal(b, &orgReq) + if err != nil { + panic(err) + } + + if orgReq.Login == authenticatedLogin { + w.WriteHeader(http.StatusUnprocessableEntity) + _, err := w.Write([]byte(fmt.Sprintf("%s is a user, not an organization", orgReq.Login))) + if err != nil { + panic(err) + } + } + + if orgReq.Login == existingOrg { + w.WriteHeader(http.StatusUnprocessableEntity) + _, err := w.Write([]byte(fmt.Sprintf("Organization %s already exists", orgReq.Login))) + if err != nil { + panic(err) + } + } + + org := github.Organization{Login: &orgReq.Login} + b, _ = json.Marshal(org) + _, err = w.Write(b) + if err != nil { + panic(err) + } + }).Methods("POST") + r.HandleFunc("/api/v3/orgs/{org}", func(w http.ResponseWriter, r *http.Request) { orgName := mux.Vars(r)["org"] + if orgName != existingOrg { + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(fmt.Sprintf("Organization %s not found", orgName))) + if err != nil { + panic(err) + } + } + org := github.Organization{Login: &orgName} b, _ := json.Marshal(org) _, err := w.Write(b) @@ -45,6 +105,14 @@ func main() { panic(err) } + if repoReq.Name == "repo-already-exists" { + w.WriteHeader(http.StatusUnprocessableEntity) + _, err := w.Write([]byte(fmt.Sprintf("Repo %s already exists", repoReq.Name))) + if err != nil { + panic(err) + } + } + cloneURL := gitDaemonURL + path.Join(orgName, repoReq.Name, ".git") repo := github.Repository{Name: &repoReq.Name, CloneURL: &cloneURL} b, _ = json.Marshal(repo) @@ -54,6 +122,57 @@ func main() { } }).Methods("POST") + r.HandleFunc("/api/v3/user/repos", func(w http.ResponseWriter, r *http.Request) { + b, err := ioutil.ReadAll(r.Body) + if err != nil { + panic(err) + } + var repoReq struct { + Name string `json:"name,omitempty"` + } + err = json.Unmarshal(b, &repoReq) + if err != nil { + panic(err) + } + + if repoReq.Name == existingRepo { + w.WriteHeader(http.StatusUnprocessableEntity) + _, err := w.Write([]byte(fmt.Sprintf("Repo %s already exists", repoReq.Name))) + if err != nil { + panic(err) + } + } + + cloneURL := gitDaemonURL + path.Join(authenticatedLogin, repoReq.Name, ".git") + repo := github.Repository{Name: &repoReq.Name, CloneURL: &cloneURL} + b, _ = json.Marshal(repo) + _, err = w.Write(b) + if err != nil { + panic(err) + } + }).Methods("POST") + + r.HandleFunc("/api/v3/repos/{owner}/{repo}", func(w http.ResponseWriter, r *http.Request) { + ownerName := mux.Vars(r)["owner"] + repoName := mux.Vars(r)["repo"] + + if repoName != existingRepo { + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(fmt.Sprintf("Repo %s not found", repoName))) + if err != nil { + panic(err) + } + } + + cloneURL := gitDaemonURL + path.Join(ownerName, repoName, ".git") + org := github.Repository{Name: &repoName, CloneURL: &cloneURL} + b, _ := json.Marshal(org) + _, err := w.Write(b) + if err != nil { + panic(err) + } + }) + err := http.ListenAndServe(":"+port, r) if err != nil { panic(err)