7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ZOZOAdvent Calendar 2024

Day 23

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

Last updated at Posted at 2024-12-22

この記事はZOZO AdventCalender 2024シリーズ5の23日目の記事です。

はじめに

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

Go言語でGit操作

Go言語でGit操作をするためのライブラリはいくつかありますが、主要な選択肢としてはGit bookで紹介されているものになります。

ライブラリ 特徴
git2go libgit2のGoバインディング
Gitのコアがスコープ外としている機能も備わっており、かなり高度なことができる
デメリットとしてはCGOで実装されているため、クロスコンパイルのハードルが上がる
go-git Pure Goで実装されているため、クロスコンパイルが容易
libgit2のGoバインディングであるgit2goと比べると機能が少ないが、基本的な操作はカバーしている
インメモリストレージが簡単に使える
Gitコマンドとの互換性もドキュメント化されている

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

go-git

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

主要なオブジェクト

go-gitは、Gitオブジェクトを表すオブジェクトを通して、Gitリポジトリを操作します。
そのため、Gitコマンドと対応する関数が用意されているわけではないため、まずはGitオブジェクトを理解しておくことが重要です。
Gitオブジェクトについては、完全に理解するのは中々難しいんですが、Git bookを読むとある程度わかるようになると思います。

リポジトリを操作するための主要なオブジェクトは以下の通りです。

Repository

Repositoryは、Gitリポジトリを表すオブジェクトです。
go-gitを使ってGitリポジトリを操作するためには、まずRepositoryオブジェクトを取得する必要があり、全ての操作の起点となります。
Repositoryオブジェクトの取得は大別して以下の3つの方法があります。

  • localに既に存在するリポジトリから取得する方法(open)
  • リモートリポジトリから取得する方法(clone)
  • 新規にリポジトリを作成する方法(init)

Open

既にlocalにclone済みのリポジトリでRepositoryオブジェクトを取得する方法です。
PlainOpenを使います。

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を使います。
またCloneする場合、いくつかの認証方法がありますが、go-gitではusername/passwordを使うbasic認証、AccessTokenによる認証、ssh認証の3つがサポートされています。
exampleが用意されているものを紹介します。

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

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のドキュメントに従ってssh-agentに秘密鍵が登録済みの場合、URLをgit@github.com:<owner>/<repository name>.gitの形式で指定するだけでCloneできます。

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を使います。

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オブジェクトは、Git bookでいうところのRefに相当します。Gitの参照という章で詳しく説明されています。

CreateBranch

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

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, pullcommitなどの操作を行うために使います。
基本的なGitコマンドの操作は、Worktreeオブジェクトを使って行うケースが多いです。

Checkout

現在のブランチを切り替えるためには、WorktreeオブジェクトのCheckoutメソッドを使います。
CheckoutOptionsCreateオプションを有効にすることで、新しいブランチを作成して切り替えることもできます。

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オブジェクトのMapです。

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コマンドと同じように、指定したファイルをステージングします。

// 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コマンドと同じように、リモートリポジトリから変更を取得します。

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コマンドと同じように、ワーキングツリーの変更をコミットします。

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オブジェクトを取得し、Pushメソッドを使います。

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コマンドと同じように、指定したブランチをマージします。

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を使うことができます。
go-billyを使うことで、ファイルシステムに依存しないコードを書くことができます。
go-gitのCloneやInitは、billy.Filesystem Interface型を引数に渡せるようになっているため、billy.Filesystemのインメモリ実装であるmemfsパッケージを使ってCloneやInitを行います。

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)
	}
}

ここで注意する点として、メモリ上に展開したファイルを扱うため、ファイルシステムに依存しないコードを書くことができますが、メモリを消費するため、大きなリポジトリを扱う場合は注意が必要です。
大きなリポジトリをインメモリで扱う場合は、CloneOptionsDepthオプションやSingleBranchオプションを使うことで、サイズを抑えてメモリの消費を抑えることができます。

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

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())
	}
}

上記のコードを実行すると、以下の出力が得られます。

$ go run main.go
hello, go-git!
?: test_dir/test_file
test_dir not found
$

1行目の出力は、ファイルの中身が表示されていることを示しています。
2行目の出力は、test_dir/test_fileがワーキングツリーに存在していることを示しています。?は、git statusコマンドで表示されるステータスのうち、Untrackedを表しています。
ステータスの詳細はこちらを参照してください。
3行目の出力は、test_dirがローカルファイルシステムに存在していないことを示しています。
ここでの注意点は、メモリに展開したリポジトリのファイルを扱う場合は、memfsを通してファイルを操作する必要があることです。
memfsを通して作成したファイルが、os.ReadDirで取得できなかったのと同様にlocalのファイルシステムに存在するファイルもmemfsを通して操作することができません。

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

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
}

上記のコードを実行すると、以下の出力が得られます。

$ 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を使うのがオススメです。
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を作成する必要があります。
GitHubのクライアントは、github.NewClientを使って作成します。
また、GitHub APIを利用するためにはTokenが必要です。
これから示すサンプルでは、一番ベーシックな認証方法である、PersonalAccessTokenを使ってGitHub APIを利用する方法を紹介します。

import (
  "github.com/google/go-github/v67/github"
)

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

PullRequestの作成

go-githubを使ってPullRequestを作成するためのサンプルコードを紹介します。
PullRequestを作成するためには、PullRequestServiceCreateメソッドを使います。PullRequestServiceは、github.NewClientで作成したクライアントを経由してアクセスします。
Createメソッドは、ownerrepotitlebody, headbaseを引数に取り、PullRequestオブジェクトを返します。
bodyはPullRequestの本文、headはPullRequestを作成するブランチ、baseはPullRequestをマージするブランチを指定します。

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をマージするためには、PullRequestServiceMergeメソッドを使います。
Mergeメソッドは、ownerreponumbercommitMessageを引数に取り、PullRequestMergeResultオブジェクトを返します。
numberはPullRequestの番号、commitMessageはマージコミットのメッセージを指定します。
サンプルでは指定していませんが、PullRequestOptionsを使ってマージオプションを指定することもできます。

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が必要です。
AppIDPrivateKeyはGitHub Appsの設定画面から取得できます。
github_apps_setting.png
github_apps_privatekey.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
これらの値をSecretMangerに登録しておき、実行時に取得するようにします。

AWS Parameters and Secrets Lambda Extension

Lambda関数からSecretManagerにアクセスするには、AWS Parameters and Secrets Lambda Extensionを使用するとシークレットの値をキャッシュすることができ、効率よくシークレットを取得することができます。
GoでAWS Parameters and Secrets Lambda Extensionを使う場合、aws lambda get-layer-version-by-arnコマンドを使ってextensionのLayerを取得し、Lambda関数のイメージに追加しておきます。
筆者の場合は、Dockerfileでawsコマンドを使うための認証情報を設定するのが面倒だったため、事前に以下のスクリプトで取得したLayerをDockerfileでコピーする形でLambda関数のイメージに追加しました。

#!/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の一覧はこちらで確認できます。

Dockerfileは以下のようになります。

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を使う場合の実装例です。

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というライブラリを使っています。

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するサンプルコードを紹介します。

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オブジェクトを使って、CheckoutCommitPushなどの操作を行い、FSオブジェクトを使ってファイルの取得や書き込みを行うことで、メモリ上に展開したリポジトリを操作することができます。

まとめ

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

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?