├── acceptance_test ├── fixture1.txt ├── fixture2.txt ├── fixture3.txt └── Makefile ├── .gitignore ├── main.go ├── .mockery.yaml ├── Makefile ├── .github ├── release.yml ├── renovate.json └── workflows │ ├── acceptance-test.yaml │ ├── go.yaml │ └── release.yaml ├── pkg ├── git │ ├── commitstrategy │ │ ├── commit_strategy_test.go │ │ └── commit_strategy.go │ ├── release.go │ ├── gitobject.go │ ├── git_test.go │ └── git.go ├── env │ └── env.go ├── cmd │ ├── repository_options.go │ ├── release_test.go │ ├── empty_commit_test.go │ ├── commit_attribute_options.go │ ├── forkcommit_test.go │ ├── release.go │ ├── forkcommit.go │ ├── empty_commit.go │ ├── pull_request_test.go │ ├── commit.go │ ├── pull_request.go │ ├── cmd.go │ ├── cmd_test.go │ └── commit_test.go ├── di │ ├── di.go │ └── wire_gen.go ├── github │ ├── client │ │ ├── github_test.go │ │ └── github.go │ ├── github.go │ ├── default_branch.go │ ├── fork.go │ ├── release_test.go │ ├── gitobject_test.go │ ├── release.go │ ├── gitobject.go │ ├── pull_request.go │ └── branch.go ├── usecases │ ├── forkcommit │ │ ├── forkcommit.go │ │ └── forkcommit_test.go │ ├── release │ │ ├── release.go │ │ └── release_test.go │ ├── gitobject │ │ ├── create.go │ │ └── create_test.go │ ├── pullrequest │ │ ├── pull_request.go │ │ └── pull_request_test.go │ └── commit │ │ └── commit.go └── fs │ ├── fs.go │ └── fs_test.go ├── mocks └── github.com │ └── int128 │ └── ghcp │ └── pkg │ ├── cmd_mock │ └── mocks.go │ ├── usecases │ ├── commit_mock │ │ └── mocks.go │ ├── release_mock │ │ └── mocks.go │ ├── forkcommit_mock │ │ └── mocks.go │ ├── pullrequest_mock │ │ └── mocks.go │ └── gitobject_mock │ │ └── mocks.go │ ├── env_mock │ └── mocks.go │ ├── git │ └── commitstrategy_mock │ │ └── mocks.go │ └── fs_mock │ └── mocks.go ├── README.md └── LICENSE /acceptance_test/fixture1.txt: -------------------------------------------------------------------------------- 1 | fixture1 2 | -------------------------------------------------------------------------------- /acceptance_test/fixture2.txt: -------------------------------------------------------------------------------- 1 | fixture2 2 | -------------------------------------------------------------------------------- /acceptance_test/fixture3.txt: -------------------------------------------------------------------------------- 1 | fixture3 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /dist 3 | /ghcp 4 | /coverage.out 5 | /tools/bin 6 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/int128/ghcp/pkg/di" 7 | ) 8 | 9 | var version = "HEAD" 10 | 11 | func main() { 12 | os.Exit(di.NewCmd().Run(os.Args, version)) 13 | } 14 | -------------------------------------------------------------------------------- /.mockery.yaml: -------------------------------------------------------------------------------- 1 | dir: mocks/{{.SrcPackagePath}}_mock 2 | pkgname: '{{.SrcPackageName}}_mock' 3 | filename: mocks.go 4 | template: testify 5 | packages: 6 | github.com/int128/ghcp: 7 | config: 8 | all: true 9 | recursive: true 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | CGO_ENABLED=0 go build -ldflags '-X main.version=$(GITHUB_REF_NAME)' 4 | 5 | .PHONY: generate 6 | generate: 7 | go tool wire ./pkg/di 8 | rm -fr mocks/ 9 | go tool mockery 10 | 11 | .PHONY: lint 12 | lint: 13 | go tool golangci-lint run 14 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes 2 | changelog: 3 | categories: 4 | - title: Features 5 | labels: 6 | - '*' 7 | exclude: 8 | labels: 9 | - renovate 10 | - refactoring 11 | - title: Refactoring 12 | labels: 13 | - refactoring 14 | - title: Dependencies 15 | labels: 16 | - renovate 17 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>int128/renovate-base", 5 | "github>int128/go-renovate-config#v1.9.0", 6 | "github>int128/go-renovate-config:go-version#v1.9.0", 7 | "helpers:pinGitHubActionDigests" 8 | ], 9 | "packageRules": [ 10 | { 11 | "matchPackagePrefixes": [ 12 | "github.com/google/go-github/v" 13 | ], 14 | "matchUpdateTypes": [ 15 | "major" 16 | ], 17 | "automerge": true 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /pkg/git/commitstrategy/commit_strategy_test.go: -------------------------------------------------------------------------------- 1 | package commitstrategy 2 | 3 | import "testing" 4 | 5 | func TestRebaseOn(t *testing.T) { 6 | s := RebaseOn("develop") 7 | 8 | if s.RebaseUpstream() != "develop" { 9 | t.Errorf("RebaseUpstream wants %s but got %s", "develop", s.RebaseUpstream()) 10 | } 11 | if s.IsFastForward() { 12 | t.Errorf("IsFastForward wants false but got true") 13 | } 14 | if !s.IsRebase() { 15 | t.Errorf("IsRebase wants true but got false") 16 | } 17 | if s.NoParent() { 18 | t.Errorf("NoParent wants false but got true") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/git/release.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | // ReleaseID represents an ID of release. 4 | type ReleaseID struct { 5 | Repository RepositoryID 6 | InternalID int64 // GitHub API will allocate this ID 7 | } 8 | 9 | // Release represents a release associated to a tag. 10 | type Release struct { 11 | ID ReleaseID 12 | TagName TagName 13 | TargetCommitish string // branch name or commit SHA 14 | Name string 15 | } 16 | 17 | // ReleaseAsset represents a release asset. 18 | type ReleaseAsset struct { 19 | Release ReleaseID 20 | Name string 21 | RealPath string 22 | } 23 | -------------------------------------------------------------------------------- /pkg/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/google/wire" 7 | ) 8 | 9 | var Set = wire.NewSet( 10 | wire.Struct(new(Env)), 11 | wire.Bind(new(Interface), new(*Env)), 12 | ) 13 | 14 | type Interface interface { 15 | Getenv(key string) string 16 | Chdir(dir string) error 17 | } 18 | 19 | // Env provides environment dependencies, 20 | // such as environment variables and current directory. 21 | type Env struct{} 22 | 23 | func (e *Env) Getenv(key string) string { 24 | return os.Getenv(key) 25 | } 26 | 27 | func (e *Env) Chdir(dir string) error { 28 | return os.Chdir(dir) 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/acceptance-test.yaml: -------------------------------------------------------------------------------- 1 | name: acceptance-test 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - .github/workflows/acceptance-test.yaml 7 | - '**.go' 8 | - go.* 9 | - acceptance_test/** 10 | push: 11 | branches: 12 | - master 13 | paths: 14 | - .github/workflows/acceptance-test.yaml 15 | - '**.go' 16 | - go.* 17 | - acceptance_test/** 18 | 19 | jobs: 20 | run: 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 10 23 | steps: 24 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 25 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 26 | with: 27 | go-version-file: go.mod 28 | cache-dependency-path: go.sum 29 | - run: make 30 | - run: make -C acceptance_test 31 | env: 32 | GITHUB_TOKEN: ${{ github.token }} 33 | - run: make -C acceptance_test clean-up 34 | if: always() 35 | -------------------------------------------------------------------------------- /pkg/cmd/repository_options.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/int128/ghcp/pkg/git" 8 | "github.com/spf13/pflag" 9 | ) 10 | 11 | type repositoryOptions struct { 12 | RepositoryOwner string 13 | RepositoryName string 14 | } 15 | 16 | func (o *repositoryOptions) register(f *pflag.FlagSet) { 17 | f.StringVarP(&o.RepositoryName, "repo", "r", "", "Repository name, either -r OWNER/REPO or -u OWNER -r REPO (mandatory)") 18 | f.StringVarP(&o.RepositoryOwner, "owner", "u", "", "Repository owner") 19 | } 20 | 21 | func (o repositoryOptions) repositoryID() (git.RepositoryID, error) { 22 | if o.RepositoryName == "" { 23 | return git.RepositoryID{}, fmt.Errorf("you need to set the repository name") 24 | } 25 | if o.RepositoryOwner != "" { 26 | return git.RepositoryID{Owner: o.RepositoryOwner, Name: o.RepositoryName}, nil 27 | } 28 | s := strings.SplitN(o.RepositoryName, "/", 2) 29 | if len(s) != 2 { 30 | return git.RepositoryID{}, fmt.Errorf("you need to set OWNER/REPO when owner flag is omitted") 31 | } 32 | return git.RepositoryID{Owner: s[0], Name: s[1]}, nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/di/di.go: -------------------------------------------------------------------------------- 1 | //go:build wireinject 2 | // +build wireinject 3 | 4 | // Package di provides dependency injection. 5 | package di 6 | 7 | import ( 8 | "github.com/google/wire" 9 | "github.com/int128/ghcp/pkg/cmd" 10 | "github.com/int128/ghcp/pkg/env" 11 | "github.com/int128/ghcp/pkg/fs" 12 | "github.com/int128/ghcp/pkg/github" 13 | "github.com/int128/ghcp/pkg/github/client" 14 | "github.com/int128/ghcp/pkg/usecases/commit" 15 | "github.com/int128/ghcp/pkg/usecases/forkcommit" 16 | "github.com/int128/ghcp/pkg/usecases/gitobject" 17 | "github.com/int128/ghcp/pkg/usecases/pullrequest" 18 | "github.com/int128/ghcp/pkg/usecases/release" 19 | ) 20 | 21 | func NewCmd() cmd.Interface { 22 | wire.Build( 23 | cmd.Set, 24 | client.Set, 25 | env.Set, 26 | 27 | wire.Value(cmd.NewInternalRunnerFunc(NewCmdInternalRunner)), 28 | ) 29 | return nil 30 | } 31 | 32 | func NewCmdInternalRunner(client.Interface) *cmd.InternalRunner { 33 | wire.Build( 34 | cmd.Set, 35 | fs.Set, 36 | github.Set, 37 | 38 | gitobject.Set, 39 | commit.Set, 40 | forkcommit.Set, 41 | pullrequest.Set, 42 | release.Set, 43 | ) 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: go 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - .github/workflows/go.yaml 7 | - '**.go' 8 | - '**/go.*' 9 | push: 10 | branches: 11 | - master 12 | paths: 13 | - .github/workflows/go.yaml 14 | - '**.go' 15 | - '**/go.*' 16 | 17 | jobs: 18 | test: 19 | runs-on: ubuntu-latest 20 | timeout-minutes: 10 21 | steps: 22 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 23 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 24 | with: 25 | go-version-file: go.mod 26 | cache-dependency-path: go.sum 27 | - run: go test -v ./... 28 | - run: make lint 29 | 30 | generate: 31 | runs-on: ubuntu-latest 32 | timeout-minutes: 10 33 | steps: 34 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 35 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 36 | with: 37 | go-version-file: go.mod 38 | cache-dependency-path: go.sum 39 | - run: go mod tidy 40 | - run: make generate 41 | - uses: int128/update-generated-files-action@d9aac571db84cee6c16fa20190621e9deb2bc575 # v2.67.0 42 | -------------------------------------------------------------------------------- /pkg/cmd/release_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/usecases/release_mock" 7 | "github.com/int128/ghcp/pkg/git" 8 | "github.com/int128/ghcp/pkg/github/client" 9 | "github.com/int128/ghcp/pkg/usecases/release" 10 | "github.com/stretchr/testify/mock" 11 | ) 12 | 13 | func TestCmd_Run_release(t *testing.T) { 14 | t.Run("BasicOptions", func(t *testing.T) { 15 | releaseUseCase := release_mock.NewMockInterface(t) 16 | releaseUseCase.EXPECT(). 17 | Do(mock.Anything, release.Input{ 18 | Repository: git.RepositoryID{Owner: "owner", Name: "repo"}, 19 | TagName: "v1.0.0", 20 | TargetBranchOrCommitSHA: "COMMIT_SHA", 21 | Paths: []string{"file1", "file2"}, 22 | }). 23 | Return(nil) 24 | r := Runner{ 25 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 26 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 27 | NewInternalRunner: newInternalRunner(InternalRunner{ReleaseUseCase: releaseUseCase}), 28 | } 29 | args := []string{ 30 | cmdName, 31 | releaseCmdName, 32 | "--token", "YOUR_TOKEN", 33 | "-r", "owner/repo", 34 | "-t", "v1.0.0", 35 | "--target", "COMMIT_SHA", 36 | "file1", 37 | "file2", 38 | } 39 | exitCode := r.Run(args, version) 40 | if exitCode != exitCodeOK { 41 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 42 | } 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/cmd/empty_commit_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/usecases/commit_mock" 7 | "github.com/int128/ghcp/pkg/git" 8 | "github.com/int128/ghcp/pkg/git/commitstrategy" 9 | "github.com/int128/ghcp/pkg/github/client" 10 | "github.com/int128/ghcp/pkg/usecases/commit" 11 | "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | func TestCmd_Run_empty_commit(t *testing.T) { 15 | t.Run("BasicOptions", func(t *testing.T) { 16 | commitUseCase := commit_mock.NewMockInterface(t) 17 | commitUseCase.EXPECT(). 18 | Do(mock.Anything, commit.Input{ 19 | TargetRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 20 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 21 | CommitStrategy: commitstrategy.FastForward, 22 | CommitMessage: "commit-message", 23 | }). 24 | Return(nil) 25 | r := Runner{ 26 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 27 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 28 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 29 | } 30 | args := []string{ 31 | cmdName, 32 | emptyCommitCmdName, 33 | "--token", "YOUR_TOKEN", 34 | "-r", "owner/repo", 35 | "-m", "commit-message", 36 | } 37 | exitCode := r.Run(args, version) 38 | if exitCode != exitCodeOK { 39 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/git/gitobject.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | // CommitSHA represents a pointer to a commit. 4 | type CommitSHA string 5 | 6 | // CommitMessage represents a message of a commit. 7 | type CommitMessage string 8 | 9 | // NewCommit represents a commit. 10 | type NewCommit struct { 11 | Repository RepositoryID 12 | Message CommitMessage 13 | Author *CommitAuthor // optional 14 | Committer *CommitAuthor // optional 15 | ParentCommitSHA CommitSHA // optional 16 | TreeSHA TreeSHA 17 | } 18 | 19 | // CommitAuthor represents an author of commit. 20 | type CommitAuthor struct { 21 | Name string 22 | Email string 23 | } 24 | 25 | // TreeSHA represents a pointer to a tree. 26 | type TreeSHA string 27 | 28 | // File represents a file in a tree. 29 | type File struct { 30 | Filename string // filename (including path separators) 31 | BlobSHA BlobSHA // blob SHA 32 | Executable bool // if the file is executable 33 | } 34 | 35 | // Mode returns mode of the file, i.e. 100644 or 100755. 36 | func (f *File) Mode() string { 37 | if f.Executable { 38 | return "100755" 39 | } 40 | return "100644" 41 | } 42 | 43 | // NewTree represents a tree. 44 | type NewTree struct { 45 | Repository RepositoryID 46 | BaseTreeSHA TreeSHA 47 | Files []File 48 | } 49 | 50 | // BlobSHA represents a pointer to a blob. 51 | type BlobSHA string 52 | 53 | // NewBlob represents a blob. 54 | type NewBlob struct { 55 | Repository RepositoryID 56 | Content string // base64 encoded content 57 | } 58 | -------------------------------------------------------------------------------- /acceptance_test/Makefile: -------------------------------------------------------------------------------- 1 | GHCP := ../ghcp 2 | GHCP_FLAGS := --debug -u int128 -r ghcp 3 | 4 | GITHUB_RUN_NUMBER ?= 0 5 | BRANCH_PREFIX := ghcp-acceptance-test-$(GITHUB_RUN_NUMBER) 6 | GITHUB_PR_NUMBER := $(word 3,$(subst /, ,$(GITHUB_REF))) 7 | 8 | .PHONY: test 9 | test: 10 | # create master branch 11 | $(GHCP) commit $(GHCP_FLAGS) -b $(BRANCH_PREFIX)-master --no-parent -m "Create master branch" fixture1.txt 12 | sleep 1 13 | 14 | # create feature branch from master branch and add a file 15 | $(GHCP) commit $(GHCP_FLAGS) -b $(BRANCH_PREFIX)-feature --parent $(BRANCH_PREFIX)-master -m "Add fixture2.txt" fixture2.txt 16 | sleep 1 17 | 18 | # add a file to feature branch 19 | $(GHCP) commit $(GHCP_FLAGS) -b $(BRANCH_PREFIX)-feature -m "Add fixture3.txt" --author-name="octocat" --author-email="octocat@github.com" fixture3.txt 20 | sleep 1 21 | 22 | # add a file to feature branch but do nothing 23 | $(GHCP) commit $(GHCP_FLAGS) -b $(BRANCH_PREFIX)-feature -m "Add fixture1.txt" fixture1.txt 24 | sleep 1 25 | 26 | # create a pull request from feature to master 27 | $(GHCP) pull-request $(GHCP_FLAGS) -b $(BRANCH_PREFIX)-feature --base $(BRANCH_PREFIX)-master \ 28 | --title "ghcp: acceptance-test $(BRANCH_PREFIX)" \ 29 | --body "ref: #$(GITHUB_PR_NUMBER)" 30 | sleep 1 31 | 32 | $(GHCP) pull-request $(GHCP_FLAGS) -b $(BRANCH_PREFIX)-feature --base $(BRANCH_PREFIX)-master \ 33 | --title "ghcp: acceptance-test: This should not be created" \ 34 | --body "ref: #$(GITHUB_PR_NUMBER)" 35 | 36 | .PHONY: clean-up 37 | clean-up: 38 | -git push origin --delete $(BRANCH_PREFIX)-master 39 | -git push origin --delete $(BRANCH_PREFIX)-feature 40 | -------------------------------------------------------------------------------- /pkg/git/commitstrategy/commit_strategy.go: -------------------------------------------------------------------------------- 1 | package commitstrategy 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/int128/ghcp/pkg/git" 7 | ) 8 | 9 | // CommitStrategy represents a method to create a commit object. 10 | type CommitStrategy interface { 11 | IsFastForward() bool 12 | IsRebase() bool 13 | RebaseUpstream() git.RefName 14 | NoParent() bool 15 | String() string 16 | } 17 | 18 | type commitStrategy struct { 19 | name string 20 | fastForward bool 21 | rebase bool 22 | noParent bool 23 | } 24 | 25 | func (s *commitStrategy) IsFastForward() bool { return s.fastForward } 26 | func (s *commitStrategy) IsRebase() bool { return s.rebase } 27 | func (s *commitStrategy) RebaseUpstream() git.RefName { return "" } 28 | func (s *commitStrategy) NoParent() bool { return s.noParent } 29 | func (s *commitStrategy) String() string { return s.name } 30 | 31 | // FastForward represents the fast-forward. 32 | var FastForward CommitStrategy = &commitStrategy{name: "fast-forward", fastForward: true} 33 | 34 | // NoParent represents the method to create a commit without any parent 35 | var NoParent CommitStrategy = &commitStrategy{name: "no-parent", noParent: true} 36 | 37 | // RebaseOn represents the rebase on the upstream. 38 | func RebaseOn(upstreamRef git.RefName) CommitStrategy { 39 | return &rebase{ 40 | commitStrategy: commitStrategy{name: fmt.Sprintf("rebase-on-%v", upstreamRef), rebase: true}, 41 | upstreamRef: upstreamRef, 42 | } 43 | } 44 | 45 | type rebase struct { 46 | commitStrategy 47 | upstreamRef git.RefName 48 | } 49 | 50 | func (f *rebase) RebaseUpstream() git.RefName { return f.upstreamRef } 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - .github/workflows/release.yaml 9 | - pkg/** 10 | - '*.go' 11 | - go.* 12 | tags: 13 | - v* 14 | pull_request: 15 | branches: 16 | - master 17 | paths: 18 | - .github/workflows/release.yaml 19 | - pkg/** 20 | - '*.go' 21 | - go.* 22 | 23 | jobs: 24 | build: 25 | strategy: 26 | matrix: 27 | platform: 28 | - runs-on: ubuntu-latest 29 | GOOS: linux 30 | GOARCH: amd64 31 | - runs-on: ubuntu-latest 32 | GOOS: linux 33 | GOARCH: arm64 34 | - runs-on: ubuntu-latest 35 | GOOS: linux 36 | GOARCH: arm 37 | - runs-on: macos-latest 38 | GOOS: darwin 39 | GOARCH: amd64 40 | - runs-on: macos-latest 41 | GOOS: darwin 42 | GOARCH: arm64 43 | - runs-on: windows-latest 44 | GOOS: windows 45 | GOARCH: amd64 46 | runs-on: ${{ matrix.platform.runs-on }} 47 | env: 48 | GOOS: ${{ matrix.platform.GOOS }} 49 | GOARCH: ${{ matrix.platform.GOARCH }} 50 | timeout-minutes: 10 51 | steps: 52 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 53 | - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 54 | with: 55 | go-version-file: go.mod 56 | cache-dependency-path: go.sum 57 | - run: make 58 | - uses: int128/go-release-action@2979cc5b15ceb7ae458e95b0a9467afc7ae25259 # v2.0.0 59 | with: 60 | binary: ghcp 61 | -------------------------------------------------------------------------------- /pkg/github/client/github_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/google/go-github/v74/github" 11 | ) 12 | 13 | func TestNewGitHubClient(t *testing.T) { 14 | ctx := context.TODO() 15 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | authorization := r.Header.Get("authorization") 17 | if want := "Bearer YOUR_TOKEN"; authorization != want { 18 | t.Errorf("authorization wants %s but %s (%s)", want, authorization, r.URL) 19 | } 20 | switch { 21 | case r.Method == "POST" && r.URL.Path == "/api/graphql": 22 | w.Header().Set("content-type", "application/json") 23 | if _, err := fmt.Fprint(w, "{}"); err != nil { 24 | t.Errorf("error while writing body: %s", err) 25 | } 26 | case r.Method == "POST" && r.URL.Path == "/api/v3/repos/owner/repo/git/blobs": 27 | w.Header().Set("content-type", "application/json") 28 | if _, err := fmt.Fprint(w, "{}"); err != nil { 29 | t.Errorf("error while writing body: %s", err) 30 | } 31 | default: 32 | t.Logf("Not found: %s %s", r.Method, r.URL) 33 | http.NotFound(w, r) 34 | } 35 | })) 36 | defer s.Close() 37 | 38 | o := Option{ 39 | Token: "YOUR_TOKEN", 40 | URLv3: s.URL + "/api/v3/", 41 | } 42 | c, err := New(o) 43 | if err != nil { 44 | t.Fatalf("Init returned error: %s", err) 45 | } 46 | 47 | // v4 API 48 | var q struct{} 49 | var v map[string]interface{} 50 | if err := c.Query(ctx, &q, v); err != nil { 51 | t.Errorf("Query returned error: %s", err) 52 | } 53 | 54 | // v3 API 55 | if _, _, err := c.CreateBlob(ctx, "owner", "repo", &github.Blob{}); err != nil { 56 | t.Errorf("CreateBlob returned error: %s", err) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/git/git_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBranchName_QualifiedName(t *testing.T) { 8 | t.Run("Valid", func(t *testing.T) { 9 | qn := BranchName("master").QualifiedName() 10 | want := RefQualifiedName{Prefix: "refs/heads/", Name: "master"} 11 | if qn != want { 12 | t.Errorf("QualifiedName wants %+v but %+v", want, qn) 13 | } 14 | }) 15 | t.Run("Empty", func(t *testing.T) { 16 | qn := BranchName("").QualifiedName() 17 | if qn != (RefQualifiedName{}) { 18 | t.Errorf("QualifiedName wants zero but %+v", qn) 19 | } 20 | }) 21 | } 22 | 23 | func TestRefQualifiedName_IsValid(t *testing.T) { 24 | for _, c := range []struct { 25 | RefQualifiedName RefQualifiedName 26 | Valid bool 27 | }{ 28 | {RefQualifiedName{}, false}, 29 | {RefQualifiedName{Prefix: "refs/heads/"}, false}, 30 | {RefQualifiedName{Name: "master"}, false}, 31 | {RefQualifiedName{Prefix: "refs/heads/", Name: "master"}, true}, 32 | } { 33 | t.Run(c.RefQualifiedName.String(), func(t *testing.T) { 34 | valid := c.RefQualifiedName.IsValid() 35 | if valid != c.Valid { 36 | t.Errorf("IsValid wants %v but %v", c.Valid, valid) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func TestRefQualifiedName_String(t *testing.T) { 43 | for _, c := range []struct { 44 | RefQualifiedName RefQualifiedName 45 | String string 46 | }{ 47 | {RefQualifiedName{}, ""}, 48 | {RefQualifiedName{Prefix: "refs/heads/"}, ""}, 49 | {RefQualifiedName{Name: "master"}, ""}, 50 | {RefQualifiedName{Prefix: "refs/heads/", Name: "master"}, "refs/heads/master"}, 51 | } { 52 | t.Run(c.RefQualifiedName.String(), func(t *testing.T) { 53 | s := c.RefQualifiedName.String() 54 | if s != c.String { 55 | t.Errorf("String wants %v but %v", c.String, s) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/cmd/commit_attribute_options.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/pflag" 6 | 7 | "github.com/int128/ghcp/pkg/git" 8 | ) 9 | 10 | type commitAttributeOptions struct { 11 | CommitMessage string 12 | AuthorName string 13 | AuthorEmail string 14 | CommitterName string 15 | CommitterEmail string 16 | } 17 | 18 | func (o *commitAttributeOptions) register(f *pflag.FlagSet) { 19 | f.StringVarP(&o.CommitMessage, "message", "m", "", "Commit message (mandatory)") 20 | f.StringVarP(&o.AuthorName, "author-name", "", "", "Author name (default: login name)") 21 | f.StringVarP(&o.AuthorEmail, "author-email", "", "", "Author email (default: login email)") 22 | f.StringVarP(&o.CommitterName, "committer-name", "", "", "Committer name (default: login name)") 23 | f.StringVarP(&o.CommitterEmail, "committer-email", "", "", "Committer email (default: login email)") 24 | } 25 | 26 | func (o *commitAttributeOptions) validate() error { 27 | if (o.AuthorName == "" && o.AuthorEmail != "") || (o.AuthorName != "" && o.AuthorEmail == "") { 28 | return fmt.Errorf("you need to set both --author-name and --author-email") 29 | } 30 | if (o.CommitterName == "" && o.CommitterEmail != "") || (o.CommitterName != "" && o.CommitterEmail == "") { 31 | return fmt.Errorf("you need to set both --committer-name and --committer-email") 32 | } 33 | return nil 34 | } 35 | 36 | func (o *commitAttributeOptions) committer() *git.CommitAuthor { 37 | if o.CommitterName != "" && o.CommitterEmail != "" { 38 | return &git.CommitAuthor{ 39 | Name: o.CommitterName, 40 | Email: o.CommitterEmail, 41 | } 42 | } 43 | return nil 44 | } 45 | 46 | func (o *commitAttributeOptions) author() *git.CommitAuthor { 47 | if o.AuthorName != "" && o.AuthorEmail != "" { 48 | return &git.CommitAuthor{ 49 | Name: o.AuthorName, 50 | Email: o.AuthorEmail, 51 | } 52 | } 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /pkg/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/wire" 7 | "github.com/int128/ghcp/pkg/git" 8 | "github.com/int128/ghcp/pkg/github/client" 9 | "github.com/shurcooL/githubv4" 10 | ) 11 | 12 | var Set = wire.NewSet( 13 | wire.Struct(new(GitHub), "*"), 14 | wire.Bind(new(Interface), new(*GitHub)), 15 | ) 16 | 17 | type Interface interface { 18 | CreateFork(ctx context.Context, id git.RepositoryID) (*git.RepositoryID, error) 19 | 20 | QueryForCommit(ctx context.Context, in QueryForCommitInput) (*QueryForCommitOutput, error) 21 | CreateBranch(ctx context.Context, in CreateBranchInput) error 22 | UpdateBranch(ctx context.Context, in UpdateBranchInput) error 23 | CreateCommit(ctx context.Context, commit git.NewCommit) (git.CommitSHA, error) 24 | 25 | QueryCommit(ctx context.Context, in QueryCommitInput) (*QueryCommitOutput, error) 26 | CreateTree(ctx context.Context, tree git.NewTree) (git.TreeSHA, error) 27 | CreateBlob(ctx context.Context, blob git.NewBlob) (git.BlobSHA, error) 28 | 29 | GetReleaseByTagOrNil(ctx context.Context, repo git.RepositoryID, tag git.TagName) (*git.Release, error) 30 | CreateRelease(ctx context.Context, r git.Release) (*git.Release, error) 31 | CreateReleaseAsset(ctx context.Context, a git.ReleaseAsset) error 32 | 33 | QueryForPullRequest(ctx context.Context, in QueryForPullRequestInput) (*QueryForPullRequestOutput, error) 34 | CreatePullRequest(ctx context.Context, in CreatePullRequestInput) (*CreatePullRequestOutput, error) 35 | RequestPullRequestReview(ctx context.Context, in RequestPullRequestReviewInput) error 36 | 37 | QueryDefaultBranch(ctx context.Context, in QueryDefaultBranchInput) (*QueryDefaultBranchOutput, error) 38 | } 39 | 40 | // GitHub provides GitHub API access. 41 | type GitHub struct { 42 | Client client.Interface 43 | } 44 | 45 | type InternalRepositoryNodeID githubv4.ID 46 | type InternalBranchNodeID githubv4.ID 47 | -------------------------------------------------------------------------------- /pkg/github/default_branch.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/int128/ghcp/pkg/git" 10 | "github.com/shurcooL/githubv4" 11 | ) 12 | 13 | type QueryDefaultBranchInput struct { 14 | BaseRepository git.RepositoryID 15 | HeadRepository git.RepositoryID 16 | } 17 | 18 | type QueryDefaultBranchOutput struct { 19 | BaseDefaultBranchName git.BranchName 20 | HeadDefaultBranchName git.BranchName 21 | } 22 | 23 | // QueryDefaultBranch returns the default branch names. 24 | // You can set both repositories or either repository. 25 | func (c *GitHub) QueryDefaultBranch(ctx context.Context, in QueryDefaultBranchInput) (*QueryDefaultBranchOutput, error) { 26 | if !in.BaseRepository.IsValid() || !in.HeadRepository.IsValid() { 27 | return nil, errors.New("you need to set both BaseRepository and HeadRepository") 28 | } 29 | var q struct { 30 | BaseRepository struct { 31 | DefaultBranchRef struct { 32 | Name string 33 | } 34 | } `graphql:"baseRepository: repository(owner: $baseOwner, name: $baseRepo)"` 35 | HeadRepository struct { 36 | DefaultBranchRef struct { 37 | Name string 38 | } 39 | } `graphql:"headRepository: repository(owner: $headOwner, name: $headRepo)"` 40 | } 41 | v := map[string]interface{}{ 42 | "baseOwner": githubv4.String(in.BaseRepository.Owner), 43 | "baseRepo": githubv4.String(in.BaseRepository.Name), 44 | "headOwner": githubv4.String(in.HeadRepository.Owner), 45 | "headRepo": githubv4.String(in.HeadRepository.Name), 46 | } 47 | slog.Debug("Querying the default branch name with", "params", v) 48 | if err := c.Client.Query(ctx, &q, v); err != nil { 49 | return nil, fmt.Errorf("GitHub API error: %w", err) 50 | } 51 | slog.Debug("Got the response", "response", q) 52 | return &QueryDefaultBranchOutput{ 53 | BaseDefaultBranchName: git.BranchName(q.BaseRepository.DefaultBranchRef.Name), 54 | HeadDefaultBranchName: git.BranchName(q.HeadRepository.DefaultBranchRef.Name), 55 | }, nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/github/fork.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "time" 8 | 9 | "github.com/google/go-github/v74/github" 10 | "github.com/int128/ghcp/pkg/git" 11 | "github.com/sethvargo/go-retry" 12 | "github.com/shurcooL/githubv4" 13 | ) 14 | 15 | // CreateFork creates a fork of the repository. 16 | // This returns ID of the fork. 17 | func (c *GitHub) CreateFork(ctx context.Context, id git.RepositoryID) (*git.RepositoryID, error) { 18 | fork, _, err := c.Client.CreateFork(ctx, id.Owner, id.Name, nil) 19 | if err != nil { 20 | if _, ok := err.(*github.AcceptedError); !ok { 21 | return nil, fmt.Errorf("GitHub API error: %w", err) 22 | } 23 | slog.Debug("Fork in progress", "error", err) 24 | } 25 | forkRepository := git.RepositoryID{ 26 | Owner: fork.GetOwner().GetLogin(), 27 | Name: fork.GetName(), 28 | } 29 | if err := c.waitUntilGitDataIsAvailable(ctx, forkRepository); err != nil { 30 | return nil, fmt.Errorf("git data is not available on %s: %w", forkRepository, err) 31 | } 32 | return &forkRepository, nil 33 | } 34 | 35 | func (c *GitHub) waitUntilGitDataIsAvailable(ctx context.Context, id git.RepositoryID) error { 36 | operation := func(ctx context.Context) error { 37 | var q struct { 38 | Repository struct { 39 | DefaultBranchRef struct { 40 | Target struct { 41 | Commit struct { 42 | Oid string 43 | } `graphql:"... on Commit"` 44 | } 45 | } 46 | } `graphql:"repository(owner: $owner, name: $repo)"` 47 | } 48 | v := map[string]any{ 49 | "owner": githubv4.String(id.Owner), 50 | "repo": githubv4.String(id.Name), 51 | } 52 | slog.Debug("Querying the repository", "params", v) 53 | if err := c.Client.Query(ctx, &q, v); err != nil { 54 | return retry.RetryableError(fmt.Errorf("GitHub API error: %w", err)) 55 | } 56 | slog.Debug("Got the response", "response", q) 57 | return nil 58 | } 59 | if err := retry.Exponential(ctx, 500*time.Millisecond, operation); err != nil { 60 | return fmt.Errorf("retry over: %w", err) 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/git/git.go: -------------------------------------------------------------------------------- 1 | // Package git provides the models of Git such as a repository and branch. 2 | package git 3 | 4 | // RepositoryID represents a pointer to a repository. 5 | type RepositoryID struct { 6 | Owner string 7 | Name string 8 | } 9 | 10 | // IsValid returns true if owner and name is not empty. 11 | func (id RepositoryID) IsValid() bool { 12 | return id.Owner != "" && id.Name != "" 13 | } 14 | 15 | func (id RepositoryID) String() string { 16 | if !id.IsValid() { 17 | return "" 18 | } 19 | return id.Owner + "/" + id.Name 20 | } 21 | 22 | // BranchName represents name of a branch. 23 | type BranchName string 24 | 25 | // QualifiedName returns RefQualifiedName. 26 | // If the BranchName is empty, it returns a zero value. 27 | func (b BranchName) QualifiedName() RefQualifiedName { 28 | if b == "" { 29 | return RefQualifiedName{} 30 | } 31 | return RefQualifiedName{"refs/heads/", string(b)} 32 | } 33 | 34 | // TagName represents name of a tag. 35 | type TagName string 36 | 37 | // Name returns the name of tag. 38 | func (t TagName) Name() string { 39 | return string(t) 40 | } 41 | 42 | // QualifiedName returns RefQualifiedName. 43 | // If the TagName is empty, it returns a zero value. 44 | func (t TagName) QualifiedName() RefQualifiedName { 45 | if t == "" { 46 | return RefQualifiedName{} 47 | } 48 | return RefQualifiedName{"refs/tags/", string(t)} 49 | } 50 | 51 | // RefName represents name of a ref, that is a branch or a tag. 52 | // This may be simple name or qualified name. 53 | type RefName string 54 | 55 | // RefQualifiedName represents qualified name of a ref, e.g. refs/heads/master. 56 | type RefQualifiedName struct { 57 | Prefix string 58 | Name string 59 | } 60 | 61 | func (r RefQualifiedName) IsValid() bool { 62 | return r.Prefix != "" && r.Name != "" 63 | } 64 | 65 | func (r RefQualifiedName) String() string { 66 | if !r.IsValid() { 67 | return "" 68 | } 69 | return r.Prefix + r.Name 70 | } 71 | 72 | // NewBranch represents a branch. 73 | type NewBranch struct { 74 | Repository RepositoryID 75 | BranchName BranchName 76 | CommitSHA CommitSHA 77 | } 78 | -------------------------------------------------------------------------------- /pkg/github/release_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/google/go-github/v74/github" 11 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/github/client_mock" 12 | "github.com/int128/ghcp/pkg/git" 13 | ) 14 | 15 | func TestGitHub_GetReleaseByTagOrNil(t *testing.T) { 16 | ctx := context.TODO() 17 | repositoryID := git.RepositoryID{Owner: "owner", Name: "repo"} 18 | 19 | t.Run("Exists", func(t *testing.T) { 20 | var resp github.Response 21 | resp.Response = &http.Response{StatusCode: 200} 22 | gitHubClient := client_mock.NewMockInterface(t) 23 | gitHubClient.EXPECT(). 24 | GetReleaseByTag(ctx, "owner", "repo", "v1.0.0"). 25 | Return(&github.RepositoryRelease{ 26 | ID: github.Ptr(int64(1234567890)), 27 | Name: github.Ptr("ReleaseName"), 28 | TagName: github.Ptr("v1.0.0"), 29 | }, &resp, nil) 30 | gitHub := GitHub{ 31 | Client: gitHubClient, 32 | } 33 | got, err := gitHub.GetReleaseByTagOrNil(ctx, repositoryID, "v1.0.0") 34 | if err != nil { 35 | t.Fatalf("GetReleaseByTagOrNil returned error: %+v", err) 36 | } 37 | want := &git.Release{ 38 | ID: git.ReleaseID{ 39 | Repository: repositoryID, 40 | InternalID: 1234567890, 41 | }, 42 | TagName: "v1.0.0", 43 | Name: "ReleaseName", 44 | } 45 | if diff := cmp.Diff(want, got); diff != "" { 46 | t.Errorf("mismatch (-want +got):\n%s", diff) 47 | } 48 | }) 49 | t.Run("NotExist", func(t *testing.T) { 50 | var resp github.Response 51 | resp.Response = &http.Response{StatusCode: 404} 52 | gitHubClient := client_mock.NewMockInterface(t) 53 | gitHubClient.EXPECT(). 54 | GetReleaseByTag(ctx, "owner", "repo", "v1.0.0"). 55 | Return(nil, &resp, errors.New("not found")) 56 | gitHub := GitHub{ 57 | Client: gitHubClient, 58 | } 59 | got, err := gitHub.GetReleaseByTagOrNil(ctx, repositoryID, "v1.0.0") 60 | if err != nil { 61 | t.Fatalf("GetReleaseByTagOrNil returned error: %+v", err) 62 | } 63 | if got != nil { 64 | t.Errorf("wants nil but got %+v", got) 65 | } 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/di/wire_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by Wire. DO NOT EDIT. 2 | 3 | //go:generate go run -mod=mod github.com/google/wire/cmd/wire 4 | //go:build !wireinject 5 | // +build !wireinject 6 | 7 | package di 8 | 9 | import ( 10 | "github.com/int128/ghcp/pkg/cmd" 11 | "github.com/int128/ghcp/pkg/env" 12 | "github.com/int128/ghcp/pkg/fs" 13 | "github.com/int128/ghcp/pkg/github" 14 | "github.com/int128/ghcp/pkg/github/client" 15 | "github.com/int128/ghcp/pkg/usecases/commit" 16 | "github.com/int128/ghcp/pkg/usecases/forkcommit" 17 | "github.com/int128/ghcp/pkg/usecases/gitobject" 18 | "github.com/int128/ghcp/pkg/usecases/pullrequest" 19 | "github.com/int128/ghcp/pkg/usecases/release" 20 | ) 21 | 22 | // Injectors from di.go: 23 | 24 | func NewCmd() cmd.Interface { 25 | envEnv := &env.Env{} 26 | newFunc := _wireNewFuncValue 27 | newInternalRunnerFunc := _wireNewInternalRunnerFuncValue 28 | runner := &cmd.Runner{ 29 | Env: envEnv, 30 | NewGitHub: newFunc, 31 | NewInternalRunner: newInternalRunnerFunc, 32 | } 33 | return runner 34 | } 35 | 36 | var ( 37 | _wireNewFuncValue = client.NewFunc(client.New) 38 | _wireNewInternalRunnerFuncValue = cmd.NewInternalRunnerFunc(NewCmdInternalRunner) 39 | ) 40 | 41 | func NewCmdInternalRunner(clientInterface client.Interface) *cmd.InternalRunner { 42 | fileSystem := &fs.FileSystem{} 43 | gitHub := &github.GitHub{ 44 | Client: clientInterface, 45 | } 46 | createGitObject := &gitobject.CreateGitObject{ 47 | FileSystem: fileSystem, 48 | GitHub: gitHub, 49 | } 50 | commitCommit := &commit.Commit{ 51 | CreateGitObject: createGitObject, 52 | FileSystem: fileSystem, 53 | GitHub: gitHub, 54 | } 55 | forkCommit := &forkcommit.ForkCommit{ 56 | Commit: commitCommit, 57 | GitHub: gitHub, 58 | } 59 | pullRequest := &pullrequest.PullRequest{ 60 | GitHub: gitHub, 61 | } 62 | releaseRelease := &release.Release{ 63 | FileSystem: fileSystem, 64 | GitHub: gitHub, 65 | } 66 | internalRunner := &cmd.InternalRunner{ 67 | CommitUseCase: commitCommit, 68 | ForkCommitUseCase: forkCommit, 69 | PullRequestUseCase: pullRequest, 70 | ReleaseUseCase: releaseRelease, 71 | } 72 | return internalRunner 73 | } 74 | -------------------------------------------------------------------------------- /pkg/usecases/forkcommit/forkcommit.go: -------------------------------------------------------------------------------- 1 | package forkcommit 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/google/wire" 9 | 10 | "github.com/int128/ghcp/pkg/git" 11 | "github.com/int128/ghcp/pkg/git/commitstrategy" 12 | "github.com/int128/ghcp/pkg/github" 13 | "github.com/int128/ghcp/pkg/usecases/commit" 14 | ) 15 | 16 | var Set = wire.NewSet( 17 | wire.Struct(new(ForkCommit), "*"), 18 | wire.Bind(new(Interface), new(*ForkCommit)), 19 | ) 20 | 21 | type Interface interface { 22 | Do(ctx context.Context, in Input) error 23 | } 24 | 25 | type Input struct { 26 | ParentRepository git.RepositoryID 27 | TargetBranchName git.BranchName 28 | CommitStrategy commitstrategy.CommitStrategy 29 | CommitMessage git.CommitMessage 30 | Author *git.CommitAuthor // optional 31 | Committer *git.CommitAuthor // optional 32 | Paths []string 33 | NoFileMode bool 34 | DryRun bool 35 | } 36 | 37 | type ForkCommit struct { 38 | Commit commit.Interface 39 | GitHub github.Interface 40 | } 41 | 42 | func (u *ForkCommit) Do(ctx context.Context, in Input) error { 43 | if !in.ParentRepository.IsValid() { 44 | return errors.New("you must set GitHub repository") 45 | } 46 | if in.TargetBranchName == "" { 47 | return errors.New("you must set target branch name") 48 | } 49 | if in.CommitMessage == "" { 50 | return errors.New("you must set commit message") 51 | } 52 | if len(in.Paths) == 0 { 53 | return errors.New("you must set one or more paths") 54 | } 55 | 56 | fork, err := u.GitHub.CreateFork(ctx, in.ParentRepository) 57 | if err != nil { 58 | return fmt.Errorf("could not fork the repository: %w", err) 59 | } 60 | if err := u.Commit.Do(ctx, commit.Input{ 61 | TargetRepository: *fork, 62 | TargetBranchName: in.TargetBranchName, 63 | ParentRepository: in.ParentRepository, 64 | CommitStrategy: in.CommitStrategy, 65 | CommitMessage: in.CommitMessage, 66 | Author: in.Author, 67 | Committer: in.Committer, 68 | Paths: in.Paths, 69 | NoFileMode: in.NoFileMode, 70 | DryRun: in.DryRun, 71 | }); err != nil { 72 | return fmt.Errorf("could not fork and commit: %w", err) 73 | } 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /pkg/github/gitobject_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/google/go-github/v74/github" 8 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/github/client_mock" 9 | "github.com/int128/ghcp/pkg/git" 10 | ) 11 | 12 | func TestGitHub_CreateCommit(t *testing.T) { 13 | ctx := context.TODO() 14 | repositoryID := git.RepositoryID{Owner: "owner", Name: "repo"} 15 | 16 | t.Run("SingleParent", func(t *testing.T) { 17 | gitHubClient := client_mock.NewMockInterface(t) 18 | gitHubClient.EXPECT(). 19 | CreateCommit(ctx, "owner", "repo", &github.Commit{ 20 | Message: github.Ptr("message"), 21 | Parents: []*github.Commit{{SHA: github.Ptr("parentCommitSHA")}}, 22 | Tree: &github.Tree{SHA: github.Ptr("treeSHA")}, 23 | }, (*github.CreateCommitOptions)(nil)). 24 | Return(&github.Commit{ 25 | SHA: github.Ptr("commitSHA"), 26 | }, nil, nil) 27 | gitHub := GitHub{ 28 | Client: gitHubClient, 29 | } 30 | commitSHA, err := gitHub.CreateCommit(ctx, git.NewCommit{ 31 | Repository: repositoryID, 32 | Message: "message", 33 | ParentCommitSHA: "parentCommitSHA", 34 | TreeSHA: "treeSHA", 35 | }) 36 | if err != nil { 37 | t.Fatalf("CreateCommit returned error: %+v", err) 38 | } 39 | if commitSHA != "commitSHA" { 40 | t.Errorf("commitSHA wants commitSHA but %s", commitSHA) 41 | } 42 | }) 43 | 44 | t.Run("NoParent", func(t *testing.T) { 45 | gitHubClient := client_mock.NewMockInterface(t) 46 | gitHubClient.EXPECT(). 47 | CreateCommit(ctx, "owner", "repo", &github.Commit{ 48 | Message: github.Ptr("message"), 49 | Tree: &github.Tree{SHA: github.Ptr("treeSHA")}, 50 | }, (*github.CreateCommitOptions)(nil)). 51 | Return(&github.Commit{ 52 | SHA: github.Ptr("commitSHA"), 53 | }, nil, nil) 54 | gitHub := GitHub{ 55 | Client: gitHubClient, 56 | } 57 | commitSHA, err := gitHub.CreateCommit(ctx, git.NewCommit{ 58 | Repository: repositoryID, 59 | Message: "message", 60 | TreeSHA: "treeSHA", 61 | }) 62 | if err != nil { 63 | t.Fatalf("CreateCommit returned error: %+v", err) 64 | } 65 | if commitSHA != "commitSHA" { 66 | t.Errorf("commitSHA wants commitSHA but %s", commitSHA) 67 | } 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /mocks/github.com/int128/ghcp/pkg/cmd_mock/mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package cmd_mock 6 | 7 | import ( 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 12 | // The first argument is typically a *testing.T value. 13 | func NewMockInterface(t interface { 14 | mock.TestingT 15 | Cleanup(func()) 16 | }) *MockInterface { 17 | mock := &MockInterface{} 18 | mock.Mock.Test(t) 19 | 20 | t.Cleanup(func() { mock.AssertExpectations(t) }) 21 | 22 | return mock 23 | } 24 | 25 | // MockInterface is an autogenerated mock type for the Interface type 26 | type MockInterface struct { 27 | mock.Mock 28 | } 29 | 30 | type MockInterface_Expecter struct { 31 | mock *mock.Mock 32 | } 33 | 34 | func (_m *MockInterface) EXPECT() *MockInterface_Expecter { 35 | return &MockInterface_Expecter{mock: &_m.Mock} 36 | } 37 | 38 | // Run provides a mock function for the type MockInterface 39 | func (_mock *MockInterface) Run(args []string, version string) int { 40 | ret := _mock.Called(args, version) 41 | 42 | if len(ret) == 0 { 43 | panic("no return value specified for Run") 44 | } 45 | 46 | var r0 int 47 | if returnFunc, ok := ret.Get(0).(func([]string, string) int); ok { 48 | r0 = returnFunc(args, version) 49 | } else { 50 | r0 = ret.Get(0).(int) 51 | } 52 | return r0 53 | } 54 | 55 | // MockInterface_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run' 56 | type MockInterface_Run_Call struct { 57 | *mock.Call 58 | } 59 | 60 | // Run is a helper method to define mock.On call 61 | // - args []string 62 | // - version string 63 | func (_e *MockInterface_Expecter) Run(args interface{}, version interface{}) *MockInterface_Run_Call { 64 | return &MockInterface_Run_Call{Call: _e.mock.On("Run", args, version)} 65 | } 66 | 67 | func (_c *MockInterface_Run_Call) Run(run func(args []string, version string)) *MockInterface_Run_Call { 68 | _c.Call.Run(func(args mock.Arguments) { 69 | var arg0 []string 70 | if args[0] != nil { 71 | arg0 = args[0].([]string) 72 | } 73 | var arg1 string 74 | if args[1] != nil { 75 | arg1 = args[1].(string) 76 | } 77 | run( 78 | arg0, 79 | arg1, 80 | ) 81 | }) 82 | return _c 83 | } 84 | 85 | func (_c *MockInterface_Run_Call) Return(n int) *MockInterface_Run_Call { 86 | _c.Call.Return(n) 87 | return _c 88 | } 89 | 90 | func (_c *MockInterface_Run_Call) RunAndReturn(run func(args []string, version string) int) *MockInterface_Run_Call { 91 | _c.Call.Return(run) 92 | return _c 93 | } 94 | -------------------------------------------------------------------------------- /pkg/github/release.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/google/go-github/v74/github" 11 | "github.com/int128/ghcp/pkg/git" 12 | ) 13 | 14 | // GetReleaseByTagOrNil returns the release associated to the tag. 15 | // It returns nil if it does not exist. 16 | func (c *GitHub) GetReleaseByTagOrNil(ctx context.Context, repo git.RepositoryID, tag git.TagName) (*git.Release, error) { 17 | slog.Debug("Getting the release associated to the tag", "tag", tag, "repository", repo) 18 | release, resp, err := c.Client.GetReleaseByTag(ctx, repo.Owner, repo.Name, tag.Name()) 19 | if resp != nil && resp.StatusCode == http.StatusNotFound { 20 | slog.Debug("GitHub API returned 404", "tag", tag, "repository", repo, "error", err) 21 | return nil, nil 22 | } 23 | if err != nil { 24 | return nil, fmt.Errorf("GitHub API error: %w", err) 25 | } 26 | return &git.Release{ 27 | ID: git.ReleaseID{ 28 | Repository: repo, 29 | InternalID: release.GetID(), 30 | }, 31 | TagName: git.TagName(release.GetTagName()), 32 | Name: release.GetName(), 33 | }, nil 34 | } 35 | 36 | // CreateRelease creates a release. 37 | func (c *GitHub) CreateRelease(ctx context.Context, r git.Release) (*git.Release, error) { 38 | slog.Debug("Creating a release", "release", r) 39 | release, _, err := c.Client.CreateRelease(ctx, r.ID.Repository.Owner, r.ID.Repository.Name, &github.RepositoryRelease{ 40 | Name: github.Ptr(r.Name), 41 | TagName: github.Ptr(r.TagName.Name()), 42 | TargetCommitish: github.Ptr(r.TargetCommitish), 43 | }) 44 | if err != nil { 45 | return nil, fmt.Errorf("GitHub API error: %w", err) 46 | } 47 | return &git.Release{ 48 | ID: git.ReleaseID{ 49 | Repository: r.ID.Repository, 50 | InternalID: release.GetID(), 51 | }, 52 | TagName: git.TagName(release.GetTagName()), 53 | TargetCommitish: release.GetTargetCommitish(), 54 | Name: release.GetName(), 55 | }, nil 56 | } 57 | 58 | // CreateRelease creates a release asset. 59 | func (c *GitHub) CreateReleaseAsset(ctx context.Context, a git.ReleaseAsset) error { 60 | slog.Debug("Creating a release asset", "asset", a) 61 | f, err := os.Open(a.RealPath) 62 | if err != nil { 63 | return fmt.Errorf("could not open the file: %w", err) 64 | } 65 | defer func() { 66 | if err := f.Close(); err != nil { 67 | slog.Error("Failed to close the file", "error", err) 68 | } 69 | }() 70 | _, _, err = c.Client.UploadReleaseAsset(ctx, a.Release.Repository.Owner, a.Release.Repository.Name, a.Release.InternalID, &github.UploadOptions{ 71 | Name: a.Name, 72 | }, f) 73 | if err != nil { 74 | return fmt.Errorf("GitHub API error: %w", err) 75 | } 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/cmd/forkcommit_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/usecases/forkcommit_mock" 7 | "github.com/int128/ghcp/pkg/git" 8 | "github.com/int128/ghcp/pkg/git/commitstrategy" 9 | "github.com/int128/ghcp/pkg/github/client" 10 | "github.com/int128/ghcp/pkg/usecases/forkcommit" 11 | "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | func TestCmd_Run_forkcommit(t *testing.T) { 15 | t.Run("BasicOptions", func(t *testing.T) { 16 | commitUseCase := forkcommit_mock.NewMockInterface(t) 17 | commitUseCase.EXPECT(). 18 | Do(mock.Anything, forkcommit.Input{ 19 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 20 | TargetBranchName: "topic", 21 | CommitStrategy: commitstrategy.FastForward, 22 | CommitMessage: "commit-message", 23 | Paths: []string{"file1", "file2"}, 24 | }). 25 | Return(nil) 26 | r := Runner{ 27 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 28 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 29 | NewInternalRunner: newInternalRunner(InternalRunner{ForkCommitUseCase: commitUseCase}), 30 | } 31 | args := []string{ 32 | cmdName, 33 | forkCommitCmdName, 34 | "--token", "YOUR_TOKEN", 35 | "-r", "owner/repo", 36 | "-b", "topic", 37 | "-m", "commit-message", 38 | "file1", 39 | "file2", 40 | } 41 | exitCode := r.Run(args, version) 42 | if exitCode != exitCodeOK { 43 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 44 | } 45 | }) 46 | 47 | t.Run("--parent", func(t *testing.T) { 48 | commitUseCase := forkcommit_mock.NewMockInterface(t) 49 | commitUseCase.EXPECT(). 50 | Do(mock.Anything, forkcommit.Input{ 51 | TargetBranchName: "topic", 52 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 53 | CommitStrategy: commitstrategy.RebaseOn("develop"), 54 | CommitMessage: "commit-message", 55 | Paths: []string{"file1", "file2"}, 56 | }). 57 | Return(nil) 58 | r := Runner{ 59 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 60 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 61 | NewInternalRunner: newInternalRunner(InternalRunner{ForkCommitUseCase: commitUseCase}), 62 | } 63 | args := []string{ 64 | cmdName, 65 | forkCommitCmdName, 66 | "--token", "YOUR_TOKEN", 67 | "-u", "owner", 68 | "-r", "repo", 69 | "-m", "commit-message", 70 | "-b", "topic", 71 | "--parent", "develop", 72 | "file1", 73 | "file2", 74 | } 75 | exitCode := r.Run(args, version) 76 | if exitCode != exitCodeOK { 77 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 78 | } 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/usecases/forkcommit/forkcommit_test.go: -------------------------------------------------------------------------------- 1 | package forkcommit 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/github_mock" 8 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/usecases/commit_mock" 9 | "github.com/int128/ghcp/pkg/git" 10 | "github.com/int128/ghcp/pkg/git/commitstrategy" 11 | "github.com/int128/ghcp/pkg/usecases/commit" 12 | ) 13 | 14 | func TestForkCommit_Do(t *testing.T) { 15 | ctx := context.TODO() 16 | parentRepositoryID := git.RepositoryID{Owner: "upstream", Name: "repo"} 17 | forkedRepositoryID := git.RepositoryID{Owner: "owner", Name: "repo"} 18 | 19 | t.Run("FromDefaultBranch", func(t *testing.T) { 20 | in := Input{ 21 | ParentRepository: parentRepositoryID, 22 | TargetBranchName: "topic", 23 | CommitStrategy: commitstrategy.FastForward, 24 | CommitMessage: "message", 25 | Paths: []string{"path"}, 26 | } 27 | 28 | gitHub := github_mock.NewMockInterface(t) 29 | gitHub.EXPECT(). 30 | CreateFork(ctx, parentRepositoryID). 31 | Return(&forkedRepositoryID, nil) 32 | 33 | commitUseCase := commit_mock.NewMockInterface(t) 34 | commitUseCase.EXPECT(). 35 | Do(ctx, commit.Input{ 36 | TargetRepository: forkedRepositoryID, 37 | TargetBranchName: "topic", 38 | ParentRepository: parentRepositoryID, 39 | CommitStrategy: commitstrategy.FastForward, 40 | CommitMessage: "message", 41 | Paths: []string{"path"}, 42 | }). 43 | Return(nil) 44 | 45 | u := ForkCommit{ 46 | Commit: commitUseCase, 47 | GitHub: gitHub, 48 | } 49 | if err := u.Do(ctx, in); err != nil { 50 | t.Errorf("err wants nil but %+v", err) 51 | } 52 | }) 53 | 54 | t.Run("FromBranch", func(t *testing.T) { 55 | in := Input{ 56 | TargetBranchName: "topic", 57 | ParentRepository: parentRepositoryID, 58 | CommitStrategy: commitstrategy.RebaseOn("develop"), 59 | CommitMessage: "message", 60 | Paths: []string{"path"}, 61 | } 62 | 63 | gitHub := github_mock.NewMockInterface(t) 64 | gitHub.EXPECT(). 65 | CreateFork(ctx, parentRepositoryID). 66 | Return(&forkedRepositoryID, nil) 67 | 68 | commitUseCase := commit_mock.NewMockInterface(t) 69 | commitUseCase.EXPECT(). 70 | Do(ctx, commit.Input{ 71 | TargetRepository: forkedRepositoryID, 72 | TargetBranchName: "topic", 73 | ParentRepository: parentRepositoryID, 74 | CommitStrategy: commitstrategy.RebaseOn("develop"), 75 | CommitMessage: "message", 76 | Paths: []string{"path"}, 77 | }). 78 | Return(nil) 79 | 80 | u := ForkCommit{ 81 | Commit: commitUseCase, 82 | GitHub: gitHub, 83 | } 84 | if err := u.Do(ctx, in); err != nil { 85 | t.Errorf("err wants nil but %+v", err) 86 | } 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /mocks/github.com/int128/ghcp/pkg/usecases/commit_mock/mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package commit_mock 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/int128/ghcp/pkg/usecases/commit" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func NewMockInterface(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *MockInterface { 20 | mock := &MockInterface{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // MockInterface is an autogenerated mock type for the Interface type 29 | type MockInterface struct { 30 | mock.Mock 31 | } 32 | 33 | type MockInterface_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *MockInterface) EXPECT() *MockInterface_Expecter { 38 | return &MockInterface_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // Do provides a mock function for the type MockInterface 42 | func (_mock *MockInterface) Do(ctx context.Context, in commit.Input) error { 43 | ret := _mock.Called(ctx, in) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for Do") 47 | } 48 | 49 | var r0 error 50 | if returnFunc, ok := ret.Get(0).(func(context.Context, commit.Input) error); ok { 51 | r0 = returnFunc(ctx, in) 52 | } else { 53 | r0 = ret.Error(0) 54 | } 55 | return r0 56 | } 57 | 58 | // MockInterface_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do' 59 | type MockInterface_Do_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // Do is a helper method to define mock.On call 64 | // - ctx context.Context 65 | // - in commit.Input 66 | func (_e *MockInterface_Expecter) Do(ctx interface{}, in interface{}) *MockInterface_Do_Call { 67 | return &MockInterface_Do_Call{Call: _e.mock.On("Do", ctx, in)} 68 | } 69 | 70 | func (_c *MockInterface_Do_Call) Run(run func(ctx context.Context, in commit.Input)) *MockInterface_Do_Call { 71 | _c.Call.Run(func(args mock.Arguments) { 72 | var arg0 context.Context 73 | if args[0] != nil { 74 | arg0 = args[0].(context.Context) 75 | } 76 | var arg1 commit.Input 77 | if args[1] != nil { 78 | arg1 = args[1].(commit.Input) 79 | } 80 | run( 81 | arg0, 82 | arg1, 83 | ) 84 | }) 85 | return _c 86 | } 87 | 88 | func (_c *MockInterface_Do_Call) Return(err error) *MockInterface_Do_Call { 89 | _c.Call.Return(err) 90 | return _c 91 | } 92 | 93 | func (_c *MockInterface_Do_Call) RunAndReturn(run func(ctx context.Context, in commit.Input) error) *MockInterface_Do_Call { 94 | _c.Call.Return(run) 95 | return _c 96 | } 97 | -------------------------------------------------------------------------------- /mocks/github.com/int128/ghcp/pkg/usecases/release_mock/mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package release_mock 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/int128/ghcp/pkg/usecases/release" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func NewMockInterface(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *MockInterface { 20 | mock := &MockInterface{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // MockInterface is an autogenerated mock type for the Interface type 29 | type MockInterface struct { 30 | mock.Mock 31 | } 32 | 33 | type MockInterface_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *MockInterface) EXPECT() *MockInterface_Expecter { 38 | return &MockInterface_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // Do provides a mock function for the type MockInterface 42 | func (_mock *MockInterface) Do(ctx context.Context, in release.Input) error { 43 | ret := _mock.Called(ctx, in) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for Do") 47 | } 48 | 49 | var r0 error 50 | if returnFunc, ok := ret.Get(0).(func(context.Context, release.Input) error); ok { 51 | r0 = returnFunc(ctx, in) 52 | } else { 53 | r0 = ret.Error(0) 54 | } 55 | return r0 56 | } 57 | 58 | // MockInterface_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do' 59 | type MockInterface_Do_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // Do is a helper method to define mock.On call 64 | // - ctx context.Context 65 | // - in release.Input 66 | func (_e *MockInterface_Expecter) Do(ctx interface{}, in interface{}) *MockInterface_Do_Call { 67 | return &MockInterface_Do_Call{Call: _e.mock.On("Do", ctx, in)} 68 | } 69 | 70 | func (_c *MockInterface_Do_Call) Run(run func(ctx context.Context, in release.Input)) *MockInterface_Do_Call { 71 | _c.Call.Run(func(args mock.Arguments) { 72 | var arg0 context.Context 73 | if args[0] != nil { 74 | arg0 = args[0].(context.Context) 75 | } 76 | var arg1 release.Input 77 | if args[1] != nil { 78 | arg1 = args[1].(release.Input) 79 | } 80 | run( 81 | arg0, 82 | arg1, 83 | ) 84 | }) 85 | return _c 86 | } 87 | 88 | func (_c *MockInterface_Do_Call) Return(err error) *MockInterface_Do_Call { 89 | _c.Call.Return(err) 90 | return _c 91 | } 92 | 93 | func (_c *MockInterface_Do_Call) RunAndReturn(run func(ctx context.Context, in release.Input) error) *MockInterface_Do_Call { 94 | _c.Call.Return(run) 95 | return _c 96 | } 97 | -------------------------------------------------------------------------------- /mocks/github.com/int128/ghcp/pkg/usecases/forkcommit_mock/mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package forkcommit_mock 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/int128/ghcp/pkg/usecases/forkcommit" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func NewMockInterface(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *MockInterface { 20 | mock := &MockInterface{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // MockInterface is an autogenerated mock type for the Interface type 29 | type MockInterface struct { 30 | mock.Mock 31 | } 32 | 33 | type MockInterface_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *MockInterface) EXPECT() *MockInterface_Expecter { 38 | return &MockInterface_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // Do provides a mock function for the type MockInterface 42 | func (_mock *MockInterface) Do(ctx context.Context, in forkcommit.Input) error { 43 | ret := _mock.Called(ctx, in) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for Do") 47 | } 48 | 49 | var r0 error 50 | if returnFunc, ok := ret.Get(0).(func(context.Context, forkcommit.Input) error); ok { 51 | r0 = returnFunc(ctx, in) 52 | } else { 53 | r0 = ret.Error(0) 54 | } 55 | return r0 56 | } 57 | 58 | // MockInterface_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do' 59 | type MockInterface_Do_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // Do is a helper method to define mock.On call 64 | // - ctx context.Context 65 | // - in forkcommit.Input 66 | func (_e *MockInterface_Expecter) Do(ctx interface{}, in interface{}) *MockInterface_Do_Call { 67 | return &MockInterface_Do_Call{Call: _e.mock.On("Do", ctx, in)} 68 | } 69 | 70 | func (_c *MockInterface_Do_Call) Run(run func(ctx context.Context, in forkcommit.Input)) *MockInterface_Do_Call { 71 | _c.Call.Run(func(args mock.Arguments) { 72 | var arg0 context.Context 73 | if args[0] != nil { 74 | arg0 = args[0].(context.Context) 75 | } 76 | var arg1 forkcommit.Input 77 | if args[1] != nil { 78 | arg1 = args[1].(forkcommit.Input) 79 | } 80 | run( 81 | arg0, 82 | arg1, 83 | ) 84 | }) 85 | return _c 86 | } 87 | 88 | func (_c *MockInterface_Do_Call) Return(err error) *MockInterface_Do_Call { 89 | _c.Call.Return(err) 90 | return _c 91 | } 92 | 93 | func (_c *MockInterface_Do_Call) RunAndReturn(run func(ctx context.Context, in forkcommit.Input) error) *MockInterface_Do_Call { 94 | _c.Call.Return(run) 95 | return _c 96 | } 97 | -------------------------------------------------------------------------------- /mocks/github.com/int128/ghcp/pkg/usecases/pullrequest_mock/mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package pullrequest_mock 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/int128/ghcp/pkg/usecases/pullrequest" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func NewMockInterface(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *MockInterface { 20 | mock := &MockInterface{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // MockInterface is an autogenerated mock type for the Interface type 29 | type MockInterface struct { 30 | mock.Mock 31 | } 32 | 33 | type MockInterface_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *MockInterface) EXPECT() *MockInterface_Expecter { 38 | return &MockInterface_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // Do provides a mock function for the type MockInterface 42 | func (_mock *MockInterface) Do(ctx context.Context, in pullrequest.Input) error { 43 | ret := _mock.Called(ctx, in) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for Do") 47 | } 48 | 49 | var r0 error 50 | if returnFunc, ok := ret.Get(0).(func(context.Context, pullrequest.Input) error); ok { 51 | r0 = returnFunc(ctx, in) 52 | } else { 53 | r0 = ret.Error(0) 54 | } 55 | return r0 56 | } 57 | 58 | // MockInterface_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do' 59 | type MockInterface_Do_Call struct { 60 | *mock.Call 61 | } 62 | 63 | // Do is a helper method to define mock.On call 64 | // - ctx context.Context 65 | // - in pullrequest.Input 66 | func (_e *MockInterface_Expecter) Do(ctx interface{}, in interface{}) *MockInterface_Do_Call { 67 | return &MockInterface_Do_Call{Call: _e.mock.On("Do", ctx, in)} 68 | } 69 | 70 | func (_c *MockInterface_Do_Call) Run(run func(ctx context.Context, in pullrequest.Input)) *MockInterface_Do_Call { 71 | _c.Call.Run(func(args mock.Arguments) { 72 | var arg0 context.Context 73 | if args[0] != nil { 74 | arg0 = args[0].(context.Context) 75 | } 76 | var arg1 pullrequest.Input 77 | if args[1] != nil { 78 | arg1 = args[1].(pullrequest.Input) 79 | } 80 | run( 81 | arg0, 82 | arg1, 83 | ) 84 | }) 85 | return _c 86 | } 87 | 88 | func (_c *MockInterface_Do_Call) Return(err error) *MockInterface_Do_Call { 89 | _c.Call.Return(err) 90 | return _c 91 | } 92 | 93 | func (_c *MockInterface_Do_Call) RunAndReturn(run func(ctx context.Context, in pullrequest.Input) error) *MockInterface_Do_Call { 94 | _c.Call.Return(run) 95 | return _c 96 | } 97 | -------------------------------------------------------------------------------- /pkg/usecases/release/release.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "path/filepath" 9 | 10 | "github.com/google/wire" 11 | "github.com/int128/ghcp/pkg/fs" 12 | "github.com/int128/ghcp/pkg/git" 13 | "github.com/int128/ghcp/pkg/github" 14 | ) 15 | 16 | var Set = wire.NewSet( 17 | wire.Struct(new(Release), "*"), 18 | wire.Bind(new(Interface), new(*Release)), 19 | ) 20 | 21 | type Interface interface { 22 | Do(ctx context.Context, in Input) error 23 | } 24 | 25 | type Input struct { 26 | Repository git.RepositoryID 27 | TagName git.TagName 28 | TargetBranchOrCommitSHA string // optional 29 | Paths []string 30 | DryRun bool 31 | } 32 | 33 | // Release create a release with the files to the tag in the repository. 34 | type Release struct { 35 | FileSystem fs.Interface 36 | GitHub github.Interface 37 | } 38 | 39 | func (u *Release) Do(ctx context.Context, in Input) error { 40 | if !in.Repository.IsValid() { 41 | return errors.New("you must set GitHub repository") 42 | } 43 | if in.TagName == "" { 44 | return errors.New("you must set the tag name") 45 | } 46 | if len(in.Paths) == 0 { 47 | return errors.New("you must set one or more paths") 48 | } 49 | 50 | files, err := u.FileSystem.FindFiles(in.Paths, nil) 51 | if err != nil { 52 | return fmt.Errorf("could not find files: %w", err) 53 | } 54 | if len(files) == 0 { 55 | return errors.New("no file exists in given paths") 56 | } 57 | 58 | release, err := u.GitHub.GetReleaseByTagOrNil(ctx, in.Repository, in.TagName) 59 | if err != nil { 60 | return fmt.Errorf("could not get the release: %w", err) 61 | } 62 | if release == nil { 63 | slog.Info("No release on the tag", "tag", in.TagName) 64 | if in.DryRun { 65 | slog.Info("Do not create a release due to dry-run") 66 | return nil 67 | } 68 | release, err = u.GitHub.CreateRelease(ctx, git.Release{ 69 | ID: git.ReleaseID{Repository: in.Repository}, 70 | Name: in.TagName.Name(), 71 | TagName: in.TagName, 72 | TargetCommitish: in.TargetBranchOrCommitSHA, 73 | }) 74 | if err != nil { 75 | return fmt.Errorf("could not create a release: %w", err) 76 | } 77 | slog.Info("Created a release", "release", release.Name) 78 | } else { 79 | slog.Info("Found the release on the tag", "tag", in.TagName) 80 | } 81 | 82 | if in.DryRun { 83 | slog.Info("Do not upload files to the release due to dry-run", "release", release.Name) 84 | return nil 85 | } 86 | slog.Info("Uploading", "files", len(files)) 87 | for _, file := range files { 88 | if err := u.GitHub.CreateReleaseAsset(ctx, git.ReleaseAsset{ 89 | Release: release.ID, 90 | Name: filepath.Base(file.Path), 91 | RealPath: file.Path, 92 | }); err != nil { 93 | return fmt.Errorf("could not create a release asset: %w", err) 94 | } 95 | slog.Info("Uploaded", "file", file.Path) 96 | } 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /pkg/cmd/release.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/int128/ghcp/pkg/git" 10 | "github.com/int128/ghcp/pkg/usecases/release" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | ) 14 | 15 | const releaseCmdExample = ` To upload files to the release associated to tag TAG: 16 | ghcp release -r OWNER/REPO -t TAG FILES... 17 | 18 | If the release does not exist, it will create a release. 19 | If the tag does not exist, it will create a tag from the default branch and create a release. 20 | 21 | To create a tag and release on commit COMMIT_SHA and upload files to the release: 22 | ghcp release -r OWNER/REPO -t TAG --tagret COMMIT_SHA FILES... 23 | 24 | If the tag already exists, it ignores the target commit. 25 | If the release already exist, it only uploads the files. 26 | ` 27 | 28 | func (r *Runner) newReleaseCmd(ctx context.Context, gOpts *globalOptions) *cobra.Command { 29 | var o releaseOptions 30 | c := &cobra.Command{ 31 | Use: fmt.Sprintf("%s [flags] FILES...", releaseCmdName), 32 | Short: "Release files to the repository", 33 | Long: `This uploads the files to the release associated to the tag. It will create a release if it does not exist.`, 34 | Example: releaseCmdExample, 35 | RunE: func(_ *cobra.Command, args []string) error { 36 | if err := o.validate(); err != nil { 37 | return fmt.Errorf("invalid flag: %w", err) 38 | } 39 | targetRepository, err := o.repositoryID() 40 | if err != nil { 41 | return fmt.Errorf("invalid flag: %w", err) 42 | } 43 | 44 | ir, err := r.newInternalRunner(gOpts) 45 | if err != nil { 46 | return fmt.Errorf("error while bootstrap of the dependencies: %w", err) 47 | } 48 | in := release.Input{ 49 | Repository: targetRepository, 50 | TagName: git.TagName(o.TagName), 51 | TargetBranchOrCommitSHA: o.TargetBranchOrCommitSHA, 52 | Paths: args, 53 | DryRun: o.DryRun, 54 | } 55 | if err := ir.ReleaseUseCase.Do(ctx, in); err != nil { 56 | slog.Debug("Stacktrace", "stacktrace", err) 57 | return fmt.Errorf("could not release the files: %s", err) 58 | } 59 | return nil 60 | }, 61 | } 62 | o.register(c.Flags()) 63 | return c 64 | } 65 | 66 | type releaseOptions struct { 67 | repositoryOptions 68 | 69 | TagName string 70 | TargetBranchOrCommitSHA string 71 | DryRun bool 72 | } 73 | 74 | func (o releaseOptions) validate() error { 75 | if o.TagName == "" { 76 | return errors.New("you need to set --tag") 77 | } 78 | return nil 79 | } 80 | 81 | func (o *releaseOptions) register(f *pflag.FlagSet) { 82 | o.repositoryOptions.register(f) 83 | f.StringVarP(&o.TagName, "tag", "t", "", "Tag name (mandatory)") 84 | f.StringVar(&o.TargetBranchOrCommitSHA, "target", "", "Branch name or commit SHA of a tag. Unused if the Git tag already exists (default: the default branch)") 85 | f.BoolVar(&o.DryRun, "dry-run", false, "Do not create a release and assets actually") 86 | } 87 | -------------------------------------------------------------------------------- /pkg/fs/fs.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/google/wire" 13 | ) 14 | 15 | var Set = wire.NewSet( 16 | wire.Struct(new(FileSystem)), 17 | wire.Bind(new(Interface), new(*FileSystem)), 18 | ) 19 | 20 | type Interface interface { 21 | FindFiles(paths []string, filter FindFilesFilter) ([]File, error) 22 | ReadAsBase64EncodedContent(filename string) (string, error) 23 | } 24 | 25 | // FindFilesFilter is an interface to filter directories and files. 26 | type FindFilesFilter interface { 27 | SkipDir(path string) bool // If true, it skips entering the directory 28 | ExcludeFile(path string) bool // If true, it excludes the file from the result 29 | } 30 | 31 | type nullFindFilesFilter struct{} 32 | 33 | func (*nullFindFilesFilter) SkipDir(string) bool { return false } 34 | func (*nullFindFilesFilter) ExcludeFile(string) bool { return false } 35 | 36 | type File struct { 37 | Path string 38 | Executable bool 39 | } 40 | 41 | // FileSystem provides manipulation of file system. 42 | type FileSystem struct{} 43 | 44 | // FindFiles returns a list of files in the paths. 45 | // If the filter is nil, it returns any files. 46 | func (fs *FileSystem) FindFiles(paths []string, filter FindFilesFilter) ([]File, error) { 47 | if filter == nil { 48 | filter = &nullFindFilesFilter{} 49 | } 50 | var files []File 51 | for _, path := range paths { 52 | if err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error { 53 | if err != nil { 54 | return fmt.Errorf("error while walk: %w", err) 55 | } 56 | if info.Mode().IsDir() { 57 | if filter.SkipDir(path) { 58 | return filepath.SkipDir 59 | } 60 | return nil 61 | } 62 | if info.Mode().IsRegular() { 63 | if filter.ExcludeFile(path) { 64 | return nil 65 | } 66 | files = append(files, File{ 67 | Path: path, 68 | Executable: info.Mode()&0100 != 0, // mask the executable bit of owner 69 | }) 70 | return nil 71 | } 72 | return nil 73 | }); err != nil { 74 | return nil, fmt.Errorf("error while finding files in %s: %w", path, err) 75 | } 76 | } 77 | return files, nil 78 | } 79 | 80 | // ReadAsBase64EncodedContent returns content of the file as base64 encoded string. 81 | func (fs *FileSystem) ReadAsBase64EncodedContent(filename string) (string, error) { 82 | r, err := os.Open(filename) 83 | if err != nil { 84 | return "", fmt.Errorf("error while opening file %s: %w", filename, err) 85 | } 86 | defer func() { 87 | if err := r.Close(); err != nil { 88 | slog.Error("Failed to close the file", "error", err) 89 | } 90 | }() 91 | var s strings.Builder 92 | e := base64.NewEncoder(base64.StdEncoding, &s) 93 | if _, err := io.Copy(e, r); err != nil { 94 | return "", fmt.Errorf("error while encoding file %s: %w", filename, err) 95 | } 96 | if err := e.Close(); err != nil { 97 | return "", fmt.Errorf("error while encoding file %s: %w", filename, err) 98 | } 99 | return s.String(), nil 100 | } 101 | -------------------------------------------------------------------------------- /pkg/cmd/forkcommit.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/pflag" 11 | 12 | "github.com/int128/ghcp/pkg/git" 13 | "github.com/int128/ghcp/pkg/git/commitstrategy" 14 | "github.com/int128/ghcp/pkg/usecases/forkcommit" 15 | ) 16 | 17 | func (r *Runner) newForkCommitCmd(ctx context.Context, gOpts *globalOptions) *cobra.Command { 18 | var o forkCommitOptions 19 | c := &cobra.Command{ 20 | Use: fmt.Sprintf("%s [flags] FILES...", forkCommitCmdName), 21 | Short: "Fork the repository and commit files to a branch", 22 | Long: `This forks the repository and commits the files to a new branch.`, 23 | RunE: func(_ *cobra.Command, args []string) error { 24 | if err := o.validate(); err != nil { 25 | return fmt.Errorf("invalid flag: %w", err) 26 | } 27 | upstreamRepository, err := o.Upstream.repositoryID() 28 | if err != nil { 29 | return fmt.Errorf("invalid flag: %w", err) 30 | } 31 | 32 | ir, err := r.newInternalRunner(gOpts) 33 | if err != nil { 34 | return fmt.Errorf("error while bootstrap of the dependencies: %w", err) 35 | } 36 | in := forkcommit.Input{ 37 | ParentRepository: upstreamRepository, 38 | TargetBranchName: git.BranchName(o.TargetBranchName), 39 | CommitStrategy: o.commitStrategy(), 40 | CommitMessage: git.CommitMessage(o.CommitMessage), 41 | Author: o.author(), 42 | Committer: o.committer(), 43 | Paths: args, 44 | NoFileMode: o.NoFileMode, 45 | DryRun: o.DryRun, 46 | } 47 | if err := ir.ForkCommitUseCase.Do(ctx, in); err != nil { 48 | slog.Debug("Stacktrace", "stacktrace", err) 49 | return fmt.Errorf("could not commit the files: %s", err) 50 | } 51 | return nil 52 | }, 53 | } 54 | o.register(c.Flags()) 55 | return c 56 | } 57 | 58 | type forkCommitOptions struct { 59 | commitAttributeOptions 60 | Upstream repositoryOptions 61 | 62 | UpstreamBranchName string 63 | TargetBranchName string 64 | NoFileMode bool 65 | DryRun bool 66 | } 67 | 68 | func (o forkCommitOptions) validate() error { 69 | if o.TargetBranchName == "" { 70 | return errors.New("--branch is missing") 71 | } 72 | if err := o.commitAttributeOptions.validate(); err != nil { 73 | return fmt.Errorf("%w", err) 74 | } 75 | return nil 76 | } 77 | 78 | func (o forkCommitOptions) commitStrategy() commitstrategy.CommitStrategy { 79 | if o.UpstreamBranchName != "" { 80 | return commitstrategy.RebaseOn(git.RefName(o.UpstreamBranchName)) 81 | } 82 | return commitstrategy.FastForward 83 | } 84 | 85 | func (o *forkCommitOptions) register(f *pflag.FlagSet) { 86 | f.StringVarP(&o.Upstream.RepositoryName, "repo", "r", "", "Upstream repository name, either -r OWNER/REPO or -u OWNER -r REPO (mandatory)") 87 | f.StringVarP(&o.Upstream.RepositoryOwner, "owner", "u", "", "Upstream repository owner") 88 | f.StringVar(&o.UpstreamBranchName, "parent", "", "Upstream branch name (default: the default branch of the upstream repository)") 89 | f.StringVarP(&o.TargetBranchName, "branch", "b", "", "Name of the branch to create (mandatory)") 90 | f.BoolVar(&o.NoFileMode, "no-file-mode", false, "Ignore executable bit of file and treat as 0644") 91 | f.BoolVar(&o.DryRun, "dry-run", false, "Upload files but do not update the branch actually") 92 | o.commitAttributeOptions.register(f) 93 | } 94 | -------------------------------------------------------------------------------- /mocks/github.com/int128/ghcp/pkg/usecases/gitobject_mock/mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package gitobject_mock 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/int128/ghcp/pkg/usecases/gitobject" 11 | mock "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 15 | // The first argument is typically a *testing.T value. 16 | func NewMockInterface(t interface { 17 | mock.TestingT 18 | Cleanup(func()) 19 | }) *MockInterface { 20 | mock := &MockInterface{} 21 | mock.Mock.Test(t) 22 | 23 | t.Cleanup(func() { mock.AssertExpectations(t) }) 24 | 25 | return mock 26 | } 27 | 28 | // MockInterface is an autogenerated mock type for the Interface type 29 | type MockInterface struct { 30 | mock.Mock 31 | } 32 | 33 | type MockInterface_Expecter struct { 34 | mock *mock.Mock 35 | } 36 | 37 | func (_m *MockInterface) EXPECT() *MockInterface_Expecter { 38 | return &MockInterface_Expecter{mock: &_m.Mock} 39 | } 40 | 41 | // Do provides a mock function for the type MockInterface 42 | func (_mock *MockInterface) Do(ctx context.Context, in gitobject.Input) (*gitobject.Output, error) { 43 | ret := _mock.Called(ctx, in) 44 | 45 | if len(ret) == 0 { 46 | panic("no return value specified for Do") 47 | } 48 | 49 | var r0 *gitobject.Output 50 | var r1 error 51 | if returnFunc, ok := ret.Get(0).(func(context.Context, gitobject.Input) (*gitobject.Output, error)); ok { 52 | return returnFunc(ctx, in) 53 | } 54 | if returnFunc, ok := ret.Get(0).(func(context.Context, gitobject.Input) *gitobject.Output); ok { 55 | r0 = returnFunc(ctx, in) 56 | } else { 57 | if ret.Get(0) != nil { 58 | r0 = ret.Get(0).(*gitobject.Output) 59 | } 60 | } 61 | if returnFunc, ok := ret.Get(1).(func(context.Context, gitobject.Input) error); ok { 62 | r1 = returnFunc(ctx, in) 63 | } else { 64 | r1 = ret.Error(1) 65 | } 66 | return r0, r1 67 | } 68 | 69 | // MockInterface_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do' 70 | type MockInterface_Do_Call struct { 71 | *mock.Call 72 | } 73 | 74 | // Do is a helper method to define mock.On call 75 | // - ctx context.Context 76 | // - in gitobject.Input 77 | func (_e *MockInterface_Expecter) Do(ctx interface{}, in interface{}) *MockInterface_Do_Call { 78 | return &MockInterface_Do_Call{Call: _e.mock.On("Do", ctx, in)} 79 | } 80 | 81 | func (_c *MockInterface_Do_Call) Run(run func(ctx context.Context, in gitobject.Input)) *MockInterface_Do_Call { 82 | _c.Call.Run(func(args mock.Arguments) { 83 | var arg0 context.Context 84 | if args[0] != nil { 85 | arg0 = args[0].(context.Context) 86 | } 87 | var arg1 gitobject.Input 88 | if args[1] != nil { 89 | arg1 = args[1].(gitobject.Input) 90 | } 91 | run( 92 | arg0, 93 | arg1, 94 | ) 95 | }) 96 | return _c 97 | } 98 | 99 | func (_c *MockInterface_Do_Call) Return(output *gitobject.Output, err error) *MockInterface_Do_Call { 100 | _c.Call.Return(output, err) 101 | return _c 102 | } 103 | 104 | func (_c *MockInterface_Do_Call) RunAndReturn(run func(ctx context.Context, in gitobject.Input) (*gitobject.Output, error)) *MockInterface_Do_Call { 105 | _c.Call.Return(run) 106 | return _c 107 | } 108 | -------------------------------------------------------------------------------- /pkg/cmd/empty_commit.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | 11 | "github.com/int128/ghcp/pkg/git" 12 | "github.com/int128/ghcp/pkg/git/commitstrategy" 13 | "github.com/int128/ghcp/pkg/usecases/commit" 14 | ) 15 | 16 | const emptyCommitCmdExample = ` To create an empty commit to the default branch: 17 | ghcp empty-commit -r OWNER/REPO -m MESSAGE 18 | 19 | To create an empty commit to the branch: 20 | ghcp empty-commit -r OWNER/REPO -b BRANCH -m MESSAGE 21 | 22 | If the branch does not exist, ghcp creates a branch from the default branch. 23 | It the branch exists, ghcp updates the branch by fast-forward. 24 | 25 | To create an empty commit to a new branch from the parent branch: 26 | ghcp empty-commit -r OWNER/REPO -b BRANCH --parent PARENT -m MESSAGE 27 | 28 | If the branch exists, it will fail.` 29 | 30 | func (r *Runner) newEmptyCommitCmd(ctx context.Context, gOpts *globalOptions) *cobra.Command { 31 | var o emptyCommitOptions 32 | c := &cobra.Command{ 33 | Use: fmt.Sprintf("%s [flags]", emptyCommitCmdName), 34 | Short: "Create an empty commit to the branch", 35 | Long: `This creates an empty commit to the branch. This will create a branch if it does not exist.`, 36 | Example: emptyCommitCmdExample, 37 | Args: cobra.NoArgs, 38 | RunE: func(*cobra.Command, []string) error { 39 | if err := o.validate(); err != nil { 40 | return fmt.Errorf("invalid flag: %w", err) 41 | } 42 | targetRepository, err := o.repositoryID() 43 | if err != nil { 44 | return fmt.Errorf("invalid flag: %w", err) 45 | } 46 | 47 | ir, err := r.newInternalRunner(gOpts) 48 | if err != nil { 49 | return fmt.Errorf("error while bootstrap of the dependencies: %w", err) 50 | } 51 | in := commit.Input{ 52 | TargetRepository: targetRepository, 53 | TargetBranchName: git.BranchName(o.BranchName), 54 | ParentRepository: targetRepository, 55 | CommitStrategy: o.commitStrategy(), 56 | CommitMessage: git.CommitMessage(o.CommitMessage), 57 | Author: o.author(), 58 | Committer: o.committer(), 59 | DryRun: o.DryRun, 60 | } 61 | if err := ir.CommitUseCase.Do(ctx, in); err != nil { 62 | slog.Debug("Stacktrace", "stacktrace", err) 63 | return fmt.Errorf("could not create an empty commit: %s", err) 64 | } 65 | return nil 66 | }, 67 | } 68 | o.register(c.Flags()) 69 | return c 70 | } 71 | 72 | type emptyCommitOptions struct { 73 | commitAttributeOptions 74 | repositoryOptions 75 | 76 | BranchName string 77 | ParentRef string 78 | DryRun bool 79 | } 80 | 81 | func (o emptyCommitOptions) validate() error { 82 | if err := o.commitAttributeOptions.validate(); err != nil { 83 | return fmt.Errorf("%w", err) 84 | } 85 | return nil 86 | } 87 | 88 | func (o emptyCommitOptions) commitStrategy() commitstrategy.CommitStrategy { 89 | if o.ParentRef != "" { 90 | return commitstrategy.RebaseOn(git.RefName(o.ParentRef)) 91 | } 92 | return commitstrategy.FastForward 93 | } 94 | 95 | func (o *emptyCommitOptions) register(f *pflag.FlagSet) { 96 | o.repositoryOptions.register(f) 97 | f.StringVarP(&o.BranchName, "branch", "b", "", "Name of the branch to create or update (default: the default branch of repository)") 98 | f.StringVar(&o.ParentRef, "parent", "", "Create a commit from the parent branch/tag (default: fast-forward)") 99 | f.BoolVar(&o.DryRun, "dry-run", false, "Do not update the branch actually") 100 | o.commitAttributeOptions.register(f) 101 | } 102 | -------------------------------------------------------------------------------- /pkg/github/client/github.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | 10 | "github.com/google/go-github/v74/github" 11 | "github.com/google/wire" 12 | "github.com/shurcooL/githubv4" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | var Set = wire.NewSet( 17 | wire.Value(NewFunc(New)), 18 | ) 19 | 20 | type NewFunc func(Option) (Interface, error) 21 | 22 | type Interface interface { 23 | QueryService 24 | GitService 25 | RepositoriesService 26 | } 27 | 28 | type QueryService interface { 29 | Query(ctx context.Context, q interface{}, variables map[string]interface{}) error 30 | Mutate(ctx context.Context, m interface{}, input githubv4.Input, variables map[string]interface{}) error 31 | } 32 | 33 | type GitService interface { 34 | CreateCommit(ctx context.Context, owner string, repo string, commit *github.Commit, opts *github.CreateCommitOptions) (*github.Commit, *github.Response, error) 35 | CreateTree(ctx context.Context, owner string, repo string, baseTree string, entries []*github.TreeEntry) (*github.Tree, *github.Response, error) 36 | CreateBlob(ctx context.Context, owner string, repo string, blob *github.Blob) (*github.Blob, *github.Response, error) 37 | } 38 | 39 | type RepositoriesService interface { 40 | CreateFork(ctx context.Context, owner, repo string, opt *github.RepositoryCreateForkOptions) (*github.Repository, *github.Response, error) 41 | GetReleaseByTag(ctx context.Context, owner, repo, tag string) (*github.RepositoryRelease, *github.Response, error) 42 | CreateRelease(ctx context.Context, owner, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) 43 | UploadReleaseAsset(ctx context.Context, owner, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) 44 | } 45 | 46 | type Option struct { 47 | // A token for GitHub API. 48 | Token string 49 | 50 | // GitHub API v3 URL (for GitHub Enterprise). 51 | // e.g. https://github.example.com/api/v3/ 52 | URLv3 string 53 | } 54 | 55 | type clientSet struct { 56 | QueryService 57 | GitService 58 | RepositoriesService 59 | } 60 | 61 | func New(o Option) (Interface, error) { 62 | v4, v3, err := newClients(o) 63 | if err != nil { 64 | return nil, fmt.Errorf("error while initializing GitHub client: %w", err) 65 | } 66 | return &clientSet{ 67 | QueryService: v4, 68 | GitService: v3.Git, 69 | RepositoriesService: v3.Repositories, 70 | }, nil 71 | } 72 | 73 | func newClients(o Option) (*githubv4.Client, *github.Client, error) { 74 | hc := &http.Client{ 75 | Transport: &oauth2.Transport{ 76 | Base: http.DefaultTransport, 77 | Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: o.Token}), 78 | }, 79 | } 80 | if o.URLv3 != "" { 81 | // https://developer.github.com/enterprise/2.16/v3/ 82 | v3, err := github.NewClient(hc).WithEnterpriseURLs(o.URLv3, o.URLv3) 83 | if err != nil { 84 | return nil, nil, fmt.Errorf("error while creating a GitHub v3 client: %w", err) 85 | } 86 | // https://developer.github.com/enterprise/2.16/v4/guides/forming-calls/ 87 | v4URL, err := buildV4URL(v3.BaseURL) 88 | if err != nil { 89 | return nil, nil, fmt.Errorf("error while creating a GitHub v4 client: %w", err) 90 | } 91 | v4 := githubv4.NewEnterpriseClient(v4URL, hc) 92 | return v4, v3, nil 93 | } 94 | v4 := githubv4.NewClient(hc) 95 | v3 := github.NewClient(hc) 96 | return v4, v3, nil 97 | } 98 | 99 | func buildV4URL(v3 *url.URL) (string, error) { 100 | v4, err := v3.Parse("../graphql") 101 | if err != nil { 102 | return "", fmt.Errorf("error while building v4 URL: %w", err) 103 | } 104 | return v4.String(), nil 105 | } 106 | -------------------------------------------------------------------------------- /pkg/usecases/gitobject/create.go: -------------------------------------------------------------------------------- 1 | // Package gitobject provides the internal use-case for a set of blob, tree and commit. 2 | package gitobject 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/google/wire" 10 | 11 | "github.com/int128/ghcp/pkg/fs" 12 | "github.com/int128/ghcp/pkg/git" 13 | "github.com/int128/ghcp/pkg/github" 14 | ) 15 | 16 | var Set = wire.NewSet( 17 | wire.Struct(new(CreateGitObject), "*"), 18 | wire.Bind(new(Interface), new(*CreateGitObject)), 19 | ) 20 | 21 | type Interface interface { 22 | Do(ctx context.Context, in Input) (*Output, error) 23 | } 24 | 25 | type Input struct { 26 | Files []fs.File // nil or empty to create an empty commit 27 | Repository git.RepositoryID 28 | CommitMessage git.CommitMessage 29 | Author *git.CommitAuthor // optional 30 | Committer *git.CommitAuthor // optional 31 | ParentCommitSHA git.CommitSHA // no parent if empty 32 | ParentTreeSHA git.TreeSHA // no parent if empty 33 | NoFileMode bool 34 | } 35 | 36 | type Output struct { 37 | CommitSHA git.CommitSHA 38 | ChangedFiles int 39 | } 40 | 41 | // CreateGitObject creates blob(s), a tree and a commit. 42 | type CreateGitObject struct { 43 | FileSystem fs.Interface 44 | GitHub github.Interface 45 | } 46 | 47 | func (u *CreateGitObject) Do(ctx context.Context, in Input) (*Output, error) { 48 | treeSHA, err := u.uploadFilesIfSet(ctx, in) 49 | if err != nil { 50 | return nil, fmt.Errorf("error while creating a tree: %w", err) 51 | } 52 | 53 | commitSHA, err := u.GitHub.CreateCommit(ctx, git.NewCommit{ 54 | Repository: in.Repository, 55 | Message: in.CommitMessage, 56 | Author: in.Author, 57 | Committer: in.Committer, 58 | ParentCommitSHA: in.ParentCommitSHA, 59 | TreeSHA: treeSHA, 60 | }) 61 | if err != nil { 62 | return nil, fmt.Errorf("error while creating a commit: %w", err) 63 | } 64 | slog.Info("Created commit", "sha", commitSHA) 65 | 66 | commit, err := u.GitHub.QueryCommit(ctx, github.QueryCommitInput{ 67 | Repository: in.Repository, 68 | CommitSHA: commitSHA, 69 | }) 70 | if err != nil { 71 | return nil, fmt.Errorf("error while getting the commit %s: %w", commitSHA, err) 72 | } 73 | 74 | return &Output{ 75 | CommitSHA: commitSHA, 76 | ChangedFiles: commit.ChangedFiles, 77 | }, nil 78 | } 79 | 80 | func (u *CreateGitObject) uploadFilesIfSet(ctx context.Context, in Input) (git.TreeSHA, error) { 81 | if len(in.Files) == 0 { 82 | slog.Debug("Using the parent tree", "tree", in.ParentTreeSHA) 83 | return in.ParentTreeSHA, nil 84 | } 85 | 86 | files := make([]git.File, len(in.Files)) 87 | for i, file := range in.Files { 88 | content, err := u.FileSystem.ReadAsBase64EncodedContent(file.Path) 89 | if err != nil { 90 | return "", fmt.Errorf("error while reading file %s: %w", file.Path, err) 91 | } 92 | blobSHA, err := u.GitHub.CreateBlob(ctx, git.NewBlob{ 93 | Repository: in.Repository, 94 | Content: content, 95 | }) 96 | if err != nil { 97 | return "", fmt.Errorf("error while creating a blob for %s: %w", file.Path, err) 98 | } 99 | gitFile := git.File{ 100 | Filename: file.Path, 101 | BlobSHA: blobSHA, 102 | Executable: !in.NoFileMode && file.Executable, 103 | } 104 | files[i] = gitFile 105 | slog.Info("Uploaded", "file", file.Path, "blob", blobSHA) 106 | } 107 | 108 | treeSHA, err := u.GitHub.CreateTree(ctx, git.NewTree{ 109 | Repository: in.Repository, 110 | BaseTreeSHA: in.ParentTreeSHA, 111 | Files: files, 112 | }) 113 | if err != nil { 114 | return "", fmt.Errorf("error while creating a tree: %w", err) 115 | } 116 | slog.Info("Created a tree", "tree", treeSHA) 117 | return treeSHA, nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/usecases/pullrequest/pull_request.go: -------------------------------------------------------------------------------- 1 | package pullrequest 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/google/wire" 10 | "github.com/int128/ghcp/pkg/git" 11 | "github.com/int128/ghcp/pkg/github" 12 | ) 13 | 14 | var Set = wire.NewSet( 15 | wire.Bind(new(Interface), new(*PullRequest)), 16 | wire.Struct(new(PullRequest), "*"), 17 | ) 18 | 19 | type Interface interface { 20 | Do(ctx context.Context, in Input) error 21 | } 22 | 23 | type Input struct { 24 | BaseRepository git.RepositoryID 25 | BaseBranchName git.BranchName // if empty, use the default branch of base 26 | HeadRepository git.RepositoryID 27 | HeadBranchName git.BranchName // if empty, use the default branch of head 28 | Title string 29 | Body string // optional 30 | Reviewer string // optional 31 | Draft bool 32 | } 33 | 34 | // PullRequest provides the use-case to create a pull request. 35 | type PullRequest struct { 36 | GitHub github.Interface 37 | } 38 | 39 | func (u *PullRequest) Do(ctx context.Context, in Input) error { 40 | if !in.BaseRepository.IsValid() { 41 | return errors.New("you must set the base repository") 42 | } 43 | if !in.HeadRepository.IsValid() { 44 | return errors.New("you must set the head repository") 45 | } 46 | 47 | if in.HeadBranchName == "" || in.BaseBranchName == "" { 48 | q, err := u.GitHub.QueryDefaultBranch(ctx, github.QueryDefaultBranchInput{ 49 | BaseRepository: in.BaseRepository, 50 | HeadRepository: in.HeadRepository, 51 | }) 52 | if err != nil { 53 | return fmt.Errorf("could not determine the default branch: %w", err) 54 | } 55 | if in.BaseBranchName == "" { 56 | in.BaseBranchName = q.BaseDefaultBranchName 57 | } 58 | if in.HeadBranchName == "" { 59 | in.HeadBranchName = q.HeadDefaultBranchName 60 | } 61 | } 62 | 63 | q, err := u.GitHub.QueryForPullRequest(ctx, github.QueryForPullRequestInput{ 64 | BaseRepository: in.BaseRepository, 65 | BaseBranchName: in.BaseBranchName, 66 | HeadRepository: in.HeadRepository, 67 | HeadBranchName: in.HeadBranchName, 68 | ReviewerUser: in.Reviewer, 69 | }) 70 | if err != nil { 71 | return fmt.Errorf("could not query for creating a pull request: %w", err) 72 | } 73 | slog.Info("Logged in", "user", q.CurrentUserName) 74 | if q.HeadBranchCommitSHA == "" { 75 | return fmt.Errorf("the head branch (%s) does not exist", in.HeadBranchName) 76 | } 77 | slog.Debug("Found the head branch", "branch", in.HeadBranchName, "commit", q.HeadBranchCommitSHA) 78 | if len(q.ExistingPullRequests) > 0 { 79 | slog.Info("An open pull request already exists", "url", q.ExistingPullRequests[0].URL) 80 | return nil 81 | } 82 | createdPR, err := u.GitHub.CreatePullRequest(ctx, github.CreatePullRequestInput{ 83 | BaseRepository: in.BaseRepository, 84 | BaseBranchName: in.BaseBranchName, 85 | BaseRepositoryNodeID: q.BaseRepositoryNodeID, 86 | HeadRepository: in.HeadRepository, 87 | HeadBranchName: in.HeadBranchName, 88 | Title: in.Title, 89 | Body: in.Body, 90 | Draft: in.Draft, 91 | }) 92 | if err != nil { 93 | return fmt.Errorf("could not create a pull request: %w", err) 94 | } 95 | slog.Info("Created a pull request", "url", createdPR.URL) 96 | 97 | if in.Reviewer == "" { 98 | return nil 99 | } 100 | slog.Info("Requesting a review for pull request", "user", in.Reviewer) 101 | if err := u.GitHub.RequestPullRequestReview(ctx, github.RequestPullRequestReviewInput{ 102 | PullRequest: createdPR.PullRequestNodeID, 103 | User: q.ReviewerUserNodeID, 104 | }); err != nil { 105 | return fmt.Errorf("could not request a review for the pull request: %w", err) 106 | } 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /pkg/github/gitobject.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/google/go-github/v74/github" 9 | "github.com/shurcooL/githubv4" 10 | 11 | "github.com/int128/ghcp/pkg/git" 12 | ) 13 | 14 | type QueryCommitInput struct { 15 | Repository git.RepositoryID 16 | CommitSHA git.CommitSHA 17 | } 18 | 19 | type QueryCommitOutput struct { 20 | ChangedFiles int 21 | } 22 | 23 | // QueryCommit returns the commit. 24 | func (c *GitHub) QueryCommit(ctx context.Context, in QueryCommitInput) (*QueryCommitOutput, error) { 25 | var q struct { 26 | Repository struct { 27 | Object struct { 28 | Commit struct { 29 | ChangedFiles int 30 | } `graphql:"... on Commit"` 31 | } `graphql:"object(oid: $commitSHA)"` 32 | } `graphql:"repository(owner: $owner, name: $repo)"` 33 | } 34 | v := map[string]interface{}{ 35 | "owner": githubv4.String(in.Repository.Owner), 36 | "repo": githubv4.String(in.Repository.Name), 37 | "commitSHA": githubv4.GitObjectID(in.CommitSHA), 38 | } 39 | slog.Debug("Querying the commit", "params", v) 40 | if err := c.Client.Query(ctx, &q, v); err != nil { 41 | return nil, fmt.Errorf("GitHub API error: %w", err) 42 | } 43 | slog.Debug("Got the response", "response", q) 44 | out := QueryCommitOutput{ 45 | ChangedFiles: q.Repository.Object.Commit.ChangedFiles, 46 | } 47 | slog.Debug("Returning the commit", "commit", out) 48 | return &out, nil 49 | } 50 | 51 | // CreateCommit creates a commit and returns SHA of it. 52 | func (c *GitHub) CreateCommit(ctx context.Context, n git.NewCommit) (git.CommitSHA, error) { 53 | slog.Debug("Creating a commit", "input", n) 54 | var parents []*github.Commit 55 | if n.ParentCommitSHA != "" { 56 | parents = append(parents, &github.Commit{SHA: github.Ptr(string(n.ParentCommitSHA))}) 57 | } 58 | commit := github.Commit{ 59 | Message: github.Ptr(string(n.Message)), 60 | Parents: parents, 61 | Tree: &github.Tree{SHA: github.Ptr(string(n.TreeSHA))}, 62 | } 63 | if n.Author != nil { 64 | commit.Author = &github.CommitAuthor{ 65 | Name: github.Ptr(n.Author.Name), 66 | Email: github.Ptr(n.Author.Email), 67 | } 68 | } 69 | if n.Committer != nil { 70 | commit.Committer = &github.CommitAuthor{ 71 | Name: github.Ptr(n.Committer.Name), 72 | Email: github.Ptr(n.Committer.Email), 73 | } 74 | } 75 | created, _, err := c.Client.CreateCommit(ctx, n.Repository.Owner, n.Repository.Name, &commit, nil) 76 | if err != nil { 77 | return "", fmt.Errorf("GitHub API error: %w", err) 78 | } 79 | return git.CommitSHA(created.GetSHA()), nil 80 | } 81 | 82 | // CreateTree creates a tree and returns SHA of it. 83 | func (c *GitHub) CreateTree(ctx context.Context, n git.NewTree) (git.TreeSHA, error) { 84 | slog.Debug("Creating a tree", "input", n) 85 | entries := make([]*github.TreeEntry, len(n.Files)) 86 | for i, file := range n.Files { 87 | entries[i] = &github.TreeEntry{ 88 | Type: github.Ptr("blob"), 89 | Path: github.Ptr(file.Filename), 90 | Mode: github.Ptr(file.Mode()), 91 | SHA: github.Ptr(string(file.BlobSHA)), 92 | } 93 | } 94 | tree, _, err := c.Client.CreateTree(ctx, n.Repository.Owner, n.Repository.Name, string(n.BaseTreeSHA), entries) 95 | if err != nil { 96 | return "", fmt.Errorf("GitHub API error: %w", err) 97 | } 98 | return git.TreeSHA(tree.GetSHA()), nil 99 | } 100 | 101 | // CreateBlob creates a blob and returns SHA of it. 102 | func (c *GitHub) CreateBlob(ctx context.Context, n git.NewBlob) (git.BlobSHA, error) { 103 | slog.Debug("Creating a blob", "size", len(n.Content), "repository", n.Repository) 104 | blob, _, err := c.Client.CreateBlob(ctx, n.Repository.Owner, n.Repository.Name, &github.Blob{ 105 | Encoding: github.Ptr("base64"), 106 | Content: github.Ptr(n.Content), 107 | }) 108 | if err != nil { 109 | return "", fmt.Errorf("GitHub API error: %w", err) 110 | } 111 | return git.BlobSHA(blob.GetSHA()), nil 112 | } 113 | -------------------------------------------------------------------------------- /pkg/cmd/pull_request_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/usecases/pullrequest_mock" 7 | "github.com/int128/ghcp/pkg/git" 8 | "github.com/int128/ghcp/pkg/github/client" 9 | "github.com/int128/ghcp/pkg/usecases/pullrequest" 10 | "github.com/stretchr/testify/mock" 11 | ) 12 | 13 | func TestCmd_Run_pull_request(t *testing.T) { 14 | t.Run("BasicOptions", func(t *testing.T) { 15 | useCase := pullrequest_mock.NewMockInterface(t) 16 | useCase.EXPECT(). 17 | Do(mock.Anything, pullrequest.Input{ 18 | HeadRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 19 | HeadBranchName: "feature", 20 | BaseRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 21 | Title: "commit-message", 22 | }). 23 | Return(nil) 24 | r := Runner{ 25 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 26 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 27 | NewInternalRunner: newInternalRunner(InternalRunner{PullRequestUseCase: useCase}), 28 | } 29 | args := []string{ 30 | cmdName, 31 | pullRequestCmdName, 32 | "--token", "YOUR_TOKEN", 33 | "-r", "owner/repo", 34 | "-b", "feature", 35 | "--title", "commit-message", 36 | } 37 | exitCode := r.Run(args, version) 38 | if exitCode != exitCodeOK { 39 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 40 | } 41 | }) 42 | 43 | t.Run("--base-repo", func(t *testing.T) { 44 | useCase := pullrequest_mock.NewMockInterface(t) 45 | useCase.EXPECT(). 46 | Do(mock.Anything, pullrequest.Input{ 47 | HeadRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 48 | HeadBranchName: "feature", 49 | BaseRepository: git.RepositoryID{Owner: "upstream-owner", Name: "upstream-repo"}, 50 | Title: "commit-message", 51 | }). 52 | Return(nil) 53 | r := Runner{ 54 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 55 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 56 | NewInternalRunner: newInternalRunner(InternalRunner{PullRequestUseCase: useCase}), 57 | } 58 | args := []string{ 59 | cmdName, 60 | pullRequestCmdName, 61 | "--token", "YOUR_TOKEN", 62 | "-r", "owner/repo", 63 | "--base-repo", "upstream-owner/upstream-repo", 64 | "-b", "feature", 65 | "--title", "commit-message", 66 | } 67 | exitCode := r.Run(args, version) 68 | if exitCode != exitCodeOK { 69 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 70 | } 71 | }) 72 | 73 | t.Run("optional-flags", func(t *testing.T) { 74 | useCase := pullrequest_mock.NewMockInterface(t) 75 | useCase.EXPECT(). 76 | Do(mock.Anything, pullrequest.Input{ 77 | HeadRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 78 | HeadBranchName: "feature", 79 | BaseRepository: git.RepositoryID{Owner: "upstream-owner", Name: "upstream-repo"}, 80 | BaseBranchName: "develop", 81 | Title: "commit-message", 82 | Body: "body", 83 | Reviewer: "the-reviewer", 84 | Draft: true, 85 | }). 86 | Return(nil) 87 | r := Runner{ 88 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 89 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 90 | NewInternalRunner: newInternalRunner(InternalRunner{PullRequestUseCase: useCase}), 91 | } 92 | args := []string{ 93 | cmdName, 94 | pullRequestCmdName, 95 | "--token", "YOUR_TOKEN", 96 | "-u", "owner", 97 | "-r", "repo", 98 | "-b", "feature", 99 | "--base-owner", "upstream-owner", 100 | "--base-repo", "upstream-repo", 101 | "--base", "develop", 102 | "--title", "commit-message", 103 | "--body", "body", 104 | "--draft", 105 | "--reviewer", "the-reviewer", 106 | } 107 | exitCode := r.Run(args, version) 108 | if exitCode != exitCodeOK { 109 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 110 | } 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /mocks/github.com/int128/ghcp/pkg/env_mock/mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package env_mock 6 | 7 | import ( 8 | mock "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 12 | // The first argument is typically a *testing.T value. 13 | func NewMockInterface(t interface { 14 | mock.TestingT 15 | Cleanup(func()) 16 | }) *MockInterface { 17 | mock := &MockInterface{} 18 | mock.Mock.Test(t) 19 | 20 | t.Cleanup(func() { mock.AssertExpectations(t) }) 21 | 22 | return mock 23 | } 24 | 25 | // MockInterface is an autogenerated mock type for the Interface type 26 | type MockInterface struct { 27 | mock.Mock 28 | } 29 | 30 | type MockInterface_Expecter struct { 31 | mock *mock.Mock 32 | } 33 | 34 | func (_m *MockInterface) EXPECT() *MockInterface_Expecter { 35 | return &MockInterface_Expecter{mock: &_m.Mock} 36 | } 37 | 38 | // Chdir provides a mock function for the type MockInterface 39 | func (_mock *MockInterface) Chdir(dir string) error { 40 | ret := _mock.Called(dir) 41 | 42 | if len(ret) == 0 { 43 | panic("no return value specified for Chdir") 44 | } 45 | 46 | var r0 error 47 | if returnFunc, ok := ret.Get(0).(func(string) error); ok { 48 | r0 = returnFunc(dir) 49 | } else { 50 | r0 = ret.Error(0) 51 | } 52 | return r0 53 | } 54 | 55 | // MockInterface_Chdir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Chdir' 56 | type MockInterface_Chdir_Call struct { 57 | *mock.Call 58 | } 59 | 60 | // Chdir is a helper method to define mock.On call 61 | // - dir string 62 | func (_e *MockInterface_Expecter) Chdir(dir interface{}) *MockInterface_Chdir_Call { 63 | return &MockInterface_Chdir_Call{Call: _e.mock.On("Chdir", dir)} 64 | } 65 | 66 | func (_c *MockInterface_Chdir_Call) Run(run func(dir string)) *MockInterface_Chdir_Call { 67 | _c.Call.Run(func(args mock.Arguments) { 68 | var arg0 string 69 | if args[0] != nil { 70 | arg0 = args[0].(string) 71 | } 72 | run( 73 | arg0, 74 | ) 75 | }) 76 | return _c 77 | } 78 | 79 | func (_c *MockInterface_Chdir_Call) Return(err error) *MockInterface_Chdir_Call { 80 | _c.Call.Return(err) 81 | return _c 82 | } 83 | 84 | func (_c *MockInterface_Chdir_Call) RunAndReturn(run func(dir string) error) *MockInterface_Chdir_Call { 85 | _c.Call.Return(run) 86 | return _c 87 | } 88 | 89 | // Getenv provides a mock function for the type MockInterface 90 | func (_mock *MockInterface) Getenv(key string) string { 91 | ret := _mock.Called(key) 92 | 93 | if len(ret) == 0 { 94 | panic("no return value specified for Getenv") 95 | } 96 | 97 | var r0 string 98 | if returnFunc, ok := ret.Get(0).(func(string) string); ok { 99 | r0 = returnFunc(key) 100 | } else { 101 | r0 = ret.Get(0).(string) 102 | } 103 | return r0 104 | } 105 | 106 | // MockInterface_Getenv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Getenv' 107 | type MockInterface_Getenv_Call struct { 108 | *mock.Call 109 | } 110 | 111 | // Getenv is a helper method to define mock.On call 112 | // - key string 113 | func (_e *MockInterface_Expecter) Getenv(key interface{}) *MockInterface_Getenv_Call { 114 | return &MockInterface_Getenv_Call{Call: _e.mock.On("Getenv", key)} 115 | } 116 | 117 | func (_c *MockInterface_Getenv_Call) Run(run func(key string)) *MockInterface_Getenv_Call { 118 | _c.Call.Run(func(args mock.Arguments) { 119 | var arg0 string 120 | if args[0] != nil { 121 | arg0 = args[0].(string) 122 | } 123 | run( 124 | arg0, 125 | ) 126 | }) 127 | return _c 128 | } 129 | 130 | func (_c *MockInterface_Getenv_Call) Return(s string) *MockInterface_Getenv_Call { 131 | _c.Call.Return(s) 132 | return _c 133 | } 134 | 135 | func (_c *MockInterface_Getenv_Call) RunAndReturn(run func(key string) string) *MockInterface_Getenv_Call { 136 | _c.Call.Return(run) 137 | return _c 138 | } 139 | -------------------------------------------------------------------------------- /pkg/usecases/release/release_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/fs_mock" 8 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/github_mock" 9 | "github.com/int128/ghcp/pkg/fs" 10 | "github.com/int128/ghcp/pkg/git" 11 | "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | func TestRelease_Do(t *testing.T) { 15 | ctx := context.TODO() 16 | targetRepositoryID := git.RepositoryID{Owner: "owner", Name: "repo"} 17 | targetTagName := git.TagName("v1.0.0") 18 | theFiles := []fs.File{ 19 | {Path: "file1"}, 20 | {Path: "dir2/file2", Executable: true}, 21 | } 22 | 23 | t.Run("CreateReleaseIfNotFound", func(t *testing.T) { 24 | in := Input{ 25 | Repository: targetRepositoryID, 26 | TagName: targetTagName, 27 | TargetBranchOrCommitSHA: "TARGET_COMMIT", 28 | Paths: []string{"path"}, 29 | } 30 | fileSystem := fs_mock.NewMockInterface(t) 31 | fileSystem.EXPECT().FindFiles([]string{"path"}, mock.Anything).Return(theFiles, nil) 32 | gitHub := github_mock.NewMockInterface(t) 33 | gitHub.EXPECT(). 34 | GetReleaseByTagOrNil(ctx, targetRepositoryID, targetTagName). 35 | Return(nil, nil) 36 | gitHub.EXPECT(). 37 | CreateRelease(ctx, git.Release{ 38 | ID: git.ReleaseID{ 39 | Repository: targetRepositoryID, 40 | }, 41 | TagName: targetTagName, 42 | Name: targetTagName.Name(), 43 | TargetCommitish: "TARGET_COMMIT", 44 | }). 45 | Return(&git.Release{ 46 | ID: git.ReleaseID{ 47 | Repository: targetRepositoryID, 48 | InternalID: 1234567890, 49 | }, 50 | TagName: targetTagName, 51 | Name: targetTagName.Name(), 52 | TargetCommitish: "TARGET_COMMIT", 53 | }, nil) 54 | gitHub.EXPECT(). 55 | CreateReleaseAsset(ctx, git.ReleaseAsset{ 56 | Release: git.ReleaseID{ 57 | Repository: targetRepositoryID, 58 | InternalID: 1234567890, 59 | }, 60 | Name: "file1", 61 | RealPath: "file1", 62 | }). 63 | Return(nil) 64 | gitHub.EXPECT(). 65 | CreateReleaseAsset(ctx, git.ReleaseAsset{ 66 | Release: git.ReleaseID{ 67 | Repository: targetRepositoryID, 68 | InternalID: 1234567890, 69 | }, 70 | Name: "file2", 71 | RealPath: "dir2/file2", 72 | }). 73 | Return(nil) 74 | 75 | useCase := Release{ 76 | FileSystem: fileSystem, 77 | GitHub: gitHub, 78 | } 79 | if err := useCase.Do(ctx, in); err != nil { 80 | t.Errorf("err wants nil but %+v", err) 81 | } 82 | }) 83 | 84 | t.Run("ReleaseAlreadyExists", func(t *testing.T) { 85 | in := Input{ 86 | Repository: targetRepositoryID, 87 | TagName: targetTagName, 88 | Paths: []string{"path"}, 89 | } 90 | fileSystem := fs_mock.NewMockInterface(t) 91 | fileSystem.EXPECT().FindFiles([]string{"path"}, mock.Anything).Return(theFiles, nil) 92 | gitHub := github_mock.NewMockInterface(t) 93 | gitHub.EXPECT(). 94 | GetReleaseByTagOrNil(ctx, targetRepositoryID, targetTagName). 95 | Return(&git.Release{ 96 | ID: git.ReleaseID{ 97 | Repository: targetRepositoryID, 98 | InternalID: 1234567890, 99 | }, 100 | TagName: targetTagName, 101 | Name: targetTagName.Name(), 102 | }, nil) 103 | gitHub.EXPECT(). 104 | CreateReleaseAsset(ctx, git.ReleaseAsset{ 105 | Release: git.ReleaseID{ 106 | Repository: targetRepositoryID, 107 | InternalID: 1234567890, 108 | }, 109 | Name: "file1", 110 | RealPath: "file1", 111 | }). 112 | Return(nil) 113 | gitHub.EXPECT(). 114 | CreateReleaseAsset(ctx, git.ReleaseAsset{ 115 | Release: git.ReleaseID{ 116 | Repository: targetRepositoryID, 117 | InternalID: 1234567890, 118 | }, 119 | Name: "file2", 120 | RealPath: "dir2/file2", 121 | }). 122 | Return(nil) 123 | 124 | useCase := Release{ 125 | FileSystem: fileSystem, 126 | GitHub: gitHub, 127 | } 128 | if err := useCase.Do(ctx, in); err != nil { 129 | t.Errorf("err wants nil but %+v", err) 130 | } 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /pkg/cmd/commit.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | 11 | "github.com/int128/ghcp/pkg/git" 12 | "github.com/int128/ghcp/pkg/git/commitstrategy" 13 | "github.com/int128/ghcp/pkg/usecases/commit" 14 | ) 15 | 16 | const commitCmdExample = ` To commit files to the default branch: 17 | ghcp commit -r OWNER/REPO -m MESSAGE FILES... 18 | 19 | To commit files to the branch: 20 | ghcp commit -r OWNER/REPO -b BRANCH -m MESSAGE FILES... 21 | 22 | If the branch does not exist, ghcp creates a branch from the default branch. 23 | It the branch exists, ghcp updates the branch by fast-forward. 24 | 25 | To commit files to a new branch from the parent branch: 26 | ghcp commit -r OWNER/REPO -b BRANCH --parent PARENT -m MESSAGE FILES... 27 | 28 | If the branch exists, it will fail. 29 | 30 | To commit files to a new branch without any parent: 31 | ghcp commit -r OWNER/REPO -b BRANCH --no-parent -m MESSAGE FILES... 32 | 33 | If the branch exists, it will fail.` 34 | 35 | func (r *Runner) newCommitCmd(ctx context.Context, gOpts *globalOptions) *cobra.Command { 36 | var o commitOptions 37 | c := &cobra.Command{ 38 | Use: fmt.Sprintf("%s [flags] FILES...", commitCmdName), 39 | Short: "Commit files to the branch", 40 | Long: `This commits the files to the branch. This will create a branch if it does not exist.`, 41 | Example: commitCmdExample, 42 | RunE: func(_ *cobra.Command, args []string) error { 43 | if err := o.validate(); err != nil { 44 | return fmt.Errorf("invalid flag: %w", err) 45 | } 46 | targetRepository, err := o.repositoryID() 47 | if err != nil { 48 | return fmt.Errorf("invalid flag: %w", err) 49 | } 50 | 51 | ir, err := r.newInternalRunner(gOpts) 52 | if err != nil { 53 | return fmt.Errorf("error while bootstrap of the dependencies: %w", err) 54 | } 55 | in := commit.Input{ 56 | TargetRepository: targetRepository, 57 | TargetBranchName: git.BranchName(o.BranchName), 58 | ParentRepository: targetRepository, 59 | CommitStrategy: o.commitStrategy(), 60 | CommitMessage: git.CommitMessage(o.CommitMessage), 61 | Author: o.author(), 62 | Committer: o.committer(), 63 | Paths: args, 64 | NoFileMode: o.NoFileMode, 65 | DryRun: o.DryRun, 66 | } 67 | if err := ir.CommitUseCase.Do(ctx, in); err != nil { 68 | slog.Debug("Stacktrace", "stacktrace", err) 69 | return fmt.Errorf("could not commit the files: %s", err) 70 | } 71 | return nil 72 | }, 73 | } 74 | o.register(c.Flags()) 75 | return c 76 | } 77 | 78 | type commitOptions struct { 79 | commitAttributeOptions 80 | repositoryOptions 81 | 82 | BranchName string 83 | ParentRef string 84 | NoParent bool 85 | NoFileMode bool 86 | DryRun bool 87 | } 88 | 89 | func (o commitOptions) validate() error { 90 | if o.ParentRef != "" && o.NoParent { 91 | return fmt.Errorf("do not set both --parent and --no-parent") 92 | } 93 | if err := o.commitAttributeOptions.validate(); err != nil { 94 | return fmt.Errorf("%w", err) 95 | } 96 | return nil 97 | } 98 | 99 | func (o commitOptions) commitStrategy() commitstrategy.CommitStrategy { 100 | if o.NoParent { 101 | return commitstrategy.NoParent 102 | } 103 | if o.ParentRef != "" { 104 | return commitstrategy.RebaseOn(git.RefName(o.ParentRef)) 105 | } 106 | return commitstrategy.FastForward 107 | } 108 | 109 | func (o *commitOptions) register(f *pflag.FlagSet) { 110 | o.repositoryOptions.register(f) 111 | f.StringVarP(&o.BranchName, "branch", "b", "", "Name of the branch to create or update (default: the default branch of repository)") 112 | f.StringVar(&o.ParentRef, "parent", "", "Create a commit from the parent branch/tag (default: fast-forward)") 113 | f.BoolVar(&o.NoParent, "no-parent", false, "Create a commit without a parent") 114 | f.BoolVar(&o.NoFileMode, "no-file-mode", false, "Ignore executable bit of file and treat as 0644") 115 | f.BoolVar(&o.DryRun, "dry-run", false, "Upload files but do not update the branch actually") 116 | o.commitAttributeOptions.register(f) 117 | } 118 | -------------------------------------------------------------------------------- /pkg/cmd/pull_request.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | 9 | "github.com/int128/ghcp/pkg/git" 10 | "github.com/int128/ghcp/pkg/usecases/pullrequest" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | ) 14 | 15 | const pullRequestCmdExample = ` To create a pull request from the feature branch to the default branch: 16 | ghcp pull-request -r OWNER/REPO -b feature --title TITLE --body BODY 17 | 18 | To create a pull request from the feature branch to the develop branch: 19 | ghcp pull-request -r OWNER/REPO -b feature --base develop --title TITLE --body BODY 20 | 21 | To create a pull request from the feature branch of the OWNER/REPO repository to the default branch of the upstream repository: 22 | ghcp pull-request -r OWNER/REPO -b feature --base-repo UPSTREAM/REPO --title TITLE --body BODY 23 | 24 | To create a pull request from the feature branch of the OWNER/REPO repository to the default branch of the upstream repository: 25 | ghcp pull-request -r OWNER/REPO -b feature --base-repo UPSTREAM/REPO --base feature --title TITLE --body BODY 26 | ` 27 | 28 | func (r *Runner) newPullRequestCmd(ctx context.Context, gOpts *globalOptions) *cobra.Command { 29 | var o pullRequestOptions 30 | c := &cobra.Command{ 31 | Use: fmt.Sprintf("%s [flags] FILES...", pullRequestCmdName), 32 | Short: "Create a pull request", 33 | Long: `This creates a pull request. Do nothing if it already exists.`, 34 | Example: pullRequestCmdExample, 35 | RunE: func(_ *cobra.Command, args []string) error { 36 | if err := o.validate(); err != nil { 37 | return fmt.Errorf("invalid flag: %w", err) 38 | } 39 | headRepository, err := o.Head.repositoryID() 40 | if err != nil { 41 | return fmt.Errorf("invalid flag: %w", err) 42 | } 43 | 44 | baseRepository := headRepository 45 | if o.Base.RepositoryName != "" { 46 | baseRepository, err = o.Base.repositoryID() 47 | if err != nil { 48 | return fmt.Errorf("invalid flag: %w", err) 49 | } 50 | } 51 | 52 | ir, err := r.newInternalRunner(gOpts) 53 | if err != nil { 54 | return fmt.Errorf("error while bootstrap of the dependencies: %w", err) 55 | } 56 | in := pullrequest.Input{ 57 | BaseRepository: baseRepository, 58 | BaseBranchName: git.BranchName(o.BaseBranchName), 59 | HeadRepository: headRepository, 60 | HeadBranchName: git.BranchName(o.HeadBranchName), 61 | Title: o.Title, 62 | Body: o.Body, 63 | Reviewer: o.Reviewer, 64 | Draft: o.Draft, 65 | } 66 | if err := ir.PullRequestUseCase.Do(ctx, in); err != nil { 67 | slog.Debug("Stacktrace", "stacktrace", err) 68 | return fmt.Errorf("could not create a pull request: %s", err) 69 | } 70 | return nil 71 | }, 72 | } 73 | o.register(c.Flags()) 74 | return c 75 | } 76 | 77 | type pullRequestOptions struct { 78 | Base repositoryOptions 79 | Head repositoryOptions 80 | 81 | BaseBranchName string 82 | HeadBranchName string 83 | Title string 84 | Body string 85 | Reviewer string 86 | Draft bool 87 | } 88 | 89 | func (o pullRequestOptions) validate() error { 90 | if o.HeadBranchName == "" || o.Title == "" { 91 | return errors.New("you need to set -b and --title") 92 | } 93 | return nil 94 | } 95 | 96 | func (o *pullRequestOptions) register(f *pflag.FlagSet) { 97 | f.StringVarP(&o.Head.RepositoryName, "head-repo", "r", "", "Head repository name, either -r OWNER/REPO or -u OWNER -r REPO (mandatory)") 98 | f.StringVarP(&o.Head.RepositoryOwner, "head-owner", "u", "", "Head repository owner") 99 | f.StringVarP(&o.HeadBranchName, "head", "b", "", "Head branch name (mandatory)") 100 | f.StringVar(&o.Base.RepositoryName, "base-repo", "", "Base repository name, either --base-repo OWNER/REPO or --base-owner OWNER --base-repo REPO (default: head)") 101 | f.StringVar(&o.Base.RepositoryOwner, "base-owner", "", "Base repository owner (default: head)") 102 | f.StringVar(&o.BaseBranchName, "base", "", "Base branch name (default: default branch of base repository)") 103 | f.StringVar(&o.Title, "title", "", "Title of a pull request (mandatory)") 104 | f.StringVar(&o.Body, "body", "", "Body of a pull request") 105 | f.StringVar(&o.Reviewer, "reviewer", "", "If set, request a review") 106 | f.BoolVar(&o.Draft, "draft", false, "If set, mark as a draft") 107 | } 108 | -------------------------------------------------------------------------------- /pkg/fs/fs_test.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | type singleNameFilter struct { 12 | t *testing.T 13 | dir string // name of directory to skip (if empty, do nothing) 14 | file string // name of file to exclude (if empty, do nothing) 15 | } 16 | 17 | func (f *singleNameFilter) SkipDir(path string) bool { 18 | f.t.Logf("visiting dir %s", path) 19 | base := filepath.Base(path) 20 | return f.dir == base 21 | } 22 | 23 | func (f *singleNameFilter) ExcludeFile(path string) bool { 24 | f.t.Logf("visiting file %s", path) 25 | base := filepath.Base(path) 26 | return f.file == base 27 | } 28 | 29 | func TestFileSystem_FindFiles(t *testing.T) { 30 | fs := &FileSystem{} 31 | tempDir := t.TempDir() 32 | if err := os.Chdir(tempDir); err != nil { 33 | t.Fatal(err) 34 | } 35 | if err := os.Mkdir("dir1", 0755); err != nil { 36 | t.Fatal(err) 37 | } 38 | if err := os.WriteFile("dir1/a.jpg", []byte{}, 0644); err != nil { 39 | t.Fatal(err) 40 | } 41 | if err := os.Mkdir("dir2", 0755); err != nil { 42 | t.Fatal(err) 43 | } 44 | if err := os.WriteFile("dir2/b.jpg", []byte{}, 0644); err != nil { 45 | t.Fatal(err) 46 | } 47 | if err := os.WriteFile("dir2/c.jpg", []byte{}, 0755); err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | t.Run("FindDirectory", func(t *testing.T) { 52 | got, err := fs.FindFiles([]string{"."}, &singleNameFilter{t: t}) 53 | if err != nil { 54 | t.Fatalf("FindFiles returned error: %+v", err) 55 | } 56 | want := []File{ 57 | {Path: "dir1/a.jpg"}, 58 | {Path: "dir2/b.jpg"}, 59 | {Path: "dir2/c.jpg", Executable: true}, 60 | } 61 | if diff := cmp.Diff(want, got); diff != "" { 62 | t.Errorf("mismatch (-want +got):\n%s", diff) 63 | } 64 | }) 65 | t.Run("FilterIsNil", func(t *testing.T) { 66 | got, err := fs.FindFiles([]string{"."}, nil) 67 | if err != nil { 68 | t.Fatalf("FindFiles returned error: %+v", err) 69 | } 70 | want := []File{ 71 | {Path: "dir1/a.jpg"}, 72 | {Path: "dir2/b.jpg"}, 73 | {Path: "dir2/c.jpg", Executable: true}, 74 | } 75 | if diff := cmp.Diff(want, got); diff != "" { 76 | t.Errorf("mismatch (-want +got):\n%s", diff) 77 | } 78 | }) 79 | t.Run("FindFiles", func(t *testing.T) { 80 | got, err := fs.FindFiles([]string{"dir1/a.jpg", "dir2/c.jpg"}, &singleNameFilter{t: t}) 81 | if err != nil { 82 | t.Fatalf("FindFiles returned error: %+v", err) 83 | } 84 | want := []File{ 85 | {Path: "dir1/a.jpg"}, 86 | {Path: "dir2/c.jpg", Executable: true}, 87 | } 88 | if diff := cmp.Diff(want, got); diff != "" { 89 | t.Errorf("mismatch (-want +got):\n%s", diff) 90 | } 91 | }) 92 | t.Run("NoSuchFile", func(t *testing.T) { 93 | files, err := fs.FindFiles([]string{"dir3"}, &singleNameFilter{t: t}) 94 | if files != nil { 95 | t.Errorf("files wants nil but %+v", files) 96 | } 97 | if err == nil { 98 | t.Fatalf("err wants non-nil but nil") 99 | } 100 | }) 101 | t.Run("ExcludeDirectory", func(t *testing.T) { 102 | got, err := fs.FindFiles([]string{"."}, &singleNameFilter{t: t, dir: "dir2"}) 103 | if err != nil { 104 | t.Fatalf("FindFiles returned error: %+v", err) 105 | } 106 | want := []File{ 107 | {Path: "dir1/a.jpg"}, 108 | } 109 | if diff := cmp.Diff(want, got); diff != "" { 110 | t.Errorf("mismatch (-want +got):\n%s", diff) 111 | } 112 | }) 113 | t.Run("SkipFile", func(t *testing.T) { 114 | got, err := fs.FindFiles([]string{"."}, &singleNameFilter{t: t, file: "b.jpg"}) 115 | if err != nil { 116 | t.Fatalf("FindFiles returned error: %+v", err) 117 | } 118 | want := []File{ 119 | {Path: "dir1/a.jpg"}, 120 | {Path: "dir2/c.jpg", Executable: true}, 121 | } 122 | if diff := cmp.Diff(want, got); diff != "" { 123 | t.Errorf("mismatch (-want +got):\n%s", diff) 124 | } 125 | }) 126 | } 127 | 128 | func TestFileSystem_ReadAsBase64EncodedContent(t *testing.T) { 129 | fs := &FileSystem{} 130 | tempDir := t.TempDir() 131 | tempFile := filepath.Join(tempDir, "fs_test") 132 | if err := os.WriteFile(tempFile, []byte("hello\nworld"), 0644); err != nil { 133 | t.Fatal(err) 134 | } 135 | content, err := fs.ReadAsBase64EncodedContent(tempFile) 136 | if err != nil { 137 | t.Fatalf("ReadAsBase64EncodedContent returned error: %+v", err) 138 | } 139 | want := "aGVsbG8Kd29ybGQ=" 140 | if want != content { 141 | t.Errorf("content wants %s but %s", want, content) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /pkg/cmd/cmd.go: -------------------------------------------------------------------------------- 1 | // Package cmd parses command line args and runs the corresponding use-case. 2 | package cmd 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "log" 8 | "log/slog" 9 | 10 | "github.com/google/wire" 11 | "github.com/int128/ghcp/pkg/env" 12 | "github.com/int128/ghcp/pkg/github/client" 13 | "github.com/int128/ghcp/pkg/usecases/commit" 14 | "github.com/int128/ghcp/pkg/usecases/forkcommit" 15 | "github.com/int128/ghcp/pkg/usecases/pullrequest" 16 | "github.com/int128/ghcp/pkg/usecases/release" 17 | "github.com/spf13/cobra" 18 | "github.com/spf13/pflag" 19 | ) 20 | 21 | const ( 22 | envGitHubToken = "GITHUB_TOKEN" 23 | envGitHubAPI = "GITHUB_API" 24 | 25 | exitCodeOK = 0 26 | exitCodeError = 1 27 | 28 | commitCmdName = "commit" 29 | emptyCommitCmdName = "empty-commit" 30 | forkCommitCmdName = "fork-commit" 31 | pullRequestCmdName = "pull-request" 32 | releaseCmdName = "release" 33 | ) 34 | 35 | var Set = wire.NewSet( 36 | wire.Bind(new(Interface), new(*Runner)), 37 | wire.Struct(new(Runner), "*"), 38 | wire.Struct(new(InternalRunner), "*"), 39 | ) 40 | 41 | type Interface interface { 42 | Run(args []string, version string) int 43 | } 44 | 45 | // Runner is the entry point for the command line application. 46 | // It bootstraps the InternalRunner and runs the specified use-case. 47 | type Runner struct { 48 | Env env.Interface 49 | NewGitHub client.NewFunc 50 | NewInternalRunner NewInternalRunnerFunc 51 | } 52 | 53 | // Run parses the command line args and runs the corresponding use-case. 54 | func (r *Runner) Run(args []string, version string) int { 55 | ctx := context.Background() 56 | 57 | var o globalOptions 58 | rootCmd := r.newRootCmd(&o) 59 | commitCmd := r.newCommitCmd(ctx, &o) 60 | rootCmd.AddCommand(commitCmd) 61 | emptyCommitCmd := r.newEmptyCommitCmd(ctx, &o) 62 | rootCmd.AddCommand(emptyCommitCmd) 63 | forkCommitCmd := r.newForkCommitCmd(ctx, &o) 64 | rootCmd.AddCommand(forkCommitCmd) 65 | pullRequestCmd := r.newPullRequestCmd(ctx, &o) 66 | rootCmd.AddCommand(pullRequestCmd) 67 | releaseCmd := r.newReleaseCmd(ctx, &o) 68 | rootCmd.AddCommand(releaseCmd) 69 | 70 | rootCmd.Version = version 71 | rootCmd.SetArgs(args[1:]) 72 | if err := rootCmd.Execute(); err != nil { 73 | return exitCodeError 74 | } 75 | return exitCodeOK 76 | } 77 | 78 | type globalOptions struct { 79 | Chdir string 80 | GitHubToken string 81 | GitHubAPI string // optional 82 | Debug bool 83 | } 84 | 85 | func (o *globalOptions) register(f *pflag.FlagSet) { 86 | f.StringVarP(&o.Chdir, "directory", "C", "", "Change to directory before operation") 87 | f.StringVar(&o.GitHubToken, "token", "", fmt.Sprintf("GitHub API token [$%s]", envGitHubToken)) 88 | f.StringVar(&o.GitHubAPI, "api", "", fmt.Sprintf("GitHub API v3 URL (v4 will be inferred) [$%s]", envGitHubAPI)) 89 | f.BoolVar(&o.Debug, "debug", false, "Show debug logs") 90 | } 91 | 92 | func (r *Runner) newRootCmd(o *globalOptions) *cobra.Command { 93 | c := &cobra.Command{ 94 | Use: "ghcp", 95 | Short: "A command to commit files to a GitHub repository", 96 | SilenceUsage: true, 97 | } 98 | o.register(c.PersistentFlags()) 99 | return c 100 | } 101 | 102 | type NewInternalRunnerFunc func(client.Interface) *InternalRunner 103 | 104 | // InternalRunner has the set of use-cases. 105 | type InternalRunner struct { 106 | CommitUseCase commit.Interface 107 | ForkCommitUseCase forkcommit.Interface 108 | PullRequestUseCase pullrequest.Interface 109 | ReleaseUseCase release.Interface 110 | } 111 | 112 | func (r *Runner) newInternalRunner(o *globalOptions) (*InternalRunner, error) { 113 | log.SetFlags(log.Lmicroseconds) 114 | if o.Debug { 115 | slog.SetLogLoggerLevel(slog.LevelDebug) 116 | } 117 | if o.Chdir != "" { 118 | if err := r.Env.Chdir(o.Chdir); err != nil { 119 | return nil, fmt.Errorf("could not change to directory %s: %w", o.Chdir, err) 120 | } 121 | slog.Info("Changed to directory", "directory", o.Chdir) 122 | } 123 | if o.GitHubToken == "" { 124 | o.GitHubToken = r.Env.Getenv(envGitHubToken) 125 | if o.GitHubToken != "" { 126 | slog.Debug("Using token from environment variable", "variable", envGitHubToken) 127 | } 128 | } 129 | if o.GitHubToken == "" { 130 | return nil, fmt.Errorf("no GitHub API token. Set environment variable %s or --token option", envGitHubToken) 131 | } 132 | if o.GitHubAPI == "" { 133 | o.GitHubAPI = r.Env.Getenv(envGitHubAPI) 134 | if o.GitHubAPI != "" { 135 | slog.Debug("Using GitHub Enterprise URL from environment variable", "variable", envGitHubAPI) 136 | } 137 | } 138 | gh, err := r.NewGitHub(client.Option{ 139 | Token: o.GitHubToken, 140 | URLv3: o.GitHubAPI, 141 | }) 142 | if err != nil { 143 | return nil, fmt.Errorf("could not connect to GitHub API: %w", err) 144 | } 145 | return r.NewInternalRunner(gh), nil 146 | } 147 | -------------------------------------------------------------------------------- /pkg/github/pull_request.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/int128/ghcp/pkg/git" 9 | "github.com/shurcooL/githubv4" 10 | ) 11 | 12 | type QueryForPullRequestInput struct { 13 | BaseRepository git.RepositoryID 14 | BaseBranchName git.BranchName 15 | HeadRepository git.RepositoryID 16 | HeadBranchName git.BranchName 17 | ReviewerUser string // optional 18 | } 19 | 20 | type QueryForPullRequestOutput struct { 21 | CurrentUserName string 22 | BaseRepositoryNodeID InternalRepositoryNodeID 23 | HeadBranchCommitSHA git.CommitSHA 24 | ExistingPullRequests []ExistingPullRequest 25 | ReviewerUserNodeID githubv4.ID // optional 26 | } 27 | 28 | type ExistingPullRequest struct { 29 | URL string 30 | } 31 | 32 | // QueryForPullRequest performs the query for creating a pull request. 33 | func (c *GitHub) QueryForPullRequest(ctx context.Context, in QueryForPullRequestInput) (*QueryForPullRequestOutput, error) { 34 | var q struct { 35 | Viewer struct { 36 | Login string 37 | } 38 | BaseRepository struct { 39 | ID githubv4.ID 40 | } `graphql:"baseRepository: repository(owner: $baseOwner, name: $baseRepo)"` 41 | HeadRepository struct { 42 | Ref struct { 43 | Target struct { 44 | OID string 45 | } 46 | AssociatedPullRequests struct { 47 | Nodes []ExistingPullRequest 48 | } `graphql:"associatedPullRequests(baseRefName: $baseRefName, states: [OPEN], first: 1)"` 49 | } `graphql:"ref(qualifiedName: $headRefName)"` 50 | } `graphql:"headRepository: repository(owner: $headOwner, name: $headRepo)"` 51 | ReviewerUser struct { 52 | ID githubv4.ID 53 | } `graphql:"reviewer: user(login: $reviewerUser) @include(if: $withReviewerUser)"` 54 | } 55 | v := map[string]interface{}{ 56 | "baseOwner": githubv4.String(in.BaseRepository.Owner), 57 | "baseRepo": githubv4.String(in.BaseRepository.Name), 58 | "baseRefName": githubv4.String(in.BaseBranchName), 59 | "headOwner": githubv4.String(in.HeadRepository.Owner), 60 | "headRepo": githubv4.String(in.HeadRepository.Name), 61 | "headRefName": githubv4.String(in.HeadBranchName.QualifiedName().String()), 62 | "reviewerUser": githubv4.String(in.ReviewerUser), 63 | "withReviewerUser": githubv4.Boolean(in.ReviewerUser != ""), 64 | } 65 | slog.Debug("Querying the existing pull request", "params", v) 66 | if err := c.Client.Query(ctx, &q, v); err != nil { 67 | return nil, fmt.Errorf("GitHub API error: %w", err) 68 | } 69 | slog.Debug("Got the response", "response", q) 70 | 71 | out := QueryForPullRequestOutput{ 72 | CurrentUserName: q.Viewer.Login, 73 | BaseRepositoryNodeID: q.BaseRepository.ID, 74 | HeadBranchCommitSHA: git.CommitSHA(q.HeadRepository.Ref.Target.OID), 75 | ExistingPullRequests: q.HeadRepository.Ref.AssociatedPullRequests.Nodes, 76 | ReviewerUserNodeID: q.ReviewerUser.ID, 77 | } 78 | slog.Debug("Returning the result", "result", out) 79 | return &out, nil 80 | } 81 | 82 | type CreatePullRequestInput struct { 83 | BaseRepository git.RepositoryID 84 | BaseBranchName git.BranchName 85 | BaseRepositoryNodeID InternalRepositoryNodeID 86 | HeadRepository git.RepositoryID 87 | HeadBranchName git.BranchName 88 | Title string 89 | Body string // optional 90 | Draft bool 91 | } 92 | 93 | type CreatePullRequestOutput struct { 94 | PullRequestNodeID githubv4.ID 95 | URL string 96 | } 97 | 98 | func (c *GitHub) CreatePullRequest(ctx context.Context, in CreatePullRequestInput) (*CreatePullRequestOutput, error) { 99 | slog.Debug("Creating a pull request", "input", in) 100 | headRefName := string(in.HeadBranchName) 101 | if in.BaseRepository != in.HeadRepository { 102 | // For cross-repository pull requests. 103 | // https://developer.github.com/v4/input_object/createpullrequestinput/ 104 | headRefName = fmt.Sprintf("%s:%s", in.HeadRepository.Owner, in.HeadBranchName) 105 | } 106 | v := githubv4.CreatePullRequestInput{ 107 | RepositoryID: in.BaseRepositoryNodeID, 108 | BaseRefName: githubv4.String(in.BaseBranchName), 109 | HeadRefName: githubv4.String(headRefName), 110 | Title: githubv4.String(in.Title), 111 | } 112 | if in.Body != "" { 113 | v.Body = githubv4.NewString(githubv4.String(in.Body)) 114 | } 115 | if in.Draft { 116 | v.Draft = githubv4.NewBoolean(true) 117 | } 118 | var m struct { 119 | CreatePullRequest struct { 120 | PullRequest struct { 121 | ID githubv4.ID 122 | URL string 123 | } 124 | } `graphql:"createPullRequest(input: $input)"` 125 | } 126 | if err := c.Client.Mutate(ctx, &m, v, nil); err != nil { 127 | return nil, fmt.Errorf("GitHub API error: %w", err) 128 | } 129 | slog.Debug("Got the response", "response", m) 130 | return &CreatePullRequestOutput{ 131 | PullRequestNodeID: m.CreatePullRequest.PullRequest.ID, 132 | URL: m.CreatePullRequest.PullRequest.URL, 133 | }, nil 134 | } 135 | 136 | type RequestPullRequestReviewInput struct { 137 | PullRequest githubv4.ID 138 | User githubv4.ID 139 | } 140 | 141 | func (c *GitHub) RequestPullRequestReview(ctx context.Context, in RequestPullRequestReviewInput) error { 142 | slog.Debug("Requesting a review for the pull request", "pullRequest", in.PullRequest, "user", in.User) 143 | v := githubv4.RequestReviewsInput{ 144 | PullRequestID: in.PullRequest, 145 | UserIDs: &[]githubv4.ID{in.User}, 146 | } 147 | var m struct { 148 | RequestReviews struct { 149 | Actor struct { 150 | Login string 151 | } 152 | } `graphql:"requestReviews(input: $input)"` 153 | } 154 | if err := c.Client.Mutate(ctx, &m, v, nil); err != nil { 155 | return fmt.Errorf("GitHub API error: %w", err) 156 | } 157 | slog.Debug("Got the response", "response", m) 158 | return nil 159 | } 160 | -------------------------------------------------------------------------------- /pkg/github/branch.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/int128/ghcp/pkg/git" 9 | "github.com/shurcooL/githubv4" 10 | ) 11 | 12 | type QueryForCommitInput struct { 13 | ParentRepository git.RepositoryID 14 | ParentRef git.RefName // optional 15 | TargetRepository git.RepositoryID 16 | TargetBranchName git.BranchName // optional 17 | } 18 | 19 | type QueryForCommitOutput struct { 20 | CurrentUserName string 21 | ParentDefaultBranchCommitSHA git.CommitSHA 22 | ParentDefaultBranchTreeSHA git.TreeSHA 23 | ParentRefCommitSHA git.CommitSHA // empty if the parent ref does not exist 24 | ParentRefTreeSHA git.TreeSHA // empty if the parent ref does not exist 25 | TargetRepositoryNodeID InternalRepositoryNodeID 26 | TargetBranchNodeID InternalBranchNodeID 27 | TargetBranchCommitSHA git.CommitSHA // empty if the branch does not exist 28 | TargetBranchTreeSHA git.TreeSHA // empty if the branch does not exist 29 | } 30 | 31 | func (q *QueryForCommitOutput) TargetBranchExists() bool { 32 | return q.TargetBranchCommitSHA != "" 33 | } 34 | 35 | // QueryForCommit returns the repository for creating or updating the branch. 36 | func (c *GitHub) QueryForCommit(ctx context.Context, in QueryForCommitInput) (*QueryForCommitOutput, error) { 37 | var q struct { 38 | Viewer struct { 39 | Login string 40 | } 41 | 42 | ParentRepository struct { 43 | // default branch 44 | DefaultBranchRef struct { 45 | Name string 46 | Target struct { 47 | Commit struct { 48 | Oid string 49 | Tree struct { 50 | Oid string 51 | } 52 | } `graphql:"... on Commit"` 53 | } 54 | } 55 | 56 | // parent ref (optional) 57 | ParentRef struct { 58 | Prefix string 59 | Name string 60 | Target struct { 61 | Commit struct { 62 | Oid string 63 | Tree struct { 64 | Oid string 65 | } 66 | } `graphql:"... on Commit"` 67 | } 68 | } `graphql:"parentRef: ref(qualifiedName: $parentRef)"` 69 | } `graphql:"parentRepository: repository(owner: $parentOwner, name: $parentRepo)"` 70 | 71 | TargetRepository struct { 72 | ID githubv4.ID 73 | Ref struct { 74 | ID githubv4.ID 75 | Target struct { 76 | Commit struct { 77 | Oid string 78 | Tree struct { 79 | Oid string 80 | } 81 | } `graphql:"... on Commit"` 82 | } 83 | } `graphql:"ref(qualifiedName: $targetRef)"` 84 | } `graphql:"targetRepository: repository(owner: $targetOwner, name: $targetRepo)"` 85 | } 86 | v := map[string]interface{}{ 87 | "parentOwner": githubv4.String(in.ParentRepository.Owner), 88 | "parentRepo": githubv4.String(in.ParentRepository.Name), 89 | "parentRef": githubv4.String(in.ParentRef), 90 | "targetOwner": githubv4.String(in.TargetRepository.Owner), 91 | "targetRepo": githubv4.String(in.TargetRepository.Name), 92 | "targetRef": githubv4.String(in.TargetBranchName.QualifiedName().String()), 93 | } 94 | slog.Debug("Querying the repository with", "params", v) 95 | if err := c.Client.Query(ctx, &q, v); err != nil { 96 | return nil, fmt.Errorf("GitHub API error: %w", err) 97 | } 98 | slog.Debug("Got the response", "response", q) 99 | out := QueryForCommitOutput{ 100 | CurrentUserName: q.Viewer.Login, 101 | ParentDefaultBranchCommitSHA: git.CommitSHA(q.ParentRepository.DefaultBranchRef.Target.Commit.Oid), 102 | ParentDefaultBranchTreeSHA: git.TreeSHA(q.ParentRepository.DefaultBranchRef.Target.Commit.Tree.Oid), 103 | ParentRefCommitSHA: git.CommitSHA(q.ParentRepository.ParentRef.Target.Commit.Oid), 104 | ParentRefTreeSHA: git.TreeSHA(q.ParentRepository.ParentRef.Target.Commit.Tree.Oid), 105 | TargetRepositoryNodeID: q.TargetRepository.ID, 106 | TargetBranchNodeID: q.TargetRepository.Ref.ID, 107 | TargetBranchCommitSHA: git.CommitSHA(q.TargetRepository.Ref.Target.Commit.Oid), 108 | TargetBranchTreeSHA: git.TreeSHA(q.TargetRepository.Ref.Target.Commit.Tree.Oid), 109 | } 110 | slog.Debug("Returning the repository", "repository", out) 111 | return &out, nil 112 | } 113 | 114 | type CreateBranchInput struct { 115 | RepositoryNodeID InternalRepositoryNodeID 116 | BranchName git.BranchName 117 | CommitSHA git.CommitSHA 118 | } 119 | 120 | // CreateBranch creates a branch and returns nil or an error. 121 | func (c *GitHub) CreateBranch(ctx context.Context, in CreateBranchInput) error { 122 | // https://docs.github.com/en/graphql/reference/mutations#createref 123 | v := githubv4.CreateRefInput{ 124 | RepositoryID: in.RepositoryNodeID, 125 | Name: githubv4.String(in.BranchName.QualifiedName().String()), 126 | Oid: githubv4.GitObjectID(in.CommitSHA), 127 | } 128 | slog.Debug("Mutation createRef", "params", v) 129 | var m struct { 130 | CreateRef struct { 131 | Ref struct { 132 | Name string 133 | } 134 | } `graphql:"createRef(input: $input)"` 135 | } 136 | if err := c.Client.Mutate(ctx, &m, v, nil); err != nil { 137 | return fmt.Errorf("GitHub API error: %w", err) 138 | } 139 | slog.Debug("Got the response", "response", m) 140 | return nil 141 | } 142 | 143 | type UpdateBranchInput struct { 144 | BranchRefNodeID InternalBranchNodeID 145 | CommitSHA git.CommitSHA 146 | Force bool 147 | } 148 | 149 | // UpdateBranch updates the branch and returns nil or an error. 150 | func (c *GitHub) UpdateBranch(ctx context.Context, in UpdateBranchInput) error { 151 | // https://docs.github.com/en/graphql/reference/mutations#updateref 152 | v := githubv4.UpdateRefInput{ 153 | RefID: in.BranchRefNodeID, 154 | Oid: githubv4.GitObjectID(in.CommitSHA), 155 | Force: githubv4.NewBoolean(githubv4.Boolean(in.Force)), 156 | } 157 | slog.Debug("Mutation updateRef", "params", v) 158 | var m struct { 159 | UpdateRef struct { 160 | Ref struct { 161 | Name string 162 | } 163 | } `graphql:"updateRef(input: $input)"` 164 | } 165 | if err := c.Client.Mutate(ctx, &m, v, nil); err != nil { 166 | return fmt.Errorf("GitHub API error: %w", err) 167 | } 168 | slog.Debug("Got the response", "response", m) 169 | return nil 170 | } 171 | -------------------------------------------------------------------------------- /pkg/cmd/cmd_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/stretchr/testify/mock" 8 | 9 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/env_mock" 10 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/usecases/commit_mock" 11 | "github.com/int128/ghcp/pkg/git" 12 | "github.com/int128/ghcp/pkg/git/commitstrategy" 13 | "github.com/int128/ghcp/pkg/github/client" 14 | "github.com/int128/ghcp/pkg/usecases/commit" 15 | ) 16 | 17 | const cmdName = "ghcp" 18 | const version = "TEST" 19 | 20 | func TestCmd_Run(t *testing.T) { 21 | input := commit.Input{ 22 | TargetRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 23 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 24 | CommitStrategy: commitstrategy.FastForward, 25 | CommitMessage: "commit-message", 26 | Paths: []string{"file1", "file2"}, 27 | } 28 | 29 | t.Run("--debug", func(t *testing.T) { 30 | commitUseCase := commit_mock.NewMockInterface(t) 31 | commitUseCase.EXPECT(). 32 | Do(mock.Anything, input).Return(nil) 33 | r := Runner{ 34 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 35 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 36 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 37 | } 38 | args := []string{ 39 | cmdName, 40 | commitCmdName, 41 | "--token", "YOUR_TOKEN", 42 | "-u", "owner", 43 | "-r", "repo", 44 | "-m", "commit-message", 45 | "--debug", 46 | "file1", 47 | "file2", 48 | } 49 | exitCode := r.Run(args, version) 50 | if exitCode != exitCodeOK { 51 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 52 | } 53 | }) 54 | 55 | t.Run("--directory", func(t *testing.T) { 56 | commitUseCase := commit_mock.NewMockInterface(t) 57 | commitUseCase.EXPECT(). 58 | Do(mock.Anything, input).Return(nil) 59 | mockEnv := newEnv(t, map[string]string{envGitHubAPI: ""}) 60 | mockEnv.EXPECT(). 61 | Chdir("dir").Return(nil) 62 | r := Runner{ 63 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 64 | Env: mockEnv, 65 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 66 | } 67 | args := []string{ 68 | cmdName, 69 | commitCmdName, 70 | "--token", "YOUR_TOKEN", 71 | "-u", "owner", 72 | "-r", "repo", 73 | "-m", "commit-message", 74 | "-C", "dir", 75 | "file1", 76 | "file2", 77 | } 78 | exitCode := r.Run(args, version) 79 | if exitCode != exitCodeOK { 80 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 81 | } 82 | }) 83 | 84 | t.Run("env/GITHUB_TOKEN", func(t *testing.T) { 85 | commitUseCase := commit_mock.NewMockInterface(t) 86 | commitUseCase.EXPECT(). 87 | Do(mock.Anything, input).Return(nil) 88 | r := Runner{ 89 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 90 | Env: newEnv(t, map[string]string{envGitHubToken: "YOUR_TOKEN", envGitHubAPI: ""}), 91 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 92 | } 93 | args := []string{ 94 | cmdName, 95 | commitCmdName, 96 | "-u", "owner", 97 | "-r", "repo", 98 | "-m", "commit-message", 99 | "file1", 100 | "file2", 101 | } 102 | exitCode := r.Run(args, version) 103 | if exitCode != exitCodeOK { 104 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 105 | } 106 | }) 107 | 108 | t.Run("NoGitHubToken", func(t *testing.T) { 109 | r := Runner{ 110 | NewGitHub: newGitHub(t, client.Option{}), 111 | Env: newEnv(t, map[string]string{envGitHubToken: ""}), 112 | NewInternalRunner: newInternalRunner(InternalRunner{}), 113 | } 114 | args := []string{ 115 | cmdName, 116 | commitCmdName, 117 | "-u", "owner", 118 | "-r", "repo", 119 | "-m", "commit-message", 120 | "file1", 121 | "file2", 122 | } 123 | exitCode := r.Run(args, version) 124 | if exitCode != exitCodeError { 125 | t.Errorf("exitCode wants %d but %d", exitCodeError, exitCode) 126 | } 127 | }) 128 | 129 | t.Run("--api", func(t *testing.T) { 130 | commitUseCase := commit_mock.NewMockInterface(t) 131 | commitUseCase.EXPECT(). 132 | Do(mock.Anything, input).Return(nil) 133 | r := Runner{ 134 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN", URLv3: "https://github.example.com/api/v3/"}), 135 | Env: newEnv(t, nil), 136 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 137 | } 138 | args := []string{ 139 | cmdName, 140 | commitCmdName, 141 | "--token", "YOUR_TOKEN", 142 | "--api", "https://github.example.com/api/v3/", 143 | "-u", "owner", 144 | "-r", "repo", 145 | "-m", "commit-message", 146 | "file1", 147 | "file2", 148 | } 149 | exitCode := r.Run(args, version) 150 | if exitCode != exitCodeOK { 151 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 152 | } 153 | }) 154 | 155 | t.Run("env/GITHUB_API", func(t *testing.T) { 156 | commitUseCase := commit_mock.NewMockInterface(t) 157 | commitUseCase.EXPECT(). 158 | Do(mock.Anything, input).Return(nil) 159 | r := Runner{ 160 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN", URLv3: "https://github.example.com/api/v3/"}), 161 | Env: newEnv(t, map[string]string{envGitHubAPI: "https://github.example.com/api/v3/"}), 162 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 163 | } 164 | args := []string{ 165 | cmdName, 166 | commitCmdName, 167 | "--token", "YOUR_TOKEN", 168 | "-u", "owner", 169 | "-r", "repo", 170 | "-m", "commit-message", 171 | "file1", 172 | "file2", 173 | } 174 | exitCode := r.Run(args, version) 175 | if exitCode != exitCodeOK { 176 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 177 | } 178 | }) 179 | } 180 | 181 | func newGitHub(t *testing.T, want client.Option) client.NewFunc { 182 | return func(got client.Option) (client.Interface, error) { 183 | if diff := cmp.Diff(want, got); diff != "" { 184 | t.Errorf("mismatch (-want +got):\n%s", diff) 185 | } 186 | return nil, nil 187 | } 188 | } 189 | 190 | func newEnv(t *testing.T, getenv map[string]string) *env_mock.MockInterface { 191 | env := env_mock.NewMockInterface(t) 192 | for k, v := range getenv { 193 | env.EXPECT().Getenv(k).Return(v) 194 | } 195 | return env 196 | } 197 | 198 | func newInternalRunner(base InternalRunner) NewInternalRunnerFunc { 199 | return func(g client.Interface) *InternalRunner { 200 | return &base 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /pkg/usecases/commit/commit.go: -------------------------------------------------------------------------------- 1 | // Package branch provides use-cases for creating or updating a branch. 2 | package commit 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "path/filepath" 10 | 11 | "github.com/google/wire" 12 | 13 | "github.com/int128/ghcp/pkg/fs" 14 | "github.com/int128/ghcp/pkg/git" 15 | "github.com/int128/ghcp/pkg/git/commitstrategy" 16 | "github.com/int128/ghcp/pkg/github" 17 | "github.com/int128/ghcp/pkg/usecases/gitobject" 18 | ) 19 | 20 | var Set = wire.NewSet( 21 | wire.Struct(new(Commit), "*"), 22 | wire.Bind(new(Interface), new(*Commit)), 23 | ) 24 | 25 | type Interface interface { 26 | Do(ctx context.Context, in Input) error 27 | } 28 | 29 | type Input struct { 30 | TargetRepository git.RepositoryID 31 | TargetBranchName git.BranchName // if empty, target is the default branch 32 | ParentRepository git.RepositoryID 33 | CommitStrategy commitstrategy.CommitStrategy 34 | CommitMessage git.CommitMessage 35 | Author *git.CommitAuthor // optional 36 | Committer *git.CommitAuthor // optional 37 | Paths []string // if empty or nil, create an empty commit 38 | NoFileMode bool 39 | DryRun bool 40 | 41 | ForceUpdate bool //TODO: support force-update as well 42 | } 43 | 44 | // Commit commits files to the default/given branch on the repository. 45 | type Commit struct { 46 | CreateGitObject gitobject.Interface 47 | FileSystem fs.Interface 48 | GitHub github.Interface 49 | } 50 | 51 | func (u *Commit) Do(ctx context.Context, in Input) error { 52 | if !in.TargetRepository.IsValid() { 53 | return errors.New("you must set GitHub repository") 54 | } 55 | if in.CommitMessage == "" { 56 | return errors.New("you must set commit message") 57 | } 58 | 59 | files, err := u.FileSystem.FindFiles(in.Paths, pathFilter{}) 60 | if err != nil { 61 | return fmt.Errorf("could not find files: %w", err) 62 | } 63 | if len(in.Paths) > 0 && len(files) == 0 { 64 | return errors.New("no file exists in given paths") 65 | } 66 | 67 | if in.TargetBranchName == "" { 68 | q, err := u.GitHub.QueryDefaultBranch(ctx, github.QueryDefaultBranchInput{ 69 | HeadRepository: in.TargetRepository, 70 | BaseRepository: in.ParentRepository, // mandatory but not used 71 | }) 72 | if err != nil { 73 | return fmt.Errorf("could not determine the default branch: %w", err) 74 | } 75 | in.TargetBranchName = q.HeadDefaultBranchName 76 | } 77 | 78 | q, err := u.GitHub.QueryForCommit(ctx, github.QueryForCommitInput{ 79 | ParentRepository: in.ParentRepository, 80 | ParentRef: in.CommitStrategy.RebaseUpstream(), // valid only if rebase 81 | TargetRepository: in.TargetRepository, 82 | TargetBranchName: in.TargetBranchName, 83 | }) 84 | if err != nil { 85 | return fmt.Errorf("could not find the repository: %w", err) 86 | } 87 | slog.Info("Author and committer", "user", q.CurrentUserName) 88 | if q.TargetBranchExists() { 89 | if err := u.updateExistingBranch(ctx, in, files, q); err != nil { 90 | return fmt.Errorf("could not update the existing branch (%s): %w", in.TargetBranchName, err) 91 | } 92 | return nil 93 | } 94 | if err := u.createNewBranch(ctx, in, files, q); err != nil { 95 | return fmt.Errorf("could not create a branch (%s) based on the default branch: %w", in.TargetBranchName, err) 96 | } 97 | return nil 98 | } 99 | 100 | type pathFilter struct{} 101 | 102 | func (f pathFilter) SkipDir(path string) bool { 103 | base := filepath.Base(path) 104 | if base == ".git" { 105 | slog.Debug("Exclude .git directory", "path", path) 106 | return true 107 | } 108 | return false 109 | } 110 | 111 | func (f pathFilter) ExcludeFile(string) bool { 112 | return false 113 | } 114 | 115 | func (u *Commit) createNewBranch(ctx context.Context, in Input, files []fs.File, q *github.QueryForCommitOutput) error { 116 | gitObj := gitobject.Input{ 117 | Files: files, 118 | Repository: in.TargetRepository, 119 | CommitMessage: in.CommitMessage, 120 | Author: in.Author, 121 | Committer: in.Committer, 122 | NoFileMode: in.NoFileMode, 123 | } 124 | switch { 125 | case in.CommitStrategy.IsFastForward(): 126 | slog.Info("Creating a branch", "branch", in.TargetBranchName) 127 | gitObj.ParentCommitSHA = q.ParentDefaultBranchCommitSHA 128 | gitObj.ParentTreeSHA = q.ParentDefaultBranchTreeSHA 129 | case in.CommitStrategy.IsRebase(): 130 | slog.Info("Creating a branch", "branch", in.TargetBranchName, "ref", in.CommitStrategy.RebaseUpstream()) 131 | gitObj.ParentCommitSHA = q.ParentRefCommitSHA 132 | gitObj.ParentTreeSHA = q.ParentRefTreeSHA 133 | case in.CommitStrategy.NoParent(): 134 | slog.Info("Creating a branch with no parent", "branch", in.TargetBranchName) 135 | default: 136 | return fmt.Errorf("unknown commit strategy %+v", in.CommitStrategy) 137 | } 138 | 139 | slog.Debug("Creating a commit", "files", len(gitObj.Files)) 140 | commit, err := u.CreateGitObject.Do(ctx, gitObj) 141 | if err != nil { 142 | return fmt.Errorf("error while creating a commit: %w", err) 143 | } 144 | slog.Info("Created a commit", "changedFiles", commit.ChangedFiles) 145 | if len(files) > 0 && commit.ChangedFiles == 0 { 146 | slog.Warn("Nothing to commit because the branch has the same file(s)") 147 | return nil 148 | } 149 | if in.DryRun { 150 | slog.Info("Do not create a branch due to dry-run", "branch", in.TargetBranchName) 151 | return nil 152 | } 153 | 154 | slog.Debug("Creating a branch", "branch", in.TargetBranchName) 155 | createBranchIn := github.CreateBranchInput{ 156 | RepositoryNodeID: q.TargetRepositoryNodeID, 157 | BranchName: in.TargetBranchName, 158 | CommitSHA: commit.CommitSHA, 159 | } 160 | if err := u.GitHub.CreateBranch(ctx, createBranchIn); err != nil { 161 | return fmt.Errorf("error while creating %s branch: %w", in.TargetBranchName, err) 162 | } 163 | slog.Info("Created a branch", "branch", in.TargetBranchName) 164 | return nil 165 | } 166 | 167 | func (u *Commit) updateExistingBranch(ctx context.Context, in Input, files []fs.File, q *github.QueryForCommitOutput) error { 168 | gitObj := gitobject.Input{ 169 | Files: files, 170 | Repository: in.TargetRepository, 171 | CommitMessage: in.CommitMessage, 172 | Author: in.Author, 173 | Committer: in.Committer, 174 | NoFileMode: in.NoFileMode, 175 | } 176 | switch { 177 | case in.CommitStrategy.IsFastForward(): 178 | slog.Info("Updating the branch by fast-forward", "branch", in.TargetBranchName) 179 | gitObj.ParentCommitSHA = q.TargetBranchCommitSHA 180 | gitObj.ParentTreeSHA = q.TargetBranchTreeSHA 181 | case in.CommitStrategy.IsRebase(): 182 | slog.Info("Rebasing the branch", "branch", in.TargetBranchName, "ref", in.CommitStrategy.RebaseUpstream()) 183 | gitObj.ParentCommitSHA = q.ParentRefCommitSHA 184 | gitObj.ParentTreeSHA = q.ParentRefTreeSHA 185 | case in.CommitStrategy.NoParent(): 186 | slog.Info("Updating the branch to a commit with no parent", "branch", in.TargetBranchName) 187 | default: 188 | return fmt.Errorf("unknown commit strategy %+v", in.CommitStrategy) 189 | } 190 | 191 | slog.Debug("Creating a commit", "files", len(gitObj.Files)) 192 | commit, err := u.CreateGitObject.Do(ctx, gitObj) 193 | if err != nil { 194 | return fmt.Errorf("error while creating a commit: %w", err) 195 | } 196 | slog.Info("Created a commit", "changedFiles", commit.ChangedFiles) 197 | if len(files) > 0 && commit.ChangedFiles == 0 { 198 | slog.Warn("Nothing to commit because the branch has the same file(s)", "branch", in.TargetBranchName) 199 | return nil 200 | } 201 | if in.DryRun { 202 | slog.Info("Do not update branch due to dry-run", "branch", in.TargetBranchName) 203 | return nil 204 | } 205 | 206 | slog.Debug("Updating the branch", "branch", in.TargetBranchName) 207 | updateBranchIn := github.UpdateBranchInput{ 208 | BranchRefNodeID: q.TargetBranchNodeID, 209 | CommitSHA: commit.CommitSHA, 210 | Force: in.ForceUpdate, 211 | } 212 | if err := u.GitHub.UpdateBranch(ctx, updateBranchIn); err != nil { 213 | return fmt.Errorf("error while updating %s branch: %w", in.TargetBranchName, err) 214 | } 215 | slog.Info("Updated the branch", "branch", in.TargetBranchName) 216 | return nil 217 | } 218 | -------------------------------------------------------------------------------- /mocks/github.com/int128/ghcp/pkg/git/commitstrategy_mock/mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package commitstrategy_mock 6 | 7 | import ( 8 | "github.com/int128/ghcp/pkg/git" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // NewMockCommitStrategy creates a new instance of MockCommitStrategy. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 13 | // The first argument is typically a *testing.T value. 14 | func NewMockCommitStrategy(t interface { 15 | mock.TestingT 16 | Cleanup(func()) 17 | }) *MockCommitStrategy { 18 | mock := &MockCommitStrategy{} 19 | mock.Mock.Test(t) 20 | 21 | t.Cleanup(func() { mock.AssertExpectations(t) }) 22 | 23 | return mock 24 | } 25 | 26 | // MockCommitStrategy is an autogenerated mock type for the CommitStrategy type 27 | type MockCommitStrategy struct { 28 | mock.Mock 29 | } 30 | 31 | type MockCommitStrategy_Expecter struct { 32 | mock *mock.Mock 33 | } 34 | 35 | func (_m *MockCommitStrategy) EXPECT() *MockCommitStrategy_Expecter { 36 | return &MockCommitStrategy_Expecter{mock: &_m.Mock} 37 | } 38 | 39 | // IsFastForward provides a mock function for the type MockCommitStrategy 40 | func (_mock *MockCommitStrategy) IsFastForward() bool { 41 | ret := _mock.Called() 42 | 43 | if len(ret) == 0 { 44 | panic("no return value specified for IsFastForward") 45 | } 46 | 47 | var r0 bool 48 | if returnFunc, ok := ret.Get(0).(func() bool); ok { 49 | r0 = returnFunc() 50 | } else { 51 | r0 = ret.Get(0).(bool) 52 | } 53 | return r0 54 | } 55 | 56 | // MockCommitStrategy_IsFastForward_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsFastForward' 57 | type MockCommitStrategy_IsFastForward_Call struct { 58 | *mock.Call 59 | } 60 | 61 | // IsFastForward is a helper method to define mock.On call 62 | func (_e *MockCommitStrategy_Expecter) IsFastForward() *MockCommitStrategy_IsFastForward_Call { 63 | return &MockCommitStrategy_IsFastForward_Call{Call: _e.mock.On("IsFastForward")} 64 | } 65 | 66 | func (_c *MockCommitStrategy_IsFastForward_Call) Run(run func()) *MockCommitStrategy_IsFastForward_Call { 67 | _c.Call.Run(func(args mock.Arguments) { 68 | run() 69 | }) 70 | return _c 71 | } 72 | 73 | func (_c *MockCommitStrategy_IsFastForward_Call) Return(b bool) *MockCommitStrategy_IsFastForward_Call { 74 | _c.Call.Return(b) 75 | return _c 76 | } 77 | 78 | func (_c *MockCommitStrategy_IsFastForward_Call) RunAndReturn(run func() bool) *MockCommitStrategy_IsFastForward_Call { 79 | _c.Call.Return(run) 80 | return _c 81 | } 82 | 83 | // IsRebase provides a mock function for the type MockCommitStrategy 84 | func (_mock *MockCommitStrategy) IsRebase() bool { 85 | ret := _mock.Called() 86 | 87 | if len(ret) == 0 { 88 | panic("no return value specified for IsRebase") 89 | } 90 | 91 | var r0 bool 92 | if returnFunc, ok := ret.Get(0).(func() bool); ok { 93 | r0 = returnFunc() 94 | } else { 95 | r0 = ret.Get(0).(bool) 96 | } 97 | return r0 98 | } 99 | 100 | // MockCommitStrategy_IsRebase_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsRebase' 101 | type MockCommitStrategy_IsRebase_Call struct { 102 | *mock.Call 103 | } 104 | 105 | // IsRebase is a helper method to define mock.On call 106 | func (_e *MockCommitStrategy_Expecter) IsRebase() *MockCommitStrategy_IsRebase_Call { 107 | return &MockCommitStrategy_IsRebase_Call{Call: _e.mock.On("IsRebase")} 108 | } 109 | 110 | func (_c *MockCommitStrategy_IsRebase_Call) Run(run func()) *MockCommitStrategy_IsRebase_Call { 111 | _c.Call.Run(func(args mock.Arguments) { 112 | run() 113 | }) 114 | return _c 115 | } 116 | 117 | func (_c *MockCommitStrategy_IsRebase_Call) Return(b bool) *MockCommitStrategy_IsRebase_Call { 118 | _c.Call.Return(b) 119 | return _c 120 | } 121 | 122 | func (_c *MockCommitStrategy_IsRebase_Call) RunAndReturn(run func() bool) *MockCommitStrategy_IsRebase_Call { 123 | _c.Call.Return(run) 124 | return _c 125 | } 126 | 127 | // NoParent provides a mock function for the type MockCommitStrategy 128 | func (_mock *MockCommitStrategy) NoParent() bool { 129 | ret := _mock.Called() 130 | 131 | if len(ret) == 0 { 132 | panic("no return value specified for NoParent") 133 | } 134 | 135 | var r0 bool 136 | if returnFunc, ok := ret.Get(0).(func() bool); ok { 137 | r0 = returnFunc() 138 | } else { 139 | r0 = ret.Get(0).(bool) 140 | } 141 | return r0 142 | } 143 | 144 | // MockCommitStrategy_NoParent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NoParent' 145 | type MockCommitStrategy_NoParent_Call struct { 146 | *mock.Call 147 | } 148 | 149 | // NoParent is a helper method to define mock.On call 150 | func (_e *MockCommitStrategy_Expecter) NoParent() *MockCommitStrategy_NoParent_Call { 151 | return &MockCommitStrategy_NoParent_Call{Call: _e.mock.On("NoParent")} 152 | } 153 | 154 | func (_c *MockCommitStrategy_NoParent_Call) Run(run func()) *MockCommitStrategy_NoParent_Call { 155 | _c.Call.Run(func(args mock.Arguments) { 156 | run() 157 | }) 158 | return _c 159 | } 160 | 161 | func (_c *MockCommitStrategy_NoParent_Call) Return(b bool) *MockCommitStrategy_NoParent_Call { 162 | _c.Call.Return(b) 163 | return _c 164 | } 165 | 166 | func (_c *MockCommitStrategy_NoParent_Call) RunAndReturn(run func() bool) *MockCommitStrategy_NoParent_Call { 167 | _c.Call.Return(run) 168 | return _c 169 | } 170 | 171 | // RebaseUpstream provides a mock function for the type MockCommitStrategy 172 | func (_mock *MockCommitStrategy) RebaseUpstream() git.RefName { 173 | ret := _mock.Called() 174 | 175 | if len(ret) == 0 { 176 | panic("no return value specified for RebaseUpstream") 177 | } 178 | 179 | var r0 git.RefName 180 | if returnFunc, ok := ret.Get(0).(func() git.RefName); ok { 181 | r0 = returnFunc() 182 | } else { 183 | r0 = ret.Get(0).(git.RefName) 184 | } 185 | return r0 186 | } 187 | 188 | // MockCommitStrategy_RebaseUpstream_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RebaseUpstream' 189 | type MockCommitStrategy_RebaseUpstream_Call struct { 190 | *mock.Call 191 | } 192 | 193 | // RebaseUpstream is a helper method to define mock.On call 194 | func (_e *MockCommitStrategy_Expecter) RebaseUpstream() *MockCommitStrategy_RebaseUpstream_Call { 195 | return &MockCommitStrategy_RebaseUpstream_Call{Call: _e.mock.On("RebaseUpstream")} 196 | } 197 | 198 | func (_c *MockCommitStrategy_RebaseUpstream_Call) Run(run func()) *MockCommitStrategy_RebaseUpstream_Call { 199 | _c.Call.Run(func(args mock.Arguments) { 200 | run() 201 | }) 202 | return _c 203 | } 204 | 205 | func (_c *MockCommitStrategy_RebaseUpstream_Call) Return(refName git.RefName) *MockCommitStrategy_RebaseUpstream_Call { 206 | _c.Call.Return(refName) 207 | return _c 208 | } 209 | 210 | func (_c *MockCommitStrategy_RebaseUpstream_Call) RunAndReturn(run func() git.RefName) *MockCommitStrategy_RebaseUpstream_Call { 211 | _c.Call.Return(run) 212 | return _c 213 | } 214 | 215 | // String provides a mock function for the type MockCommitStrategy 216 | func (_mock *MockCommitStrategy) String() string { 217 | ret := _mock.Called() 218 | 219 | if len(ret) == 0 { 220 | panic("no return value specified for String") 221 | } 222 | 223 | var r0 string 224 | if returnFunc, ok := ret.Get(0).(func() string); ok { 225 | r0 = returnFunc() 226 | } else { 227 | r0 = ret.Get(0).(string) 228 | } 229 | return r0 230 | } 231 | 232 | // MockCommitStrategy_String_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'String' 233 | type MockCommitStrategy_String_Call struct { 234 | *mock.Call 235 | } 236 | 237 | // String is a helper method to define mock.On call 238 | func (_e *MockCommitStrategy_Expecter) String() *MockCommitStrategy_String_Call { 239 | return &MockCommitStrategy_String_Call{Call: _e.mock.On("String")} 240 | } 241 | 242 | func (_c *MockCommitStrategy_String_Call) Run(run func()) *MockCommitStrategy_String_Call { 243 | _c.Call.Run(func(args mock.Arguments) { 244 | run() 245 | }) 246 | return _c 247 | } 248 | 249 | func (_c *MockCommitStrategy_String_Call) Return(s string) *MockCommitStrategy_String_Call { 250 | _c.Call.Return(s) 251 | return _c 252 | } 253 | 254 | func (_c *MockCommitStrategy_String_Call) RunAndReturn(run func() string) *MockCommitStrategy_String_Call { 255 | _c.Call.Return(run) 256 | return _c 257 | } 258 | -------------------------------------------------------------------------------- /pkg/usecases/pullrequest/pull_request_test.go: -------------------------------------------------------------------------------- 1 | package pullrequest 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/github_mock" 8 | "github.com/int128/ghcp/pkg/git" 9 | "github.com/int128/ghcp/pkg/github" 10 | ) 11 | 12 | func TestPullRequest_Do(t *testing.T) { 13 | ctx := context.TODO() 14 | baseRepositoryID := git.RepositoryID{Owner: "base", Name: "repo"} 15 | headRepositoryID := git.RepositoryID{Owner: "head", Name: "repo"} 16 | 17 | t.Run("when head and base branch name are given", func(t *testing.T) { 18 | in := Input{ 19 | BaseRepository: baseRepositoryID, 20 | BaseBranchName: "develop", 21 | HeadRepository: headRepositoryID, 22 | HeadBranchName: "feature", 23 | Title: "the-title", 24 | } 25 | t.Run("when the pull request does not exist", func(t *testing.T) { 26 | gitHub := github_mock.NewMockInterface(t) 27 | gitHub.EXPECT(). 28 | QueryForPullRequest(ctx, github.QueryForPullRequestInput{ 29 | BaseRepository: baseRepositoryID, 30 | BaseBranchName: "develop", 31 | HeadRepository: headRepositoryID, 32 | HeadBranchName: "feature", 33 | }). 34 | Return(&github.QueryForPullRequestOutput{ 35 | CurrentUserName: "you", 36 | HeadBranchCommitSHA: "HeadCommitSHA", 37 | }, nil) 38 | gitHub.EXPECT(). 39 | CreatePullRequest(ctx, github.CreatePullRequestInput{ 40 | BaseRepository: baseRepositoryID, 41 | BaseBranchName: "develop", 42 | HeadRepository: headRepositoryID, 43 | HeadBranchName: "feature", 44 | Title: "the-title", 45 | }). 46 | Return(&github.CreatePullRequestOutput{ 47 | URL: "https://github.com/octocat/Spoon-Knife/pull/19445", 48 | }, nil) 49 | useCase := PullRequest{ 50 | GitHub: gitHub, 51 | } 52 | if err := useCase.Do(ctx, in); err != nil { 53 | t.Errorf("err wants nil but %+v", err) 54 | } 55 | }) 56 | t.Run("when an open pull request already exists", func(t *testing.T) { 57 | gitHub := github_mock.NewMockInterface(t) 58 | gitHub.EXPECT(). 59 | QueryForPullRequest(ctx, github.QueryForPullRequestInput{ 60 | BaseRepository: baseRepositoryID, 61 | BaseBranchName: "develop", 62 | HeadRepository: headRepositoryID, 63 | HeadBranchName: "feature", 64 | }). 65 | Return(&github.QueryForPullRequestOutput{ 66 | CurrentUserName: "you", 67 | HeadBranchCommitSHA: "HeadCommitSHA", 68 | ExistingPullRequests: []github.ExistingPullRequest{ 69 | { 70 | URL: "https://github.com/octocat/Spoon-Knife/pull/19445", 71 | }, 72 | }, 73 | }, nil) 74 | useCase := PullRequest{ 75 | GitHub: gitHub, 76 | } 77 | if err := useCase.Do(ctx, in); err != nil { 78 | t.Errorf("err wants nil but %+v", err) 79 | } 80 | }) 81 | t.Run("when the head branch does not exist", func(t *testing.T) { 82 | gitHub := github_mock.NewMockInterface(t) 83 | gitHub.EXPECT(). 84 | QueryForPullRequest(ctx, github.QueryForPullRequestInput{ 85 | BaseRepository: baseRepositoryID, 86 | BaseBranchName: "develop", 87 | HeadRepository: headRepositoryID, 88 | HeadBranchName: "feature", 89 | }). 90 | Return(&github.QueryForPullRequestOutput{ 91 | CurrentUserName: "you", 92 | }, nil) 93 | useCase := PullRequest{ 94 | GitHub: gitHub, 95 | } 96 | if err := useCase.Do(ctx, in); err == nil { 97 | t.Errorf("err wants non-nil but got nil") 98 | } 99 | }) 100 | }) 101 | 102 | t.Run("when the default base branch is given", func(t *testing.T) { 103 | in := Input{ 104 | BaseRepository: baseRepositoryID, 105 | HeadRepository: headRepositoryID, 106 | BaseBranchName: "staging", 107 | Title: "the-title", 108 | } 109 | gitHub := github_mock.NewMockInterface(t) 110 | gitHub.EXPECT(). 111 | QueryDefaultBranch(ctx, github.QueryDefaultBranchInput{ 112 | BaseRepository: baseRepositoryID, 113 | HeadRepository: headRepositoryID, 114 | }). 115 | Return(&github.QueryDefaultBranchOutput{ 116 | BaseDefaultBranchName: "master", 117 | HeadDefaultBranchName: "develop", 118 | }, nil) 119 | gitHub.EXPECT(). 120 | QueryForPullRequest(ctx, github.QueryForPullRequestInput{ 121 | BaseRepository: baseRepositoryID, 122 | BaseBranchName: "staging", 123 | HeadRepository: headRepositoryID, 124 | HeadBranchName: "develop", 125 | }). 126 | Return(&github.QueryForPullRequestOutput{ 127 | CurrentUserName: "you", 128 | HeadBranchCommitSHA: "HeadCommitSHA", 129 | }, nil) 130 | gitHub.EXPECT(). 131 | CreatePullRequest(ctx, github.CreatePullRequestInput{ 132 | BaseRepository: baseRepositoryID, 133 | BaseBranchName: "staging", 134 | HeadRepository: headRepositoryID, 135 | HeadBranchName: "develop", 136 | Title: "the-title", 137 | }). 138 | Return(&github.CreatePullRequestOutput{ 139 | URL: "https://github.com/octocat/Spoon-Knife/pull/19445", 140 | }, nil) 141 | useCase := PullRequest{ 142 | GitHub: gitHub, 143 | } 144 | if err := useCase.Do(ctx, in); err != nil { 145 | t.Errorf("err wants nil but %+v", err) 146 | } 147 | }) 148 | 149 | t.Run("when a reviewer is set", func(t *testing.T) { 150 | in := Input{ 151 | BaseRepository: baseRepositoryID, 152 | BaseBranchName: "develop", 153 | HeadRepository: headRepositoryID, 154 | HeadBranchName: "feature", 155 | Title: "the-title", 156 | Reviewer: "the-reviewer", 157 | } 158 | gitHub := github_mock.NewMockInterface(t) 159 | gitHub.EXPECT(). 160 | QueryForPullRequest(ctx, github.QueryForPullRequestInput{ 161 | BaseRepository: baseRepositoryID, 162 | BaseBranchName: "develop", 163 | HeadRepository: headRepositoryID, 164 | HeadBranchName: "feature", 165 | ReviewerUser: "the-reviewer", 166 | }). 167 | Return(&github.QueryForPullRequestOutput{ 168 | CurrentUserName: "you", 169 | HeadBranchCommitSHA: "HeadCommitSHA", 170 | ReviewerUserNodeID: "TheReviewerID", 171 | }, nil) 172 | gitHub.EXPECT(). 173 | CreatePullRequest(ctx, github.CreatePullRequestInput{ 174 | BaseRepository: baseRepositoryID, 175 | BaseBranchName: "develop", 176 | HeadRepository: headRepositoryID, 177 | HeadBranchName: "feature", 178 | Title: "the-title", 179 | }). 180 | Return(&github.CreatePullRequestOutput{ 181 | URL: "https://github.com/octocat/Spoon-Knife/pull/19445", 182 | PullRequestNodeID: "ThePullRequestID", 183 | }, nil) 184 | gitHub.EXPECT(). 185 | RequestPullRequestReview(ctx, github.RequestPullRequestReviewInput{ 186 | PullRequest: "ThePullRequestID", 187 | User: "TheReviewerID", 188 | }). 189 | Return(nil) 190 | useCase := PullRequest{ 191 | GitHub: gitHub, 192 | } 193 | if err := useCase.Do(ctx, in); err != nil { 194 | t.Errorf("err wants nil but %+v", err) 195 | } 196 | }) 197 | 198 | t.Run("when optional values are set", func(t *testing.T) { 199 | in := Input{ 200 | BaseRepository: baseRepositoryID, 201 | BaseBranchName: "develop", 202 | HeadRepository: headRepositoryID, 203 | HeadBranchName: "feature", 204 | Title: "the-title", 205 | Body: "the-body", 206 | Draft: true, 207 | } 208 | gitHub := github_mock.NewMockInterface(t) 209 | gitHub.EXPECT(). 210 | QueryForPullRequest(ctx, github.QueryForPullRequestInput{ 211 | BaseRepository: baseRepositoryID, 212 | BaseBranchName: "develop", 213 | HeadRepository: headRepositoryID, 214 | HeadBranchName: "feature", 215 | }). 216 | Return(&github.QueryForPullRequestOutput{ 217 | CurrentUserName: "you", 218 | HeadBranchCommitSHA: "HeadCommitSHA", 219 | ReviewerUserNodeID: "TheReviewerID", 220 | }, nil) 221 | gitHub.EXPECT(). 222 | CreatePullRequest(ctx, github.CreatePullRequestInput{ 223 | BaseRepository: baseRepositoryID, 224 | BaseBranchName: "develop", 225 | HeadRepository: headRepositoryID, 226 | HeadBranchName: "feature", 227 | Title: "the-title", 228 | Body: "the-body", 229 | Draft: true, 230 | }). 231 | Return(&github.CreatePullRequestOutput{ 232 | URL: "https://github.com/octocat/Spoon-Knife/pull/19445", 233 | PullRequestNodeID: "ThePullRequestID", 234 | }, nil) 235 | useCase := PullRequest{ 236 | GitHub: gitHub, 237 | } 238 | if err := useCase.Do(ctx, in); err != nil { 239 | t.Errorf("err wants nil but %+v", err) 240 | } 241 | }) 242 | } 243 | -------------------------------------------------------------------------------- /pkg/usecases/gitobject/create_test.go: -------------------------------------------------------------------------------- 1 | package gitobject 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/fs_mock" 9 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/github_mock" 10 | "github.com/int128/ghcp/pkg/fs" 11 | "github.com/int128/ghcp/pkg/git" 12 | "github.com/int128/ghcp/pkg/github" 13 | ) 14 | 15 | func TestCreateBlobTreeCommit_Do(t *testing.T) { 16 | ctx := context.TODO() 17 | repositoryID := git.RepositoryID{Owner: "owner", Name: "repo"} 18 | 19 | t.Run("BasicOptions", func(t *testing.T) { 20 | 21 | fileSystem := fs_mock.NewMockInterface(t) 22 | fileSystem.EXPECT(). 23 | ReadAsBase64EncodedContent("file1"). 24 | Return("base64content1", nil) 25 | fileSystem.EXPECT(). 26 | ReadAsBase64EncodedContent("file2"). 27 | Return("base64content2", nil) 28 | 29 | gitHub := github_mock.NewMockInterface(t) 30 | gitHub.EXPECT(). 31 | CreateBlob(ctx, git.NewBlob{ 32 | Repository: repositoryID, 33 | Content: "base64content1", 34 | }). 35 | Return(git.BlobSHA("blobSHA1"), nil) 36 | gitHub.EXPECT(). 37 | CreateBlob(ctx, git.NewBlob{ 38 | Repository: repositoryID, 39 | Content: "base64content2", 40 | }). 41 | Return(git.BlobSHA("blobSHA2"), nil) 42 | gitHub.EXPECT(). 43 | CreateTree(ctx, git.NewTree{ 44 | Repository: repositoryID, 45 | BaseTreeSHA: "masterTreeSHA", 46 | Files: []git.File{ 47 | { 48 | Filename: "file1", 49 | BlobSHA: "blobSHA1", 50 | }, { 51 | Filename: "file2", 52 | BlobSHA: "blobSHA2", 53 | Executable: true, 54 | }, 55 | }, 56 | }). 57 | Return(git.TreeSHA("treeSHA"), nil) 58 | gitHub.EXPECT(). 59 | CreateCommit(ctx, git.NewCommit{ 60 | Repository: repositoryID, 61 | TreeSHA: "treeSHA", 62 | ParentCommitSHA: "masterCommitSHA", 63 | Message: "message", 64 | }). 65 | Return(git.CommitSHA("commitSHA"), nil) 66 | gitHub.EXPECT(). 67 | QueryCommit(ctx, github.QueryCommitInput{ 68 | Repository: repositoryID, 69 | CommitSHA: "commitSHA", 70 | }). 71 | Return(&github.QueryCommitOutput{ 72 | ChangedFiles: 1, 73 | }, nil) 74 | 75 | useCase := CreateGitObject{ 76 | FileSystem: fileSystem, 77 | GitHub: gitHub, 78 | } 79 | got, err := useCase.Do(ctx, Input{ 80 | Files: []fs.File{ 81 | {Path: "file1"}, 82 | {Path: "file2", Executable: true}, 83 | }, 84 | Repository: repositoryID, 85 | CommitMessage: "message", 86 | ParentCommitSHA: "masterCommitSHA", 87 | ParentTreeSHA: "masterTreeSHA", 88 | }) 89 | if err != nil { 90 | t.Fatalf("Do returned error: %+v", err) 91 | } 92 | want := &Output{ 93 | CommitSHA: "commitSHA", 94 | ChangedFiles: 1, 95 | } 96 | if diff := cmp.Diff(want, got); diff != "" { 97 | t.Errorf("mismatch (-want +got):\n%s", diff) 98 | } 99 | }) 100 | 101 | t.Run("NoFileMode", func(t *testing.T) { 102 | 103 | fileSystem := fs_mock.NewMockInterface(t) 104 | fileSystem.EXPECT(). 105 | ReadAsBase64EncodedContent("file1"). 106 | Return("base64content1", nil) 107 | fileSystem.EXPECT(). 108 | ReadAsBase64EncodedContent("file2"). 109 | Return("base64content2", nil) 110 | 111 | gitHub := github_mock.NewMockInterface(t) 112 | gitHub.EXPECT(). 113 | CreateBlob(ctx, git.NewBlob{ 114 | Repository: repositoryID, 115 | Content: "base64content1", 116 | }). 117 | Return(git.BlobSHA("blobSHA1"), nil) 118 | gitHub.EXPECT(). 119 | CreateBlob(ctx, git.NewBlob{ 120 | Repository: repositoryID, 121 | Content: "base64content2", 122 | }). 123 | Return(git.BlobSHA("blobSHA2"), nil) 124 | gitHub.EXPECT(). 125 | CreateTree(ctx, git.NewTree{ 126 | Repository: repositoryID, 127 | BaseTreeSHA: "masterTreeSHA", 128 | Files: []git.File{ 129 | { 130 | Filename: "file1", 131 | BlobSHA: "blobSHA1", 132 | }, { 133 | Filename: "file2", 134 | BlobSHA: "blobSHA2", 135 | // no Executable 136 | }, 137 | }, 138 | }). 139 | Return(git.TreeSHA("treeSHA"), nil) 140 | gitHub.EXPECT(). 141 | CreateCommit(ctx, git.NewCommit{ 142 | Repository: repositoryID, 143 | TreeSHA: "treeSHA", 144 | ParentCommitSHA: "masterCommitSHA", 145 | Message: "message", 146 | }). 147 | Return(git.CommitSHA("commitSHA"), nil) 148 | gitHub.EXPECT(). 149 | QueryCommit(ctx, github.QueryCommitInput{ 150 | Repository: repositoryID, 151 | CommitSHA: "commitSHA", 152 | }). 153 | Return(&github.QueryCommitOutput{ 154 | ChangedFiles: 1, 155 | }, nil) 156 | 157 | useCase := CreateGitObject{ 158 | FileSystem: fileSystem, 159 | GitHub: gitHub, 160 | } 161 | got, err := useCase.Do(ctx, Input{ 162 | Files: []fs.File{ 163 | {Path: "file1"}, 164 | {Path: "file2", Executable: true}, 165 | }, 166 | Repository: repositoryID, 167 | CommitMessage: "message", 168 | ParentCommitSHA: "masterCommitSHA", 169 | ParentTreeSHA: "masterTreeSHA", 170 | NoFileMode: true, 171 | }) 172 | if err != nil { 173 | t.Fatalf("Do returned error: %+v", err) 174 | } 175 | want := &Output{ 176 | CommitSHA: "commitSHA", 177 | ChangedFiles: 1, 178 | } 179 | if diff := cmp.Diff(want, got); diff != "" { 180 | t.Errorf("mismatch (-want +got):\n%s", diff) 181 | } 182 | }) 183 | 184 | t.Run("NoFile", func(t *testing.T) { 185 | 186 | fileSystem := fs_mock.NewMockInterface(t) 187 | 188 | gitHub := github_mock.NewMockInterface(t) 189 | gitHub.EXPECT(). 190 | CreateCommit(ctx, git.NewCommit{ 191 | Repository: repositoryID, 192 | TreeSHA: "masterTreeSHA", 193 | ParentCommitSHA: "masterCommitSHA", 194 | Message: "message", 195 | }). 196 | Return(git.CommitSHA("commitSHA"), nil) 197 | gitHub.EXPECT(). 198 | QueryCommit(ctx, github.QueryCommitInput{ 199 | Repository: repositoryID, 200 | CommitSHA: "commitSHA", 201 | }). 202 | Return(&github.QueryCommitOutput{ 203 | ChangedFiles: 1, 204 | }, nil) 205 | 206 | useCase := CreateGitObject{ 207 | FileSystem: fileSystem, 208 | GitHub: gitHub, 209 | } 210 | got, err := useCase.Do(ctx, Input{ 211 | Files: nil, 212 | Repository: repositoryID, 213 | CommitMessage: "message", 214 | ParentCommitSHA: "masterCommitSHA", 215 | ParentTreeSHA: "masterTreeSHA", 216 | }) 217 | if err != nil { 218 | t.Fatalf("Do returned error: %+v", err) 219 | } 220 | want := &Output{ 221 | CommitSHA: "commitSHA", 222 | ChangedFiles: 1, 223 | } 224 | if diff := cmp.Diff(want, got); diff != "" { 225 | t.Errorf("mismatch (-want +got):\n%s", diff) 226 | } 227 | }) 228 | 229 | t.Run("CommitterAndAuthor", func(t *testing.T) { 230 | 231 | fileSystem := fs_mock.NewMockInterface(t) 232 | 233 | gitHub := github_mock.NewMockInterface(t) 234 | gitHub.EXPECT(). 235 | CreateCommit(ctx, git.NewCommit{ 236 | Repository: repositoryID, 237 | TreeSHA: "masterTreeSHA", 238 | ParentCommitSHA: "masterCommitSHA", 239 | Message: "message", 240 | Committer: &git.CommitAuthor{Name: "SomeCommitter", Email: "committer@example.com"}, 241 | Author: &git.CommitAuthor{Name: "SomeAuthor", Email: "author@example.com"}, 242 | }). 243 | Return(git.CommitSHA("commitSHA"), nil) 244 | gitHub.EXPECT(). 245 | QueryCommit(ctx, github.QueryCommitInput{ 246 | Repository: repositoryID, 247 | CommitSHA: "commitSHA", 248 | }). 249 | Return(&github.QueryCommitOutput{ 250 | ChangedFiles: 1, 251 | }, nil) 252 | 253 | useCase := CreateGitObject{ 254 | FileSystem: fileSystem, 255 | GitHub: gitHub, 256 | } 257 | got, err := useCase.Do(ctx, Input{ 258 | Files: nil, 259 | Repository: repositoryID, 260 | CommitMessage: "message", 261 | Committer: &git.CommitAuthor{Name: "SomeCommitter", Email: "committer@example.com"}, 262 | Author: &git.CommitAuthor{Name: "SomeAuthor", Email: "author@example.com"}, 263 | ParentCommitSHA: "masterCommitSHA", 264 | ParentTreeSHA: "masterTreeSHA", 265 | }) 266 | if err != nil { 267 | t.Fatalf("Do returned error: %+v", err) 268 | } 269 | want := &Output{ 270 | CommitSHA: "commitSHA", 271 | ChangedFiles: 1, 272 | } 273 | if diff := cmp.Diff(want, got); diff != "" { 274 | t.Errorf("mismatch (-want +got):\n%s", diff) 275 | } 276 | }) 277 | } 278 | -------------------------------------------------------------------------------- /pkg/cmd/commit_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/int128/ghcp/mocks/github.com/int128/ghcp/pkg/usecases/commit_mock" 7 | "github.com/int128/ghcp/pkg/git" 8 | "github.com/int128/ghcp/pkg/git/commitstrategy" 9 | "github.com/int128/ghcp/pkg/github/client" 10 | "github.com/int128/ghcp/pkg/usecases/commit" 11 | "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | func TestCmd_Run_commit(t *testing.T) { 15 | t.Run("BasicOptions", func(t *testing.T) { 16 | commitUseCase := commit_mock.NewMockInterface(t) 17 | commitUseCase.EXPECT(). 18 | Do(mock.Anything, commit.Input{ 19 | TargetRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 20 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 21 | CommitStrategy: commitstrategy.FastForward, 22 | CommitMessage: "commit-message", 23 | Paths: []string{"file1", "file2"}, 24 | }). 25 | Return(nil) 26 | r := Runner{ 27 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 28 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 29 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 30 | } 31 | args := []string{ 32 | cmdName, 33 | commitCmdName, 34 | "--token", "YOUR_TOKEN", 35 | "-r", "owner/repo", 36 | "-m", "commit-message", 37 | "file1", 38 | "file2", 39 | } 40 | exitCode := r.Run(args, version) 41 | if exitCode != exitCodeOK { 42 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 43 | } 44 | }) 45 | 46 | t.Run("--branch", func(t *testing.T) { 47 | commitUseCase := commit_mock.NewMockInterface(t) 48 | commitUseCase.EXPECT(). 49 | Do(mock.Anything, commit.Input{ 50 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 51 | TargetRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 52 | TargetBranchName: "gh-pages", 53 | CommitStrategy: commitstrategy.FastForward, 54 | CommitMessage: "commit-message", 55 | Paths: []string{"file1", "file2"}, 56 | }). 57 | Return(nil) 58 | r := Runner{ 59 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 60 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 61 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 62 | } 63 | args := []string{ 64 | cmdName, 65 | commitCmdName, 66 | "--token", "YOUR_TOKEN", 67 | "-u", "owner", 68 | "-r", "repo", 69 | "-m", "commit-message", 70 | "-b", "gh-pages", 71 | "file1", 72 | "file2", 73 | } 74 | exitCode := r.Run(args, version) 75 | if exitCode != exitCodeOK { 76 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 77 | } 78 | }) 79 | 80 | t.Run("--parent", func(t *testing.T) { 81 | commitUseCase := commit_mock.NewMockInterface(t) 82 | commitUseCase.EXPECT(). 83 | Do(mock.Anything, commit.Input{ 84 | TargetRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 85 | TargetBranchName: "topic", 86 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 87 | CommitStrategy: commitstrategy.RebaseOn("develop"), 88 | CommitMessage: "commit-message", 89 | Paths: []string{"file1", "file2"}, 90 | }). 91 | Return(nil) 92 | r := Runner{ 93 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 94 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 95 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 96 | } 97 | args := []string{ 98 | cmdName, 99 | commitCmdName, 100 | "--token", "YOUR_TOKEN", 101 | "-u", "owner", 102 | "-r", "repo", 103 | "-m", "commit-message", 104 | "-b", "topic", 105 | "--parent", "develop", 106 | "file1", 107 | "file2", 108 | } 109 | exitCode := r.Run(args, version) 110 | if exitCode != exitCodeOK { 111 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 112 | } 113 | }) 114 | 115 | t.Run("--no-parent", func(t *testing.T) { 116 | commitUseCase := commit_mock.NewMockInterface(t) 117 | commitUseCase.EXPECT(). 118 | Do(mock.Anything, commit.Input{ 119 | TargetRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 120 | TargetBranchName: "topic", 121 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 122 | CommitStrategy: commitstrategy.NoParent, 123 | CommitMessage: "commit-message", 124 | Paths: []string{"file1", "file2"}, 125 | }). 126 | Return(nil) 127 | r := Runner{ 128 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 129 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 130 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 131 | } 132 | args := []string{ 133 | cmdName, 134 | commitCmdName, 135 | "--token", "YOUR_TOKEN", 136 | "-u", "owner", 137 | "-r", "repo", 138 | "-m", "commit-message", 139 | "-b", "topic", 140 | "--no-parent", 141 | "file1", 142 | "file2", 143 | } 144 | exitCode := r.Run(args, version) 145 | if exitCode != exitCodeOK { 146 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 147 | } 148 | }) 149 | 150 | t.Run("--parent and --no-parent", func(t *testing.T) { 151 | r := Runner{ 152 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 153 | Env: newEnv(t, nil), 154 | NewInternalRunner: newInternalRunner(InternalRunner{}), 155 | } 156 | args := []string{ 157 | cmdName, 158 | commitCmdName, 159 | "--token", "YOUR_TOKEN", 160 | "-u", "owner", 161 | "-r", "repo", 162 | "-m", "commit-message", 163 | "-b", "topic", 164 | "--parent", "develop", 165 | "--no-parent", 166 | "file1", 167 | "file2", 168 | } 169 | exitCode := r.Run(args, version) 170 | if exitCode != exitCodeError { 171 | t.Errorf("exitCode wants %d but %d", exitCodeError, exitCode) 172 | } 173 | }) 174 | 175 | t.Run("only --author-name", func(t *testing.T) { 176 | r := Runner{ 177 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 178 | Env: newEnv(t, nil), 179 | NewInternalRunner: newInternalRunner(InternalRunner{}), 180 | } 181 | args := []string{ 182 | cmdName, 183 | commitCmdName, 184 | "--token", "YOUR_TOKEN", 185 | "-u", "owner", 186 | "-r", "repo", 187 | "-m", "commit-message", 188 | "--author-name", "Some Author", 189 | "file1", 190 | } 191 | exitCode := r.Run(args, version) 192 | if exitCode != exitCodeError { 193 | t.Errorf("exitCode wants %d but %d", exitCodeError, exitCode) 194 | } 195 | }) 196 | 197 | t.Run("only --committer-email", func(t *testing.T) { 198 | r := Runner{ 199 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 200 | Env: newEnv(t, nil), 201 | NewInternalRunner: newInternalRunner(InternalRunner{}), 202 | } 203 | args := []string{ 204 | cmdName, 205 | commitCmdName, 206 | "--token", "YOUR_TOKEN", 207 | "-u", "owner", 208 | "-r", "repo", 209 | "-m", "commit-message", 210 | "--committer-email", "committer@example.com", 211 | "file1", 212 | } 213 | exitCode := r.Run(args, version) 214 | if exitCode != exitCodeError { 215 | t.Errorf("exitCode wants %d but %d", exitCodeError, exitCode) 216 | } 217 | }) 218 | 219 | t.Run("--no-file-mode", func(t *testing.T) { 220 | commitUseCase := commit_mock.NewMockInterface(t) 221 | commitUseCase.EXPECT(). 222 | Do(mock.Anything, commit.Input{ 223 | TargetRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 224 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 225 | CommitStrategy: commitstrategy.FastForward, 226 | CommitMessage: "commit-message", 227 | Paths: []string{"file1", "file2"}, 228 | NoFileMode: true, 229 | }). 230 | Return(nil) 231 | r := Runner{ 232 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 233 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 234 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 235 | } 236 | args := []string{ 237 | cmdName, 238 | commitCmdName, 239 | "--token", "YOUR_TOKEN", 240 | "-u", "owner", 241 | "-r", "repo", 242 | "-m", "commit-message", 243 | "--no-file-mode", 244 | "file1", 245 | "file2", 246 | } 247 | exitCode := r.Run(args, version) 248 | if exitCode != exitCodeOK { 249 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 250 | } 251 | }) 252 | 253 | t.Run("--dry-run", func(t *testing.T) { 254 | commitUseCase := commit_mock.NewMockInterface(t) 255 | commitUseCase.EXPECT(). 256 | Do(mock.Anything, commit.Input{ 257 | TargetRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 258 | ParentRepository: git.RepositoryID{Owner: "owner", Name: "repo"}, 259 | CommitStrategy: commitstrategy.FastForward, 260 | CommitMessage: "commit-message", 261 | Paths: []string{"file1", "file2"}, 262 | DryRun: true, 263 | }). 264 | Return(nil) 265 | r := Runner{ 266 | NewGitHub: newGitHub(t, client.Option{Token: "YOUR_TOKEN"}), 267 | Env: newEnv(t, map[string]string{envGitHubAPI: ""}), 268 | NewInternalRunner: newInternalRunner(InternalRunner{CommitUseCase: commitUseCase}), 269 | } 270 | args := []string{ 271 | cmdName, 272 | commitCmdName, 273 | "--token", "YOUR_TOKEN", 274 | "-u", "owner", 275 | "-r", "repo", 276 | "-m", "commit-message", 277 | "--dry-run", 278 | "file1", 279 | "file2", 280 | } 281 | exitCode := r.Run(args, version) 282 | if exitCode != exitCodeOK { 283 | t.Errorf("exitCode wants %d but %d", exitCodeOK, exitCode) 284 | } 285 | }) 286 | } 287 | -------------------------------------------------------------------------------- /mocks/github.com/int128/ghcp/pkg/fs_mock/mocks.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery; DO NOT EDIT. 2 | // github.com/vektra/mockery 3 | // template: testify 4 | 5 | package fs_mock 6 | 7 | import ( 8 | "github.com/int128/ghcp/pkg/fs" 9 | mock "github.com/stretchr/testify/mock" 10 | ) 11 | 12 | // NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 13 | // The first argument is typically a *testing.T value. 14 | func NewMockInterface(t interface { 15 | mock.TestingT 16 | Cleanup(func()) 17 | }) *MockInterface { 18 | mock := &MockInterface{} 19 | mock.Mock.Test(t) 20 | 21 | t.Cleanup(func() { mock.AssertExpectations(t) }) 22 | 23 | return mock 24 | } 25 | 26 | // MockInterface is an autogenerated mock type for the Interface type 27 | type MockInterface struct { 28 | mock.Mock 29 | } 30 | 31 | type MockInterface_Expecter struct { 32 | mock *mock.Mock 33 | } 34 | 35 | func (_m *MockInterface) EXPECT() *MockInterface_Expecter { 36 | return &MockInterface_Expecter{mock: &_m.Mock} 37 | } 38 | 39 | // FindFiles provides a mock function for the type MockInterface 40 | func (_mock *MockInterface) FindFiles(paths []string, filter fs.FindFilesFilter) ([]fs.File, error) { 41 | ret := _mock.Called(paths, filter) 42 | 43 | if len(ret) == 0 { 44 | panic("no return value specified for FindFiles") 45 | } 46 | 47 | var r0 []fs.File 48 | var r1 error 49 | if returnFunc, ok := ret.Get(0).(func([]string, fs.FindFilesFilter) ([]fs.File, error)); ok { 50 | return returnFunc(paths, filter) 51 | } 52 | if returnFunc, ok := ret.Get(0).(func([]string, fs.FindFilesFilter) []fs.File); ok { 53 | r0 = returnFunc(paths, filter) 54 | } else { 55 | if ret.Get(0) != nil { 56 | r0 = ret.Get(0).([]fs.File) 57 | } 58 | } 59 | if returnFunc, ok := ret.Get(1).(func([]string, fs.FindFilesFilter) error); ok { 60 | r1 = returnFunc(paths, filter) 61 | } else { 62 | r1 = ret.Error(1) 63 | } 64 | return r0, r1 65 | } 66 | 67 | // MockInterface_FindFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindFiles' 68 | type MockInterface_FindFiles_Call struct { 69 | *mock.Call 70 | } 71 | 72 | // FindFiles is a helper method to define mock.On call 73 | // - paths []string 74 | // - filter fs.FindFilesFilter 75 | func (_e *MockInterface_Expecter) FindFiles(paths interface{}, filter interface{}) *MockInterface_FindFiles_Call { 76 | return &MockInterface_FindFiles_Call{Call: _e.mock.On("FindFiles", paths, filter)} 77 | } 78 | 79 | func (_c *MockInterface_FindFiles_Call) Run(run func(paths []string, filter fs.FindFilesFilter)) *MockInterface_FindFiles_Call { 80 | _c.Call.Run(func(args mock.Arguments) { 81 | var arg0 []string 82 | if args[0] != nil { 83 | arg0 = args[0].([]string) 84 | } 85 | var arg1 fs.FindFilesFilter 86 | if args[1] != nil { 87 | arg1 = args[1].(fs.FindFilesFilter) 88 | } 89 | run( 90 | arg0, 91 | arg1, 92 | ) 93 | }) 94 | return _c 95 | } 96 | 97 | func (_c *MockInterface_FindFiles_Call) Return(files []fs.File, err error) *MockInterface_FindFiles_Call { 98 | _c.Call.Return(files, err) 99 | return _c 100 | } 101 | 102 | func (_c *MockInterface_FindFiles_Call) RunAndReturn(run func(paths []string, filter fs.FindFilesFilter) ([]fs.File, error)) *MockInterface_FindFiles_Call { 103 | _c.Call.Return(run) 104 | return _c 105 | } 106 | 107 | // ReadAsBase64EncodedContent provides a mock function for the type MockInterface 108 | func (_mock *MockInterface) ReadAsBase64EncodedContent(filename string) (string, error) { 109 | ret := _mock.Called(filename) 110 | 111 | if len(ret) == 0 { 112 | panic("no return value specified for ReadAsBase64EncodedContent") 113 | } 114 | 115 | var r0 string 116 | var r1 error 117 | if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok { 118 | return returnFunc(filename) 119 | } 120 | if returnFunc, ok := ret.Get(0).(func(string) string); ok { 121 | r0 = returnFunc(filename) 122 | } else { 123 | r0 = ret.Get(0).(string) 124 | } 125 | if returnFunc, ok := ret.Get(1).(func(string) error); ok { 126 | r1 = returnFunc(filename) 127 | } else { 128 | r1 = ret.Error(1) 129 | } 130 | return r0, r1 131 | } 132 | 133 | // MockInterface_ReadAsBase64EncodedContent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadAsBase64EncodedContent' 134 | type MockInterface_ReadAsBase64EncodedContent_Call struct { 135 | *mock.Call 136 | } 137 | 138 | // ReadAsBase64EncodedContent is a helper method to define mock.On call 139 | // - filename string 140 | func (_e *MockInterface_Expecter) ReadAsBase64EncodedContent(filename interface{}) *MockInterface_ReadAsBase64EncodedContent_Call { 141 | return &MockInterface_ReadAsBase64EncodedContent_Call{Call: _e.mock.On("ReadAsBase64EncodedContent", filename)} 142 | } 143 | 144 | func (_c *MockInterface_ReadAsBase64EncodedContent_Call) Run(run func(filename string)) *MockInterface_ReadAsBase64EncodedContent_Call { 145 | _c.Call.Run(func(args mock.Arguments) { 146 | var arg0 string 147 | if args[0] != nil { 148 | arg0 = args[0].(string) 149 | } 150 | run( 151 | arg0, 152 | ) 153 | }) 154 | return _c 155 | } 156 | 157 | func (_c *MockInterface_ReadAsBase64EncodedContent_Call) Return(s string, err error) *MockInterface_ReadAsBase64EncodedContent_Call { 158 | _c.Call.Return(s, err) 159 | return _c 160 | } 161 | 162 | func (_c *MockInterface_ReadAsBase64EncodedContent_Call) RunAndReturn(run func(filename string) (string, error)) *MockInterface_ReadAsBase64EncodedContent_Call { 163 | _c.Call.Return(run) 164 | return _c 165 | } 166 | 167 | // NewMockFindFilesFilter creates a new instance of MockFindFilesFilter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 168 | // The first argument is typically a *testing.T value. 169 | func NewMockFindFilesFilter(t interface { 170 | mock.TestingT 171 | Cleanup(func()) 172 | }) *MockFindFilesFilter { 173 | mock := &MockFindFilesFilter{} 174 | mock.Mock.Test(t) 175 | 176 | t.Cleanup(func() { mock.AssertExpectations(t) }) 177 | 178 | return mock 179 | } 180 | 181 | // MockFindFilesFilter is an autogenerated mock type for the FindFilesFilter type 182 | type MockFindFilesFilter struct { 183 | mock.Mock 184 | } 185 | 186 | type MockFindFilesFilter_Expecter struct { 187 | mock *mock.Mock 188 | } 189 | 190 | func (_m *MockFindFilesFilter) EXPECT() *MockFindFilesFilter_Expecter { 191 | return &MockFindFilesFilter_Expecter{mock: &_m.Mock} 192 | } 193 | 194 | // ExcludeFile provides a mock function for the type MockFindFilesFilter 195 | func (_mock *MockFindFilesFilter) ExcludeFile(path string) bool { 196 | ret := _mock.Called(path) 197 | 198 | if len(ret) == 0 { 199 | panic("no return value specified for ExcludeFile") 200 | } 201 | 202 | var r0 bool 203 | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { 204 | r0 = returnFunc(path) 205 | } else { 206 | r0 = ret.Get(0).(bool) 207 | } 208 | return r0 209 | } 210 | 211 | // MockFindFilesFilter_ExcludeFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExcludeFile' 212 | type MockFindFilesFilter_ExcludeFile_Call struct { 213 | *mock.Call 214 | } 215 | 216 | // ExcludeFile is a helper method to define mock.On call 217 | // - path string 218 | func (_e *MockFindFilesFilter_Expecter) ExcludeFile(path interface{}) *MockFindFilesFilter_ExcludeFile_Call { 219 | return &MockFindFilesFilter_ExcludeFile_Call{Call: _e.mock.On("ExcludeFile", path)} 220 | } 221 | 222 | func (_c *MockFindFilesFilter_ExcludeFile_Call) Run(run func(path string)) *MockFindFilesFilter_ExcludeFile_Call { 223 | _c.Call.Run(func(args mock.Arguments) { 224 | var arg0 string 225 | if args[0] != nil { 226 | arg0 = args[0].(string) 227 | } 228 | run( 229 | arg0, 230 | ) 231 | }) 232 | return _c 233 | } 234 | 235 | func (_c *MockFindFilesFilter_ExcludeFile_Call) Return(b bool) *MockFindFilesFilter_ExcludeFile_Call { 236 | _c.Call.Return(b) 237 | return _c 238 | } 239 | 240 | func (_c *MockFindFilesFilter_ExcludeFile_Call) RunAndReturn(run func(path string) bool) *MockFindFilesFilter_ExcludeFile_Call { 241 | _c.Call.Return(run) 242 | return _c 243 | } 244 | 245 | // SkipDir provides a mock function for the type MockFindFilesFilter 246 | func (_mock *MockFindFilesFilter) SkipDir(path string) bool { 247 | ret := _mock.Called(path) 248 | 249 | if len(ret) == 0 { 250 | panic("no return value specified for SkipDir") 251 | } 252 | 253 | var r0 bool 254 | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { 255 | r0 = returnFunc(path) 256 | } else { 257 | r0 = ret.Get(0).(bool) 258 | } 259 | return r0 260 | } 261 | 262 | // MockFindFilesFilter_SkipDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SkipDir' 263 | type MockFindFilesFilter_SkipDir_Call struct { 264 | *mock.Call 265 | } 266 | 267 | // SkipDir is a helper method to define mock.On call 268 | // - path string 269 | func (_e *MockFindFilesFilter_Expecter) SkipDir(path interface{}) *MockFindFilesFilter_SkipDir_Call { 270 | return &MockFindFilesFilter_SkipDir_Call{Call: _e.mock.On("SkipDir", path)} 271 | } 272 | 273 | func (_c *MockFindFilesFilter_SkipDir_Call) Run(run func(path string)) *MockFindFilesFilter_SkipDir_Call { 274 | _c.Call.Run(func(args mock.Arguments) { 275 | var arg0 string 276 | if args[0] != nil { 277 | arg0 = args[0].(string) 278 | } 279 | run( 280 | arg0, 281 | ) 282 | }) 283 | return _c 284 | } 285 | 286 | func (_c *MockFindFilesFilter_SkipDir_Call) Return(b bool) *MockFindFilesFilter_SkipDir_Call { 287 | _c.Call.Return(b) 288 | return _c 289 | } 290 | 291 | func (_c *MockFindFilesFilter_SkipDir_Call) RunAndReturn(run func(path string) bool) *MockFindFilesFilter_SkipDir_Call { 292 | _c.Call.Return(run) 293 | return _c 294 | } 295 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghcp [![go](https://github.com/int128/ghcp/actions/workflows/go.yaml/badge.svg)](https://github.com/int128/ghcp/actions/workflows/go.yaml) [![GoDoc](https://godoc.org/github.com/int128/ghcp?status.svg)](https://godoc.org/github.com/int128/ghcp) 2 | 3 | This is a release engineering tool for GitHub. 4 | It depends on GitHub APIs and works without git installation. 5 | 6 | It provides the following features: 7 | 8 | - Commit files to a repository 9 | - Create an empty commit 10 | - Fork a repository and commit files to the forked repository 11 | - Create a pull request 12 | - Upload files to GitHub Releases 13 | 14 | 15 | ## Getting Started 16 | 17 | You can install the latest release from [GitHub Releases](https://github.com/int128/ghcp/releases) or Homebrew. 18 | 19 | ```sh 20 | # GitHub Releases 21 | curl -fL -o /tmp/ghcp.zip https://github.com/int128/ghcp/releases/download/v1.8.0/ghcp_linux_amd64.zip 22 | unzip /tmp/ghcp.zip -d ~/bin 23 | 24 | # Homebrew 25 | brew install int128/ghcp/ghcp 26 | ``` 27 | 28 | You need to get a personal access token from the [settings](https://github.com/settings/tokens) and set it to `GITHUB_TOKEN` environment variable or `--token` option. 29 | 30 | 31 | ### Commit files to a branch 32 | 33 | To commit files to the default branch: 34 | 35 | ```sh 36 | ghcp commit -r OWNER/REPO -m MESSAGE file1 file2 37 | ``` 38 | 39 | To commit files to `feature` branch: 40 | 41 | ```sh 42 | ghcp commit -r OWNER/REPO -b feature -m MESSAGE file1 file2 43 | ``` 44 | 45 | If `feature` branch does not exist, ghcp will create it from the default branch. 46 | 47 | To create `feature` branch from `develop` branch: 48 | 49 | ```sh 50 | ghcp commit -r OWNER/REPO -b feature --parent=develop -m MESSAGE file1 file2 51 | ``` 52 | 53 | If `feature` branch already exists, ghcp will fail. 54 | Currently only fast-forward is supported. 55 | 56 | ghcp performs a commit operation as follows: 57 | 58 | - An author and committer of a commit are set to the login user (depending on the token). 59 | - If the branch has same files, do not create a new commit. It prevents an empty commit. 60 | - It excludes `.git` directories. 61 | - It does not support `.gitconfig`. 62 | 63 | You can set the following options. 64 | 65 | ``` 66 | Flags: 67 | --author-email string Author email (default: login email) 68 | --author-name string Author name (default: login name) 69 | -b, --branch string Name of the branch to create or update (default: the default branch of repository) 70 | --committer-email string Committer email (default: login email) 71 | --committer-name string Committer name (default: login name) 72 | --dry-run Upload files but do not update the branch actually 73 | -h, --help help for commit 74 | -m, --message string Commit message (mandatory) 75 | --no-file-mode Ignore executable bit of file and treat as 0644 76 | --no-parent Create a commit without a parent 77 | -u, --owner string Repository owner 78 | --parent string Create a commit from the parent branch/tag (default: fast-forward) 79 | -r, --repo string Repository name, either -r OWNER/REPO or -u OWNER -r REPO (mandatory) 80 | ``` 81 | 82 | 83 | ### Create an empty commit to a branch 84 | 85 | To create an empty commit to the default branch: 86 | 87 | ```sh 88 | ghcp empty-commit -r OWNER/REPO -m MESSAGE 89 | ``` 90 | 91 | To create an empty commit to the branch: 92 | 93 | ```sh 94 | ghcp empty-commit -r OWNER/REPO -b BRANCH -m MESSAGE 95 | ``` 96 | 97 | If the branch does not exist, ghcp creates a branch from the default branch. 98 | It the branch exists, ghcp updates the branch by fast-forward. 99 | 100 | To create an empty commit to a new branch from the parent branch: 101 | 102 | ```sh 103 | ghcp empty-commit -r OWNER/REPO -b BRANCH --parent PARENT -m MESSAGE 104 | ``` 105 | 106 | If the branch exists, it will fail. 107 | 108 | You can set the following options. 109 | 110 | ``` 111 | Flags: 112 | --author-email string Author email (default: login email) 113 | --author-name string Author name (default: login name) 114 | -b, --branch string Name of the branch to create or update (default: the default branch of repository) 115 | --committer-email string Committer email (default: login email) 116 | --committer-name string Committer name (default: login name) 117 | --dry-run Do not update the branch actually 118 | -h, --help help for empty-commit 119 | -m, --message string Commit message (mandatory) 120 | -u, --owner string Repository owner 121 | --parent string Create a commit from the parent branch/tag (default: fast-forward) 122 | -r, --repo string Repository name, either -r OWNER/REPO or -u OWNER -r REPO (mandatory) 123 | ``` 124 | 125 | 126 | ### Fork the repository and commit files to a new branch 127 | 128 | To fork repository `UPSTREAM/REPO` and create `feature` branch from the default branch: 129 | 130 | ```sh 131 | ghcp fork-commit -u UPSTREAM/REPO -b feature -m MESSAGE file1 file2 132 | ``` 133 | 134 | To fork repository `UPSTREAM/REPO` and create `feature` branch from `develop` branch of the upstream: 135 | 136 | ```sh 137 | ghcp fork-commit -u UPSTREAM/REPO -b feature --parent develop -m MESSAGE file1 file2 138 | ``` 139 | 140 | If the branch already exists, ghcp will fail. 141 | Currently only fast-forward is supported. 142 | 143 | You can set the following options. 144 | 145 | ``` 146 | Flags: 147 | --author-email string Author email (default: login email) 148 | --author-name string Author name (default: login name) 149 | -b, --branch string Name of the branch to create (mandatory) 150 | --committer-email string Committer email (default: login email) 151 | --committer-name string Committer name (default: login name) 152 | --dry-run Upload files but do not update the branch actually 153 | -h, --help help for fork-commit 154 | -m, --message string Commit message (mandatory) 155 | --no-file-mode Ignore executable bit of file and treat as 0644 156 | -u, --owner string Upstream repository owner 157 | --parent string Upstream branch name (default: the default branch of the upstream repository) 158 | -r, --repo string Upstream repository name, either -r OWNER/REPO or -u OWNER -r REPO (mandatory) 159 | ``` 160 | 161 | 162 | ### Create a pull request 163 | 164 | To create a pull request from `feature` branch to the default branch: 165 | 166 | ```sh 167 | ghcp pull-request -r OWNER/REPO -b feature --title TITLE --body BODY 168 | ``` 169 | 170 | To create a pull request from `feature` branch to the `develop` branch: 171 | 172 | ```sh 173 | ghcp pull-request -r OWNER/REPO -b feature --base develop --title TITLE --body BODY 174 | ``` 175 | 176 | To create a pull request from `feature` branch of `OWNER/REPO` repository to the default branch of `UPSTREAM/REPO` repository: 177 | 178 | ```sh 179 | ghcp pull-request -r OWNER/REPO -b feature --base-repo UPSTREAM/REPO --title TITLE --body BODY 180 | ``` 181 | 182 | To create a pull request from `feature` branch of `OWNER/REPO` repository to the default branch of `UPSTREAM/REPO` repository: 183 | 184 | ```sh 185 | ghcp pull-request -r OWNER/REPO -b feature --base-repo UPSTREAM/REPO --base feature --title TITLE --body BODY 186 | ``` 187 | 188 | If an open pull request already exists, ghcp does nothing. 189 | 190 | You can set the following options. 191 | 192 | ``` 193 | Flags: 194 | --base string Base branch name (default: default branch of base repository) 195 | --base-owner string Base repository owner (default: head) 196 | --base-repo string Base repository name, either --base-repo OWNER/REPO or --base-owner OWNER --base-repo REPO (default: head) 197 | --body string Body of a pull request 198 | --draft If set, mark as a draft 199 | -b, --head string Head branch name (mandatory) 200 | -u, --head-owner string Head repository owner 201 | -r, --head-repo string Head repository name, either -r OWNER/REPO or -u OWNER -r REPO (mandatory) 202 | -h, --help help for pull-request 203 | --reviewer string If set, request a review 204 | --title string Title of a pull request (mandatory) 205 | ``` 206 | 207 | 208 | ### Release assets 209 | 210 | To upload files to the release associated to tag `v1.0.0`: 211 | 212 | ```sh 213 | ghcp release -r OWNER/REPO -t v1.0.0 dist/ 214 | ``` 215 | 216 | If the release does not exist, it will create a release. 217 | If the tag does not exist, it will create a tag from the default branch and create a release. 218 | 219 | To create a tag and release on commit `COMMIT_SHA` and upload files to the release: 220 | 221 | ```sh 222 | ghcp release -r OWNER/REPO -t v1.0.0 --target COMMIT_SHA dist/ 223 | ``` 224 | 225 | If the tag already exists, it ignores the target commit. 226 | If the release already exist, it only uploads the files. 227 | 228 | You can set the following options. 229 | 230 | ``` 231 | Flags: 232 | --dry-run Do not create a release and assets actually 233 | -h, --help help for release 234 | -u, --owner string Repository owner 235 | -r, --repo string Repository name, either -r OWNER/REPO or -u OWNER -r REPO (mandatory) 236 | -t, --tag string Tag name (mandatory) 237 | --target string Branch name or commit SHA of a tag. Unused if the Git tag already exists (default: the default branch) 238 | ``` 239 | 240 | 241 | ## Usage 242 | 243 | ### Global options 244 | 245 | You can set the following options. 246 | 247 | ``` 248 | Global Flags: 249 | --api string GitHub API v3 URL (v4 will be inferred) [$GITHUB_API] 250 | --debug Show debug logs 251 | -C, --directory string Change to directory before operation 252 | --token string GitHub API token [$GITHUB_TOKEN] 253 | ``` 254 | 255 | ### GitHub Enterprise 256 | 257 | You can set a GitHub API v3 URL by `GITHUB_API` environment variable or `--api` option. 258 | 259 | ```sh 260 | export GITHUB_API=https://github.example.com/api/v3/ 261 | ``` 262 | 263 | GitHub API v4 URL will be automatically inferred from the v3 URL by resolving the relative path `../graphql`. 264 | 265 | 266 | ## Contributions 267 | 268 | This is an open source software. 269 | Feel free to open issues and pull requests. 270 | 271 | Author: [Hidetake Iwata](https://github.com/int128) 272 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------