├── .gitignore ├── .goreleaser.yml ├── .travis.yml ├── LICENSE ├── README.md ├── cloner ├── cloner.go └── cloner_test.go ├── download.sh ├── github ├── github.go └── github_test.go ├── go.mod ├── go.sum ├── main.go ├── makefile ├── shell └── shell.go └── testdata ├── teamReposResponsePage1.json ├── teamReposResponsePage2.json ├── teamReposResponsePage3.json └── teamsResponse.json /.gitignore: -------------------------------------------------------------------------------- 1 | /src/ 2 | /github-org-clone 3 | /vendor/ 4 | /.idea/ 5 | /dist/ 6 | /bin/ 7 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - binary: github-org-clone 3 | goos: 4 | - windows 5 | - darwin 6 | - linux 7 | goarch: 8 | - amd64 9 | 10 | release: 11 | github: 12 | owner: steinfletcher 13 | name: github-org-clone 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.11" 5 | - "stable" 6 | 7 | before_install: 8 | - go get -u -v github.com/axw/gocov/gocov 9 | - go get -u -v github.com/mattn/goveralls 10 | 11 | script: 12 | - diff -u <(echo -n) <(gofmt -s -d ./) 13 | - diff -u <(echo -n) <(go vet ./...) 14 | - go test ./... -v -race 15 | 16 | env: 17 | - GO111MODULE=on 18 | 19 | jobs: 20 | include: 21 | - stage: build 22 | script: make build 23 | 24 | - stage: test 25 | script: make test 26 | 27 | - stage: release 28 | if: branch = master 29 | name: Releasing 30 | script: skip 31 | deploy: 32 | provider: script 33 | script: bash scripts/deploy.sh staging 34 | on: 35 | tags: true 36 | branch: master 37 | condition: "$TRAVIS_TAG =~ ^v.*$" 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Stein Fletcher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-org-clone 2 | 3 | [![Build Status](https://travis-ci.org/steinfletcher/github-org-clone.svg?branch=master)](https://travis-ci.org/steinfletcher/github-org-clone) 4 | 5 | A simple cli app to clone all repos managed by a github organisation or team. 6 | Requires that you pass a github api key (personal access token) and github username to the script or set the `GITHUB_TOKEN` and `GITHUB_USER` environment variable. See the help output below. 7 | 8 | ## Install 9 | 10 | The following script will install a binary from a tagged release 11 | 12 | ```bash 13 | curl https://raw.githubusercontent.com/steinfletcher/github-org-clone/master/download.sh | sh 14 | mv github-org-clone /usr/local/bin 15 | ``` 16 | 17 | Or install from master using go 18 | 19 | ```bash 20 | go get github.com/steinfletcher/github-org-clone 21 | ``` 22 | 23 | ## Use 24 | 25 | Export env vars in `~/.bashrc` or equivalent 26 | 27 | ```bash 28 | export GITHUB_USER= 29 | export GITHUB_TOKEN= 30 | ``` 31 | 32 | (Alternatively supply these as flags to the command `--username` and `--token`). 33 | 34 | Clone team repos 35 | 36 | ```bash 37 | github-org-clone --org MyOrg --team MyTeam 38 | ``` 39 | 40 | Clone organisation repos 41 | 42 | ```bash 43 | github-org-clone -o MyOrg 44 | ``` 45 | 46 | Override the default location 47 | 48 | ```bash 49 | github-org-clone -o MyOrg -d ~/projects/work 50 | ``` 51 | 52 | Override the github api url 53 | 54 | ```bash 55 | github-org-clone -o MyOrg -a https://mycustomdomain.com 56 | ``` 57 | 58 | For enterprise installations include the full path to the github api 59 | 60 | ```bash 61 | github-org-clone -o MyOrg -a https://mycustomdomain.com/api/v3 62 | ``` 63 | 64 | View docs 65 | 66 | ```bash 67 | github-org-clone -h 68 | ``` 69 | -------------------------------------------------------------------------------- /cloner/cloner.go: -------------------------------------------------------------------------------- 1 | package cloner 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/steinfletcher/github-org-clone/github" 9 | "github.com/steinfletcher/github-org-clone/shell" 10 | ) 11 | 12 | type Cloner interface { 13 | Clone(org string, team string) error 14 | } 15 | 16 | type teamCloner struct { 17 | githubCli github.Github 18 | shell shell.Shell 19 | dir string 20 | } 21 | 22 | func NewCloner(g github.Github, shell shell.Shell, dir string) Cloner { 23 | return &teamCloner{g, shell, dir} 24 | } 25 | 26 | func (tC *teamCloner) Clone(org string, team string) error { 27 | var repos []github.Repo 28 | var err error 29 | 30 | if team == "" { 31 | err, repos = tC.githubCli.OrgRepos(org) 32 | if err != nil { 33 | return err 34 | } 35 | } else { 36 | e, teams := tC.githubCli.Teams(org) 37 | if e != nil { 38 | return e 39 | } 40 | 41 | e, teamId := teamId(teams, team) 42 | if e != nil { 43 | return e 44 | } 45 | 46 | e, repos = tC.githubCli.TeamRepos(teamId) 47 | if e != nil { 48 | return e 49 | } 50 | } 51 | 52 | sem := make(chan struct{}, 12) 53 | var wg sync.WaitGroup 54 | 55 | for _, repo := range repos { 56 | fmt.Println(fmt.Sprintf("Cloning %s", repo.Name)) 57 | wg.Add(1) 58 | go tC.clone(&wg, repo.SshUrl, repo.Name, tC.dir, sem) 59 | } 60 | 61 | wg.Wait() 62 | close(sem) 63 | return nil 64 | } 65 | 66 | func (tC *teamCloner) clone(wg *sync.WaitGroup, sshUrl string, repoName string, dir string, sem chan struct{}) { 67 | sem <- struct{}{} 68 | defer func() { <-sem }() 69 | 70 | defer wg.Done() 71 | tC.shell.Exec("git", []string{"clone", sshUrl, fmt.Sprintf("%s/%s", dir, repoName)}) 72 | fmt.Println(fmt.Sprintf("Finished cloning %s", repoName)) 73 | } 74 | 75 | func teamId(teams []github.Team, team string) (error, int) { 76 | for _, t := range teams { 77 | if t.Name == team { 78 | return nil, t.Id 79 | } 80 | } 81 | return errors.New(fmt.Sprintf("No team with name=%s exists with org", team)), 0 82 | } 83 | -------------------------------------------------------------------------------- /cloner/cloner_test.go: -------------------------------------------------------------------------------- 1 | package cloner 2 | 3 | import ( 4 | "github.com/steinfletcher/github-org-clone/github" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestGetTeamId(t *testing.T) { 10 | teams := []github.Team{ 11 | {Name: "team1", Id: 1}, 12 | {Name: "team2", Id: 51}, 13 | {Name: "team3", Id: 101}, 14 | } 15 | 16 | _, id := teamId(teams, "team3") 17 | 18 | assert.Equal(t, id, 101) 19 | } 20 | 21 | func TestGetTeamIdErrorsIfNotFound(t *testing.T) { 22 | teams := []github.Team{ 23 | {Name: "team1", Id: 1}, 24 | } 25 | 26 | err, _ := teamId(teams, "team3") 27 | 28 | assert.NotNil(t, err) 29 | assert.Contains(t, err.Error(), "No team with name") 30 | } 31 | -------------------------------------------------------------------------------- /download.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Code generated by godownloader on 2018-06-19T09:33:44Z. DO NOT EDIT. 4 | # 5 | 6 | usage() { 7 | this=$1 8 | cat </dev/null 118 | } 119 | echoerr() { 120 | echo "$@" 1>&2 121 | } 122 | log_prefix() { 123 | echo "$0" 124 | } 125 | _logp=6 126 | log_set_priority() { 127 | _logp="$1" 128 | } 129 | log_priority() { 130 | if test -z "$1"; then 131 | echo "$_logp" 132 | return 133 | fi 134 | [ "$1" -le "$_logp" ] 135 | } 136 | log_tag() { 137 | case $1 in 138 | 0) echo "emerg" ;; 139 | 1) echo "alert" ;; 140 | 2) echo "crit" ;; 141 | 3) echo "err" ;; 142 | 4) echo "warning" ;; 143 | 5) echo "notice" ;; 144 | 6) echo "info" ;; 145 | 7) echo "debug" ;; 146 | *) echo "$1" ;; 147 | esac 148 | } 149 | log_debug() { 150 | log_priority 7 || return 0 151 | echoerr "$(log_prefix)" "$(log_tag 7)" "$@" 152 | } 153 | log_info() { 154 | log_priority 6 || return 0 155 | echoerr "$(log_prefix)" "$(log_tag 6)" "$@" 156 | } 157 | log_err() { 158 | log_priority 3 || return 0 159 | echoerr "$(log_prefix)" "$(log_tag 3)" "$@" 160 | } 161 | log_crit() { 162 | log_priority 2 || return 0 163 | echoerr "$(log_prefix)" "$(log_tag 2)" "$@" 164 | } 165 | uname_os() { 166 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 167 | case "$os" in 168 | msys_nt) os="windows" ;; 169 | esac 170 | echo "$os" 171 | } 172 | uname_arch() { 173 | arch=$(uname -m) 174 | case $arch in 175 | x86_64) arch="amd64" ;; 176 | x86) arch="386" ;; 177 | i686) arch="386" ;; 178 | i386) arch="386" ;; 179 | aarch64) arch="arm64" ;; 180 | armv5*) arch="armv5" ;; 181 | armv6*) arch="armv6" ;; 182 | armv7*) arch="armv7" ;; 183 | esac 184 | echo ${arch} 185 | } 186 | uname_os_check() { 187 | os=$(uname_os) 188 | case "$os" in 189 | darwin) return 0 ;; 190 | dragonfly) return 0 ;; 191 | freebsd) return 0 ;; 192 | linux) return 0 ;; 193 | android) return 0 ;; 194 | nacl) return 0 ;; 195 | netbsd) return 0 ;; 196 | openbsd) return 0 ;; 197 | plan9) return 0 ;; 198 | solaris) return 0 ;; 199 | windows) return 0 ;; 200 | esac 201 | log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 202 | return 1 203 | } 204 | uname_arch_check() { 205 | arch=$(uname_arch) 206 | case "$arch" in 207 | 386) return 0 ;; 208 | amd64) return 0 ;; 209 | arm64) return 0 ;; 210 | armv5) return 0 ;; 211 | armv6) return 0 ;; 212 | armv7) return 0 ;; 213 | ppc64) return 0 ;; 214 | ppc64le) return 0 ;; 215 | mips) return 0 ;; 216 | mipsle) return 0 ;; 217 | mips64) return 0 ;; 218 | mips64le) return 0 ;; 219 | s390x) return 0 ;; 220 | amd64p32) return 0 ;; 221 | esac 222 | log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 223 | return 1 224 | } 225 | untar() { 226 | tarball=$1 227 | case "${tarball}" in 228 | *.tar.gz | *.tgz) tar -xzf "${tarball}" ;; 229 | *.tar) tar -xf "${tarball}" ;; 230 | *.zip) unzip "${tarball}" ;; 231 | *) 232 | log_err "untar unknown archive format for ${tarball}" 233 | return 1 234 | ;; 235 | esac 236 | } 237 | mktmpdir() { 238 | test -z "$TMPDIR" && TMPDIR="$(mktemp -d)" 239 | mkdir -p "${TMPDIR}" 240 | echo "${TMPDIR}" 241 | } 242 | http_download_curl() { 243 | local_file=$1 244 | source_url=$2 245 | header=$3 246 | if [ -z "$header" ]; then 247 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 248 | else 249 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 250 | fi 251 | if [ "$code" != "200" ]; then 252 | log_debug "http_download_curl received HTTP status $code" 253 | return 1 254 | fi 255 | return 0 256 | } 257 | http_download_wget() { 258 | local_file=$1 259 | source_url=$2 260 | header=$3 261 | if [ -z "$header" ]; then 262 | wget -q -O "$local_file" "$source_url" 263 | else 264 | wget -q --header "$header" -O "$local_file" "$source_url" 265 | fi 266 | } 267 | http_download() { 268 | log_debug "http_download $2" 269 | if is_command curl; then 270 | http_download_curl "$@" 271 | return 272 | elif is_command wget; then 273 | http_download_wget "$@" 274 | return 275 | fi 276 | log_crit "http_download unable to find wget or curl" 277 | return 1 278 | } 279 | http_copy() { 280 | tmp=$(mktemp) 281 | http_download "${tmp}" "$1" "$2" || return 1 282 | body=$(cat "$tmp") 283 | rm -f "${tmp}" 284 | echo "$body" 285 | } 286 | github_release() { 287 | owner_repo=$1 288 | version=$2 289 | test -z "$version" && version="latest" 290 | giturl="https://github.com/${owner_repo}/releases/${version}" 291 | json=$(http_copy "$giturl" "Accept:application/json") 292 | test -z "$json" && return 1 293 | version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') 294 | test -z "$version" && return 1 295 | echo "$version" 296 | } 297 | hash_sha256() { 298 | TARGET=${1:-/dev/stdin} 299 | if is_command gsha256sum; then 300 | hash=$(gsha256sum "$TARGET") || return 1 301 | echo "$hash" | cut -d ' ' -f 1 302 | elif is_command sha256sum; then 303 | hash=$(sha256sum "$TARGET") || return 1 304 | echo "$hash" | cut -d ' ' -f 1 305 | elif is_command shasum; then 306 | hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 307 | echo "$hash" | cut -d ' ' -f 1 308 | elif is_command openssl; then 309 | hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 310 | echo "$hash" | cut -d ' ' -f a 311 | else 312 | log_crit "hash_sha256 unable to find command to compute sha-256 hash" 313 | return 1 314 | fi 315 | } 316 | hash_sha256_verify() { 317 | TARGET=$1 318 | checksums=$2 319 | if [ -z "$checksums" ]; then 320 | log_err "hash_sha256_verify checksum file not specified in arg2" 321 | return 1 322 | fi 323 | BASENAME=${TARGET##*/} 324 | want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) 325 | if [ -z "$want" ]; then 326 | log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" 327 | return 1 328 | fi 329 | got=$(hash_sha256 "$TARGET") 330 | if [ "$want" != "$got" ]; then 331 | log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" 332 | return 1 333 | fi 334 | } 335 | cat /dev/null <= 200 && r.StatusCode < 300 124 | } 125 | 126 | func hasNextPage(response *http.Response) bool { 127 | h := response.Header.Get("link") 128 | pageLinks := strings.Split(h, ",") 129 | for _, link := range pageLinks { 130 | if strings.Contains(link, "rel=\"next\"") { 131 | return true 132 | } 133 | } 134 | return false 135 | } 136 | -------------------------------------------------------------------------------- /github/github_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "gopkg.in/h2non/gock.v1" 6 | "testing" 7 | ) 8 | 9 | func TestFetchTeams(t *testing.T) { 10 | defer gock.Off() 11 | 12 | gock.New("https://api.github.com"). 13 | MatchHeader("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="). 14 | Get("/orgs/MyOrg/teams"). 15 | Reply(200). 16 | File("../testdata/teamsResponse.json") 17 | 18 | githubCli := NewGithub("username", "password", "https://api.github.com") 19 | 20 | _, teams := githubCli.Teams("MyOrg") 21 | 22 | assert.Equal(t, teams[0].Name, "Winners") 23 | assert.Equal(t, teams[0].Id, 2285788) 24 | } 25 | 26 | func TestFetchTeamRepos(t *testing.T) { 27 | defer gock.Off() 28 | 29 | teamReposResp("1", "; rel=\"next\", ; rel=\"last\"", "teamReposResponsePage1.json") 30 | teamReposResp("2", "; rel=\"prev\", ; rel=\"next\", ; rel=\"first\", ; rel=\"last\"", "teamReposResponsePage2.json") 31 | teamReposResp("3", "; rel=\"prev\", ; rel=\"first\"", "teamReposResponsePage3.json") 32 | 33 | githubCli := NewGithub("username", "password", "https://api.github.com") 34 | 35 | _, teams := githubCli.TeamRepos(2285789) 36 | 37 | var repos []string 38 | for _, team := range teams { 39 | repos = append(repos, team.Name) 40 | } 41 | 42 | assert.Contains(t, repos, "some-repo1") 43 | assert.Contains(t, repos, "some-repo2") 44 | assert.Contains(t, repos, "some-repo3") 45 | } 46 | 47 | func teamReposResp(page string, linkHeader string, file string) { 48 | gock.New("https://api.github.com"). 49 | MatchHeader("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="). 50 | Get("/teams/2285789/repos"). 51 | MatchParam("page", page). 52 | Reply(200). 53 | SetHeader("Link", linkHeader). 54 | File("../testdata/" + file) 55 | } 56 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/steinfletcher/github-org-clone 2 | 3 | require ( 4 | github.com/axw/gocov v0.0.0-20170322000131-3a69a0d2a4ef // indirect 5 | github.com/davecgh/go-spew v1.1.0 6 | github.com/mattn/goveralls v0.0.2 // indirect 7 | github.com/pmezard/go-difflib v1.0.0 8 | github.com/stretchr/testify v1.1.4 9 | github.com/urfave/cli v1.20.0 10 | golang.org/x/tools v0.0.0-20190315203558-658e28e1e609 // indirect 11 | gopkg.in/h2non/gock.v1 v1.0.6 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/axw/gocov v0.0.0-20170322000131-3a69a0d2a4ef h1:kh7Fi8sfEY7aCl42VEEvGv7lez2YCOmO120N1fASWGc= 2 | github.com/axw/gocov v0.0.0-20170322000131-3a69a0d2a4ef/go.mod h1:pc6XrbIn8RLeVSNzXCZKXNst+RTE5Ju/nySYl1Wc0B4= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/mattn/goveralls v0.0.2 h1:7eJB6EqsPhRVxvwEXGnqdO2sJI0PTsrWoTMXEk9/OQc= 6 | github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.1.4 h1:ToftOQTytwshuOSj6bDSolVUa3GINfJP/fg3OkkOzQQ= 10 | github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 11 | github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= 12 | github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= 13 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 14 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 15 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 16 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 17 | golang.org/x/tools v0.0.0-20190315203558-658e28e1e609 h1:g5RFjXWg75ZHTsJL0E7UR+PPONCjnmiSaOZosC8LXbk= 18 | golang.org/x/tools v0.0.0-20190315203558-658e28e1e609/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 19 | gopkg.in/h2non/gock.v1 v1.0.6 h1:IAqf4+w4WUgEvxxb9YhNToYkeiHHPWmHRcRsRU5E8wg= 20 | gopkg.in/h2non/gock.v1 v1.0.6/go.mod h1:KHI4Z1sxDW6P4N3DfTWSEza07YpkQP7KJBfglRMEjKY= 21 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/steinfletcher/github-org-clone/cloner" 6 | "github.com/steinfletcher/github-org-clone/github" 7 | "github.com/steinfletcher/github-org-clone/shell" 8 | "github.com/urfave/cli" 9 | "log" 10 | "os" 11 | "time" 12 | ) 13 | 14 | var ( 15 | version = "dev" 16 | commit = "" 17 | date = time.Now().String() 18 | ) 19 | 20 | func main() { 21 | app := cli.NewApp() 22 | app.Author = "Stein Fletcher" 23 | app.Name = "github-org-clone" 24 | app.Usage = "clone github team repos" 25 | app.UsageText = "github-org-clone -o MyOrg -t MyTeam" 26 | app.Version = version 27 | app.EnableBashCompletion = true 28 | app.Description = "A simple cli to clone all the repos managed by a github team" 29 | app.Metadata = map[string]interface{}{ 30 | "commit": commit, 31 | "date": date, 32 | } 33 | 34 | app.Flags = []cli.Flag{ 35 | cli.StringFlag{ 36 | Name: "org, o", 37 | Usage: "github organisation", 38 | }, 39 | cli.StringFlag{ 40 | Name: "team, t", 41 | Usage: "github team", 42 | }, 43 | cli.StringFlag{ 44 | Name: "username, u", 45 | Usage: "github username", 46 | EnvVar: "GITHUB_USER,GITHUB_USERNAME", 47 | }, 48 | cli.StringFlag{ 49 | Name: "token, k", 50 | Usage: "github personal access token", 51 | EnvVar: "GITHUB_TOKEN,GITHUB_API_KEY,GITHUB_PERSONAL_ACCESS_TOKEN", 52 | }, 53 | cli.StringFlag{ 54 | Name: "dir, d", 55 | Usage: "directory to clone into. Defaults to the org name or org/team name if defined", 56 | }, 57 | cli.StringFlag{ 58 | Name: "api, a", 59 | Value: "https://api.github.com", 60 | Usage: "github api url", 61 | }, 62 | } 63 | 64 | app.Action = func(c *cli.Context) error { 65 | username := c.String("username") 66 | token := c.String("token") 67 | team := c.String("team") 68 | org := c.String("org") 69 | dir := c.String("dir") 70 | api := c.String("api") 71 | 72 | if len(username) == 0 { 73 | die("env var GITHUB_USERNAME or flag -u must be set", c) 74 | } 75 | 76 | if len(token) == 0 { 77 | die("env var GITHUB_TOKEN or flag -k must be set", c) 78 | } 79 | 80 | if len(org) == 0 { 81 | die("github organisation (-o) not set", c) 82 | } 83 | 84 | if len(dir) == 0 { 85 | if len(team) == 0 { 86 | dir = org 87 | } else { 88 | if _, err := os.Stat(org); os.IsNotExist(err) { 89 | os.Mkdir(org, os.ModePerm) 90 | } 91 | dir = fmt.Sprintf("%s/%s", org, team) 92 | } 93 | } 94 | 95 | sh := shell.NewShell() 96 | githubCli := github.NewGithub(username, token, api) 97 | cl := cloner.NewCloner(githubCli, sh, dir) 98 | 99 | err := cl.Clone(org, team) 100 | if err != nil { 101 | return cli.NewExitError(err.Error(), 1) 102 | } 103 | 104 | return nil 105 | } 106 | 107 | app.Run(os.Args) 108 | } 109 | 110 | func die(msg string, c *cli.Context) { 111 | cli.ShowAppHelp(c) 112 | log.Fatal(msg) 113 | } 114 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | 3 | test: 4 | go test -v -race ./... 5 | 6 | build: 7 | go build 8 | 9 | all: test build 10 | -------------------------------------------------------------------------------- /shell/shell.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | ) 7 | 8 | type Shell interface { 9 | Exec(cmd string, args []string) error 10 | } 11 | 12 | type shell struct{} 13 | 14 | func NewShell() Shell { 15 | return &shell{} 16 | } 17 | 18 | func (s *shell) Exec(cmd string, args []string) error { 19 | out, err := exec.Command(cmd, args...).Output() 20 | if err != nil { 21 | fmt.Printf("%s", err) 22 | return err 23 | } else { 24 | fmt.Printf("%s", out) 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /testdata/teamReposResponsePage1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 83663111, 4 | "name": "some-repo1", 5 | "full_name": "MyOrg/some-repo1", 6 | "owner": { 7 | "login": "MyOrg", 8 | "id": 14093131, 9 | "avatar_url": "https://avatars1.githubusercontent.com/u/14091082?v=4", 10 | "gravatar_id": "", 11 | "url": "https://api.github.com/users/MyOrg", 12 | "html_url": "https://github.com/MyOrg", 13 | "followers_url": "https://api.github.com/users/MyOrg/followers", 14 | "following_url": "https://api.github.com/users/MyOrg/following{/other_user}", 15 | "gists_url": "https://api.github.com/users/MyOrg/gists{/gist_id}", 16 | "starred_url": "https://api.github.com/users/MyOrg/starred{/owner}{/repo}", 17 | "subscriptions_url": "https://api.github.com/users/MyOrg/subscriptions", 18 | "organizations_url": "https://api.github.com/users/MyOrg/orgs", 19 | "repos_url": "https://api.github.com/users/MyOrg/repos", 20 | "events_url": "https://api.github.com/users/MyOrg/events{/privacy}", 21 | "received_events_url": "https://api.github.com/users/MyOrg/received_events", 22 | "type": "Organization", 23 | "site_admin": false 24 | }, 25 | "private": true, 26 | "html_url": "https://github.com/MyOrg/some-repo1", 27 | "description": null, 28 | "fork": false, 29 | "url": "https://api.github.com/repos/MyOrg/some-repo1", 30 | "forks_url": "https://api.github.com/repos/MyOrg/some-repo1/forks", 31 | "keys_url": "https://api.github.com/repos/MyOrg/some-repo1/keys{/key_id}", 32 | "collaborators_url": "https://api.github.com/repos/MyOrg/some-repo1/collaborators{/collaborator}", 33 | "teams_url": "https://api.github.com/repos/MyOrg/some-repo1/teams", 34 | "hooks_url": "https://api.github.com/repos/MyOrg/some-repo1/hooks", 35 | "issue_events_url": "https://api.github.com/repos/MyOrg/some-repo1/issues/events{/number}", 36 | "events_url": "https://api.github.com/repos/MyOrg/some-repo1/events", 37 | "assignees_url": "https://api.github.com/repos/MyOrg/some-repo1/assignees{/user}", 38 | "branches_url": "https://api.github.com/repos/MyOrg/some-repo1/branches{/branch}", 39 | "tags_url": "https://api.github.com/repos/MyOrg/some-repo1/tags", 40 | "blobs_url": "https://api.github.com/repos/MyOrg/some-repo1/git/blobs{/sha}", 41 | "git_tags_url": "https://api.github.com/repos/MyOrg/some-repo1/git/tags{/sha}", 42 | "git_refs_url": "https://api.github.com/repos/MyOrg/some-repo1/git/refs{/sha}", 43 | "trees_url": "https://api.github.com/repos/MyOrg/some-repo1/git/trees{/sha}", 44 | "statuses_url": "https://api.github.com/repos/MyOrg/some-repo1/statuses/{sha}", 45 | "languages_url": "https://api.github.com/repos/MyOrg/some-repo1/languages", 46 | "stargazers_url": "https://api.github.com/repos/MyOrg/some-repo1/stargazers", 47 | "contributors_url": "https://api.github.com/repos/MyOrg/some-repo1/contributors", 48 | "subscribers_url": "https://api.github.com/repos/MyOrg/some-repo1/subscribers", 49 | "subscription_url": "https://api.github.com/repos/MyOrg/some-repo1/subscription", 50 | "commits_url": "https://api.github.com/repos/MyOrg/some-repo1/commits{/sha}", 51 | "git_commits_url": "https://api.github.com/repos/MyOrg/some-repo1/git/commits{/sha}", 52 | "comments_url": "https://api.github.com/repos/MyOrg/some-repo1/comments{/number}", 53 | "issue_comment_url": "https://api.github.com/repos/MyOrg/some-repo1/issues/comments{/number}", 54 | "contents_url": "https://api.github.com/repos/MyOrg/some-repo1/contents/{+path}", 55 | "compare_url": "https://api.github.com/repos/MyOrg/some-repo1/compare/{base}...{head}", 56 | "merges_url": "https://api.github.com/repos/MyOrg/some-repo1/merges", 57 | "archive_url": "https://api.github.com/repos/MyOrg/some-repo1/{archive_format}{/ref}", 58 | "downloads_url": "https://api.github.com/repos/MyOrg/some-repo1/downloads", 59 | "issues_url": "https://api.github.com/repos/MyOrg/some-repo1/issues{/number}", 60 | "pulls_url": "https://api.github.com/repos/MyOrg/some-repo1/pulls{/number}", 61 | "milestones_url": "https://api.github.com/repos/MyOrg/some-repo1/milestones{/number}", 62 | "notifications_url": "https://api.github.com/repos/MyOrg/some-repo1/notifications{?since,all,participating}", 63 | "labels_url": "https://api.github.com/repos/MyOrg/some-repo1/labels{/name}", 64 | "releases_url": "https://api.github.com/repos/MyOrg/some-repo1/releases{/id}", 65 | "deployments_url": "https://api.github.com/repos/MyOrg/some-repo1/deployments", 66 | "size": 517, 67 | "homepage": null, 68 | "language": "Java", 69 | "default_branch": "master", 70 | "created_at": "2017-03-02T10:10:40Z", 71 | "updated_at": "2017-03-06T15:13:35Z", 72 | "pushed_at": "2017-12-15T13:03:25Z", 73 | "clone_url": "https://github.com/MyOrg/some-repo1.git", 74 | "mirror_url": null, 75 | "git_url": "git://github.com/MyOrg/some-repo1.git", 76 | "ssh_url": "git@github.com:MyOrg/some-repo1.git", 77 | "svn_url": "https://github.com/MyOrg/some-repo1", 78 | "open_issues_count": 0, 79 | "stargazers_count": 0, 80 | "watchers_count": 0, 81 | "forks_count": 0, 82 | "has_issues": true, 83 | "has_projects": true, 84 | "has_wiki": true, 85 | "has_downloads": true, 86 | "has_pages": false, 87 | "archived": false, 88 | "license": null, 89 | "forks": 0, 90 | "open_issues": 0, 91 | "watchers": 0, 92 | "permissions": { 93 | "pull": true, 94 | "push": true, 95 | "admin": true 96 | } 97 | } 98 | ] -------------------------------------------------------------------------------- /testdata/teamReposResponsePage2.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 83663222, 4 | "name": "some-repo2", 5 | "full_name": "MyOrg/some-repo2", 6 | "owner": { 7 | "login": "MyOrg", 8 | "id": 14093131, 9 | "avatar_url": "https://avatars1.githubusercontent.com/u/14091082?v=4", 10 | "gravatar_id": "", 11 | "url": "https://api.github.com/users/MyOrg", 12 | "html_url": "https://github.com/MyOrg", 13 | "followers_url": "https://api.github.com/users/MyOrg/followers", 14 | "following_url": "https://api.github.com/users/MyOrg/following{/other_user}", 15 | "gists_url": "https://api.github.com/users/MyOrg/gists{/gist_id}", 16 | "starred_url": "https://api.github.com/users/MyOrg/starred{/owner}{/repo}", 17 | "subscriptions_url": "https://api.github.com/users/MyOrg/subscriptions", 18 | "organizations_url": "https://api.github.com/users/MyOrg/orgs", 19 | "repos_url": "https://api.github.com/users/MyOrg/repos", 20 | "events_url": "https://api.github.com/users/MyOrg/events{/privacy}", 21 | "received_events_url": "https://api.github.com/users/MyOrg/received_events", 22 | "type": "Organization", 23 | "site_admin": false 24 | }, 25 | "private": true, 26 | "html_url": "https://github.com/MyOrg/some-repo2", 27 | "description": null, 28 | "fork": false, 29 | "url": "https://api.github.com/repos/MyOrg/some-repo2", 30 | "forks_url": "https://api.github.com/repos/MyOrg/some-repo2/forks", 31 | "keys_url": "https://api.github.com/repos/MyOrg/some-repo2/keys{/key_id}", 32 | "collaborators_url": "https://api.github.com/repos/MyOrg/some-repo2/collaborators{/collaborator}", 33 | "teams_url": "https://api.github.com/repos/MyOrg/some-repo2/teams", 34 | "hooks_url": "https://api.github.com/repos/MyOrg/some-repo2/hooks", 35 | "issue_events_url": "https://api.github.com/repos/MyOrg/some-repo2/issues/events{/number}", 36 | "events_url": "https://api.github.com/repos/MyOrg/some-repo2/events", 37 | "assignees_url": "https://api.github.com/repos/MyOrg/some-repo2/assignees{/user}", 38 | "branches_url": "https://api.github.com/repos/MyOrg/some-repo2/branches{/branch}", 39 | "tags_url": "https://api.github.com/repos/MyOrg/some-repo2/tags", 40 | "blobs_url": "https://api.github.com/repos/MyOrg/some-repo2/git/blobs{/sha}", 41 | "git_tags_url": "https://api.github.com/repos/MyOrg/some-repo2/git/tags{/sha}", 42 | "git_refs_url": "https://api.github.com/repos/MyOrg/some-repo2/git/refs{/sha}", 43 | "trees_url": "https://api.github.com/repos/MyOrg/some-repo2/git/trees{/sha}", 44 | "statuses_url": "https://api.github.com/repos/MyOrg/some-repo2/statuses/{sha}", 45 | "languages_url": "https://api.github.com/repos/MyOrg/some-repo2/languages", 46 | "stargazers_url": "https://api.github.com/repos/MyOrg/some-repo2/stargazers", 47 | "contributors_url": "https://api.github.com/repos/MyOrg/some-repo2/contributors", 48 | "subscribers_url": "https://api.github.com/repos/MyOrg/some-repo2/subscribers", 49 | "subscription_url": "https://api.github.com/repos/MyOrg/some-repo2/subscription", 50 | "commits_url": "https://api.github.com/repos/MyOrg/some-repo2/commits{/sha}", 51 | "git_commits_url": "https://api.github.com/repos/MyOrg/some-repo2/git/commits{/sha}", 52 | "comments_url": "https://api.github.com/repos/MyOrg/some-repo2/comments{/number}", 53 | "issue_comment_url": "https://api.github.com/repos/MyOrg/some-repo2/issues/comments{/number}", 54 | "contents_url": "https://api.github.com/repos/MyOrg/some-repo2/contents/{+path}", 55 | "compare_url": "https://api.github.com/repos/MyOrg/some-repo2/compare/{base}...{head}", 56 | "merges_url": "https://api.github.com/repos/MyOrg/some-repo2/merges", 57 | "archive_url": "https://api.github.com/repos/MyOrg/some-repo2/{archive_format}{/ref}", 58 | "downloads_url": "https://api.github.com/repos/MyOrg/some-repo2/downloads", 59 | "issues_url": "https://api.github.com/repos/MyOrg/some-repo2/issues{/number}", 60 | "pulls_url": "https://api.github.com/repos/MyOrg/some-repo2/pulls{/number}", 61 | "milestones_url": "https://api.github.com/repos/MyOrg/some-repo2/milestones{/number}", 62 | "notifications_url": "https://api.github.com/repos/MyOrg/some-repo2/notifications{?since,all,participating}", 63 | "labels_url": "https://api.github.com/repos/MyOrg/some-repo2/labels{/name}", 64 | "releases_url": "https://api.github.com/repos/MyOrg/some-repo2/releases{/id}", 65 | "deployments_url": "https://api.github.com/repos/MyOrg/some-repo2/deployments", 66 | "size": 517, 67 | "homepage": null, 68 | "language": "Java", 69 | "default_branch": "master", 70 | "created_at": "2017-03-02T10:10:40Z", 71 | "updated_at": "2017-03-06T15:13:35Z", 72 | "pushed_at": "2017-12-15T13:03:25Z", 73 | "clone_url": "https://github.com/MyOrg/some-repo2.git", 74 | "mirror_url": null, 75 | "git_url": "git://github.com/MyOrg/some-repo2.git", 76 | "ssh_url": "git@github.com:MyOrg/some-repo2.git", 77 | "svn_url": "https://github.com/MyOrg/some-repo2", 78 | "open_issues_count": 0, 79 | "stargazers_count": 0, 80 | "watchers_count": 0, 81 | "forks_count": 0, 82 | "has_issues": true, 83 | "has_projects": true, 84 | "has_wiki": true, 85 | "has_downloads": true, 86 | "has_pages": false, 87 | "archived": false, 88 | "license": null, 89 | "forks": 0, 90 | "open_issues": 0, 91 | "watchers": 0, 92 | "permissions": { 93 | "pull": true, 94 | "push": true, 95 | "admin": true 96 | } 97 | } 98 | ] -------------------------------------------------------------------------------- /testdata/teamReposResponsePage3.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 83663333, 4 | "name": "some-repo3", 5 | "full_name": "MyOrg/some-repo3", 6 | "owner": { 7 | "login": "MyOrg", 8 | "id": 14093131, 9 | "avatar_url": "https://avatars1.githubusercontent.com/u/14091082?v=4", 10 | "gravatar_id": "", 11 | "url": "https://api.github.com/users/MyOrg", 12 | "html_url": "https://github.com/MyOrg", 13 | "followers_url": "https://api.github.com/users/MyOrg/followers", 14 | "following_url": "https://api.github.com/users/MyOrg/following{/other_user}", 15 | "gists_url": "https://api.github.com/users/MyOrg/gists{/gist_id}", 16 | "starred_url": "https://api.github.com/users/MyOrg/starred{/owner}{/repo}", 17 | "subscriptions_url": "https://api.github.com/users/MyOrg/subscriptions", 18 | "organizations_url": "https://api.github.com/users/MyOrg/orgs", 19 | "repos_url": "https://api.github.com/users/MyOrg/repos", 20 | "events_url": "https://api.github.com/users/MyOrg/events{/privacy}", 21 | "received_events_url": "https://api.github.com/users/MyOrg/received_events", 22 | "type": "Organization", 23 | "site_admin": false 24 | }, 25 | "private": true, 26 | "html_url": "https://github.com/MyOrg/some-repo3", 27 | "description": null, 28 | "fork": false, 29 | "url": "https://api.github.com/repos/MyOrg/some-repo3", 30 | "forks_url": "https://api.github.com/repos/MyOrg/some-repo3/forks", 31 | "keys_url": "https://api.github.com/repos/MyOrg/some-repo3/keys{/key_id}", 32 | "collaborators_url": "https://api.github.com/repos/MyOrg/some-repo3/collaborators{/collaborator}", 33 | "teams_url": "https://api.github.com/repos/MyOrg/some-repo3/teams", 34 | "hooks_url": "https://api.github.com/repos/MyOrg/some-repo3/hooks", 35 | "issue_events_url": "https://api.github.com/repos/MyOrg/some-repo3/issues/events{/number}", 36 | "events_url": "https://api.github.com/repos/MyOrg/some-repo3/events", 37 | "assignees_url": "https://api.github.com/repos/MyOrg/some-repo3/assignees{/user}", 38 | "branches_url": "https://api.github.com/repos/MyOrg/some-repo3/branches{/branch}", 39 | "tags_url": "https://api.github.com/repos/MyOrg/some-repo3/tags", 40 | "blobs_url": "https://api.github.com/repos/MyOrg/some-repo3/git/blobs{/sha}", 41 | "git_tags_url": "https://api.github.com/repos/MyOrg/some-repo3/git/tags{/sha}", 42 | "git_refs_url": "https://api.github.com/repos/MyOrg/some-repo3/git/refs{/sha}", 43 | "trees_url": "https://api.github.com/repos/MyOrg/some-repo3/git/trees{/sha}", 44 | "statuses_url": "https://api.github.com/repos/MyOrg/some-repo3/statuses/{sha}", 45 | "languages_url": "https://api.github.com/repos/MyOrg/some-repo3/languages", 46 | "stargazers_url": "https://api.github.com/repos/MyOrg/some-repo3/stargazers", 47 | "contributors_url": "https://api.github.com/repos/MyOrg/some-repo3/contributors", 48 | "subscribers_url": "https://api.github.com/repos/MyOrg/some-repo3/subscribers", 49 | "subscription_url": "https://api.github.com/repos/MyOrg/some-repo3/subscription", 50 | "commits_url": "https://api.github.com/repos/MyOrg/some-repo3/commits{/sha}", 51 | "git_commits_url": "https://api.github.com/repos/MyOrg/some-repo3/git/commits{/sha}", 52 | "comments_url": "https://api.github.com/repos/MyOrg/some-repo3/comments{/number}", 53 | "issue_comment_url": "https://api.github.com/repos/MyOrg/some-repo3/issues/comments{/number}", 54 | "contents_url": "https://api.github.com/repos/MyOrg/some-repo3/contents/{+path}", 55 | "compare_url": "https://api.github.com/repos/MyOrg/some-repo3/compare/{base}...{head}", 56 | "merges_url": "https://api.github.com/repos/MyOrg/some-repo3/merges", 57 | "archive_url": "https://api.github.com/repos/MyOrg/some-repo3/{archive_format}{/ref}", 58 | "downloads_url": "https://api.github.com/repos/MyOrg/some-repo3/downloads", 59 | "issues_url": "https://api.github.com/repos/MyOrg/some-repo3/issues{/number}", 60 | "pulls_url": "https://api.github.com/repos/MyOrg/some-repo3/pulls{/number}", 61 | "milestones_url": "https://api.github.com/repos/MyOrg/some-repo3/milestones{/number}", 62 | "notifications_url": "https://api.github.com/repos/MyOrg/some-repo3/notifications{?since,all,participating}", 63 | "labels_url": "https://api.github.com/repos/MyOrg/some-repo3/labels{/name}", 64 | "releases_url": "https://api.github.com/repos/MyOrg/some-repo3/releases{/id}", 65 | "deployments_url": "https://api.github.com/repos/MyOrg/some-repo3/deployments", 66 | "size": 517, 67 | "homepage": null, 68 | "language": "Java", 69 | "default_branch": "master", 70 | "created_at": "2017-03-02T10:10:40Z", 71 | "updated_at": "2017-03-06T15:13:35Z", 72 | "pushed_at": "2017-12-15T13:03:25Z", 73 | "clone_url": "https://github.com/MyOrg/some-repo3.git", 74 | "mirror_url": null, 75 | "git_url": "git://github.com/MyOrg/some-repo3.git", 76 | "ssh_url": "git@github.com:MyOrg/some-repo3.git", 77 | "svn_url": "https://github.com/MyOrg/some-repo3", 78 | "open_issues_count": 0, 79 | "stargazers_count": 0, 80 | "watchers_count": 0, 81 | "forks_count": 0, 82 | "has_issues": true, 83 | "has_projects": true, 84 | "has_wiki": true, 85 | "has_downloads": true, 86 | "has_pages": false, 87 | "archived": false, 88 | "license": null, 89 | "forks": 0, 90 | "open_issues": 0, 91 | "watchers": 0, 92 | "permissions": { 93 | "pull": true, 94 | "push": true, 95 | "admin": true 96 | } 97 | } 98 | ] -------------------------------------------------------------------------------- /testdata/teamsResponse.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Winners", 4 | "id": 2285788, 5 | "slug": "winners", 6 | "description": "", 7 | "privacy": "secret", 8 | "url": "https://api.github.com/teams/2285788", 9 | "members_url": "https://api.github.com/teams/2285788/members{/member}", 10 | "repositories_url": "https://api.github.com/teams/2285788/repos", 11 | "permission": "pull" 12 | } 13 | ] --------------------------------------------------------------------------------