[go-git] Goで操作するGit & GitHub!

:::note info
この記事は[ZOZO AdventCalender 2024](https://qiita.com/advent-calendar/2024/zozo)シリーズ5の23日目の記事です。
:::

# はじめに
本記事では、Go言語でGitリポジトリを操作するためのライブラリである`go-git`を使って、Gitリポジトリの操作を行う方法について解説します。  
また、Go言語からGitHubのAPIを利用するためのライブラリである`go-github`を使って、GitHubのリポジトリの操作を行う方法についても解説します。  
さらに、`go-git`と`go-github`を組み合わせて、Lambdaなどのサーバーレス環境でリポジトリをCloneする方法についても解説します。  

# Go言語でGit操作
Go言語でGit操作をするためのライブラリはいくつかありますが、主要な選択肢としては[Git bookで紹介されているもの](https://git-scm.com/book/en/v2/Appendix-B:-Embedding-Git-in-your-Applications-Command-line-Git)になります。
| ライブラリ | 特徴 |
| --- | --- |
| [git2go](https://github.com/libgit2/git2go) | libgit2のGoバインディング<br>Gitのコアがスコープ外としている機能も備わっており、かなり高度なことができる<br>デメリットとしてはCGOで実装されているため、クロスコンパイルのハードルが上がる |
| [go-git](https://github.com/go-git/go-git) | Pure Goで実装されているため、クロスコンパイルが容易<br>libgit2のGoバインディングであるgit2goと比べると機能が少ないが、基本的な操作はカバーしている<br>**インメモリストレージが簡単に使える**<br>Gitコマンドとの互換性も[ドキュメント](https://github.com/go-git/go-git/blob/master/COMPATIBILITY.md)化されている|

Go言語でどのライブラリを使うかですが、個人的には`go-git`を使うことをオススメします。  
理由としては、`go-git`はPure Goで実装されているため、クロスコンパイルが容易であること、また、基本的な操作はカバーしていることが挙げられます。  
[COMPATIBLITY.md](https://github.com/go-git/go-git/blob/master/COMPATIBILITY.md)を確認して、自分が使いたい機能がサポートされていない場合は、`git2go`を選択することになります。  
本記事では、`go-git`を使ってGitリポジトリの操作を行う方法について解説します。  

## go-git
本記事では、`go-git`を使ってGitリポジトリを操作するためのサンプルコードを紹介します。  
ただし簡単な操作は、[go-gitのexample](https://github.com/go-git/go-git/tree/master/_examples)を紹介する形として、直感的にわかりずらい操作を中心に解説します。  
またインメモリストレージを使う方法と、ハマりがちなポイントを解説します。

## 主要なオブジェクト
`go-git`は、Gitオブジェクトを表すオブジェクトを通して、Gitリポジトリを操作します。  
そのため、Gitコマンドと対応する関数が用意されているわけではないため、まずはGitオブジェクトを理解しておくことが重要です。  
[Gitオブジェクト](https://git-scm.com/book/ja/v2/Git%E3%81%AE%E5%86%85%E5%81%B4-Git%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88)については、完全に理解するのは中々難しいんですが、Git bookを読むとある程度わかるようになると思います。  

リポジトリを操作するための主要なオブジェクトは以下の通りです。
- [Repository](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#Repository)
- [Reference](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0/plumbing#Reference)
- [Worktree](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#Worktree)
- [Remote](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#Remote)

### Repository
`Repository`は、Gitリポジトリを表すオブジェクトです。  
`go-git`を使ってGitリポジトリを操作するためには、まず`Repository`オブジェクトを取得する必要があり、全ての操作の起点となります。  
`Repository`オブジェクトの取得は大別して以下の3つの方法があります。  
- localに既に存在するリポジトリから取得する方法(open)
- リモートリポジトリから取得する方法(clone)
- 新規にリポジトリを作成する方法(init)

#### Open
既にlocalにclone済みのリポジトリで`Repository`オブジェクトを取得する方法です。  
[PlainOpen](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#PlainOpen)を使います。  
```go
package main

import (
	"fmt"
	"os"
	
	"github.com/go-git/go-git/v5"
)

func main() {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainOpen("/path/to/repo")
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	fmt.Println(repo)
}
```
#### clone
リモートリポジトリをcloneして`Repository`オブジェクトを取得する方法です。  
[PlainClone](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#PlainOpen)を使います。  
またCloneする場合、いくつかの認証方法がありますが、`go-git`ではusername/passwordを使うbasic認証、AccessTokenによる認証、ssh認証の3つがサポートされています。  
exampleが用意されているものを紹介します。  
- [認証を必要としない場合のexample](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#example-PlainClone)
- [Basic認証のexample](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#example-PlainClone-UsernamePassword)
- [PersonalAccessToken認証のexample](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#example-PlainClone-AccessToken)
- [SSH認証のexample](https://github.com/go-git/go-git/blob/master/_examples/clone/auth/ssh/private_key/main.go)
- [SSH-Agentを利用した認証のexample](https://github.com/go-git/go-git/blob/master/_examples/clone/auth/ssh/ssh_agent/main.go)

またGitHubAppsが発行するInstallAccessTokenを使う場合、URLを`https://x-access-token:<token>@<owner>/<repository name>.git`の形式にして、`PlainClone`を使うことでCloneできます。  
```go
package main

import (
	"fmt"
	"os"
	
	"github.com/go-git/go-git/v5"
)

func main() {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainClone("/path/to/repo", false, &git.CloneOptions{
		URL: "https://x-access-token:<token>@<owner>/<repoisoty name>.git",
	})
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	fmt.Println(repo)
}
```

なお[GitHubのドキュメントに従って](https://docs.github.com/ja/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent)ssh-agentに秘密鍵が登録済みの場合、URLを`git@github.com:<owner>/<repository name>.git`の形式で指定するだけでCloneできます。
```go
package main

import (
	"fmt"
	"os"
	
	"github.com/go-git/go-git/v5"
)

func main() {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainClone("/path/to/repo", false, &git.CloneOptions{
		URL: "git@github.com:<owner>/<repository name>.git",
	})
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	fmt.Println(repo)
}
```

#### Init
新規にリポジトリを作成して`Repository`オブジェクトを取得する方法です。  
[PlainInit](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#PlainInit)を使います。  
```go
package main

import (
	"fmt"
	"os"
	
	"github.com/go-git/go-git/v5"
)

func main() {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainInit("/path/to/repo", false)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	fmt.Println(repo)
}
```
上記のコードを実行すると、指定したパスに`.git`ディレクトリを持つ新しいリポジトリが作成されます。  

### Reference
[Referenceオブジェクト](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0/plumbing#Reference)は、Git bookでいうところの`Ref`に相当します。[Gitの参照](https://git-scm.com/book/ja/v2/Git%E3%81%AE%E5%86%85%E5%81%B4-Git%E3%81%AE%E5%8F%82%E7%85%A7)という章で詳しく説明されています。  

#### CreateBranch
新しいブランチの作成は、元となるブランチのhashを取得して、取得したhashを使って新しい`Reference`を作成することで行います。  
作成した`Reference`を`Repository`オブジェクトの`Storer`(ストレージを実装するオブジェクト)を使って保存することで、新しいブランチが作成されます。  
```go
import (
	"fmt"
	"os"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
)

func CreateBranch(branchName string) error {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainOpen("/path/to/repo")
	if err != nil {
		return errors.Join(err, errors.New("failed to open repository"))
	}
	headRef, err := repo.Head()
	if err != nil {
		return errors.Join(err, errors.New("failed to get head ref"))
	}
	ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), headRef.Hash())
	if err := repo.Storer.SetReference(ref); err != nil {
		return errors.Join(err, errors.New(fmt.Sprintf("failed to set reference. ref: %s", ref)))
	}
	return nil
}
```
他のやり方としては、`Repository`オブジェクトの`CreateBranch`を使う方法や、`Worktree`オブジェクトの`Checkout`メソッドを使って新しいブランチを作成する方法もあります。  

### Worktree
`Worktree`オブジェクトは、`Repository`オブジェクトから取得できるリポジトリのワーキングツリーを表すオブジェクトで、`checkout`, `status`, `add`, `pull`、`commit`などの操作を行うために使います。  
基本的なGitコマンドの操作は、`Worktree`オブジェクトを使って行うケースが多いです。  

#### Checkout
現在のブランチを切り替えるためには、`Worktree`オブジェクトの`Checkout`メソッドを使います。  
[CheckoutOptions](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#CheckoutOptions)の`Create`オプションを有効にすることで、新しいブランチを作成して切り替えることもできます。  
```go
func CheckoutBranch(branchName string) error {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainOpen("/path/to/repo")
	if err != nil {
		return errors.Join(err, errors.New("failed to open repository"))
	}
	w, err := repo.Worktree()
	if err != nil {
		return errors.Join(err, errors.New("failed to get worktree"))
	}
	return w.Checkout(&git.CheckoutOptions{
		Branch: plumbing.NewBranchReferenceName(branchName),
		Create: false, // trueで新しいブランチを作成して切り替える
	})
}
```

#### Status
ワーキングツリーの状態を確認するためには、`Worktree`オブジェクトの`Status`メソッドを使います。  
`Status`メソッドは、`git status`コマンドと同じように、ワーキングツリーの状態を確認することができます。  
`Status`で返される`FileStatus`は、keyがファイルパス、valueが[FileStatus](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#FileStatus)オブジェクトのMapです。  
```go
func Status() error {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainOpen("/path/to/repo")
	if err != nil {
		return errors.Join(err, errors.New("failed to open repository"))
	}
	w, err := repo.Worktree()
	if err != nil {
		return errors.Join(err, errors.New("failed to get worktree"))
	}
	status, err := w.Status()
	if err != nil {
		return errors.Join(err, errors.New("failed to get status"))
	}
	for fname, st := range status {
		fmt.Printf("%s: %s\n", string(st.Worktree), fname)
	}
	return nil
}
```

#### Add
ワーキングツリーに変更をステージングするためには、`Worktree`オブジェクトの`Add`メソッドを使います。  
`Add`メソッドは、`git add`コマンドと同じように、指定したファイルをステージングします。  
```go
// targets: ステージングするファイルのパスのリスト
func Add(targets []string) error {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainOpen("/path/to/repo")
	if err != nil {
		return errors.Join(err, errors.New("failed to open repository"))
	}
	w, err := repo.Worktree()
	if err != nil {
		return errors.Join(err, errors.New("failed to get worktree"))
	}
	for _, p := range targets {
		if _, err := w.Add(p); err != nil {
			return errors.Join(err, errors.New(fmt.Sprintf("failed to add. path: %s", p)))
		}
	}
	return nil
}
```

#### Pull
リモートリポジトリから変更を取得するためには、`Worktree`オブジェクトの`Pull`メソッドを使います。  
`Pull`メソッドは、`git pull`コマンドと同じように、リモートリポジトリから変更を取得します。  
```go
func Pull() error {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainOpen("/path/to/repo")
	if err != nil {
		return errors.Join(err, errors.New("failed to open repository"))
	}
	// RepositoryからWorktreeオブジェクトを取得
	w, err := repo.Worktree()
	if err != nil {
		return errors.Join(err, errors.New("failed to get worktree"))
	}
	err = w.Pull(&git.PullOptions{
		RemoteName: "origin",
		Progress:   os.Stdout,
	})
	if err != nil && err != git.NoErrAlreadyUpToDate {
		return errors.Join(err, errors.New("failed to pull from remote. name: origin"))
	}
	return nil
}
```

#### Commit
ワーキングツリーの変更をコミットするためには、`Worktree`オブジェクトの`Commit`メソッドを使います。  
`Commit`メソッドは、`git commit`コマンドと同じように、ワーキングツリーの変更をコミットします。  
```go
import (
	"time"
	
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing/object"
)

func Commit(message string) error {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainOpen("/path/to/repo")
	if err != nil {
		return errors.Join(err, errors.New("failed to open repository"))
	}
	w, err := repo.Worktree()
	if err != nil {
		return errors.Join(err, errors.New("failed to get worktree"))
	}
	author := &object.Signature{
		Name:  <git ユーザ名 ex: command[bot]>,
		Email: <git email ex: command[bot]@users.noreply.github.com>,
		When:  time.Now(),
	}
	_, err = w.Commit(message, &git.CommitOptions{Author: author})
	if err != nil {
		return errors.Join(err, errors.New("failed to commit"))
	}
	return nil
}
```

### Remote
ローカルリポジトリの変更をリモートリポジトリに反映するためには、`Repository`オブジェクトの`Remote`メソッドを使って[Remote](https://pkg.go.dev/github.com/go-git/go-git/v5@v5.12.0#Remote)オブジェクトを取得し、`Push`メソッドを使います。
```go
import (
	"fmt"
	"os"
	
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/config"
)

func Push(branchName string) error {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainOpen("/path/to/repo")
	if err != nil {
		return errors.Join(err, errors.New("failed to open repository"))
	}
	remote, err := repo.Remote("origin")
	if err != nil {
		return errors.Join(err, errors.New("failed to get remote. name: origin"))
	}
	return remote.Push(&git.PushOptions{
		Progress: os.Stdout,
		RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branchName, branchName))},
	})
	return nil
}

```

### Merge
ブランチをマージするためには、`Repository`オブジェクトの`Merge`メソッドを使います。  
`Merge`メソッドは、`git merge`コマンドと同じように、指定したブランチをマージします。  
```go
import (
	"fmt"
	"os"
	
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
)

func Merge(branchName string) error {
	// Repositoryオブジェクトを取得
	repo, err := git.PlainOpen("/path/to/repo")
	if err != nil {
	  return errors.Join(err, errors.New("failed to open repository"))
	}
	mergeRef, err := repo.Reference(plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branchName)), false)
	if err != nil {
		return errors.Join(err, errors.New(fmt.Sprintf("failed to get merge ref. name: %s", branchName)))
	}
	return repo.Merge(mergeRef, &git.MergeOptions{})
}
```

## インメモリストレージ
`go-git`は、`memory`パッケージを使ってインメモリストレージを使うことができます。  
インメモリストレージを使うことで、ファイルシステムにファイルを書き込むことなく、Gitリポジトリを操作することができます。  
インメモリストレージを使う場合、`memory.NewStorage()`を使って`Storage`オブジェクトを作成し、CloneやInitの際に`Storage`オブジェクトを指定することで、インメモリストレージを使うことができます。  

またメモリ上に展開したファイルを扱うために、go-git orgが提供している仮想ファイルシステムの実装ライブラリである[go-billy](https://github.com/go-git/go-billy)を使うことができます。  
`go-billy`を使うことで、ファイルシステムに依存しないコードを書くことができます。
`go-git`のCloneやInitは、`billy.Filesystem` Interface型を引数に渡せるようになっているため、`billy.Filesystem`のインメモリ実装である`memfs`パッケージを使ってCloneやInitを行います。

```go
package main

import (
  "fmt"
  "os"
  
  "github.com/go-git/go-git/v5"
  "github.com/go-git/go-git/v5/storage/memory"
  "github.com/go-git/go-billy/v5/memfs"
)

func main() {
	fs = memfs.New()
	r, err = git.Clone(memory.NewStorage(), fs, &git.CloneOptions{
	 	URL:           "git@github.com/<owner>/<repository name>.git",
	})
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}
```
ここで注意する点として、メモリ上に展開したファイルを扱うため、ファイルシステムに依存しないコードを書くことができますが、メモリを消費するため、大きなリポジトリを扱う場合は注意が必要です。  
大きなリポジトリをインメモリで扱う場合は、`CloneOptions`の`Depth`オプションや`SingleBranch`オプションを使うことで、サイズを抑えてメモリの消費を抑えることができます。  

次にメモリ上に展開したファイルを扱う例です。
go-billyのfilesystemを使います。
```go:main.go
package main

import (
	"fmt"
	"os"

	"github.com/go-git/go-billy/v5/memfs"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/storage/memory"
)

func main() {
	fs := memfs.New()
	repo, err := git.Init(memory.NewStorage(), fs)
	if err != nil {
		panic(err)
	}

	// memfsを通してディレクトリを作成
	if err := fs.MkdirAll("test_dir", 0o755); err != nil {
		panic(err)
	}

	// memfsを通してファイルを作成
	f, err := fs.Create("test_dir/test_file")
	if err != nil {
		panic(err)
	}
	defer f.Close()
	// ファイルに書き込み
	if _, err := f.Write([]byte("hello, go-git!")); err != nil {
		panic(err)
	}

	// ファイルの中身を表示してみる
	read_f, err := fs.Open("test_dir/test_file")
	if err != nil {
		panic(err)
	}
	defer read_f.Close()
	content, err := io.ReadAll(read_f)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(content))

	w, err := repo.Worktree()
	if err != nil {
		panic(err)
	}
	status, err := w.Status()
	if err != nil {
		panic(err)
	}
	for fname, st := range status {
		fmt.Printf("%s: %s\n", string(st.Worktree), fname)
	}

	// localをlsしてみる
	// localに対する操作なのでosパッケージを使う
	files, err := os.ReadDir("test_dir")
	if err != nil {
		if os.IsNotExist(err) {
			fmt.Println("test_dir not found")
			return
		}
		panic(err)
	}
	// ここには到達しない
	for _, file := range files {
		fmt.Println(file.Name())
	}
}

```
上記のコードを実行すると、以下の出力が得られます。
```bash
$ go run main.go
hello, go-git!
?: test_dir/test_file
test_dir not found
$
```
1行目の出力は、ファイルの中身が表示されていることを示しています。  
2行目の出力は、`test_dir/test_file`がワーキングツリーに存在していることを示しています。`?`は、`git status`コマンドで表示されるステータスのうち、`Untracked`を表しています。  
ステータスの詳細は[こちら](https://pkg.go.dev/github.com/go-git/go-git/v5#StatusCode)を参照してください。
3行目の出力は、`test_dir`がローカルファイルシステムに存在していないことを示しています。  
ここでの注意点は、メモリに展開したリポジトリのファイルを扱う場合は、`memfs`を通してファイルを操作する必要があることです。  
`memfs`を通して作成したファイルが、`os.ReadDir`で取得できなかったのと同様にlocalのファイルシステムに存在するファイルも`memfs`を通して操作することができません。  

`go-billy`にはlocalのファイルを扱うための`osfs`も実装されています。
ファイルを扱う処理は、billy.Filesystemを受け取る関数として実装することで、filesystemが`memfs`か`osfs`かを意識することなく、ファイルを扱うことができます。  
```go:main.go
package main

import (
	"fmt"
	"io"

	"github.com/go-git/go-billy/v5"
	"github.com/go-git/go-billy/v5/memfs"
	"github.com/go-git/go-billy/v5/osfs"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/storage/memory"
)

func main() {
	// memoru上にリポジトリを作成
	mfs := memfs.New()
	memrepo, err := git.Init(memory.NewStorage(), mfs)
	if err != nil {
		panic(err)
	}

	// localのファイルシステム上にリポジトリを作成
	ofs := osfs.New("/tmp/temp-repo")
	osrepo, err := git.PlainInit("/tmp/temp-repo", false)
	if err != nil {
		panic(err)
	}

	// memory上のリポジトリにファイルを追加
	if err := CreateFile(mfs, "hello.txt", "Hello, memfs!"); err != nil {
		panic(err)
	}
	// localのリポジトリにファイルを追加
	if err := CreateFile(ofs, "hello.txt", "Hello, osfs!"); err != nil {
		panic(err)
	}

	// memory上のリポジトリのstatusを表示
	memwt, err := memrepo.Worktree()
	memstatus, err := memwt.Status()
	if err != nil {
		panic(err)
	}
	for path, status := range memstatus {
		fmt.Printf("memory status: %s %s\n", string(status.Worktree), path)
	}
	// memoriy上のリポジトリのファイルを読み込む
	content, err := ReadFile(mfs, "hello.txt")
	if err != nil {
		panic(err)
	}
	fmt.Printf("memory: %s\n", content)

	// localのリポジトリのstatusを表示
	oswt, err := osrepo.Worktree()
	osstatus, err := oswt.Status()
	if err != nil {
		panic(err)
	}
	for path, status := range osstatus {
		fmt.Printf("local status: %s %s\n", string(status.Worktree), path)
	}
	// localのリポジトリのファイルを読み込む
	content, err = ReadFile(ofs, "hello.txt")
	if err != nil {
		panic(err)
	}
	fmt.Printf("local: %s\n", content)
}

func CreateFile(fs billy.Filesystem, path string, content string) error {
	f, err := fs.Create(path)
	if err != nil {
		return err
	}
	defer f.Close()
	if _, err := f.Write([]byte(content)); err != nil {
		return err
	}
	return nil
}

func ReadFile(fs billy.Filesystem, path string) (string, error) {
	f, err := fs.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	content, err := io.ReadAll(f)
	if err != nil {
		return "", err
	}
	return string(content), nil
}
```
上記のコードを実行すると、以下の出力が得られます。
```bash
$ go run main.go
memory status: ? hello.txt
memory: Hello, memfs!
local status: ? hello.txt
local: Hello, osfs!
$
```

# Go言語でGitHub操作
Go言語でGitHubを操作するには、GitHub APIを利用します。  
Go言語でGitHub APIを実行する手段としては、直接HTTPリクエストを送信する方法もありますが、googleが提供している[go-github](https://github.com/google/go-github)を使うのがオススメです。  
`go-github`は、GitHub APIをGo言語で利用するためのライブラリで、GitHub APIの各エンドポイントに対応したメソッドが提供されています。  
また、`go-github`は、GitHub APIのレスポンスをGoの構造体にマッピングして返すため、APIのレスポンスを扱いやすくなっています。  

## go-github
`go-github`を使ってGitHub APIを利用するためのサンプルコードを紹介します。  
`go-github`を使ってGitHub APIを利用するためには、まずGitHubの[Client](https://pkg.go.dev/github.com/google/go-github/v67/github#Client)を作成する必要があります。  
GitHubのクライアントは、`github.NewClient`を使って作成します。  
また、GitHub APIを利用するためにはTokenが必要です。  
これから示すサンプルでは、一番ベーシックな認証方法である、PersonalAccessTokenを使ってGitHub APIを利用する方法を紹介します。  
```go
import (
  "github.com/google/go-github/v67/github"
)

// GitHubのクライアントを作成
client := github.NewClient(nil).WithAuthToken("... your access token ...")
```

## PullRequestの作成
`go-github`を使ってPullRequestを作成するためのサンプルコードを紹介します。  
PullRequestを作成するためには、`PullRequestService`の`Create`メソッドを使います。`PullRequestService`は、`github.NewClient`で作成したクライアントを経由してアクセスします。  
`Create`メソッドは、`owner`、`repo`、`title`、`body`, `head`、`base`を引数に取り、`PullRequest`オブジェクトを返します。  
`body`はPullRequestの本文、`head`はPullRequestを作成するブランチ、`base`はPullRequestをマージするブランチを指定します。  
```go
import (
	"context"
	"github.com/google/go-github/v67/github"
)

func CreatePullRequest(token, owner, repo, base, head, title, body string) (*github.PullRequest, error) {
	// GitHubのクライアントを作成
	client := github.NewClient(nil).WithAuthToken(token)
	pr, resp, err := client.PullRequests.Create(context.Background(), owner, repo, &github.NewPullRequest{
		Title: github.String(title),
		Body:  github.String(body),
		Base:  github.String(base),
		Head:  github.String(head),
	})
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to create pull request"))
	}
	if resp.StatusCode != 201 {
		return nil, fmt.Errorf("Failed to create pull request: %s", resp.Status)
	}
	return pr, nil
}
```

## PullRequestのマージ
`go-github`を使ってPullRequestをマージするためのサンプルコードを紹介します。  
PullRequestをマージするためには、`PullRequestService`の`Merge`メソッドを使います。  
`Merge`メソッドは、`owner`、`repo`、`number`、`commitMessage`を引数に取り、`PullRequestMergeResult`オブジェクトを返します。  
`number`はPullRequestの番号、`commitMessage`はマージコミットのメッセージを指定します。  
サンプルでは指定していませんが、`PullRequestOptions`を使ってマージオプションを指定することもできます。  
```go
import (
  "context"
  "github.com/google/go-github/v67/github"
)

func MergePullRequest(token, owner, repo, commitMessage string, number int) (*github.PullRequestMergeResult, error) {
	// GitHubのクライアントを作成
	client := github.NewClient(nil).WithAuthToken(token)
	result, resp, err := client.PullRequests.Merge(c.ctx, owner, repo, number, commitMessage, &github.PullRequestOptions{})
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to merge pull request"))
	}
	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("Failed to merge pull request: %s", resp.Status)
	}
	return result, nil
}
```

その他にも、ここでは紹介しきれないぐらい`go-github`には、GitHub APIの各エンドポイントに対応したメソッドが提供されています。  
やりたいことに応じて、GitHub APIのドキュメントを参照して、エンドポイントに対応したメソッドを使って実装してみてください。  

# Lambda環境でGitHub AppsからInstallAccessTokenを使ってリポジトリをCloneする
Lambda環境でGitHub AppsからInstallAccessTokenを使ってリポジトリをCloneするサンプルコードを紹介します。  

## Lambda環境でGitHub AppsからTokenを取得する
GitHub AppsからTokenを取得するには、GitHub Appsの`AppID`, `PrivateKey`, `InstallationID`が必要です。  
`AppID`・`PrivateKey`はGitHub Appsの設定画面から取得できます。  
![github_apps_setting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108538/af8a0adb-f524-f404-fe31-10c2cf11525f.png)
![github_apps_privatekey.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108538/26365f99-31cf-a2d9-978f-fd1b5d21ece3.png)
`InstallationID`は、GitHub AppsがインストールされているリポジトリのIDです。以下のURLから取得できます。  
`https://github.com/apps/<App name>/installations/<InstallationID>`
画面は以下のようなRepository Accessを設定する画面になります。  
![CleanShot 2024-12-23 at 01.59.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/108538/b3135a95-f980-3f64-9141-0c9dfcc0e96e.png)
これらの値をSecretMangerに登録しておき、実行時に取得するようにします。  

### AWS Parameters and Secrets Lambda Extension
Lambda関数からSecretManagerにアクセスするには、[AWS Parameters and Secrets Lambda Extension](https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/retrieving-secrets_lambda.html)を使用するとシークレットの値をキャッシュすることができ、効率よくシークレットを取得することができます。  
Goで`AWS Parameters and Secrets Lambda Extension`を使う場合、`aws lambda get-layer-version-by-arn`コマンドを使ってextensionのLayerを取得し、Lambda関数のイメージに追加しておきます。  
筆者の場合は、Dockerfileでawsコマンドを使うための認証情報を設定するのが面倒だったため、事前に以下のスクリプトで取得したLayerをDockerfileでコピーする形でLambda関数のイメージに追加しました。  
```bash
#!/usr/bin/env bash
# Download the Lambda extension from the AWS Lambda Extensions API
script_dir="$(dirname "$0")"
curl -o "${script_dir}/../bin/AWS-Parameters-and-Secrets-Lambda-Extension.zip" "$(aws lambda get-layer-version-by-arn --arn arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:12 --query Content.Location --output text)"
```
`--arn`で指定するARNは、AWSのリージョンとLambda関数のアーキテクチャによって異なるため、適宜変更してください。  
ARNの一覧は[こちら](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/ps-integration-lambda-extensions.html#ps-integration-lambda-extensions-add)で確認できます。  

Dockerfileは以下のようになります。  
```Docker
FROM public.ecr.aws/docker/library/golang:1.23-bookworm AS builder
RUN apt update && apt install -y unzip
WORKDIR /go/src/dir
COPY . .
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o /usr/local/bin/command-name .

RUN mkdir -p /opt
COPY AWS-Parameters-and-Secrets-Lambda-Extension.zip .
RUN unzip AWS-Parameters-and-Secrets-Lambda-Extension.zip -d /opt
RUN rm AWS-Parameters-and-Secrets-Lambda-Extension.zip

ENTRYPOINT ["command-name"]
```

Goで`AWS Parameters and Secrets Lambda Extension`を使う場合の実装例です。  
```go
import (
  "context"
  "encoding/json"
  "fmt"
  "io"
  "net/http"
  "os"
)

type GetSecretValueOutput struct {
	ARN          string `json:"ARN"`
	CreatedDate  string `json:"CreatedDate"`
	Name         string `json:"Name"`
	SecretBinary string `json:"SecretBinary"`
	SecretString string `json:"SecretString"`
}

func GetSecretValueWithLambdaExtension(secretName string) (string, error) {
	secretExtensionEP := fmt.Sprintf("http://localhost:2773/secretsmanager/get?secretId=%s", secretName)
	headers := map[string]string{
		"X-Aws-Parameters-Secrets-Token": os.Getenv("AWS_SESSION_TOKEN"),
	}
	client := &http.Client{}
	req, err := http.NewRequest("GET", secretExtensionEP, nil)
	if err != nil {
		return "", err
	}
	for key, value := range headers {
		req.Header.Set(key, value)
	}
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("Failed to get secret: received status %d", resp.StatusCode)
	}
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	var secretValue GetSecretValueOutput
	if err := json.Unmarshal(body, &secretValue); err != nil {
		return "", errors.Join(err, errors.New("failed to unmarshal secret value"))
	}
	return secretValue.SecretString, nil
}
```

### GitHub AppsからInstallAccessTokenを取得してGitHubクライアントを作成する
GitHub AppsからInstallAccessTokenを取得してGitHubクライアントを作成するサンプルコードを紹介します。  
Tokenの取得には、[go-githubauth](https://github.com/jferrl/go-githubauth)というライブラリを使っています。  
```go
import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"strconv"
	"time"

	"github.com/google/go-github/v64/github"
	"github.com/jferrl/go-githubauth"
	"golang.org/x/oauth2"
)

type GitHubAppValues struct {
	AppID          string `json:"app_id"`
	InstallationID string `json:"installation_id"`
	PrivateKey     string `json:"private_key"`
}

func ParseJson[T any](jsonStr string, result *T) error {
	if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
		return errors.Join(err, errors.New("failed to unmarshal json"))
	}
	return nil
}

// AWS Parameters and Secrets Lambda Extensionを使ってGitHub Appのシークレットを取得
// AppIDとInstallationIDが入ったシークレット名とプライベートキーが入ったシークレット名を指定する
// プライベートキーは、秘密鍵形式でSecretManagerに登録するため、個別に取得する
func getGitHubAppSecret() (GitHubAppValues, error) {
	jsonstr, err := GetSecretValueWithLambdaExtension("AppIDとInstallationIDが入ったシークレット名")
	if err != nil {
		return GitHubAppValues{}, errors.Join(err, errors.New("failed to get secret value with lambda extension"))
	}
	privkey, err := GetSecretValueWithLambdaExtension("プライベートキーが入ったシークレット名")
	if err != nil {
		return GitHubAppValues{}, errors.Join(err, errors.New("failed to get secret value with lambda extension"))
	}
	var appValues GitHubAppValues
	if err := ParseJson(jsonstr, &appValues); err != nil {
		return GitHubAppValues{}, errors.Join(err, errors.New("failed to parse json"))
	}
	appValues.PrivateKey = privkey
	return appValues, nil
}

// GitHub AppsからTokenを取得
func FetchToken() (oauth2.TokenSource, error) {
	appValues, err := getGitHubAppSecret()
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to get github app secret"))
	}

	appid, err := strconv.ParseInt(appValues.AppID, 10, 64)
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to parse app id"))
	}
	appTokenSource, err := githubauth.NewApplicationTokenSource(appid, []byte(appValues.PrivateKey))
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to create application token source"))
	}

	instid, err := strconv.ParseInt(appValues.InstallationID, 10, 64)
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to parse installation id"))
	}
	return githubauth.NewInstallationTokenSource(instid, appTokenSource), nil
}

// Tokenを使って認証するGitHubクライアントを作成
func getClient() (*github.Client, error) {
	tokenSource, err := FetchToken()
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to fetch token"))
	}

	httpClient := oauth2.NewClient(context.Background(), tokenSource)
	client := github.NewClient(httpClient)
	return client, nil
}

type GitHubAPIClient struct {
	client *github.Client
	ctx    context.Context
}

func NewGitHubAPIClient() (*GitHubAPIClient, error) {
	client, err := getClient()
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to get github client"))
	}
	return &GitHubAPIClient{client: client, ctx: context.Background()}, nil
}

// Tokenの無効化を行う関数
func (c *GitHubAPIClient) RevokeToken() error {
	res, err := c.client.Apps.RevokeInstallationToken(c.ctx)
	if err != nil {
		return errors.Join(err, errors.New("failed to revoke token"))
	}
	if res.StatusCode != 204 {
		return fmt.Errorf("Failed to revoke token: %s", res.Status)
	}
	return nil
}
```

## Lambda環境でインメモリストレージを使ってリポジトリをCloneする
最後にこれまで紹介した実装を使いつつ、Lambda環境でインメモリストレージを使ってリポジトリをCloneするサンプルコードを紹介します。  
```go
import (
	"time"

	"github.com/go-git/go-billy/v5"
	"github.com/go-git/go-billy/v5/memfs"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/config"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/go-git/go-git/v5/storage/memory"
)

type GitClient struct {
	FS billy.Filesystem
	Author     *object.Signature
	Repository *git.Repository
}

func NewGitClient(repositoryPath, repositoryName string) (*GitClient, error) {
	var r *git.Repository
	var err error
	var fs billy.Filesystem
	author := &object.Signature{
		Name:  <git ユーザ名 ex: command[bot]>,
		Email: <git email ex: command[bot]@users.noreply.github.com>,
		When:  time.Now(),
	}
	tokenSrc, err := FetchToken()
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to fetch token"))
	}
	token, err := tokenSrc.Token()
	if err != nil {
		return nil, errors.Join(err, errors.New("failed to get token"))
	}
	fs = memfs.New()
	r, err = git.Clone(memory.NewStorage(), fs, &git.CloneOptions{
		URL:           fmt.Sprintf("https://x-access-token:%s@github.com/<owner>/%s.git", token.AccessToken, repositoryName),
		ReferenceName: plumbing.ReferenceName("refs/heads/master"),
		SingleBranch:  true,
		Depth:         1,
	})
	if err != nil {
		return nil, errors.Join(err, errors.New(fmt.Sprintf("failed to clone repository. name: %s", repositoryName)))
	}
	return &GitClient{Author: author, Repository: r, FS: fs}, nil
}
```
上記コードの`NewGitClient`を実行すると、GitHub AppsのInstallAccessTokenを使ってリポジトリをメモリ上にCloneすることができます。  
`NewGitClient`で取得した、`GitClient`オブジェクトが持つ、`Repository`オブジェクトを使って、`Checkout`や`Commit`、`Push`などの操作を行い、`FS`オブジェクトを使ってファイルの取得や書き込みを行うことで、メモリ上に展開したリポジトリを操作することができます。  

# まとめ
Go言語でGitリポジトリを操作する方法と、GitHub APIを使ってGitHubを操作する方法を紹介しました。  
`go-git`の特徴である、メモリストレージを使うことで、Lambda関数などのサーバレス環境でもリポジトリのCloneから操作までを行うことができます。  
これらのライブラリを使って、自動化スクリプトやCI/CDパイプラインを作成することで、開発効率を向上させることができます。