├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── go.yml │ ├── release.yml │ └── update-wiki.yml ├── .gitignore ├── .gitlint ├── .golangci.yml ├── .gometalinter.json ├── .goreleaser.yml ├── .pdd ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── cmd └── gitstrap │ ├── cmd_apply.go │ ├── cmd_create.go │ ├── cmd_delete.go │ ├── cmd_get.go │ ├── cmd_init.go │ ├── cmd_list.go │ ├── main.go │ └── utils.go ├── go.mod ├── go.sum ├── internal ├── github │ ├── org.go │ ├── repo.go │ ├── team.go │ └── utils.go ├── gitstrap │ ├── apply.go │ ├── create.go │ ├── debug_transport.go │ ├── delete.go │ ├── errors.go │ ├── get.go │ ├── gitstrap.go │ ├── list.go │ ├── list_filters.go │ ├── pagination.go │ ├── pagination_test.go │ └── utils.go ├── spec │ ├── hook.go │ ├── metadata.go │ ├── model.go │ ├── model_reader.go │ ├── model_test.go │ ├── org.go │ ├── protection.go │ ├── readme.go │ ├── repo.go │ └── team.go └── utils │ ├── tag.go │ └── tag_test.go ├── man └── gitstrap.1 ├── scripts └── download.sh └── wiki ├── Home.md └── Specifications.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [g4s8, OrlovM] 4 | custom: ["bitcoin:bc1qc878l49ga0mjkyur3h6355dsdfkjrn265t9qp7"] 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Make sure the title of the issue explains the problem you are having. Also, the description of the issue must clearly explain what is broken, not what you want us to implement. Go through this checklist and make sure you answer "YES" to all points: 11 | 12 | - You have all pre-requisites listed in README.md installed 13 | - You are sure that you are not reporting a duplicate (search all issues) 14 | - You say "is broken" or "doesn't work" in the title 15 | - You tell us what you are trying to do 16 | - You explain the results you are getting 17 | - You suggest an alternative result you would like to see 18 | 19 | This article will help you understand what we are looking for: http://www.yegor256.com/2014/11/24/principles-of-bug-tracking.html 20 | 21 | Thank you for your contribution! 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "gomod" 7 | directory: "/" 8 | labels: 9 | - "dependencies" 10 | - "bot" 11 | assignees: 12 | - "g4s8" 13 | reviewers: 14 | - "g4s8" 15 | schedule: 16 | interval: "daily" 17 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | "on": 3 | push: 4 | branches: ["master"] 5 | pull_request: 6 | branches: ["master"] 7 | jobs: 8 | check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-go@v3 13 | with: 14 | go-version: '1.20' 15 | - uses: actions/cache@v2 16 | with: 17 | path: ~/go/pkg/mod 18 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 19 | restore-keys: | 20 | ${{ runner.os }}-go- 21 | - name: Build 22 | run: make build 23 | - name: test 24 | run: make test 25 | - name: Test race 26 | run: make test-race 27 | lint: 28 | needs: check 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/setup-go@v3 32 | with: 33 | go-version: '1.20' 34 | - uses: actions/checkout@v3 35 | - uses: golangci/golangci-lint-action@v3 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: gorelease 3 | "on": 4 | push: 5 | tags: ['*'] 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | - uses: actions/setup-go@v2 14 | with: 15 | go-version: 1.15 16 | - uses: goreleaser/goreleaser-action@v2 17 | with: 18 | version: latest 19 | args: release --rm-dist 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.API_GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/update-wiki.yml: -------------------------------------------------------------------------------- 1 | name: Update Wiki 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'wiki/**' 7 | branches: 8 | - master 9 | jobs: 10 | update-wiki: 11 | runs-on: ubuntu-latest 12 | name: Update wiki 13 | steps: 14 | - uses: OrlovM/Wiki-Action@v1 15 | with: 16 | path: 'wiki' 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/** 2 | bin/** 3 | _old/ 4 | /gitstrap 5 | .gitstrap.yaml 6 | -------------------------------------------------------------------------------- /.gitlint: -------------------------------------------------------------------------------- 1 | --subject-regex=\(?#\d+\)? - .+ 2 | --since=2019-10-28 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: true 3 | -------------------------------------------------------------------------------- /.gometalinter.json: -------------------------------------------------------------------------------- 1 | { 2 | "Deadline": "5m", 3 | "EnableGC": true, 4 | "Enable": [ 5 | "deadcode", 6 | "gocyclo", 7 | "gofmt", 8 | "gotype", 9 | "golint", 10 | "ineffassign", 11 | "interfacer", 12 | "misspell", 13 | "unconvert", 14 | "vet", 15 | "varcheck", 16 | "maligned", 17 | "errcheck", 18 | "goconst", 19 | "structcheck" 20 | ], 21 | "Cyclo": 10, 22 | "Aggregate": true 23 | } 24 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | --- 2 | before: 3 | hooks: 4 | - go mod download 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | - windows 11 | - darwin 12 | main: ./cmd/gitstrap/ 13 | archives: 14 | - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}' 15 | replacements: 16 | darwin: Darwin 17 | linux: Linux 18 | windows: Windows 19 | 386: i386 20 | amd64: x86_64 21 | brews: 22 | - tap: 23 | owner: g4s8 24 | name: .tap 25 | commit_author: 26 | name: goreleaser 27 | email: g4s8.public+tap@gmail.com 28 | homepage: "https://github.com/g4s8/gitstrap" 29 | folder: Formula 30 | dependencies: 31 | - git 32 | checksum: 33 | name_template: 'checksums.txt' 34 | snapshot: 35 | name_template: "{{ .Tag }}-next" 36 | changelog: 37 | sort: asc 38 | filters: 39 | exclude: 40 | - '^docs:' 41 | - '^test:' 42 | -------------------------------------------------------------------------------- /.pdd: -------------------------------------------------------------------------------- 1 | --source=. 2 | --verbose 3 | --rule min-words:10 4 | --rule min-estimate:15 5 | --rule max-estimate:90 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v2.3 2 | 3 | - 643402f - fix: get teams command crash (#91) 4 | by Mikhail <58946795+OrlovM@users.noreply.github.com> 5 | - 249655e - deps: bump github.com/creasty/defaults from 1.5.1 to 1.5.2 (#87) 6 | by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7 | - 9535d7b - refactor: refactored utils.removeOmitEmpty, added test for RemoveTagsOmitempty (#85) 8 | by Mikhail <58946795+OrlovM@users.noreply.github.com> 9 | - 81fb940 - feat: added RequiredConversationResolution to branch protection rules (#86) 10 | by Mikhail <58946795+OrlovM@users.noreply.github.com> 11 | 12 | ## v2.2 13 | 14 | - feat(init): init commands to generate stub files (#81) 15 | - feature(delete): delete commands for teams, protections, readmes and hooks 16 | - feature(delete): delete repo command 17 | - refactoring(pagination): implemented pagination utils 18 | - docs(protections): added branch protection spec to wiki 19 | - fix(protections): protection ToGithub and FromGithub fixed 20 | - feature(protections): implemented protection apply and delete commands (#71) 21 | - deps: bump github.com/urfave/cli/v2 from 2.1.1 to 2.3.0 (#69) 22 | - ci: fixed dependabot config 23 | - docs(teams): Added wiki documentation for teams (#68) 24 | - ci: Added Github actcion to update wiki (#67) 25 | - feature(teams): Implemented apply and delete team commands 26 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | Do whatever you want here. 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | To work on the project you need to have `git`, `go` and `make` tools. 2 | 3 | To start working: 4 | 1. Fork the repo 5 | 2. Clone your fork 6 | 3. Create a branch for changes 7 | 4. Commit changes to your local branch 8 | 5. Push to fork 9 | 6. Create pull request to upstream 10 | 11 | To build the project use `make` command from project root: 12 | - `make build` - builds the binary 13 | - `make test` - starts unit tests 14 | - `make link` - starts linter 15 | 16 | Before commiting changes make sure that `make` command passes successfully. 17 | 18 | Project layout is: 19 | - `internal/spec/` - specification definitions, see more details in [wiki](https://github.com/g4s8/gitstrap/wiki/Specifications) 20 | - `internal/gitstrap` - all supported operations on specificaitons 21 | - `cmd/gitstrap` - CLI main 22 | 23 | ### Debugging 24 | 25 | To debug it on your own repository you need to [generate a token](https://github.com/g4s8/gitstrap#get-github-token). 26 | 27 | For debugging, use `DEBUG=1` environment variable, e.g. `DEBUG=1 ./gitstrap get repo gitstrap` 28 | 29 | ## Code style 30 | 31 | Code style is checked by [golangci-lint](https://golangci-lint.run/) tool, you may need to install it if don't have. Run `mkae lint` to ensure 32 | your code style is OK. In pull requests it checked automatically, and CI workflow block PR from merging. 33 | 34 | ## Pull request style 35 | 36 | Primary PR rule: it's the responsibility of PR author to bring the changes to the master branch. 37 | 38 | Other important mandatory rule - it should refer to some ticket. The only exception is a minor type fix in documentation. 39 | 40 | Pull request should consist of two mandatory parts: 41 | - "Title" - says **what** is the change, it should be one small and full enough sentence with only necessary information 42 | - "Description" - says **how** this pull request fixes a problem or implements a new feature 43 | 44 | ### Title 45 | 46 | Title should be as small as possible but provide full enough information to understand what was done (not a process), 47 | and where from this sentence. 48 | It could be capitalized If needed, started from capital letter, and should not include links or references 49 | (including tickets numbers). 50 | 51 | Good PR titles examples: 52 | - Fixed Maven artifact upload - describes what was done: fixed, the what was the fixed: artifact upload, and where: Maven 53 | - Implemented GET blobs API for Docker - done: implemented, what: GET blobs API, where: Docker 54 | - Added integration test for Maven deploy - done: added, what: integration test for deploy, where: Maven 55 | 56 | Bad PR titles: 57 | - Fixed NPE - not clear WHAT was the problem, and where; good title could be: "Fixed NPE on Maven artifact download" 58 | - Added more tests - too vague; good: "Added unit tests for Foo and Bar classes" 59 | - Implementing Docker registry - the process, not the result; good: "Implemented cache layer for Docker proxy" 60 | 61 | ### Description 62 | 63 | Description starts with a ticket number prefixed with one of these keywords: (Fixed, Closes, For, Part of), 64 | then a hyphen, and a description of the changes. 65 | Changes description provides information about **how** the problem from title was fixed. 66 | It should be a short summary of all changes to increase readability of changes before looking to code, 67 | and provide some context. The format is 68 | `(For|Closes|Fixes|Part of) #(\d+) - (
.+)`, 69 | e.g.: `For #123 - check if the file exists before accessing it and return 404 code if doesn't`. 70 | 71 | Good description describes the solution provided and may have technical details, it isn't just a copy of the title. 72 | Examples of good descriptions: 73 | - Added a new class as storage implementation over S3 blob-storage, implemented `value()` method, throw exceptions on other methods, created unit test for value 74 | - Fixed FileNotFoundException on reading blob content by checking if file exists before reading it. Return 404 code if doesn't exist 75 | 76 | ### Merging 77 | 78 | We merge PR only if all required CI checks passed and after approval of repository maintainers. 79 | We merge using squash merge, where commit messages consists of two parts: 80 | ``` 81 | 82 | 83 | 84 | PR: 85 | ``` 86 | GitHub automatically inserts title and description as commit messages, the only manual work is a PR number. 87 | 88 | ### Review 89 | 90 | It's recommended to request review from `@artipie/contributors` if possible. 91 | When the reviewers starts the review it should assign the PR to themselves, 92 | when the review is completed and some changes are requested, then it should be assigned back to the author. 93 | On approve: if reviewer and repository maintainer are two different persons, 94 | then the PR should be assigned to maintainer, and maintainer can merge it or ask for more comments. 95 | 96 | The workflow: 97 | ``` 98 | (optional) 99 | PR created | Review | Request changes | Fixed changes | Approves changes | Merge | 100 | assignee: -> -> (author) -> (reviewer) -> -> 101 | ``` 102 | 103 | When addressing review changes, two possible strategies could be used: 104 | - `git commit --ammend` + `git push --force` - in case of changes are minor or obvious, both sides agree 105 | - new commit - in case if author wants to describe review changes and keep it for history, 106 | e.g. if author doesn't agree with reviewer or maintainer, he|she may want to point that this changes was 107 | asked by a reviewer. This commit is not going to the master branch, but it will be linked into PR history. 108 | 109 | ### Commit style 110 | 111 | Commit styles are similar to PR, PR could be created from commit message: first line goes to the title, 112 | other lines to description: 113 | ``` 114 | Commit title - same as PR title 115 | 116 | For #123 - description of the commit goes 117 | to PR description. It could be multiline `and` include 118 | *markdown* formatting. 119 | ``` 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 g4s8 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files 7 | (the "Software"), to deal in the Software without restriction, 8 | including without limitation the rights * to use, copy, modify, 9 | merge, publish, distribute, sublicense, and/or sell copies of the Software, 10 | and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OUTPUT?=gitstrap 2 | BUILD_VERSION?=0.0 3 | BUILD_HASH?=stub 4 | BUILD_DATE?=2019.01.01 5 | 6 | all: clean build test lint 7 | 8 | .PHONY: build 9 | build: $(OUTPUT) 10 | 11 | $(OUTPUT): 12 | go build \ 13 | -ldflags "-X main.buildVersion=$(BUILD_VERSION) -X main.buildCommit=$(BUILD_HASH) -X main.buildDate=$(BUILD_DATE)" \ 14 | -o $(OUTPUT) ./cmd/gitstrap 15 | 16 | .PHONY: clean 17 | clean: 18 | rm -f $(OUTPUT) 19 | 20 | # run_tests_dir - run all tests in provided directory 21 | define _run_tests_dir 22 | go test -v ${TEST_OPTS} "./$(1)/..." 23 | endef 24 | 25 | .PHONY: test 26 | test: $(OUTPUT) 27 | $(call _run_tests_dir,internal) 28 | 29 | .PHONY: test-race 30 | test-race: TEST_OPTS := ${TEST_OPTS} -race 31 | test-race: test 32 | 33 | .PHONY: bench 34 | bench: TEST_OPTS := ${TEST_OPTS} -bench=. -run=^$ 35 | bench: test 36 | 37 | .PHONY: lint 38 | lint: $(OUTPUT) 39 | golangci-lint run 40 | 41 | .PHONY: install 42 | install: $(OUTPUT) 43 | install $(OUTPUT) /usr/bin 44 | 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gitstrap 2 | 3 | [![CI](https://github.com/g4s8/gitstrap/actions/workflows/go.yml/badge.svg)](https://github.com/g4s8/gitstrap/actions/workflows/go.yml) 4 | [![Hits-of-Code](https://hitsofcode.com/github/g4s8/gitstrap)](https://hitsofcode.com/view/github/g4s8/gitstrap) 5 | [![codebeat badge](https://codebeat.co/badges/89bbb569-fba9-4c68-9b21-e2520b59fbeb)](https://codebeat.co/projects/github-com-g4s8-gitstrap-master) 6 | 7 | [![GitHub release](https://img.shields.io/github/release/g4s8/gitstrap.svg?label=version)](https://github.com/g4s8/gitstrap/releases/latest) 8 | 9 | Manage your GitHub repositories as a set of resouce configuration files! 10 | 11 | Gitstrap automates routine operations with Github. 12 | It can create and configure Github repositories, teams, readmes, organizations, etc 13 | from `yaml` specification files. 14 | It helps to: 15 | * Create new repository on Github; 16 | * Manage repositories permissions; 17 | * Keep all organization repositories configuration in yaml files in one directory; 18 | * Configure webhooks for Github repo 5) configure branch protection rules; 19 | * Other repo management tasks; 20 | 21 | 22 | See Wiki for [full documentation](https://github.com/g4s8/gitstrap/wiki/Specifications). 23 | 24 | # Demo 25 | 26 | [![asciicast](https://asciinema.org/a/504401.svg)](https://asciinema.org/a/504401) 27 | 28 | # Quickstart 29 | 30 | 1. Download `gitstrap` CLI (see [Install](#install) section) 31 | 2. Get configuration from any of your repositories or from this one: `gitstrap get --owner=g4s8 gitstrap > repo.yaml` 32 | 3. Edit YAML config (see [Specification](https://github.com/g4s8/gitstrap/wiki/Specifications) reference) 33 | 4. Create or update you repository with `gitstrap apply -f repo.yaml` 34 | 35 | 36 | ## Install 37 | 38 | First you need to install it. 39 | 40 | To get binary for your platform use [download script](https://github.com/g4s8/gitstrap/blob/master/scripts/download.sh): 41 | ```sh 42 | curl -L https://raw.githubusercontent.com/g4s8/gitstrap/master/scripts/download.sh | sh 43 | ``` 44 | 45 | On MacOS you can install it using `brew` tool: 46 | ```sh 47 | brew tap g4s8/.tap https://github.com/g4s8/.tap 48 | brew install g4s8/.tap/gitstrap 49 | ``` 50 | 51 | Alternatively, you can build it using `go get github.com/g4s8/gitstrap` 52 | 53 | ## Get GitHub token 54 | 55 | To use `gitstrap` you need GitHub token. 56 | Go to settings (profile settings, developer settings, personal access token, generate new token): 57 | https://github.com/settings/tokens/new 58 | and select all `repo` checkboxes and `delete_repo` checkbox (in case you want gitstrap to be able to 59 | delete repositories). You may use this token as CLI option (`gitstrap --token=ABCD123 apply`) 60 | or save it in `~/.config/gitstrap/github_token.txt` file. 61 | 62 | ## Contributing 63 | 64 | See [CONTRIBUTING.md](https://github.com/g4s8/gitstrap/blob/master/CONTRIBUTING.md) for details. 65 | -------------------------------------------------------------------------------- /cmd/gitstrap/cmd_apply.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/g4s8/gitstrap/internal/gitstrap" 5 | "github.com/g4s8/gitstrap/internal/spec" 6 | "github.com/urfave/cli/v2" 7 | "log" 8 | ) 9 | 10 | var applyCommand = &cli.Command{ 11 | Name: "apply", 12 | Usage: "Apply new specficiation", 13 | Action: cmdForEachModel(func(g *gitstrap.Gitstrap, m *spec.Model) error { 14 | if err := g.Apply(m); err != nil { 15 | return err 16 | } 17 | log.Printf("Spec applied: %s", m.Info()) 18 | return nil 19 | }), 20 | Flags: []cli.Flag{ 21 | &cli.StringFlag{ 22 | Name: "file", 23 | Aliases: []string{"f"}, 24 | Usage: "Resource specification file", 25 | }, 26 | &cli.BoolFlag{ 27 | Name: "force", 28 | Usage: "Force create, replace existing resource if exists", 29 | }, 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /cmd/gitstrap/cmd_create.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/g4s8/gitstrap/internal/gitstrap" 7 | "github.com/g4s8/gitstrap/internal/spec" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | var createCommand = &cli.Command{ 12 | Name: "create", 13 | Aliases: []string{"c"}, 14 | Usage: "Create new resource", 15 | Action: cmdForEachModel(func(g *gitstrap.Gitstrap, m *spec.Model) error { 16 | if err := g.Create(m); err != nil { 17 | return err 18 | } 19 | log.Printf("Created: %s", m.Info()) 20 | return nil 21 | }), 22 | Flags: []cli.Flag{ 23 | &cli.StringFlag{ 24 | Name: "file", 25 | Aliases: []string{"f"}, 26 | Usage: "Resource specification file", 27 | }, 28 | &cli.BoolFlag{ 29 | Name: "force", 30 | Usage: "Force create, replace existing resource if exists", 31 | }, 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /cmd/gitstrap/cmd_delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/g4s8/gitstrap/internal/gitstrap" 9 | "github.com/g4s8/gitstrap/internal/spec" 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | var deleteCommand = &cli.Command{ 14 | Name: "delete", 15 | Aliases: []string{"remove", "del", "rm"}, 16 | Usage: "Delete resource", 17 | Action: cmdForEachModel(func(g *gitstrap.Gitstrap, m *spec.Model) error { 18 | if err := g.Delete(m); err != nil { 19 | return err 20 | } 21 | log.Printf("Deleted: %s", m.Info()) 22 | return nil 23 | }), 24 | Flags: []cli.Flag{ 25 | &cli.StringFlag{ 26 | Name: "file", 27 | Aliases: []string{"f"}, 28 | Usage: "Resource specification file", 29 | }, 30 | }, 31 | Subcommands: []*cli.Command{ 32 | { 33 | Name: "repo", 34 | Usage: "Delete repository", 35 | Flags: []cli.Flag{ 36 | &cli.StringFlag{ 37 | Name: "owner", 38 | Usage: "Repository owner or organization name", 39 | }, 40 | }, 41 | Action: newDeleteCmd(func(ctx *cli.Context) (*spec.Model, error) { 42 | m, err := spec.NewModel(spec.KindRepo) 43 | if err != nil { 44 | return nil, err 45 | } 46 | m.Metadata.Name = ctx.Args().First() 47 | m.Metadata.Owner = ctx.String("owner") 48 | return m, nil 49 | }), 50 | }, 51 | { 52 | Name: "readme", 53 | Usage: "Delete readme", 54 | Flags: []cli.Flag{ 55 | &cli.StringFlag{ 56 | Name: "owner", 57 | Usage: "Repository owner or organization name", 58 | }, 59 | }, 60 | Action: newDeleteCmd(func(ctx *cli.Context) (*spec.Model, error) { 61 | m, err := spec.NewModel(spec.KindReadme) 62 | if err != nil { 63 | return nil, err 64 | } 65 | spec := new(spec.Readme) 66 | spec.Selector.Repository = ctx.Args().First() 67 | m.Metadata.Owner = ctx.String("owner") 68 | m.Spec = spec 69 | return m, nil 70 | }), 71 | }, 72 | { 73 | Name: "hook", 74 | Usage: "Delete webhook", 75 | Flags: []cli.Flag{ 76 | &cli.StringFlag{ 77 | Name: "owner", 78 | Usage: "Repository owner or organization name", 79 | }, 80 | &cli.StringFlag{ 81 | Name: "repo", 82 | Usage: "Repository where hook is installed", 83 | }, 84 | }, 85 | Action: newDeleteCmd(func(ctx *cli.Context) (*spec.Model, error) { 86 | m, err := spec.NewModel(spec.KindHook) 87 | if err != nil { 88 | return nil, err 89 | } 90 | id, err := strconv.ParseInt(ctx.Args().First(), 10, 64) 91 | if err != nil { 92 | return nil, err 93 | } 94 | m.Metadata.ID = &id 95 | spec := new(spec.Hook) 96 | if repo := ctx.String("repo"); repo != "" { 97 | spec.Selector.Repository = repo 98 | m.Metadata.Owner = ctx.String("owner") 99 | } else { 100 | spec.Selector.Organization = ctx.String("owner") 101 | } 102 | m.Spec = spec 103 | return m, nil 104 | }), 105 | }, 106 | { 107 | Name: "team", 108 | Usage: "Delete team", 109 | Flags: []cli.Flag{ 110 | &cli.StringFlag{ 111 | Name: "org", 112 | Usage: "Organization name", 113 | Required: true, 114 | }, 115 | &cli.StringFlag{ 116 | Name: "id", 117 | Usage: "Team ID", 118 | }, 119 | }, 120 | Action: newDeleteCmd(func(ctx *cli.Context) (*spec.Model, error) { 121 | m, err := spec.NewModel(spec.KindTeam) 122 | if err != nil { 123 | return nil, err 124 | } 125 | m.Metadata.Name = ctx.Args().First() 126 | m.Metadata.Owner = ctx.String("org") 127 | if id := ctx.String("id"); id != "" { 128 | iid, err := strconv.ParseInt(id, 10, 64) 129 | if err != nil { 130 | return nil, err 131 | } 132 | m.Metadata.ID = &iid 133 | 134 | } 135 | return m, nil 136 | }), 137 | }, 138 | { 139 | Name: "protection", 140 | Usage: "Delete protection", 141 | Flags: []cli.Flag{ 142 | &cli.StringFlag{ 143 | Name: "owner", 144 | Usage: "Repository owner or organization name", 145 | }, 146 | }, 147 | Action: newDeleteCmd(func(ctx *cli.Context) (*spec.Model, error) { 148 | m, err := spec.NewModel(spec.KindProtection) 149 | if err != nil { 150 | return nil, err 151 | } 152 | m.Metadata.Repo = ctx.Args().First() 153 | m.Metadata.Name = ctx.Args().Get(1) 154 | m.Metadata.Owner = ctx.String("owner") 155 | return m, nil 156 | }), 157 | }, 158 | }, 159 | } 160 | 161 | func newDeleteCmd(model func(*cli.Context) (*spec.Model, error)) func(*cli.Context) error { 162 | return func(ctx *cli.Context) error { 163 | token, err := resolveToken(ctx) 164 | if err != nil { 165 | return err 166 | } 167 | debug := os.Getenv("DEBUG") != "" 168 | g, err := gitstrap.New(ctx.Context, token, debug) 169 | if err != nil { 170 | return err 171 | } 172 | m, err := model(ctx) 173 | if err != nil { 174 | return err 175 | } 176 | if err := g.Delete(m); err != nil { 177 | return err 178 | } 179 | log.Printf("Deleted: %s", m.Info()) 180 | return nil 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /cmd/gitstrap/cmd_get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/g4s8/gitstrap/internal/gitstrap" 8 | "github.com/urfave/cli/v2" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | var getCommand = &cli.Command{ 13 | Name: "get", 14 | Aliases: []string{"g"}, 15 | Usage: "Get resource", 16 | Subcommands: []*cli.Command{ 17 | { 18 | Name: "repo", 19 | Usage: "Get repository", 20 | Action: cmdGetRepo, 21 | Flags: []cli.Flag{ 22 | &cli.StringFlag{ 23 | Name: "owner", 24 | Usage: "Get repositories of another user or organization", 25 | }, 26 | }, 27 | }, 28 | { 29 | Name: "org", 30 | Usage: "Get organization", 31 | Action: cmdGetOrg, 32 | }, 33 | { 34 | Name: "hooks", 35 | Usage: "Get webhooks configurations", 36 | Action: cmdGetHooks, 37 | Flags: []cli.Flag{ 38 | &cli.StringFlag{ 39 | Name: "owner", 40 | Usage: "User name or organization owner of hooks repo", 41 | }, 42 | }, 43 | }, 44 | { 45 | Name: "teams", 46 | Usage: "Get organization team", 47 | Action: cmdGetTeams, 48 | Flags: []cli.Flag{ 49 | &cli.StringFlag{ 50 | Name: "org", 51 | Usage: "Organization name", 52 | Required: true, 53 | }, 54 | }, 55 | }, 56 | { 57 | Name: "protection", 58 | Usage: "Get repository branch protection rules", 59 | Action: cmdGetProtections, 60 | Flags: []cli.Flag{ 61 | &cli.StringFlag{ 62 | Name: "owner", 63 | Usage: "Repository name", 64 | Required: true, 65 | }, 66 | }, 67 | }, 68 | }, 69 | } 70 | 71 | func cmdGetRepo(c *cli.Context) error { 72 | token, err := resolveToken(c) 73 | if err != nil { 74 | return err 75 | } 76 | name := c.Args().First() 77 | if name == "" { 78 | return fmt.Errorf("Requires repository name argument") 79 | } 80 | owner := c.String("owner") 81 | debug := os.Getenv("DEBUG") != "" 82 | g, err := gitstrap.New(c.Context, token, debug) 83 | if err != nil { 84 | return err 85 | } 86 | repo, err := g.GetRepo(name, owner) 87 | if err != nil { 88 | return err 89 | } 90 | return yaml.NewEncoder(os.Stdout).Encode(repo) 91 | } 92 | 93 | func cmdGetOrg(c *cli.Context) error { 94 | token, err := resolveToken(c) 95 | if err != nil { 96 | return err 97 | } 98 | name := c.Args().First() 99 | if name == "" { 100 | return fmt.Errorf("Requires organization name argument") 101 | } 102 | debug := os.Getenv("DEBUG") != "" 103 | g, err := gitstrap.New(c.Context, token, debug) 104 | if err != nil { 105 | return err 106 | } 107 | org, err := g.GetOrg(name) 108 | if err != nil { 109 | return err 110 | } 111 | return yaml.NewEncoder(os.Stdout).Encode(org) 112 | } 113 | 114 | func cmdGetHooks(c *cli.Context) error { 115 | token, err := resolveToken(c) 116 | if err != nil { 117 | return err 118 | } 119 | name := c.Args().First() 120 | debug := os.Getenv("DEBUG") != "" 121 | g, err := gitstrap.New(c.Context, token, debug) 122 | if err != nil { 123 | return err 124 | } 125 | stream, errs := g.GetHooks(c.String("owner"), name) 126 | enc := yaml.NewEncoder(os.Stdout) 127 | for { 128 | select { 129 | case h, ok := <-stream: 130 | if !ok { 131 | return nil 132 | } 133 | if err := enc.Encode(h); err != nil { 134 | return err 135 | } 136 | case err, ok := <-errs: 137 | if ok { 138 | return err 139 | } 140 | } 141 | } 142 | } 143 | 144 | func cmdGetTeams(ctx *cli.Context) error { 145 | token, err := resolveToken(ctx) 146 | if err != nil { 147 | return err 148 | } 149 | debug := os.Getenv("DEBUG") != "" 150 | g, err := gitstrap.New(ctx.Context, token, debug) 151 | if err != nil { 152 | return err 153 | } 154 | stream, errs := g.GetTeams(ctx.String("org")) 155 | enc := yaml.NewEncoder(os.Stdout) 156 | for { 157 | select { 158 | case h, ok := <-stream: 159 | if !ok { 160 | return nil 161 | } 162 | if err := enc.Encode(h); err != nil { 163 | return err 164 | } 165 | case err, ok := <-errs: 166 | if ok { 167 | return err 168 | } 169 | } 170 | } 171 | } 172 | 173 | func cmdGetProtections(ctx *cli.Context) error { 174 | token, err := resolveToken(ctx) 175 | if err != nil { 176 | return err 177 | } 178 | debug := os.Getenv("DEBUG") != "" 179 | g, err := gitstrap.New(ctx.Context, token, debug) 180 | if err != nil { 181 | return err 182 | } 183 | repo := ctx.Args().First() 184 | name := ctx.Args().Get(1) 185 | if name == "" || repo == "" { 186 | return fmt.Errorf("Requires repo and branch name argumentas") 187 | } 188 | s, err := g.GetProtection(ctx.String("owner"), repo, name) 189 | if err != nil { 190 | return err 191 | } 192 | return yaml.NewEncoder(os.Stdout).Encode(s) 193 | } 194 | -------------------------------------------------------------------------------- /cmd/gitstrap/cmd_init.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/creasty/defaults" 7 | "github.com/g4s8/gitstrap/internal/spec" 8 | "github.com/g4s8/gitstrap/internal/utils" 9 | "github.com/urfave/cli/v2" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | const ( 14 | stubOrg = "exampleOrg" 15 | stubRepo = "exampleRepo" 16 | stubBranch = "master" 17 | ) 18 | 19 | var initCommand = &cli.Command{ 20 | Name: "init", 21 | Usage: "Generate stub specification file", 22 | Flags: []cli.Flag{ 23 | &cli.BoolFlag{ 24 | Name: "full", 25 | Value: false, 26 | Usage: "Init full spec with empty and default fields", 27 | Aliases: []string{"f"}, 28 | }, 29 | }, 30 | Subcommands: []*cli.Command{ 31 | { 32 | Name: "repo", 33 | Usage: "Generate repo stub", 34 | Flags: []cli.Flag{ 35 | &cli.StringFlag{ 36 | Name: "owner", 37 | Usage: "Repository owner or organization name", 38 | }, 39 | }, 40 | Action: newInitCmd(func(ctx *cli.Context) (*spec.Model, error) { 41 | m, err := spec.NewModel(spec.KindRepo) 42 | if err != nil { 43 | return nil, err 44 | } 45 | if n := ctx.Args().First(); n != "" { 46 | m.Metadata.Name = n 47 | } else { 48 | m.Metadata.Name = stubRepo 49 | } 50 | m.Metadata.Owner = ctx.String("owner") 51 | spec := new(spec.Repo) 52 | m.Spec = spec 53 | return m, nil 54 | }), 55 | }, 56 | { 57 | Name: "org", 58 | Usage: "Generate org stub", 59 | Flags: []cli.Flag{ 60 | &cli.StringFlag{ 61 | Name: "login", 62 | Usage: "Organization login", 63 | }, 64 | }, 65 | Action: newInitCmd(func(ctx *cli.Context) (*spec.Model, error) { 66 | m, err := spec.NewModel(spec.KindOrg) 67 | if err != nil { 68 | return nil, err 69 | } 70 | spec := new(spec.Org) 71 | if n := ctx.Args().First(); n != "" { 72 | spec.Name = n 73 | } else { 74 | spec.Name = stubOrg 75 | } 76 | if l := ctx.String("login"); l != "" { 77 | m.Metadata.Name = l 78 | } else { 79 | m.Metadata.Name = spec.Name 80 | } 81 | m.Spec = spec 82 | return m, nil 83 | }), 84 | }, 85 | { 86 | Name: "hook", 87 | Usage: "Generate hook stub", 88 | Flags: []cli.Flag{ 89 | &cli.StringFlag{ 90 | Name: "owner", 91 | Usage: "Repository owner or organization name", 92 | }, 93 | &cli.StringFlag{ 94 | Name: "repo", 95 | Usage: "Name of repository for this hook", 96 | }, 97 | }, 98 | Action: newInitCmd(func(ctx *cli.Context) (*spec.Model, error) { 99 | m, err := spec.NewModel(spec.KindHook) 100 | spec := new(spec.Hook) 101 | if err != nil { 102 | return nil, err 103 | } 104 | owner := ctx.String("owner") 105 | if repo := ctx.String("repo"); repo != "" { 106 | spec.Selector.Repository = repo 107 | m.Metadata.Owner = owner 108 | } else if owner != "" { 109 | spec.Selector.Organization = owner 110 | } else { 111 | spec.Selector.Organization = stubOrg 112 | } 113 | m.Spec = spec 114 | return m, nil 115 | }), 116 | }, 117 | { 118 | Name: "readme", 119 | Usage: "Generate readme stub", 120 | Flags: []cli.Flag{ 121 | &cli.StringFlag{ 122 | Name: "repo", 123 | Usage: "Name of repository where this readme will be created", 124 | }, 125 | }, 126 | Action: newInitCmd(func(ctx *cli.Context) (*spec.Model, error) { 127 | m, err := spec.NewModel(spec.KindReadme) 128 | spec := new(spec.Readme) 129 | if err != nil { 130 | return nil, err 131 | } 132 | if repo := ctx.String("repo"); repo != "" { 133 | spec.Selector.Repository = repo 134 | } else { 135 | spec.Selector.Repository = stubRepo 136 | } 137 | m.Spec = spec 138 | return m, nil 139 | }), 140 | }, 141 | { 142 | Name: "team", 143 | Usage: "Generate team stub", 144 | Flags: []cli.Flag{ 145 | &cli.StringFlag{ 146 | Name: "slug", 147 | Usage: "Team slug", 148 | }, 149 | &cli.StringFlag{ 150 | Name: "owner", 151 | Usage: "Organization to which the team belongs", 152 | }, 153 | }, 154 | Action: newInitCmd(func(ctx *cli.Context) (*spec.Model, error) { 155 | m, err := spec.NewModel(spec.KindTeam) 156 | spec := new(spec.Team) 157 | if err != nil { 158 | return nil, err 159 | } 160 | if owner := ctx.String("owner"); owner != "" { 161 | m.Metadata.Owner = owner 162 | } else { 163 | m.Metadata.Owner = stubOrg 164 | } 165 | if slug := ctx.String("slug"); slug != "" { 166 | m.Metadata.Name = slug 167 | } 168 | spec.Name = ctx.Args().First() 169 | m.Spec = spec 170 | return m, nil 171 | }), 172 | }, 173 | { 174 | Name: "protection", 175 | Usage: "Generate branch protection stub", 176 | Flags: []cli.Flag{ 177 | &cli.StringFlag{ 178 | Name: "owner", 179 | Usage: "Repository owner", 180 | }, 181 | &cli.StringFlag{ 182 | Name: "repo", 183 | Usage: "Repository name", 184 | }, 185 | }, 186 | Action: newInitCmd(func(ctx *cli.Context) (*spec.Model, error) { 187 | m, err := spec.NewModel(spec.KindProtection) 188 | spec := new(spec.Protection) 189 | if err != nil { 190 | return nil, err 191 | } 192 | if owner := ctx.String("owner"); owner != "" { 193 | m.Metadata.Owner = owner 194 | } else { 195 | m.Metadata.Owner = stubOrg 196 | } 197 | if repo := ctx.String("repo"); repo != "" { 198 | m.Metadata.Repo = stubRepo 199 | } 200 | if branch := ctx.Args().First(); branch != "" { 201 | m.Metadata.Name = branch 202 | } else { 203 | m.Metadata.Name = stubBranch 204 | } 205 | m.Spec = spec 206 | return m, nil 207 | }), 208 | }, 209 | }, 210 | } 211 | 212 | func newInitCmd(model func(*cli.Context) (*spec.Model, error)) func(*cli.Context) error { 213 | return func(ctx *cli.Context) error { 214 | m, err := model(ctx) 215 | if err != nil { 216 | return err 217 | } 218 | if err := defaults.Set(m.Spec); err != nil { 219 | return err 220 | } 221 | if ctx.Bool("full") { 222 | s, err := utils.RemoveTagsOmitempty(m.Spec, "yaml") 223 | if err != nil { 224 | return err 225 | } 226 | m.Spec = s 227 | } 228 | return yaml.NewEncoder(os.Stdout).Encode(m) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /cmd/gitstrap/cmd_list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | 8 | "github.com/g4s8/gitstrap/internal/gitstrap" 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | var listCommand = &cli.Command{ 13 | Name: "list", 14 | Aliases: []string{"l", "ls", "lst"}, 15 | Usage: "List resources", 16 | Subcommands: []*cli.Command{ 17 | { 18 | Name: "repo", 19 | Usage: "List repositories", 20 | Flags: []cli.Flag{ 21 | &cli.StringFlag{ 22 | Name: "owner", 23 | Usage: "List repositories of another user or organization", 24 | }, 25 | &cli.BoolFlag{ 26 | Name: "forks", 27 | Usage: "Filter only fork repositories", 28 | }, 29 | &cli.BoolFlag{ 30 | Name: "no-forks", 31 | Usage: "Filter out fork repositories", 32 | }, 33 | &cli.IntFlag{ 34 | Name: "stars-gt", 35 | Usage: "Filter by stars greater than value", 36 | }, 37 | &cli.IntFlag{ 38 | Name: "stars-lt", 39 | Usage: "Filter by stars less than value", 40 | }, 41 | }, 42 | Action: cmdListRepo, 43 | }, 44 | }, 45 | } 46 | 47 | func cmdListRepo(c *cli.Context) error { 48 | token, err := resolveToken(c) 49 | if err != nil { 50 | return err 51 | } 52 | owner := c.String("owner") 53 | debug := os.Getenv("DEBUG") != "" 54 | g, err := gitstrap.New(c.Context, token, debug) 55 | if err != nil { 56 | return err 57 | } 58 | filter := gitstrap.LfNop 59 | if c.Bool("forks") { 60 | filter = gitstrap.LfForks(filter, true) 61 | } 62 | if c.Bool("no-forks") { 63 | filter = gitstrap.LfForks(filter, false) 64 | } 65 | if gt := c.Int("stars-gt"); gt > 0 { 66 | filter = gitstrap.LfStars(filter, gitstrap.LfStarsGt(gt)) 67 | } 68 | if lt := c.Int("stars-lt"); lt > 0 { 69 | filter = gitstrap.LfStars(filter, gitstrap.LfStarsLt(lt)) 70 | } 71 | errs := make(chan error) 72 | defer close(errs) 73 | lst := g.ListRepos(filter, owner, errs) 74 | cases := make([]reflect.SelectCase, 2) 75 | cases[0] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(lst)} 76 | cases[1] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(errs)} 77 | out := os.Stdout 78 | for { 79 | choosen, val, ok := reflect.Select(cases) 80 | if !ok { 81 | break 82 | } 83 | if choosen == 0 { 84 | next := val.Interface().(*gitstrap.RepoInfo) 85 | if _, err := next.WriteTo(out); err != nil { 86 | return err 87 | } 88 | fmt.Fprint(out, "\n") 89 | } else { 90 | return val.Interface().(error) 91 | } 92 | } 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /cmd/gitstrap/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/urfave/cli/v2" 10 | ) 11 | 12 | func main() { 13 | log.SetPrefix("") 14 | log.SetFlags(0) 15 | app := cli.App{ 16 | Name: "gitstrap", 17 | Description: "CLI tool to manage GitHub repositories", 18 | Usage: "GitHub resource bootstrap", 19 | Flags: []cli.Flag{ 20 | &cli.StringFlag{ 21 | Name: "token", 22 | Usage: "GitHub API token with repo access", 23 | }, 24 | }, 25 | Commands: []*cli.Command{ 26 | getCommand, 27 | listCommand, 28 | createCommand, 29 | deleteCommand, 30 | applyCommand, 31 | initCommand, 32 | }, 33 | } 34 | if err := app.Run(os.Args); err != nil { 35 | log.Fatal(err) 36 | } 37 | } 38 | 39 | func resolveToken(c *cli.Context) (string, error) { 40 | token := c.String("token") 41 | if token != "" { 42 | return token, nil 43 | } 44 | file := os.Getenv("HOME") + "/.config/gitstrap/github_token.txt" 45 | if bin, err := os.ReadFile(file); err == nil { 46 | return strings.Trim(string(bin), "\n"), nil 47 | } 48 | return "", fmt.Errorf("GitHub token neither given as a flag, nor found in %s", file) 49 | } 50 | -------------------------------------------------------------------------------- /cmd/gitstrap/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "strings" 8 | 9 | "github.com/g4s8/gitstrap/internal/gitstrap" 10 | "github.com/g4s8/gitstrap/internal/spec" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | type errAggregate struct { 15 | errs []error 16 | } 17 | 18 | func (e *errAggregate) push(err error) { 19 | e.errs = append(e.errs, err) 20 | } 21 | 22 | func (e *errAggregate) ifAny() error { 23 | if len(e.errs) == 0 { 24 | return nil 25 | } 26 | return e 27 | } 28 | 29 | func (e *errAggregate) Error() string { 30 | sb := new(strings.Builder) 31 | sb.WriteString("There are multiple errors occured:\n") 32 | for pos, err := range e.errs { 33 | sb.WriteString(fmt.Sprintf(" %d - %s\n", pos, err)) 34 | } 35 | return sb.String() 36 | } 37 | 38 | type gtask func(g *gitstrap.Gitstrap, m *spec.Model) error 39 | 40 | func cmdForEachModel(task gtask) cli.ActionFunc { 41 | return func(ctx *cli.Context) error { 42 | token, err := resolveToken(ctx) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | models, err := spec.ReadFile(ctx.String("file")) 48 | if err != nil { 49 | return err 50 | } 51 | debug := os.Getenv("DEBUG") != "" 52 | g, err := gitstrap.New(ctx.Context, token, debug) 53 | if err != nil { 54 | return err 55 | } 56 | if ctx.Bool("force") { 57 | for _, m := range models { 58 | m.Metadata.Annotations["force"] = "true" 59 | } 60 | } 61 | errs := new(errAggregate) 62 | for _, m := range models { 63 | if err := task(g, m); err != nil { 64 | errs.push(err) 65 | } 66 | } 67 | return errs.ifAny() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/g4s8/gitstrap 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/creasty/defaults v1.8.0 7 | github.com/fatih/structtag v1.2.0 8 | github.com/g4s8/go-matchers v0.0.0-20201209072131-8aaefc3fcb9c 9 | github.com/google/go-github/v38 v38.1.0 10 | github.com/urfave/cli/v2 v2.27.5 11 | golang.org/x/oauth2 v0.26.0 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 17 | github.com/google/go-querystring v1.0.0 // indirect 18 | github.com/kr/text v0.2.0 // indirect 19 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 20 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 21 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 22 | golang.org/x/crypto v0.31.0 // indirect 23 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 3 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 4 | github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= 5 | github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= 6 | github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= 7 | github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= 8 | github.com/g4s8/go-matchers v0.0.0-20201209072131-8aaefc3fcb9c h1:TmkNYOvLjDue/qg9fS9eBbtMmP3Xv7MXxCB1E0svrRk= 9 | github.com/g4s8/go-matchers v0.0.0-20201209072131-8aaefc3fcb9c/go.mod h1:DHT9ggtm4yPn9m1IJMRZtyBGVY9k44vhjs6VfU7V1ks= 10 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 11 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 12 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 13 | github.com/google/go-github/v38 v38.1.0 h1:C6h1FkaITcBFK7gAmq4eFzt6gbhEhk7L5z6R3Uva+po= 14 | github.com/google/go-github/v38 v38.1.0/go.mod h1:cStvrz/7nFr0FoENgG6GLbp53WaelXucT+BBz/3VKx4= 15 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 16 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 17 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 18 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 19 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 20 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 21 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 22 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 23 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 24 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 25 | github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= 26 | github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 27 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 28 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 29 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 30 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 31 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 32 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 33 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 34 | golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= 35 | golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 36 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 37 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 38 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 39 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 41 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 42 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 43 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 44 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | -------------------------------------------------------------------------------- /internal/github/org.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | gh "github.com/google/go-github/v38/github" 7 | ) 8 | 9 | func OrgExist(cli *gh.Client, ctx context.Context, name string) (bool, error) { 10 | _, _, err := cli.Organizations.Get(ctx, name) 11 | if isNotFound(err) { 12 | return false, nil 13 | } 14 | if err != nil { 15 | return false, err 16 | } 17 | return true, err 18 | } 19 | 20 | func GetOrgIdByName(cli *gh.Client, ctx context.Context, name string) (int64, error) { 21 | org, _, err := cli.Organizations.Get(ctx, name) 22 | if err != nil { 23 | return 0, err 24 | } 25 | return *org.ID, nil 26 | } 27 | -------------------------------------------------------------------------------- /internal/github/repo.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | gh "github.com/google/go-github/v38/github" 7 | ) 8 | 9 | func RepoExist(cli *gh.Client, ctx context.Context, owner, name string) (bool, error) { 10 | _, _, err := cli.Repositories.Get(ctx, owner, name) 11 | if isNotFound(err) { 12 | return false, nil 13 | } 14 | if err != nil { 15 | return false, err 16 | } 17 | return true, nil 18 | } 19 | -------------------------------------------------------------------------------- /internal/github/team.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | gh "github.com/google/go-github/v38/github" 7 | ) 8 | 9 | func TeamExistBySlug(cli *gh.Client, ctx context.Context, org, slug string) (bool, error) { 10 | _, _, err := cli.Teams.GetTeamBySlug(ctx, org, slug) 11 | if isNotFound(err) { 12 | return false, nil 13 | } 14 | if err != nil { 15 | return false, err 16 | } 17 | return true, nil 18 | } 19 | 20 | func TeamExistByID(cli *gh.Client, ctx context.Context, org, ID int64) (bool, error) { 21 | _, _, err := cli.Teams.GetTeamByID(ctx, org, ID) 22 | if isNotFound(err) { 23 | return false, nil 24 | } 25 | if err != nil { 26 | return false, err 27 | } 28 | return true, nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/github/utils.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | gh "github.com/google/go-github/v38/github" 8 | ) 9 | 10 | func isNotFound(err error) bool { 11 | if err == nil { 12 | return false 13 | } 14 | ghErr := new(gh.ErrorResponse) 15 | if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { 16 | return true 17 | } 18 | return false 19 | } 20 | -------------------------------------------------------------------------------- /internal/gitstrap/apply.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "fmt" 5 | 6 | gh "github.com/g4s8/gitstrap/internal/github" 7 | "github.com/g4s8/gitstrap/internal/spec" 8 | "github.com/google/go-github/v38/github" 9 | ) 10 | 11 | // Apply specification 12 | func (g *Gitstrap) Apply(m *spec.Model) error { 13 | switch m.Kind { 14 | case spec.KindRepo: 15 | return g.applyRepo(m) 16 | case spec.KindHook: 17 | return g.applyHook(m) 18 | case spec.KindOrg: 19 | return g.applyOrg(m) 20 | case spec.KindTeam: 21 | return g.applyTeam(m) 22 | case spec.KindProtection: 23 | return g.applyProtection(m) 24 | default: 25 | return fmt.Errorf("Unsupported yet %s", m.Kind) 26 | } 27 | } 28 | 29 | func (g *Gitstrap) applyRepo(m *spec.Model) error { 30 | ctx, cancel := g.newContext() 31 | defer cancel() 32 | repo := new(spec.Repo) 33 | if err := m.GetSpec(repo); err != nil { 34 | return err 35 | } 36 | meta := m.Metadata 37 | owner := g.getOwner(m) 38 | name := meta.Name 39 | exist, err := gh.RepoExist(g.gh, ctx, owner, name) 40 | if err != nil { 41 | return err 42 | } 43 | if !exist { 44 | return g.createRepo(m) 45 | } 46 | gr := new(github.Repository) 47 | if err := repo.ToGithub(gr); err != nil { 48 | return err 49 | } 50 | gr.ID = meta.ID 51 | gr, _, err = g.gh.Repositories.Edit(ctx, owner, name, gr) 52 | if err != nil { 53 | return err 54 | } 55 | repo.FromGithub(gr) 56 | m.Spec = repo 57 | m.Metadata.FromGithubRepo(gr) 58 | m.Metadata.Owner = owner 59 | return nil 60 | } 61 | 62 | func (g *Gitstrap) applyHook(m *spec.Model) error { 63 | ctx, cancel := g.newContext() 64 | defer cancel() 65 | owner := g.getOwner(m) 66 | hook := new(spec.Hook) 67 | if err := m.GetSpec(hook); err != nil { 68 | return err 69 | } 70 | if m.Metadata.ID == nil { 71 | return g.createHook(m) 72 | } 73 | ghook := new(github.Hook) 74 | if err := hook.ToGithub(ghook); err != nil { 75 | return err 76 | } 77 | ghook.ID = m.Metadata.ID 78 | var err error 79 | if hook.Selector.Repository != "" { 80 | ghook, _, err = g.gh.Repositories.EditHook(ctx, owner, hook.Selector.Repository, *m.Metadata.ID, ghook) 81 | } else if hook.Selector.Organization != "" { 82 | ghook, _, err = g.gh.Organizations.EditHook(ctx, hook.Selector.Organization, *m.Metadata.ID, ghook) 83 | } else { 84 | err = errHookSelectorEmpty 85 | } 86 | if err != nil { 87 | return err 88 | } 89 | if err := hook.FromGithub(ghook); err != nil { 90 | return err 91 | } 92 | m.Spec = hook 93 | return nil 94 | } 95 | 96 | func (g *Gitstrap) applyOrg(m *spec.Model) error { 97 | ctx, cancel := g.newContext() 98 | defer cancel() 99 | o := new(spec.Org) 100 | if err := m.GetSpec(o); err != nil { 101 | return err 102 | } 103 | meta := m.Metadata 104 | name := meta.Name 105 | exist, err := gh.OrgExist(g.gh, ctx, name) 106 | if err != nil { 107 | return err 108 | } 109 | if !exist { 110 | return fmt.Errorf("organization %v does not exist.", name) 111 | } 112 | org := new(github.Organization) 113 | if err := o.ToGithub(org); err != nil { 114 | return err 115 | } 116 | org.ID = meta.ID 117 | org, _, err = g.gh.Organizations.Edit(ctx, name, org) 118 | if err != nil { 119 | return err 120 | } 121 | o.FromGithub(org) 122 | m.Spec = o 123 | m.Metadata.FromGithubOrg(org) 124 | return nil 125 | } 126 | 127 | func (g *Gitstrap) applyTeam(m *spec.Model) error { 128 | if m.Metadata.Owner == "" { 129 | return &errNotSpecified{"Owner"} 130 | } 131 | if m.Metadata.Name != "" { 132 | return g.editTeamBySlug(m) 133 | } 134 | if m.Metadata.ID != nil { 135 | return g.editTeamByID(m) 136 | } 137 | return g.createTeam(m) 138 | } 139 | 140 | func (g *Gitstrap) editTeamByID(m *spec.Model) error { 141 | ctx, cancel := g.newContext() 142 | defer cancel() 143 | ID := m.Metadata.ID 144 | owner := m.Metadata.Owner 145 | ownerID, err := gh.GetOrgIdByName(g.gh, ctx, owner) 146 | if err != nil { 147 | return err 148 | } 149 | exist, err := gh.TeamExistByID(g.gh, ctx, ownerID, *ID) 150 | if err != nil { 151 | return err 152 | } 153 | if !exist { 154 | return g.createTeam(m) 155 | } 156 | t := new(spec.Team) 157 | if err := m.GetSpec(t); err != nil { 158 | return err 159 | } 160 | gTeam := new(github.NewTeam) 161 | if err := t.ToGithub(gTeam); err != nil { 162 | return err 163 | } 164 | gT, _, err := g.gh.Teams.EditTeamByID(ctx, ownerID, *ID, *gTeam, false) 165 | if err != nil { 166 | return err 167 | } 168 | if err = t.FromGithub(gT); err != nil { 169 | return err 170 | } 171 | m.Spec = t 172 | m.Metadata.FromGithubTeam(gT) 173 | return nil 174 | } 175 | 176 | func (g *Gitstrap) editTeamBySlug(m *spec.Model) error { 177 | ctx, cancel := g.newContext() 178 | defer cancel() 179 | owner := m.Metadata.Owner 180 | slug := m.Metadata.Name 181 | exist, err := gh.TeamExistBySlug(g.gh, ctx, owner, slug) 182 | if err != nil { 183 | return err 184 | } 185 | if !exist { 186 | return g.createTeam(m) 187 | } 188 | t := new(spec.Team) 189 | if err := m.GetSpec(t); err != nil { 190 | return err 191 | } 192 | gTeam := new(github.NewTeam) 193 | if err := t.ToGithub(gTeam); err != nil { 194 | return err 195 | } 196 | gT, _, err := g.gh.Teams.EditTeamBySlug(ctx, owner, slug, *gTeam, false) 197 | if err != nil { 198 | return err 199 | } 200 | if err = t.FromGithub(gT); err != nil { 201 | return err 202 | } 203 | m.Spec = t 204 | m.Metadata.FromGithubTeam(gT) 205 | return nil 206 | } 207 | 208 | func (g *Gitstrap) applyProtection(m *spec.Model) error { 209 | ctx, cancel := g.newContext() 210 | defer cancel() 211 | owner := g.getOwner(m) 212 | name, err := getSpecifiedName(m) 213 | if err != nil { 214 | return fmt.Errorf("protection name is required: %w", err) 215 | } 216 | repo, err := getSpecifiedRepo(m) 217 | if err != nil { 218 | return fmt.Errorf("protection repo is required: %w", err) 219 | } 220 | bp := new(spec.Protection) 221 | if err := m.GetSpec(bp); err != nil { 222 | return err 223 | } 224 | gPreq := new(github.ProtectionRequest) 225 | if err := bp.ToGithub(gPreq); err != nil { 226 | return err 227 | } 228 | gProt, _, err := g.gh.Repositories.UpdateBranchProtection(ctx, owner, repo, name, gPreq) 229 | if err != nil { 230 | return fmt.Errorf("failed to apply protection %v/%v: %w", repo, name, err) 231 | } 232 | if err = bp.FromGithub(gProt); err != nil { 233 | return err 234 | } 235 | m.Spec = bp 236 | m.Metadata.Name = name 237 | m.Metadata.Owner = owner 238 | m.Metadata.Repo = repo 239 | return nil 240 | } 241 | -------------------------------------------------------------------------------- /internal/gitstrap/create.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/g4s8/gitstrap/internal/spec" 7 | "github.com/google/go-github/v38/github" 8 | ) 9 | 10 | func (g *Gitstrap) Create(m *spec.Model) error { 11 | switch m.Kind { 12 | case spec.KindRepo: 13 | return g.createRepo(m) 14 | case spec.KindReadme: 15 | return g.createReadme(m) 16 | case spec.KindHook: 17 | return g.createHook(m) 18 | case spec.KindTeam: 19 | return g.createTeam(m) 20 | default: 21 | return &errUnsupportModelKind{m.Kind} 22 | } 23 | } 24 | 25 | func (g *Gitstrap) createRepo(m *spec.Model) error { 26 | ctx, cancel := g.newContext() 27 | defer cancel() 28 | meta := m.Metadata 29 | repo := new(spec.Repo) 30 | if err := m.GetSpec(repo); err != nil { 31 | return err 32 | } 33 | if repo.DefaultBranch == "" { 34 | repo.DefaultBranch = "master" 35 | } 36 | owner := g.getOwner(m) 37 | fn := fmt.Sprintf("%s/%s", owner, meta.Name) 38 | grepo := new(github.Repository) 39 | if err := repo.ToGithub(grepo); err != nil { 40 | return err 41 | } 42 | grepo.Name = &meta.Name 43 | grepo.FullName = &fn 44 | org := g.resolveOrg(m) 45 | r, _, err := g.gh.Repositories.Create(ctx, org, grepo) 46 | if err != nil { 47 | return err 48 | } 49 | m.Metadata.FromGithubRepo(r) 50 | repo.FromGithub(r) 51 | m.Spec = repo 52 | return nil 53 | } 54 | 55 | func (g *Gitstrap) createReadme(m *spec.Model) error { 56 | ctx, cancel := g.newContext() 57 | defer cancel() 58 | meta := m.Metadata 59 | spec := new(spec.Readme) 60 | if err := m.GetSpec(spec); err != nil { 61 | return err 62 | } 63 | owner := m.Metadata.Owner 64 | if owner == "" { 65 | owner = g.me 66 | } 67 | repo, _, err := g.gh.Repositories.Get(ctx, owner, spec.Selector.Repository) 68 | if err != nil { 69 | return err 70 | } 71 | msg := "Updated README.md" 72 | if cm, ok := meta.Annotations["commitMessage"]; ok { 73 | msg = cm 74 | } 75 | opts := &github.RepositoryContentFileOptions{ 76 | Content: []byte(spec.String()), 77 | Message: &msg, 78 | } 79 | if meta.Annotations["force"] == "true" { 80 | getopts := &github.RepositoryContentGetOptions{} 81 | cnt, _, rsp, err := g.gh.Repositories.GetContents(ctx, owner, repo.GetName(), "README.md", getopts) 82 | if rsp.StatusCode == 404 { 83 | goto SKIP_GET 84 | } 85 | if err != nil { 86 | return err 87 | } 88 | if *cnt.Type != "file" { 89 | return &errReadmeNotFile{*cnt.Type} 90 | } 91 | opts.SHA = cnt.SHA 92 | SKIP_GET: 93 | } 94 | _, rsp, err := g.gh.Repositories.UpdateFile(ctx, owner, repo.GetName(), "README.md", opts) 95 | if err != nil { 96 | if rsp.StatusCode == 422 && opts.SHA == nil { 97 | return &errReadmeExists{owner, repo.GetName()} 98 | } 99 | return err 100 | } 101 | return nil 102 | } 103 | 104 | func (g *Gitstrap) createHook(m *spec.Model) error { 105 | ctx, cancel := g.newContext() 106 | defer cancel() 107 | owner := g.getOwner(m) 108 | hook := new(spec.Hook) 109 | if err := m.GetSpec(hook); err != nil { 110 | return err 111 | } 112 | ghook := new(github.Hook) 113 | if err := hook.ToGithub(ghook); err != nil { 114 | return err 115 | } 116 | var err error 117 | if hook.Selector.Repository != "" { 118 | ghook, _, err = g.gh.Repositories.CreateHook(ctx, owner, hook.Selector.Repository, ghook) 119 | } else if hook.Selector.Organization != "" { 120 | ghook, _, err = g.gh.Organizations.CreateHook(ctx, hook.Selector.Organization, ghook) 121 | } else { 122 | err = errHookSelectorEmpty 123 | } 124 | if err != nil { 125 | return err 126 | } 127 | if err := hook.FromGithub(ghook); err != nil { 128 | return err 129 | } 130 | m.Spec = hook 131 | return nil 132 | } 133 | 134 | func (g *Gitstrap) createTeam(m *spec.Model) error { 135 | ctx, cancel := g.newContext() 136 | defer cancel() 137 | owner, err := getSpecifiedOwner(m) 138 | if err != nil { 139 | return err 140 | } 141 | team := new(spec.Team) 142 | if err := m.GetSpec(team); err != nil { 143 | return err 144 | } 145 | gteam := new(github.NewTeam) 146 | if err := team.ToGithub(gteam); err != nil { 147 | return err 148 | } 149 | t, _, err := g.gh.Teams.CreateTeam(ctx, owner, *gteam) 150 | if err != nil { 151 | return err 152 | } 153 | m.Metadata.FromGithubTeam(t) 154 | if err := team.FromGithub(t); err != nil { 155 | return err 156 | } 157 | m.Spec = team 158 | return nil 159 | } 160 | -------------------------------------------------------------------------------- /internal/gitstrap/debug_transport.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | type logTransport struct { 11 | origin http.RoundTripper 12 | tag string 13 | } 14 | 15 | func (t *logTransport) RoundTrip(req *http.Request) (*http.Response, error) { 16 | log.Printf("[%s] >>> %s %s", t.tag, req.Method, req.URL) 17 | if req.Body != nil { 18 | defer req.Body.Close() 19 | if data, err := io.ReadAll(req.Body); err == nil { 20 | req.Body = io.NopCloser(bytes.NewBuffer(data)) 21 | log.Print(string(data)) 22 | } 23 | } 24 | rsp, err := t.origin.RoundTrip(req) 25 | if err != nil { 26 | log.Printf("[%s] %s ERR: %s", t.tag, req.URL, err) 27 | } else { 28 | log.Printf("[%s] %s <<< %d", t.tag, req.URL, rsp.StatusCode) 29 | if rsp.Body != nil { 30 | defer rsp.Body.Close() 31 | if data, err := io.ReadAll(rsp.Body); err == nil { 32 | rsp.Body = io.NopCloser(bytes.NewBuffer(data)) 33 | log.Print(string(data)) 34 | } 35 | } 36 | } 37 | return rsp, err 38 | } 39 | -------------------------------------------------------------------------------- /internal/gitstrap/delete.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "fmt" 5 | 6 | gh "github.com/g4s8/gitstrap/internal/github" 7 | "github.com/g4s8/gitstrap/internal/spec" 8 | "github.com/google/go-github/v38/github" 9 | ) 10 | 11 | func (g *Gitstrap) Delete(m *spec.Model) error { 12 | switch m.Kind { 13 | case spec.KindRepo: 14 | return g.deleteRepo(m) 15 | case spec.KindReadme: 16 | return g.deleteReadme(m) 17 | case spec.KindHook: 18 | return g.deleteHook(m) 19 | case spec.KindTeam: 20 | return g.deleteTeam(m) 21 | case spec.KindProtection: 22 | return g.deleteProtection(m) 23 | default: 24 | return &errUnsupportModelKind{m.Kind} 25 | } 26 | } 27 | 28 | func (g *Gitstrap) deleteRepo(m *spec.Model) error { 29 | ctx, cancel := g.newContext() 30 | defer cancel() 31 | meta := m.Metadata 32 | owner := meta.Owner 33 | if owner == "" { 34 | owner = g.me 35 | } 36 | if _, err := g.gh.Repositories.Delete(ctx, owner, meta.Name); err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | func (g *Gitstrap) deleteReadme(m *spec.Model) error { 43 | ctx, cancel := g.newContext() 44 | defer cancel() 45 | spec := new(spec.Readme) 46 | meta := m.Metadata 47 | if err := m.GetSpec(spec); err != nil { 48 | return err 49 | } 50 | owner := m.Metadata.Owner 51 | if owner == "" { 52 | owner = g.me 53 | } 54 | repo, _, err := g.gh.Repositories.Get(ctx, owner, spec.Selector.Repository) 55 | if err != nil { 56 | return err 57 | } 58 | msg := "README.md removed" 59 | if cm, ok := meta.Annotations["commitMessage"]; ok { 60 | msg = cm 61 | } 62 | opts := &github.RepositoryContentFileOptions{ 63 | Message: &msg, 64 | } 65 | getopts := new(github.RepositoryContentGetOptions) 66 | cnt, _, rsp, err := g.gh.Repositories.GetContents(ctx, owner, repo.GetName(), "README.md", getopts) 67 | if rsp.StatusCode == 404 { 68 | return &errReadmeNotExists{owner, repo.GetName()} 69 | } 70 | if err != nil { 71 | return err 72 | } 73 | if *cnt.Type != "file" { 74 | return &errReadmeNotFile{*cnt.Type} 75 | } 76 | opts.SHA = cnt.SHA 77 | if _, _, err := g.gh.Repositories.DeleteFile(ctx, owner, repo.GetName(), "README.md", opts); err != nil { 78 | return err 79 | } 80 | return nil 81 | } 82 | 83 | func (g *Gitstrap) deleteHook(m *spec.Model) error { 84 | ctx, cancel := g.newContext() 85 | defer cancel() 86 | hook := new(spec.Hook) 87 | if err := m.GetSpec(hook); err != nil { 88 | return err 89 | } 90 | owner := g.getOwner(m) 91 | if m.Metadata.ID == nil { 92 | return errHookIdRequired 93 | } 94 | id := *m.Metadata.ID 95 | if hook.Selector.Repository != "" { 96 | _, err := g.gh.Repositories.DeleteHook(ctx, owner, hook.Selector.Repository, id) 97 | return err 98 | } else if hook.Selector.Organization != "" { 99 | _, err := g.gh.Organizations.DeleteHook(ctx, hook.Selector.Organization, id) 100 | return err 101 | } else { 102 | return errHookSelectorEmpty 103 | } 104 | } 105 | 106 | func (g *Gitstrap) deleteTeam(m *spec.Model) error { 107 | ctx, cancel := g.newContext() 108 | defer cancel() 109 | owner, err := getSpecifiedOwner(m) 110 | if err != nil { 111 | return err 112 | } 113 | slug, err := getSpecifiedName(m) 114 | if err != nil { 115 | goto deleteByID 116 | } 117 | if _, err := g.gh.Teams.DeleteTeamBySlug(ctx, owner, slug); err != nil { 118 | return err 119 | } 120 | return nil 121 | deleteByID: 122 | ID, err := getSpecifiedID(m) 123 | if err != nil { 124 | return &errNotSpecified{"Name and ID"} 125 | } 126 | ownerID, err := gh.GetOrgIdByName(g.gh, ctx, owner) 127 | if err != nil { 128 | return err 129 | } 130 | if _, err := g.gh.Teams.DeleteTeamByID(ctx, ownerID, *ID); err != nil { 131 | return err 132 | } 133 | return nil 134 | } 135 | 136 | func (g *Gitstrap) deleteProtection(m *spec.Model) error { 137 | ctx, cancel := g.newContext() 138 | defer cancel() 139 | owner, err := getSpecifiedOwner(m) 140 | if err != nil { 141 | return fmt.Errorf("protection owner is required: %w", err) 142 | } 143 | repo, err := getSpecifiedRepo(m) 144 | if err != nil { 145 | return fmt.Errorf("protection repo is required: %w", err) 146 | } 147 | name, err := getSpecifiedName(m) 148 | if err != nil { 149 | return fmt.Errorf("protection name is required: %w", err) 150 | } 151 | _, err = g.gh.Repositories.RemoveBranchProtection(ctx, owner, repo, name) 152 | if err != nil { 153 | return fmt.Errorf("failed to delete protection %v/%v: %w", repo, name, err) 154 | } 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /internal/gitstrap/errors.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/g4s8/gitstrap/internal/spec" 8 | ) 9 | 10 | var ( 11 | errHookSelectorEmpty = errors.New("hook selector is empty: requires repository or organization") 12 | errHookIdRequired = errors.New("Hook metadata ID required") 13 | ) 14 | 15 | type errReadmeNotExists struct { 16 | owner, repo string 17 | } 18 | 19 | func (e *errReadmeNotExists) Error() string { 20 | return fmt.Sprintf("README.md `%s/%s` doesn't exist", e.owner, e.repo) 21 | } 22 | 23 | type errReadmeExists struct { 24 | owner, repo string 25 | } 26 | 27 | func (e *errReadmeExists) Error() string { 28 | return fmt.Sprintf("README.md already exists in %s/%s (try --force for replacing it)", e.owner, e.repo) 29 | } 30 | 31 | type errReadmeNotFile struct { 32 | rtype string 33 | } 34 | 35 | func (e *errReadmeNotFile) Error() string { 36 | return fmt.Sprintf("README is no a file: `%s`", e.rtype) 37 | } 38 | 39 | type errUnsupportModelKind struct { 40 | kind spec.Kind 41 | } 42 | 43 | func (e *errUnsupportModelKind) Error() string { 44 | return fmt.Sprintf("Unsupported model kind: `%s`", e.kind) 45 | } 46 | 47 | type errNotSpecified struct { 48 | field string 49 | } 50 | 51 | func (e *errNotSpecified) Error() string { 52 | return fmt.Sprintf("%v is not specified", e.field) 53 | } 54 | -------------------------------------------------------------------------------- /internal/gitstrap/get.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/g4s8/gitstrap/internal/spec" 8 | "github.com/google/go-github/v38/github" 9 | ) 10 | 11 | // GetRepo repository resource 12 | func (g *Gitstrap) GetRepo(name string, owner string) (*spec.Model, error) { 13 | ctx, cancel := g.newContext() 14 | if owner == "" { 15 | owner = g.me 16 | } 17 | defer cancel() 18 | r, _, err := g.gh.Repositories.Get(ctx, owner, name) 19 | if err != nil { 20 | return nil, err 21 | } 22 | model, err := spec.NewModel(spec.KindRepo) 23 | if err != nil { 24 | panic(err) 25 | } 26 | model.Metadata.FromGithubRepo(r) 27 | if owner != "" { 28 | model.Metadata.Owner = owner 29 | } else { 30 | model.Metadata.Owner = g.me 31 | } 32 | repo := new(spec.Repo) 33 | repo.FromGithub(r) 34 | model.Spec = repo 35 | return model, nil 36 | } 37 | 38 | func (g *Gitstrap) GetOrg(name string) (*spec.Model, error) { 39 | ctx, cancel := g.newContext() 40 | defer cancel() 41 | o, _, err := g.gh.Organizations.Get(ctx, name) 42 | if err != nil { 43 | return nil, err 44 | } 45 | model, err := spec.NewModel(spec.KindOrg) 46 | if err != nil { 47 | panic(err) 48 | } 49 | model.Metadata.FromGithubOrg(o) 50 | model.Metadata.Name = name 51 | model.Metadata.ID = o.ID 52 | org := new(spec.Org) 53 | org.FromGithub(o) 54 | model.Spec = org 55 | return model, nil 56 | } 57 | 58 | func (g *Gitstrap) GetHooks(owner, name string) (<-chan *spec.Model, <-chan error) { 59 | const ( 60 | hooksPageSize = 10 61 | ) 62 | ctx, cancel := g.newContext() 63 | out := make(chan *spec.Model, hooksPageSize) 64 | errs := make(chan error) 65 | if owner == "" { 66 | owner = g.me 67 | } 68 | go func() { 69 | defer close(out) 70 | defer close(errs) 71 | defer cancel() 72 | 73 | opts := &github.ListOptions{PerPage: hooksPageSize} 74 | pag := new(pagination) 75 | for pag.moveNext(opts) { 76 | var ( 77 | ghooks []*github.Hook 78 | rsp *github.Response 79 | err error 80 | ) 81 | if name != "" { 82 | ghooks, rsp, err = g.gh.Repositories.ListHooks(ctx, owner, name, opts) 83 | } else { 84 | ghooks, rsp, err = g.gh.Organizations.ListHooks(ctx, owner, opts) 85 | } 86 | pag.update(rsp) 87 | if err != nil { 88 | errs <- err 89 | return 90 | } 91 | for _, gh := range ghooks { 92 | s, err := spec.NewModel(spec.KindHook) 93 | if err != nil { 94 | panic(err) 95 | } 96 | s.Metadata.Owner = owner 97 | s.Metadata.ID = gh.ID 98 | hook := new(spec.Hook) 99 | if name != "" { 100 | hook.Selector.Repository = name 101 | } else { 102 | hook.Selector.Organization = owner 103 | } 104 | if err := hook.FromGithub(gh); err != nil { 105 | errs <- err 106 | return 107 | } 108 | s.Spec = hook 109 | out <- s 110 | } 111 | } 112 | 113 | }() 114 | return out, errs 115 | } 116 | 117 | // GetTeams fetches organization teams definitions into spec channel 118 | func (g *Gitstrap) GetTeams(org string) (<-chan *spec.Model, <-chan error) { 119 | const ( 120 | perPage = 10 121 | ) 122 | res := make(chan *spec.Model, perPage) 123 | errs := make(chan error) 124 | ctx, cancel := g.newContext() 125 | go func() { 126 | defer close(res) 127 | defer close(errs) 128 | defer cancel() 129 | opts := &github.ListOptions{PerPage: perPage} 130 | pag := new(pagination) 131 | for pag.moveNext(opts) { 132 | batch, rsp, err := g.gh.Teams.ListTeams(ctx, org, opts) 133 | pag.update(rsp) 134 | if err != nil { 135 | errs <- err 136 | return 137 | } 138 | for _, next := range batch { 139 | team := new(spec.Team) 140 | if err := team.FromGithub(next); err != nil { 141 | errs <- err 142 | return 143 | } 144 | model, err := spec.NewModel(spec.KindTeam) 145 | if err != nil { 146 | panic(err) 147 | } 148 | model.Metadata.ID = next.ID 149 | model.Metadata.Owner = org 150 | model.Metadata.Name = next.GetSlug() 151 | if next.Parent != nil { 152 | model.Metadata.Annotations["team/parent.id"] = strconv.FormatInt(next.Parent.GetID(), 10) 153 | model.Metadata.Annotations["team/parent.slug"] = next.Parent.GetSlug() 154 | } 155 | model.Spec = team 156 | res <- model 157 | } 158 | } 159 | }() 160 | return res, errs 161 | } 162 | 163 | func (g *Gitstrap) GetProtection(owner, repo, branch string) (*spec.Model, error) { 164 | if owner == "" { 165 | owner = g.me 166 | } 167 | ctx, cancel := g.newContext() 168 | defer cancel() 169 | res, _, err := g.gh.Repositories.GetBranchProtection(ctx, owner, repo, branch) 170 | if err != nil { 171 | return nil, fmt.Errorf("failed to fetch protection rule: %w", err) 172 | } 173 | bp := new(spec.Protection) 174 | if err := bp.FromGithub(res); err != nil { 175 | return nil, fmt.Errorf("failed to parse protection spec: %w", err) 176 | } 177 | m, err := spec.NewModel(spec.KindProtection) 178 | if err != nil { 179 | panic(err) 180 | } 181 | m.Metadata.Name = branch 182 | m.Metadata.Repo = repo 183 | m.Metadata.Owner = owner 184 | m.Spec = bp 185 | return m, nil 186 | } 187 | -------------------------------------------------------------------------------- /internal/gitstrap/gitstrap.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "context" 5 | "github.com/google/go-github/v38/github" 6 | "golang.org/x/oauth2" 7 | "log" 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | // Gitstrap - main context 13 | type Gitstrap struct { 14 | ctx context.Context 15 | gh *github.Client 16 | debug bool 17 | me string 18 | } 19 | 20 | // New gitstrap context 21 | func New(ctx context.Context, token string, debug bool) (*Gitstrap, error) { 22 | g := new(Gitstrap) 23 | g.debug = debug 24 | g.ctx = ctx 25 | if debug { 26 | // print first chars of token if debug 27 | log.Printf("Debug mode enabled: token='%s***%s'", token[:3], token[len(token)-2:]) 28 | } 29 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 30 | tc := oauth2.NewClient(ctx, ts) 31 | if debug { 32 | // attach logging HTTP transport on debug 33 | tr := new(logTransport) 34 | tr.tag = "GH" 35 | if tc.Transport != nil { 36 | tr.origin = tc.Transport 37 | } else { 38 | tr.origin = http.DefaultTransport 39 | } 40 | tc.Transport = tr 41 | } 42 | g.gh = github.NewClient(tc) 43 | me, _, err := g.gh.Users.Get(g.ctx, "") 44 | if err != nil { 45 | return nil, err 46 | } 47 | g.me = me.GetLogin() 48 | return g, nil 49 | } 50 | 51 | func (g *Gitstrap) newContext() (context.Context, context.CancelFunc) { 52 | return context.WithTimeout(g.ctx, 25*time.Second) 53 | } 54 | -------------------------------------------------------------------------------- /internal/gitstrap/list.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "math" 7 | "strconv" 8 | 9 | "github.com/google/go-github/v38/github" 10 | ) 11 | 12 | type RepoInfo struct { 13 | name string 14 | public bool 15 | fork bool 16 | stars int 17 | forks int 18 | } 19 | 20 | func (r *RepoInfo) isFork() (s string) { 21 | if r.fork { 22 | s = "fork" 23 | } 24 | return 25 | } 26 | 27 | func (r *RepoInfo) visibility() (s string) { 28 | if r.public { 29 | s = "public" 30 | } else { 31 | s = "private" 32 | } 33 | return 34 | } 35 | 36 | func (r *RepoInfo) starsStr() string { 37 | if r.stars < 1000 { 38 | return strconv.Itoa(r.stars) 39 | } 40 | val := float64(r.stars) / 1000 41 | if val < 10 { 42 | return fmt.Sprintf("%.1fK", val) 43 | } 44 | return fmt.Sprintf("%dK", int(math.Floor(val))) 45 | } 46 | 47 | func (r *RepoInfo) forksStr() string { 48 | if r.forks < 1000 { 49 | return strconv.Itoa(r.stars) 50 | } 51 | val := float64(r.forks) / 1000 52 | if val < 10 { 53 | return fmt.Sprintf("%.1fK", val) 54 | } 55 | return fmt.Sprintf("%dK", int(math.Floor(val))) 56 | } 57 | 58 | func (r *RepoInfo) WriteTo(w io.Writer) (int64, error) { 59 | n, err := fmt.Fprintf(w, "| %40s | %4s | %7s | %8s ★ | %8s ⎇ |", 60 | r.name, r.isFork(), r.visibility(), 61 | r.starsStr(), r.forksStr()) 62 | return int64(n), err 63 | } 64 | 65 | // ListRepos lists repositories 66 | func (g *Gitstrap) ListRepos(filter ListFilter, owner string, errs chan<- error) (<-chan *RepoInfo) { 67 | if filter == nil { 68 | filter = LfNop 69 | } 70 | const ( 71 | pageSize = 10 72 | ) 73 | res := make(chan *RepoInfo, pageSize) 74 | ctx, cancel := g.newContext() 75 | go func() { 76 | defer close(res) 77 | defer cancel() 78 | opts := &github.RepositoryListOptions{ 79 | Visibility: "all", 80 | } 81 | pag := new(pagination) 82 | opts.PerPage = pageSize 83 | for pag.moveNext(&opts.ListOptions) { 84 | list, rsp, err := g.gh.Repositories.List(ctx, owner, opts) 85 | if err != nil { 86 | errs <- err 87 | return 88 | } 89 | pag.update(rsp) 90 | for _, item := range list { 91 | entry := new(RepoInfo) 92 | entry.name = item.GetFullName() 93 | entry.public = !item.GetPrivate() 94 | entry.fork = item.GetFork() 95 | entry.stars = item.GetStargazersCount() 96 | entry.forks = item.GetForksCount() 97 | if filter.check(entry) { 98 | res <- entry 99 | } 100 | } 101 | } 102 | 103 | }() 104 | return res 105 | } 106 | -------------------------------------------------------------------------------- /internal/gitstrap/list_filters.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | // ListFilter for list results 4 | type ListFilter interface { 5 | check(*RepoInfo) bool 6 | } 7 | 8 | // LfNop - list filter does nothing 9 | var LfNop ListFilter = &lfNop{} 10 | 11 | type lfNop struct{} 12 | 13 | func (f *lfNop) check(r *RepoInfo) bool { 14 | return true 15 | } 16 | 17 | // LfForks - list filter by fork criteria 18 | func LfForks(origin ListFilter, fork bool) ListFilter { 19 | return &lfFork{origin, fork} 20 | } 21 | 22 | type lfFork struct { 23 | origin ListFilter 24 | fork bool 25 | } 26 | 27 | func (f *lfFork) check(r *RepoInfo) bool { 28 | return f.origin.check(r) && r.fork == f.fork 29 | } 30 | 31 | // LfStarsCriteria - criteria of repository stars for filtering 32 | type LfStarsCriteria func(int) bool 33 | 34 | // LfStarsGt - list filter stars criteria: greater than `val` 35 | func LfStarsGt(val int) LfStarsCriteria { 36 | return func(x int) bool { 37 | return x > val 38 | } 39 | } 40 | 41 | // LfStarsLt - list filter stars criteria: less than `val` 42 | func LfStarsLt(val int) LfStarsCriteria { 43 | return func(x int) bool { 44 | return x < val 45 | } 46 | } 47 | 48 | // LfStars - list filter by stars count 49 | func LfStars(origin ListFilter, criteria LfStarsCriteria) ListFilter { 50 | return &lfStars{origin, criteria} 51 | } 52 | 53 | type lfStars struct { 54 | origin ListFilter 55 | criteria LfStarsCriteria 56 | } 57 | 58 | func (f *lfStars) check(i *RepoInfo) bool { 59 | return f.origin.check(i) && f.criteria(i.stars) 60 | } 61 | -------------------------------------------------------------------------------- /internal/gitstrap/pagination.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "github.com/google/go-github/v38/github" 5 | ) 6 | 7 | type pagination struct { 8 | next, last int 9 | } 10 | 11 | func (p *pagination) update(rsp *github.Response) { 12 | p.next = rsp.NextPage 13 | p.last = rsp.LastPage 14 | } 15 | 16 | func (p *pagination) moveNext(opts *github.ListOptions) bool { 17 | if opts.Page == 0 && p.next == 0 { 18 | // initial case 19 | opts.Page = 1 20 | return true 21 | } 22 | if p.last == 0 { 23 | // final case 24 | return false 25 | } 26 | opts.Page = p.next 27 | return true 28 | } 29 | -------------------------------------------------------------------------------- /internal/gitstrap/pagination_test.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "testing" 5 | "github.com/google/go-github/v38/github" 6 | m "github.com/g4s8/go-matchers" 7 | ) 8 | 9 | func TestPagination(t *testing.T) { 10 | assert := m.Assert(t) 11 | pag := new(pagination) 12 | opts := new(github.ListOptions) 13 | assert.That("Moved to first page", pag.moveNext(opts), m.Is(true)) 14 | assert.That("First move updates options", opts.Page, m.Is(1)) 15 | rsp := &github.Response{ 16 | NextPage: 1, 17 | LastPage: 2, 18 | } 19 | pag.update(rsp) 20 | assert.That("Moved to next page", pag.moveNext(opts), m.Is(true)) 21 | assert.That("Next page updates options", opts.Page, m.Is(1)) 22 | rsp.NextPage = 2 23 | pag.update(rsp) 24 | assert.That("Moved to last page", pag.moveNext(opts), m.Is(true)) 25 | rsp.NextPage = 0 26 | rsp.LastPage = 0 27 | pag.update(rsp) 28 | assert.That("Last page updates options", opts.Page, m.Is(2)) 29 | assert.That("Can't move after last page", pag.moveNext(opts), m.Is(false)) 30 | } 31 | -------------------------------------------------------------------------------- /internal/gitstrap/utils.go: -------------------------------------------------------------------------------- 1 | package gitstrap 2 | 3 | import ( 4 | "github.com/g4s8/gitstrap/internal/spec" 5 | ) 6 | 7 | func (g *Gitstrap) getOwner(m *spec.Model) string { 8 | owner := m.Metadata.Owner 9 | if owner == "" { 10 | owner = g.me 11 | } 12 | return owner 13 | } 14 | 15 | // resolveOrg determines whether the owner is an organization. 16 | // If the owner is the same as the authorized user, it returns an empty string. 17 | // Otherwise it returns owner, because githab only allows repositories 18 | // to be created in a personal account or in an organization the user is a member of. 19 | func (g *Gitstrap) resolveOrg(m *spec.Model) string { 20 | if m.Metadata.Owner == g.me { 21 | return "" 22 | } 23 | return m.Metadata.Owner 24 | } 25 | 26 | func getSpecifiedOwner(m *spec.Model) (string, error) { 27 | owner := m.Metadata.Owner 28 | if owner == "" { 29 | return "", &errNotSpecified{"Owner"} 30 | } 31 | return owner, nil 32 | } 33 | 34 | func getSpecifiedName(m *spec.Model) (string, error) { 35 | name := m.Metadata.Name 36 | if name == "" { 37 | return "", &errNotSpecified{"Name"} 38 | } 39 | return name, nil 40 | } 41 | 42 | func getSpecifiedID(m *spec.Model) (*int64, error) { 43 | ID := m.Metadata.ID 44 | if ID == nil { 45 | return nil, &errNotSpecified{"ID"} 46 | } 47 | return ID, nil 48 | } 49 | 50 | func getSpecifiedRepo(m *spec.Model) (string, error) { 51 | repo := m.Metadata.Repo 52 | if repo == "" { 53 | return "", &errNotSpecified{"Repo"} 54 | } 55 | return repo, nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/spec/hook.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/google/go-github/v38/github" 7 | ) 8 | 9 | type Hook struct { 10 | URL string `yaml:"url" default:"http://example.com/hook"` 11 | ContentType string `yaml:"contentType" default:"json"` 12 | InsecureSsl bool `yaml:"insecureSsl,omitempty" default:"false"` 13 | Secret string `yaml:"secret,omitempty"` 14 | Events []string `yaml:"events,omitempty" default:"[\"push\"]"` 15 | Active bool `yaml:"active" default:"true"` 16 | Selector struct { 17 | Repository string `yaml:"repository,omitempty"` 18 | Organization string `yaml:"organization,omitempty"` 19 | } `yaml:"selector"` 20 | } 21 | 22 | const ( 23 | hookCfgUrl = "url" 24 | hookCfgContentType = "content_type" 25 | hookCfgInsecureSSL = "insecure_ssl" 26 | ) 27 | 28 | func (h *Hook) FromGithub(g *github.Hook) error { 29 | if url, has := g.Config[hookCfgUrl]; has { 30 | h.URL = url.(string) 31 | } 32 | if ct, has := g.Config[hookCfgContentType]; has { 33 | h.ContentType = ct.(string) 34 | } 35 | if issl, has := g.Config[hookCfgInsecureSSL]; has { 36 | var err error 37 | h.InsecureSsl, err = strconv.ParseBool(issl.(string)) 38 | if err != nil { 39 | return err 40 | } 41 | } 42 | h.Events = g.Events 43 | h.Active = g.GetActive() 44 | return nil 45 | } 46 | 47 | func (h *Hook) ToGithub(g *github.Hook) error { 48 | g.Config = make(map[string]interface{}) 49 | g.Config[hookCfgUrl] = h.URL 50 | g.Config[hookCfgContentType] = h.ContentType 51 | g.Config[hookCfgInsecureSSL] = strconv.FormatBool(h.InsecureSsl) 52 | g.Events = h.Events 53 | g.Active = &h.Active 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /internal/spec/metadata.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/google/go-github/v38/github" 8 | ) 9 | 10 | // Metadata for spec 11 | type Metadata struct { 12 | Name string `yaml:"name,omitempty"` 13 | Repo string `yaml:"repo,omitempty"` 14 | Owner string `yaml:"owner,omitempty"` 15 | ID *int64 `yaml:"id,omitempty"` 16 | Annotations map[string]string `yaml:"annotations,omitempty"` 17 | } 18 | 19 | func (m *Metadata) FromGithubRepo(r *github.Repository) { 20 | m.ID = r.ID 21 | m.Name = r.GetName() 22 | } 23 | 24 | func (m *Metadata) FromGithubOrg(o *github.Organization) { 25 | m.ID = o.ID 26 | m.Name = o.GetName() 27 | } 28 | 29 | func (m *Metadata) FromGithubTeam(t *github.Team) { 30 | m.ID = t.ID 31 | m.Name = *t.Slug 32 | m.Owner = *t.Organization.Login 33 | } 34 | 35 | func (m *Metadata) Info() string { 36 | sb := new(strings.Builder) 37 | if m.ID != nil { 38 | fmt.Fprintf(sb, "ID=%d ", *m.ID) 39 | } 40 | if m.Owner != "" { 41 | fmt.Fprintf(sb, "%s/%s ", m.Owner, m.Name) 42 | } 43 | if m.Name != "" { 44 | fmt.Fprintf(sb, "%s", m.Name) 45 | } 46 | return sb.String() 47 | } 48 | -------------------------------------------------------------------------------- /internal/spec/model.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "strings" 8 | 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | // Model of spec 13 | type Model struct { 14 | Version string `yaml:"version"` 15 | Kind Kind `yaml:"kind"` 16 | Metadata *Metadata `yaml:"metadata,omitempty"` 17 | Spec interface{} `yaml:"-"` 18 | } 19 | 20 | const ( 21 | // Version of spec 22 | Version = "v2" 23 | ) 24 | 25 | // Kind of specification 26 | type Kind string 27 | 28 | func (k Kind) validate() error { 29 | for _, v := range [...]Kind{KindRepo, KindReadme, KindOrg, KindHook, KindTeam, KindProtection} { 30 | if k == v { 31 | return nil 32 | } 33 | } 34 | return &errUnknownKind{k} 35 | } 36 | 37 | // Require this kind to be another kind 38 | // panics with ErrInvalidKind error if doesn't. 39 | // Could be handlerd with ErrInvalidKind.RecoverHandler 40 | func (k Kind) Require(req Kind) { 41 | if k != req { 42 | panic(&ErrInvalidKind{Expected: req, Actual: k}) 43 | } 44 | } 45 | 46 | const ( 47 | // KindRepo - repository model kind 48 | KindRepo = Kind("Repository") 49 | // KindReadme - repository readme model kind 50 | KindReadme = Kind("Readme") 51 | // KindOrg - organization model kind 52 | KindOrg = Kind("Organization") 53 | // KindHook - repository webhook 54 | KindHook = Kind("WebHook") 55 | // KindTeam - organization team 56 | KindTeam = Kind("Team") 57 | // KindProtection - repository branch protection rule 58 | KindProtection = Kind("Protection") 59 | ) 60 | 61 | // NewModel with kind 62 | func NewModel(kind Kind) (*Model, error) { 63 | if err := kind.validate(); err != nil { 64 | return nil, err 65 | } 66 | meta := new(Metadata) 67 | meta.Annotations = make(map[string]string) 68 | return &Model{Version: Version, Kind: kind, Metadata: meta}, nil 69 | } 70 | 71 | type errUnknownKind struct { 72 | kind Kind 73 | } 74 | 75 | func (e *errUnknownKind) Error() string { 76 | return fmt.Sprintf("unknown spec kind: `%s`", e.kind) 77 | } 78 | 79 | // ErrInvalidKind - error that kind is not the value as expected 80 | type ErrInvalidKind struct { 81 | // Expected and Actual values of kind 82 | Expected, Actual Kind 83 | } 84 | 85 | func (e *ErrInvalidKind) Error() string { 86 | return fmt.Sprintf("Invalid model kind: expects `%s` but was `%s`", e.Expected, e.Actual) 87 | } 88 | 89 | // RecoverHandler could be used to catch this error on panic with defer 90 | func (e *ErrInvalidKind) RecoverHandler(out *error) { 91 | if rec := recover(); rec != nil { 92 | if err, ok := rec.(error); ok && errors.Is(err, e) { 93 | *out = err 94 | } else { 95 | panic(rec) 96 | } 97 | } 98 | } 99 | 100 | type errInvalidSpecType struct { 101 | spec interface{} 102 | } 103 | 104 | func (e *errInvalidSpecType) Error() string { 105 | return fmt.Sprintf("Invalid spec type `%T`", e.spec) 106 | } 107 | 108 | var errSpecIsNil = errors.New("Model spec is nil") 109 | 110 | // GetSpec extracts a spec from model 111 | func (m *Model) GetSpec(out interface{}) (re error) { 112 | if m.Spec == nil { 113 | return errSpecIsNil 114 | } 115 | errh := new(ErrInvalidKind) 116 | defer errh.RecoverHandler(&re) 117 | var ok bool 118 | switch s := out.(type) { 119 | case *Repo: 120 | m.Kind.Require(KindRepo) 121 | var t *Repo 122 | t, ok = m.Spec.(*Repo) 123 | *s = *t 124 | case *Readme: 125 | var t *Readme 126 | m.Kind.Require(KindReadme) 127 | t, ok = m.Spec.(*Readme) 128 | *s = *t 129 | case *Org: 130 | var t *Org 131 | m.Kind.Require(KindOrg) 132 | t, ok = m.Spec.(*Org) 133 | *s = *t 134 | case *Hook: 135 | var t *Hook 136 | m.Kind.Require(KindHook) 137 | t, ok = m.Spec.(*Hook) 138 | *s = *t 139 | case *Team: 140 | var t *Team 141 | m.Kind.Require(KindTeam) 142 | t, ok = m.Spec.(*Team) 143 | *s = *t 144 | case *Protection: 145 | m.Kind.Require(KindProtection) 146 | var t *Protection 147 | t, ok = m.Spec.(*Protection) 148 | *s = *t 149 | default: 150 | return &errInvalidSpecType{s} 151 | } 152 | if !ok { 153 | return &errInvalidSpecType{m.Spec} 154 | } 155 | return nil 156 | } 157 | 158 | func (m *Model) MarshalYAML() (interface{}, error) { 159 | type M Model 160 | type temp struct { 161 | *M `yaml:",inline"` 162 | Spec interface{} `yaml:"spec"` 163 | } 164 | t := &temp{(*M)(m), m.Spec} 165 | return t, nil 166 | } 167 | 168 | func (m *Model) UnmarshalYAML(value *yaml.Node) error { 169 | type M Model 170 | type temp struct { 171 | *M `yaml:",inline"` 172 | Spec yaml.Node `yaml:"spec"` 173 | } 174 | obj := &temp{M: (*M)(m)} 175 | if err := value.Decode(obj); err != nil { 176 | return err 177 | } 178 | switch m.Kind { 179 | case KindRepo: 180 | m.Spec = new(Repo) 181 | case KindReadme: 182 | m.Spec = new(Readme) 183 | case KindOrg: 184 | m.Spec = new(Org) 185 | case KindHook: 186 | m.Spec = new(Hook) 187 | case KindTeam: 188 | m.Spec = new(Team) 189 | case KindProtection: 190 | m.Spec = new(Protection) 191 | default: 192 | return &errUnknownKind{m.Kind} 193 | } 194 | return obj.Spec.Decode(m.Spec) 195 | } 196 | 197 | func (m *Model) Info() string { 198 | sb := new(strings.Builder) 199 | sb.WriteString(string(m.Kind)) 200 | sb.WriteString(": ") 201 | sb.WriteString(m.Metadata.Info()) 202 | return sb.String() 203 | } 204 | -------------------------------------------------------------------------------- /internal/spec/model_reader.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "gopkg.in/yaml.v3" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | var errReadNoDocument = errors.New("Not YAML document") 13 | 14 | // FromDecoder creates model from yaml decoder 15 | func (m *Model) FromDecoder(d *yaml.Decoder) error { 16 | type Doc struct { 17 | Model `yaml:"inline"` 18 | } 19 | doc := new(Doc) 20 | if err := d.Decode(&doc); err != nil { 21 | return err 22 | } 23 | if d == nil { 24 | return errReadNoDocument 25 | } 26 | *m = doc.Model 27 | if m.Metadata == nil { 28 | m.Metadata = new(Metadata) 29 | } 30 | return nil 31 | } 32 | 33 | // FromReader creates model from io reader 34 | func (m *Model) FromReader(r io.Reader) error { 35 | return m.FromDecoder(yaml.NewDecoder(r)) 36 | } 37 | 38 | // ReadFile models from file 39 | func ReadFile(name string) ([]*Model, error) { 40 | fn, err := filepath.Abs(name) 41 | if err != nil { 42 | return nil, err 43 | } 44 | f, err := os.Open(fn) 45 | if err != nil { 46 | return nil, err 47 | } 48 | defer f.Close() 49 | return ReadStream(bufio.NewReader(f)) 50 | } 51 | 52 | // ReadStream of models from reader 53 | func ReadStream(r io.Reader) ([]*Model, error) { 54 | dec := yaml.NewDecoder(r) 55 | res := make([]*Model, 0) 56 | for { 57 | model := new(Model) 58 | err := model.FromDecoder(dec) 59 | if err == nil { 60 | res = append(res, model) 61 | } else if errors.Is(err, errReadNoDocument) { 62 | continue 63 | } else if errors.Is(err, io.EOF) { 64 | break 65 | } else { 66 | return nil, err 67 | } 68 | } 69 | return res, nil 70 | } 71 | -------------------------------------------------------------------------------- /internal/spec/model_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | m "github.com/g4s8/go-matchers" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | func Test_unmarshall(t *testing.T) { 12 | assert := m.Assert(t) 13 | src := []byte(` 14 | version: "v2" 15 | kind: "Repository" 16 | metadata: 17 | name: gitstrap 18 | owner: g4s8 19 | annotations: 20 | foo: bar 21 | baz: 4 22 | spec: 23 | id: 1 24 | owner: "testing" 25 | description: "for test" 26 | `) 27 | model := new(Model) 28 | assert.That("Unmarshal model without errors", yaml.Unmarshal(src, &model), m.Nil()) 29 | assert.That("Model kind is OK", model.Kind, m.Eq(KindRepo)) 30 | assert.That("Model version is OK", model.Version, m.Eq(Version)) 31 | t.Run("Model metadata is OK", func(t *testing.T) { 32 | assert := m.Assert(t) 33 | assert.That("Name is OK", model.Metadata.Name, m.Eq("gitstrap")) 34 | assert.That("Owner is OK", model.Metadata.Owner, m.Eq("g4s8")) 35 | assert.That("Annotations[0] is OK", model.Metadata.Annotations["foo"], m.Eq("bar")) 36 | assert.That("Annotations[1] is OK", model.Metadata.Annotations["baz"], m.Eq("4")) 37 | }) 38 | t.Run("Model spec is correct", func(t *testing.T) { 39 | assert := m.Assert(t) 40 | repo, typeok := model.Spec.(*Repo) 41 | assert.That("Spec type is OK", typeok, m.Is(true)) 42 | assert.That("Repo description is OK", *repo.Description, m.Eq("for test")) 43 | }) 44 | } 45 | 46 | func Test_marshall(t *testing.T) { 47 | assert := m.Assert(t) 48 | model := new(Model) 49 | model.Version = Version 50 | model.Kind = KindRepo 51 | repo := new(Repo) 52 | model.Spec = repo 53 | _, err := yaml.Marshal(model) 54 | assert.That("Marshal without errors", err, m.Nil()) 55 | } 56 | 57 | func Test_validate(t *testing.T) { 58 | for _, k := range []Kind{KindRepo, KindHook, KindOrg, KindReadme} { 59 | t.Run(fmt.Sprintf("validate kind %s", k), func(t *testing.T) { 60 | assert := m.Assert(t) 61 | assert.That("no validation errors", k.validate(), m.Nil()) 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/spec/org.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "github.com/google/go-github/v38/github" 5 | ) 6 | 7 | type Org struct { 8 | Name string `yaml:"name"` 9 | Description string `yaml:"description,omitempty"` 10 | Company string `yaml:"company,omitempty"` 11 | Blog string `yaml:"blog,omitempty"` 12 | Location string `yaml:"location,omitempty"` 13 | Email string `yaml:"email,omitempty"` 14 | Twitter string `yaml:"twitter,omitempty"` 15 | Verified bool `yaml:"verified,omitempty"` 16 | } 17 | 18 | func (o *Org) FromGithub(g *github.Organization) { 19 | o.Name = g.GetName() 20 | o.Company = g.GetCompany() 21 | o.Blog = g.GetBlog() 22 | o.Location = g.GetLocation() 23 | o.Email = g.GetEmail() 24 | o.Twitter = g.GetTwitterUsername() 25 | o.Description = g.GetDescription() 26 | o.Verified = g.GetIsVerified() 27 | } 28 | 29 | func (o *Org) ToGithub(g *github.Organization) error { 30 | g.Name = &o.Name 31 | g.Description = &o.Description 32 | g.Company = &o.Company 33 | g.Blog = &o.Blog 34 | g.Location = &o.Location 35 | g.Email = &o.Email 36 | g.TwitterUsername = &o.Twitter 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/spec/protection.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "github.com/google/go-github/v38/github" 5 | ) 6 | 7 | // Protection rule of repositry branch 8 | type Protection struct { 9 | // Checks represents required status checks for merge 10 | Checks []string `yaml:"checks,omitempty"` 11 | // Strict update with target branch is requried 12 | Strict bool `yaml:"strictUpdate,omitempty"` 13 | // Review represents pull request review enforcement 14 | Review struct { 15 | // Require pull request reviews enforcement of a protected branch. 16 | Require bool `yaml:"require,omitempty"` 17 | // Dismiss pull request review 18 | Dismiss struct { 19 | // Users who can dismiss review 20 | Users []string `yaml:"users,omitempty"` 21 | // Teams who can dismiss review 22 | Teams []string `yaml:"teams,omitempty"` 23 | // Automatically dismiss approving reviews when someone pushes a new commit. 24 | Stale bool `yaml:"stale,omitempty"` 25 | } `yaml:"dismiss,omitempty"` 26 | // RequireOwner blocks merging pull requests until code owners review them. 27 | RequireOwner bool `yaml:"requireOwner,omitempty"` 28 | // Count is the number of reviewers required to approve pull requests. 29 | Count int `yaml:"count,omitempty"` 30 | } `yaml:"review,omitempty"` 31 | // EnforceAdmins the same rules 32 | EnforceAdmins bool `yaml:"enforceAdmins,omitempty"` 33 | // LinearHistory is required for merging branch 34 | LinearHistory bool `yaml:"linearHistory,omitempty"` 35 | // ForcePush is allowed 36 | ForcePush bool `yaml:"forcePush,omitempty"` 37 | // CanDelete target branch 38 | CanDelete bool `yaml:"canDelete,omitempty"` 39 | // Permissions 40 | Permissions struct { 41 | // Restrict permissions is enabled 42 | Restrict bool `yaml:"restrict,omitempty"` 43 | // Users with push access 44 | Users []string `yaml:"users,omitempty"` 45 | // Teams with push access 46 | Teams []string `yaml:"teams,omitempty"` 47 | // Apps with push access 48 | Apps []string `yaml:"apps,omitempty"` 49 | } `yaml:"permissions,omitempty"` 50 | // ConversationResolution, if set to true, requires all comments 51 | // on the pull request to be resolved before it can be merged to a protected branch. 52 | ConversationResolution bool `yaml:"conversationResolution,omitempty"` 53 | } 54 | 55 | func (bp *Protection) FromGithub(g *github.Protection) error { 56 | if c := g.RequiredStatusChecks; c != nil { 57 | bp.Checks = make([]string, len(c.Contexts)) 58 | copy(bp.Checks, c.Contexts) 59 | bp.Strict = c.Strict 60 | } 61 | if p := g.RequiredPullRequestReviews; p != nil { 62 | bp.Review.Require = true 63 | if p.DismissStaleReviews { 64 | bp.Review.Dismiss.Stale = true 65 | } 66 | bp.Review.RequireOwner = p.RequireCodeOwnerReviews 67 | bp.Review.Count = p.RequiredApprovingReviewCount 68 | if r := p.DismissalRestrictions; r != nil { 69 | bp.Review.Dismiss.Users = make([]string, len(r.Users)) 70 | for i, u := range r.Users { 71 | bp.Review.Dismiss.Users[i] = u.GetLogin() 72 | } 73 | bp.Review.Dismiss.Teams = make([]string, len(r.Teams)) 74 | for i, t := range r.Teams { 75 | bp.Review.Dismiss.Teams[i] = t.GetSlug() 76 | } 77 | } 78 | } 79 | if e := g.EnforceAdmins; e != nil { 80 | bp.EnforceAdmins = e.Enabled 81 | } 82 | if l := g.RequireLinearHistory; l != nil { 83 | bp.LinearHistory = l.Enabled 84 | } 85 | if f := g.AllowForcePushes; f != nil { 86 | bp.ForcePush = f.Enabled 87 | } 88 | if d := g.AllowDeletions; d != nil { 89 | bp.CanDelete = d.Enabled 90 | } 91 | if r := g.Restrictions; r != nil { 92 | bp.Permissions.Restrict = true 93 | bp.Permissions.Users = make([]string, len(r.Users)) 94 | for i, u := range r.Users { 95 | bp.Permissions.Users[i] = u.GetLogin() 96 | } 97 | bp.Permissions.Teams = make([]string, len(r.Teams)) 98 | for i, t := range r.Teams { 99 | bp.Permissions.Teams[i] = t.GetSlug() 100 | } 101 | bp.Permissions.Apps = make([]string, len(r.Apps)) 102 | for i, a := range r.Apps { 103 | bp.Permissions.Apps[i] = a.GetSlug() 104 | } 105 | } 106 | if cr := g.RequiredConversationResolution; cr != nil { 107 | bp.ConversationResolution = cr.Enabled 108 | } 109 | return nil 110 | } 111 | 112 | func (bp *Protection) ToGithub(pr *github.ProtectionRequest) error { 113 | pr.EnforceAdmins = bp.EnforceAdmins 114 | pr.RequireLinearHistory = &bp.LinearHistory 115 | pr.AllowForcePushes = &bp.ForcePush 116 | pr.AllowDeletions = &bp.CanDelete 117 | if len(bp.Checks) != 0 || bp.Strict { 118 | pr.RequiredStatusChecks = bp.requiredChecksToGithub() 119 | } 120 | if bp.Review.Require { 121 | pr.RequiredPullRequestReviews = bp.reviewToGithub() 122 | } 123 | if bp.Permissions.Restrict { 124 | pr.Restrictions = bp.permissionsToGithub() 125 | } 126 | pr.RequiredConversationResolution = &bp.ConversationResolution 127 | return nil 128 | } 129 | 130 | func (bp *Protection) requiredChecksToGithub() *github.RequiredStatusChecks { 131 | c := new(github.RequiredStatusChecks) 132 | c.Contexts = *getEmptyIfNil(bp.Checks) 133 | c.Strict = bp.Strict 134 | return c 135 | } 136 | 137 | func (bp *Protection) reviewToGithub() *github.PullRequestReviewsEnforcementRequest { 138 | e := new(github.PullRequestReviewsEnforcementRequest) 139 | e.DismissalRestrictionsRequest = new(github.DismissalRestrictionsRequest) 140 | if d := bp.Review.Dismiss; d.Teams != nil || d.Users != nil { 141 | e.DismissalRestrictionsRequest.Teams = getEmptyIfNil(d.Teams) 142 | e.DismissalRestrictionsRequest.Users = getEmptyIfNil(d.Users) 143 | } 144 | e.DismissStaleReviews = bp.Review.Dismiss.Stale 145 | e.RequireCodeOwnerReviews = bp.Review.RequireOwner 146 | e.RequiredApprovingReviewCount = bp.Review.Count 147 | return e 148 | } 149 | 150 | func (bp *Protection) permissionsToGithub() *github.BranchRestrictionsRequest { 151 | r := new(github.BranchRestrictionsRequest) 152 | r.Teams = *getEmptyIfNil(bp.Permissions.Teams) 153 | r.Users = *getEmptyIfNil(bp.Permissions.Users) 154 | r.Apps = bp.Permissions.Apps 155 | return r 156 | } 157 | 158 | func getEmptyIfNil(slice []string) *[]string { 159 | if slice != nil { 160 | return &slice 161 | } 162 | return &[]string{} 163 | } 164 | -------------------------------------------------------------------------------- /internal/spec/readme.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Readme struct { 9 | Selector struct { 10 | Repository string `yaml:"repository"` 11 | } `yaml:"selector"` 12 | Title string `yaml:"title,omitempty"` 13 | Abstract string `yaml:"abstract,omitempty"` 14 | Topics []struct { 15 | Heading string `yaml:"heading"` 16 | Body string `yaml:"body"` 17 | } `yaml:"topics,omitempty"` 18 | } 19 | 20 | func (s *Readme) String() string { 21 | sb := new(strings.Builder) 22 | if s.Title != "" { 23 | sb.WriteString(fmt.Sprintf("# %s\n\n", s.Title)) 24 | } 25 | if s.Abstract != "" { 26 | sb.WriteString(s.Abstract) 27 | sb.WriteString("\n\n") 28 | } 29 | for _, topic := range s.Topics { 30 | sb.WriteString(fmt.Sprintf("## %s\n\n", topic.Heading)) 31 | sb.WriteString(topic.Body) 32 | sb.WriteString("\n\n") 33 | } 34 | return sb.String() 35 | } 36 | -------------------------------------------------------------------------------- /internal/spec/repo.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "github.com/google/go-github/v38/github" 5 | ) 6 | 7 | const ( 8 | FeatureIssues = "issues" 9 | FeatureWiki = "wiki" 10 | FeaturePages = "pages" 11 | FeatureProjects = "projects" 12 | FeatureDownloads = "downloads" 13 | ) 14 | 15 | const ( 16 | RepoVisibilityPublic = "public" 17 | RepoVisibilityPrivate = "private" 18 | ) 19 | 20 | const ( 21 | MergeCommit = "commit" 22 | MergeRebase = "rebase" 23 | MergeSquash = "squash" 24 | ) 25 | 26 | // Repo spec 27 | type Repo struct { 28 | Description *string `yaml:"description,omitempty"` 29 | Homepage *string `yaml:"homepage,omitempty"` 30 | DefaultBranch string `yaml:"defaultBranch,omitempty" default:"master"` 31 | MergeStrategy []string `yaml:"mergeStrategy,omitempty" default:"[\"merge\"]"` 32 | DeleteBranchOnMerge *bool `yaml:"deleteBranchOnMerge,omitempty"` 33 | Topics []string `yaml:"topics,omitempty"` 34 | Archived *bool `yaml:"archived,omitempty"` 35 | Disabled *bool `yaml:"disabled,omitempty"` 36 | License *string `yaml:"license,omitempty"` 37 | Visibiliy *string `yaml:"visibility,omitempty" default:"public"` 38 | Features []string `yaml:"features,omitempty"` 39 | } 40 | 41 | func (spec *Repo) FromGithub(repo *github.Repository) { 42 | spec.Description = repo.Description 43 | spec.Homepage = repo.Homepage 44 | spec.DefaultBranch = repo.GetDefaultBranch() 45 | if l := repo.GetLicense(); l != nil { 46 | spec.License = l.Key 47 | } 48 | spec.MergeStrategy = make([]string, 0, 3) 49 | if repo.GetAllowMergeCommit() { 50 | spec.MergeStrategy = append(spec.MergeStrategy, "commit") 51 | } 52 | if repo.GetAllowRebaseMerge() { 53 | spec.MergeStrategy = append(spec.MergeStrategy, "rebase") 54 | } 55 | if repo.GetAllowSquashMerge() { 56 | spec.MergeStrategy = append(spec.MergeStrategy, "squash") 57 | } 58 | spec.DeleteBranchOnMerge = repo.DeleteBranchOnMerge 59 | spec.Topics = repo.Topics 60 | if repo.GetArchived() { 61 | spec.Archived = repo.Archived 62 | } 63 | if repo.GetDisabled() { 64 | spec.Disabled = repo.Disabled 65 | } 66 | spec.Visibiliy = new(string) 67 | if repo.GetPrivate() { 68 | *spec.Visibiliy = RepoVisibilityPrivate 69 | } else { 70 | *spec.Visibiliy = RepoVisibilityPublic 71 | } 72 | // issues, wiki, pages, projects, downloads 73 | spec.Features = make([]string, 0, 5) 74 | if repo.GetHasIssues() { 75 | spec.Features = append(spec.Features, FeatureIssues) 76 | } 77 | if repo.GetHasWiki() { 78 | spec.Features = append(spec.Features, FeatureWiki) 79 | } 80 | if repo.GetHasPages() { 81 | spec.Features = append(spec.Features, FeaturePages) 82 | } 83 | if repo.GetHasProjects() { 84 | spec.Features = append(spec.Features, FeatureProjects) 85 | } 86 | if repo.GetHasDownloads() { 87 | spec.Features = append(spec.Features, FeatureDownloads) 88 | } 89 | } 90 | 91 | func (s *Repo) ToGithub(r *github.Repository) error { 92 | r.Description = s.Description 93 | r.Homepage = s.Homepage 94 | r.DefaultBranch = &s.DefaultBranch 95 | r.AllowMergeCommit = new(bool) 96 | r.AllowRebaseMerge = new(bool) 97 | r.AllowSquashMerge = new(bool) 98 | for _, ms := range s.MergeStrategy { 99 | switch ms { 100 | case "commit": 101 | *r.AllowMergeCommit = true 102 | case "rebase": 103 | *r.AllowRebaseMerge = true 104 | case "squash": 105 | *r.AllowSquashMerge = true 106 | } 107 | } 108 | if !r.GetAllowMergeCommit() && !r.GetAllowRebaseMerge() && !r.GetAllowSquashMerge() { 109 | // enabled at least merge commit 110 | *r.AllowMergeCommit = true 111 | } 112 | r.DeleteBranchOnMerge = s.DeleteBranchOnMerge 113 | r.Topics = s.Topics 114 | r.Archived = s.Archived 115 | r.Disabled = s.Disabled 116 | if s.License != nil { 117 | r.License = new(github.License) 118 | r.License.Key = s.License 119 | } 120 | r.Private = new(bool) 121 | if s.Visibiliy == nil || *s.Visibiliy == "public" { 122 | *r.Private = false 123 | } else if s.Visibiliy != nil && *s.Visibiliy == "private" { 124 | *r.Private = true 125 | } 126 | r.HasIssues = new(bool) 127 | r.HasWiki = new(bool) 128 | r.HasPages = new(bool) 129 | r.HasProjects = new(bool) 130 | r.HasDownloads = new(bool) 131 | for _, f := range s.Features { 132 | // issues, wiki, pages, projects, downloads 133 | switch f { 134 | case "issues": 135 | *r.HasIssues = true 136 | case "wiki": 137 | *r.HasWiki = true 138 | case "pages": 139 | *r.HasPages = true 140 | case "projects": 141 | *r.HasProjects = true 142 | case "downloads": 143 | *r.HasDownloads = true 144 | } 145 | } 146 | return nil 147 | } 148 | -------------------------------------------------------------------------------- /internal/spec/team.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "github.com/google/go-github/v38/github" 5 | ) 6 | 7 | type Team struct { 8 | Name string `yaml:"name,omitempty" default:"NewTeam"` 9 | Description string `yaml:"description,omitempty"` 10 | Permission string `yaml:"permission,omitempty"` 11 | Privacy string `yaml:"privacy,omitempty"` 12 | } 13 | 14 | func (t *Team) FromGithub(g *github.Team) error { 15 | t.Name = g.GetName() 16 | t.Description = g.GetDescription() 17 | t.Permission = g.GetPermission() 18 | t.Privacy = g.GetPrivacy() 19 | return nil 20 | } 21 | 22 | func (t *Team) ToGithub(g *github.NewTeam) error { 23 | g.Name = t.Name 24 | g.Description = &t.Description 25 | g.Privacy = &t.Privacy 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/utils/tag.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/fatih/structtag" 8 | ) 9 | 10 | //RemoveTagsOmitempty returns struct of new type with same fields and values as s, 11 | //but removes option "omitempty" from tags with specified key. 12 | func RemoveTagsOmitempty(s interface{}, key string) (interface{}, error) { 13 | var value reflect.Value 14 | if reflect.TypeOf(s).Kind() == reflect.Ptr { 15 | value = reflect.Indirect(reflect.ValueOf(s)) 16 | } else { 17 | value = reflect.ValueOf(s) 18 | } 19 | t := value.Type() 20 | nf := t.NumField() 21 | sf := make([]reflect.StructField, nf) 22 | for i := 0; i < nf; i++ { 23 | field := t.Field(i) 24 | tag, err := removeOmitempty(field.Tag, key) 25 | if err != nil { 26 | return nil, err 27 | } 28 | field.Tag = *tag 29 | sf[i] = field 30 | } 31 | newType := reflect.StructOf(sf) 32 | newValue := value.Convert(newType) 33 | return newValue.Interface(), nil 34 | } 35 | 36 | func removeOmitempty(tag reflect.StructTag, key string) (*reflect.StructTag, error) { 37 | tags, err := structtag.Parse(string(tag)) 38 | if err != nil { 39 | return nil, err 40 | } 41 | yamlTag, err := tags.Get(key) 42 | if err != nil { 43 | return &tag, nil 44 | } 45 | for i, v := range yamlTag.Options { 46 | if v == "omitempty" { 47 | yamlTag.Options = append(yamlTag.Options[:i], yamlTag.Options[i+1:]...) 48 | break 49 | } 50 | } 51 | stringTags := fmt.Sprintf(`%v`, tags) 52 | newTag := reflect.StructTag(stringTags) 53 | return &newTag, nil 54 | } 55 | -------------------------------------------------------------------------------- /internal/utils/tag_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | m "github.com/g4s8/go-matchers" 8 | ) 9 | 10 | type original struct { 11 | A int `yaml:"a,omitempty,test"` 12 | B string `yaml:"b,omitempty"` 13 | C bool `yaml:"c,test,omitempty"` 14 | D int `json:"d,omitempty"` 15 | E string 16 | } 17 | 18 | type target struct { 19 | A int `yaml:"a,test"` 20 | B string `yaml:"b"` 21 | C bool `yaml:"c,test"` 22 | D int `json:"d,omitempty"` 23 | E string 24 | } 25 | 26 | func TestRemoveTagsOmitempty(t *testing.T) { 27 | assert := m.Assert(t) 28 | o := original{1, "a", true, 2, "b"} 29 | target := target{1, "a", true, 2, "b"} 30 | modified, err := RemoveTagsOmitempty(o, "yaml") 31 | if err != nil { 32 | t.Error(err) 33 | } 34 | tVal := reflect.ValueOf(target) 35 | mVal := reflect.ValueOf(modified) 36 | tType := tVal.Type() 37 | mType := mVal.Type() 38 | for i := 0; i < tVal.NumField(); i++ { 39 | wantField := tVal.Field(i).Interface() 40 | gotField := mVal.Field(i).Interface() 41 | assert.That("Fields are equal", gotField, m.Eq(wantField)) 42 | wantT := tType.Field(i) 43 | gotT := mType.Field(i) 44 | assert.That("StructFields are equal", gotT, m.Eq(wantT)) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /man/gitstrap.1: -------------------------------------------------------------------------------- 1 | .TH "gitstrap" "1" "version: 0.2.6" "30 Sep 2019" "gitstrap manual" 2 | 3 | .SH NAME 4 | .PP 5 | gitstrap - command line tool to bootstrap GitHub repository 6 | 7 | .SH SYNOPSIS 8 | .PP 9 | gitstrap [flags] (create|destroy) 10 | 11 | .SH DESCRIPTION 12 | .PP 13 | This tool automates routine operations when creating new GitHub repository. 14 | .PP 15 | It can create and configure GitHub repository from yaml configuration file. 16 | Gitstrap helps to: 17 | 1) create new repository on GitHub 18 | 2) sync with local directory 19 | 3) apply templates, such as README with badges, CI configs, LICENSE stuff, etc 20 | 4) configure webhooks for Github repo 21 | 5) invite collaborators 22 | 23 | .SH OPTIONS 24 | .PP 25 | \fB-accept\fP 26 | dont ask when creating or deleting repository, 27 | say \fByes\fP to all prompts. 28 | 29 | .PP 30 | \fB-config=".gitstrap.yaml"\fP 31 | gitstrap config file location (default \fB"$PWD/.gitstrap.yaml"\fP) 32 | 33 | .PP 34 | \fB-debug\fP 35 | Show debug logs 36 | 37 | .PP 38 | \fB-org=""\fP 39 | GitHub organization name (optional) 40 | 41 | .PP 42 | \fB-token=""\fP 43 | GitHub API token 44 | 45 | .PP 46 | \fB-version\fP 47 | Show version 48 | -------------------------------------------------------------------------------- /scripts/download.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | OWNER="g4s8" 5 | NAME="gitstrap" 6 | RELEASES_URL="https://github.com/$OWNER/$NAME/releases" 7 | test -z "$TMPDIR" && TMPDIR="$(mktemp -d)" 8 | TAR_FILE="$TMPDIR/$NAME.tar.gz" 9 | 10 | last_version() { 11 | curl -sL -o /dev/null -w %{url_effective} "$RELEASES_URL/latest" | 12 | rev | 13 | cut -f1 -d'/'| 14 | rev 15 | } 16 | 17 | download() { 18 | test -z "$VERSION" && VERSION="$(last_version)" 19 | test -z "$VERSION" && { 20 | echo "Unable to get $NAME version." >&2 21 | exit 1 22 | } 23 | rm -f "$TAR_FILE" 24 | local url="$RELEASES_URL/download/$VERSION/${NAME}_$(uname -s)_$(uname -m).tar.gz" 25 | curl -sL -o "$TAR_FILE" $url 26 | } 27 | 28 | download 29 | tar -xzf "$TAR_FILE" -C "$TMPDIR" 30 | rm -f $TAR_FILE 31 | mv -i $TMPDIR $PWD/$NAME 32 | 33 | -------------------------------------------------------------------------------- /wiki/Home.md: -------------------------------------------------------------------------------- 1 | The `gitstrap` project aims to automate routine operations with GitHub such as managing repositories, organizations, teams, web-hooks and other resources. Each resource is represented as a `yaml` specification document that could be fetched from an existing resource or created from scratch. Each document can be updated and the specification resource configuration can be applied using CLI `gitstrap` tool. 2 | 3 | The full specification format can be found here: [/specifications](https://github.com/g4s8/gitstrap/wiki/Specifications) 4 | 5 | ## Tutorial 6 | 7 | 1. [Download and install](#download-and-install) 8 | 2. [Configuration](#configuration) 9 | 3. [CLI overview](#cli-overview) 10 | 4. [Basic examples](#basic-examples) 11 | 12 | ### Download and install 13 | 14 | For Linux system you can use download script to get latest binary: 15 | ```bash 16 | curl -L https://raw.githubusercontent.com/g4s8/gitstrap/master/scripts/download.sh | sh 17 | ``` 18 | This script downloads `gistrap` CLI into `./gitstrap/` path of current directory. The binary could be copied to any of `$PATH` directories. 19 | 20 | On MacOS it can be installed using HomeBrew: 21 | ```bash 22 | brew tap g4s8/.tap https://github.com/g4s8/.tap 23 | brew install g4s8/.tap/gitstrap 24 | ``` 25 | 26 | On any system (including Windows) the CLI binary can be found at [releases page](https://github.com/g4s8/gitstrap#install). 27 | 28 | Alternatively, it can be built from sources (you'll need `git`, `make` and `go` installed on the system): 29 | ```bash 30 | git clone --depth=1 https://github.com/g4s8/gitstrap.git 31 | cd gitstrap 32 | make build 33 | sudo make install 34 | ``` 35 | 36 | ### Configuration 37 | 38 | To use `gitstrap` CLI you need GitHub token. Some features depend on optional permissions: 39 | - `repo` - required 40 | - `admin:org` - to manage organizations 41 | - `admin:org_hook` - to manage organization web-hooks 42 | - `delete_repo` - to delete repositories 43 | 44 | A new token can be created here: https://github.com/settings/tokens/new 45 | 46 | When a token is generated, it should be placed at `~/.config/gitstrap/github_token.txt` location. 47 | 48 | ### CLI overview 49 | 50 | The `gitstrap` CLI consists of these primary commands: 51 | - `get` - get GitHub resource, convert it into `yaml` format and print (possible sub-commands: `repo`, `org`, `hooks`, `teams`, `protection`) 52 | - `create` - create a new resource from spec `yaml` file, and fail if it already exists 53 | - `apply` - apply resource specification to existing resource or create it if doesn't exist 54 | - `delete` - delete resource by `yaml` spec 55 | 56 | Global options: 57 | - `--token` - overrides GitHub token from `~/.config/gitstrap/github_token.txt` 58 | 59 | Use `gitstrap --help` or `gitstrap --help` for more details. 60 | 61 | ### Basic examples 62 | 63 | Assuming you have installed and configured `gitstrap`, now you can try to create some resources. 64 | 65 | Let's start with simple repository: create a new `yaml` file in current directory: `example-repo.yaml` with content (see the meaning of config fields at [spec reference](https://github.com/g4s8/gitstrap/wiki/Specifications)): 66 | ```yaml 67 | version: v2.0-alpha 68 | kind: Repository 69 | metadata: 70 | name: example 71 | spec: 72 | description: Example repo created with gitstrap 73 | license: mit 74 | visibility: public 75 | features: 76 | - issues 77 | - wiki 78 | ``` 79 | Create a repository with `gitstrap apply -f example-repo.yaml`. On success, it should print that repository was created. 80 | 81 | But it's empty for now, so let's add a README file: create a new specification file `example-readme.yaml`: 82 | ```yaml 83 | version: v2 84 | kind: Readme 85 | spec: 86 | selector: 87 | repository: example 88 | title: Example repository 89 | abstract: > 90 | This is example repository created with gitstrap 91 | ``` 92 | And add it to repo using `gitstrap create -f example-readme.yaml` (`gitstrap` doesn't support README updating, so only `create` command could be used). 93 | Now you can check this repository at `/example` location under your account: `https://github.com//example`. 94 | 95 | Let's add a webhook to the repo to call our URL on each `git push` in repository, create a new file `example-hook.yaml` with content: 96 | ```yaml 97 | version: v2 98 | kind: WebHook 99 | spec: 100 | url: https://my-domain.com/hook 101 | contentType: form 102 | events: 103 | - push 104 | selector: 105 | repository: example 106 | ``` 107 | And apply it with `gitstrap apply -f example-hook.yaml`. 108 | -------------------------------------------------------------------------------- /wiki/Specifications.md: -------------------------------------------------------------------------------- 1 | 2 | *The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", 3 | "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this 4 | document are to be interpreted as described in [RFC 2119](https://tools.ietf.org/html/rfc2119).* 5 | 6 | Gitsrap works mostly with resource specification yaml documents. Each file should contain at least one document and may contain multiple documents separated by `---` yaml document separator. 7 | 8 | Each specification document is a yaml mapping, it consists of these keys: 9 | - `version` - specification document version 10 | - `kind` - specification kind 11 | - `metadata` - specification metadata 12 | - `spec` - resource specification 13 | 14 | Version should be `v2`. Kind is a string which describes the specification kind, it must be one of: 15 | - [Repository](#Repository) 16 | - [Organization](#Organization) 17 | - [WebHook](#WebHook) 18 | - [Readme](#Readme) 19 | - [Team](#Team) 20 | 21 | A `spec` may have `selector` key to attach to the correct resource. 22 | 23 | Metadata is differ for specification, but most of them has these keys: 24 | - `name` - resource name (e.g. repository name or organization name) 25 | - `id` - resource ID (e.g. repository, webhook, organization ID) 26 | - `owner` - resource owner, e.g. repository owner 27 | 28 | ## Repository 29 | 30 | Describes GitHub repository resource, it has: 31 | - `description` (string) - repository description 32 | - `homepage` (string) - repository home page URI 33 | - `defaultBranch` (string, default: `"master"`) - the name of the default branch 34 | - `mergeStrategy` (list of strings, default: `[merge]`) - pull request merge options, could be one of: 35 | - `merge` - enable merge commits PR merging 36 | - `rebase` - enable rebase PR merge 37 | - `squash` - enable squash PR merge 38 | - `deleteBranchOnMerge` (bool) - enables delete branch options on PR merge 39 | - `topics` (list of strings) - repository topics, keywords in GitHub page description 40 | - `archived` (bool, readonly) - true if repsitory is archived 41 | - `disabled` (bool, readonly) - true if repository is disabled 42 | - `license` (string) - license GitHub key, e.g. (`mit`) 43 | - visibility (string. default: `"public"`) - one of: 44 | - `public` - repository is public 45 | - `private` - repository is private 46 | - `features` (list of strings) - enables repository features: 47 | - `issues` - enable issues 48 | - `wiki` - enable wiki pages 49 | - `pages` - enable GitHub pages 50 | - `projects` - enable repository project board 51 | - `downloads` - ??? 52 | 53 | Repository metadata must specify repository `name`, and may have `owner`, in case if `owner` is not a current user (current user = token owner). When updating existing repository, metadata must contain `id` of the repository. The full metadata could be fetched with `get` command. 54 | 55 | Example: 56 | ```yaml 57 | version: v2.0-alpha 58 | kind: Repository 59 | metadata: 60 | name: gitstrap 61 | owner: g4s8 62 | id: 12345 63 | spec: 64 | description: CLI for managing GitHub repositories 65 | defaultBranch: master 66 | mergeStrategy: 67 | - squash 68 | deleteBranchOnMerge: true 69 | topics: 70 | - cli 71 | - git 72 | - github 73 | - webhooks 74 | license: mit 75 | visibility: public 76 | features: 77 | - issues 78 | - pages 79 | - wiki 80 | - downloads 81 | ``` 82 | 83 | ## Organization 84 | 85 | Describes GitHub organization, it has: 86 | - `name` (string) - the shorthand name of the company. 87 | - `description` (string) - organization description 88 | - `company` (string) - organization company affiliation 89 | - `blog` (string) - URI for organization blog 90 | - `location` (string) - geo location of organization 91 | - `email` (string) - public email address 92 | - `twitter` (string) - twitter account username 93 | - `verified` (bool, readonly) - true if organization was verified by GitHub 94 | 95 | Example: 96 | ```yaml 97 | version: v2.0-alpha 98 | kind: Organization 99 | metadata: 100 | name: artipie 101 | id: 12345 102 | spec: 103 | name: Artipie 104 | description: Binary Artifact Management Toolkit 105 | blog: https://www.artipie.com 106 | email: team@artipie.com 107 | verified: true 108 | ``` 109 | 110 | ## WebHook 111 | 112 | Describes repository or organization web-hook. It has: 113 | - `url` (string, required) - webhook URL 114 | - `contentType` (string, required) - one of: 115 | - `json` - send JSON payloads 116 | - `form` - send HTTP form payloads 117 | - `events` (list of strings, required) - list of [GitHub events](https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads) to trigger web-hook 118 | - `insecureSsl` (bool, default: false) - if true, disable SSL certificate verification 119 | - `secret` (string, writeonly) - specify secret payload when creating or updating the hook 120 | - `active` (bool, default: true) - if false, the hook will be disabled but now removed 121 | - `selector` (mapping, required) - specifies hook selector. It must have only one of two keys, either: 122 | - `repository` - the name of repository for this hook 123 | - `organization` - the name of organization for this hook 124 | 125 | Metadata: 126 | - `owner` (string, optional) - may specify the owner of the repository if hook's selector is repository and repository owner is not a current user 127 | - `id` (number, required on update) - it must be specified to update existing web-hook, if not specified a new hook will be created. It could be fetched with `get` command. 128 | 129 | Example: 130 | ```yaml 131 | version: v2.0-alpha 132 | kind: WebHook 133 | metadata: 134 | owner: g4s8 135 | id: 12345 136 | spec: 137 | url: http://example.com/hook 138 | contentType: json 139 | events: 140 | - pull_request 141 | active: true 142 | selector: 143 | repository: gitstrap 144 | ``` 145 | 146 | ## Readme 147 | 148 | Readme could be described by specification with these fields: 149 | - `selector` (mapping, required) 150 | - `repository` (string, required) - the name of repository where this readme will be created 151 | - `title` (string, optional) - The main title in the readme 152 | - `abstract` (string, optional) - Short abstract about the repository 153 | - `topics` (array, optional) 154 | - `heading` (string, required) - Subs-title for topic 155 | - `body` (string, required) - Topic content 156 | 157 | Metadata: 158 | - `owner` (string, optional) - could be specified to create readme in organization or another user repo 159 | 160 | Exampple: 161 | ```yaml 162 | version: v2 163 | kind: Readme 164 | metadata: 165 | owner: artipie 166 | spec: 167 | selector: 168 | repository: conan-adapter 169 | title: Conan Artipie adapter 170 | abstract: > 171 | Conan is a C/C++ repository, this adapter is an SDK for working 172 | with Conan data and metadata and a HTTP endpoint for the Conan 173 | repository. 174 | topics: 175 | - heading: How does Conan work 176 | body: TODO 177 | - heading: How to use Artipie Conan SDK 178 | body: TODO 179 | - heading: How to configure and start Artipie Conan endpoint 180 | body: TODO 181 | ``` 182 | 183 | ## Team 184 | 185 | Describes GitHub organization's team, it has: 186 | - `name` (string, required) - team name 187 | - `description` (string, optional) - team description 188 | - `privacy` (string, default: secret) - team privacy 189 | - `permission` (string, readonly) - team permission. Permission is deprecated when creating or editing a team in an org using the new GitHub permission model. 190 | 191 | Metadata: 192 | - `owner` (string, required) - organization to which the team belongs. 193 | - `name` (string, required on update) - team slug. It must be specified to update existing team, if not specified, gitstrap will try to update by id. 194 | - `id` (number, required on update) - it must be specified to update existing team if name is not specified. If name and id are not specified a new team will be created. It could be fetched with `get` command. 195 | 196 | Example: 197 | ```yaml 198 | version: v2 199 | kind: Team 200 | metadata: 201 | name: example-team 202 | owner: artipie 203 | id: 123456 204 | spec: 205 | name: Example team 206 | description: Gitstrap example team 207 | permission: pull 208 | privacy: closed 209 | ``` 210 | 211 | ## Protection 212 | 213 | | :memo: Notification| 214 | |:-------------------| 215 | |GitHub uses `fnmatch` syntax for applying protection rules to branches. Github API interacts with protection only for specified branch, i.e. name of protection = name of branch. Therefore if your branch protected by matching syntax e.g. `*`, you can fetch protection, but can not remove via API. Full support of protection via match available via GitHub web interface.| 216 | 217 | Describes GitHub branch protection, it has: 218 | - `checks` (string, optional) - the list of status checks to require in order to merge into this branch 219 | - `strictUpdate` (bool, optional) - require branches to be up to date before merging. 220 | - `review` - represents the pull request reviews enforcement. 221 | - `require` (bool, optional) - set `true` to enforce pull request review. 222 | - `dismiss` - dismiss pull request review 223 | - `users` (list of strings, optional) - the list of user's logins with dismissal access. Only available for organization-owner repositories. 224 | - `teams` (list of strings, optional) - the list of team's slugs with dismissal access. Only available for organization-owner repositories. 225 | - `stale` (bool, optional) - specifies if approved reviews are dismissed automatically, when a new commit is pushed. 226 | - `requireOwner` (bool, optional)- blocks merging pull requests until code owners review them. 227 | - `count` (int, required) - the number of reviewers required to approve pull requests. Required if review `require` set to `true` 228 | - `enforceAdmins` (bool, optional) - enforce all configured restrictions for administrators. 229 | - `linearHistory` (bool, optional) - enforces a linear commit Git history, which prevents anyone from pushing merge commits to a branch. 230 | - `forcePush` (bool, optional) - permits force pushes to the protected branch by anyone with write access to the repository. 231 | - `canDelete` (bool, optional) - allows deletion of the protected branch by anyone with write access to the repository. 232 | - `permissions` - restrict who can push to the protected branch. Only available for organization-owner repositories. 233 | - `restrict` (bool, optional) - set `true` to enable restrictions. 234 | - `users` (list of strings, optional) - the list of user's logins with push access 235 | - `teams` (list of strings, optional) - the list of team's slugs with push access 236 | - `apps` (list od strings, optional) - the list of apps's slugs with push access 237 | 238 | Metadata: 239 | - `owner` (string, required) - repository owner 240 | - `repo` (string, required) - repository name 241 | - `name` (string, required) - branch name 242 | 243 | Example: 244 | ```yaml 245 | version: v2 246 | kind: Protection 247 | metadata: 248 | name: master 249 | repo: gitstrap 250 | owner: g4s8 251 | spec: 252 | checks: 253 | - build 254 | - test 255 | - lint 256 | strictUpdate: true 257 | review: 258 | require: true 259 | dismiss: 260 | users: 261 | - g4s8 262 | - OrlovM 263 | teams: 264 | - example-team 265 | stale: true 266 | requireOwner: true 267 | count: 1 268 | enforceAdmins: true 269 | linearHistory: true 270 | forcePush: true 271 | canDelete: true 272 | permissions: 273 | restrict: true 274 | users: 275 | - g4s8 276 | - OrlovM 277 | teams: 278 | - example-team 279 | apps: 280 | - example 281 | ``` 282 | --------------------------------------------------------------------------------