Skip to content

Instantly share code, notes, and snippets.

@obutora
Last active February 21, 2025 09:41
Show Gist options
  • Save obutora/3fce83ef05dcf272dbe7c54bbdba6f99 to your computer and use it in GitHub Desktop.
Save obutora/3fce83ef05dcf272dbe7c54bbdba6f99 to your computer and use it in GitHub Desktop.
GitHub Apps の認証情報を用いてPRを作成するTool.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt"
"github.com/google/go-github/v57/github"
"github.com/joho/godotenv"
)
type Config struct {
appID int64
installationID int64
privateKey string
owner string
repo string
baseBranch string
headBranch string
title string
body string
}
func init() {
godotenv.Load(".env")
}
func main() {
config := parseFlags()
if err := validateConfig(config); err != nil {
log.Fatalf("Configuration error: %v", err)
}
client, err := createGithubAppClient(config)
if err != nil {
log.Fatalf("Failed to create GitHub client: %v", err)
}
pr, err := createPR(client, config)
if err != nil {
log.Fatalf("Failed to create PR: %v", err)
}
fmt.Printf("Successfully created PR #%d: %s\n", pr.GetNumber(), pr.GetHTMLURL())
}
func parseFlags() *Config {
config := &Config{}
flag.Int64Var(&config.appID, "app-id", 0, "GitHub App ID")
flag.Int64Var(&config.installationID, "installation-id", 0, "GitHub App Installation ID")
flag.StringVar(&config.privateKey, "private-key", os.Getenv("GITHUB_PRIVATE_KEY"), "GitHub App private key")
flag.StringVar(&config.owner, "owner", "", "Repository owner")
flag.StringVar(&config.repo, "repo", "", "Repository name")
flag.StringVar(&config.baseBranch, "base", "main", "Base branch")
flag.StringVar(&config.headBranch, "head", "", "Head branch (branch with changes)")
flag.StringVar(&config.title, "title", "", "PR title")
flag.StringVar(&config.body, "body", "", "PR description")
flag.Parse()
return config
}
func validateConfig(config *Config) error {
if config.appID == 0 {
return fmt.Errorf("GitHub App ID is required")
}
if config.installationID == 0 {
return fmt.Errorf("GitHub App Installation ID is required")
}
if config.privateKey == "" {
return fmt.Errorf("GitHub App private key is required")
}
if config.owner == "" {
return fmt.Errorf("repository owner is required")
}
if config.repo == "" {
return fmt.Errorf("repository name is required")
}
if config.headBranch == "" {
return fmt.Errorf("head branch is required")
}
if config.title == "" {
return fmt.Errorf("PR title is required")
}
return nil
}
func createJWT(appID int64, privateKey string) (string, error) {
// デバッグ用のログ出力
log.Printf("Attempting to create JWT with App ID: %d", appID)
// 秘密鍵の内容をクリーンアップ
privateKey = strings.TrimSpace(privateKey)
// 秘密鍵をパース
signKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey))
if err != nil {
log.Printf("Private key parsing error: %v", err)
return "", fmt.Errorf("parsing private key: %v", err)
}
// JWTトークンを作成
token := jwt.New(jwt.SigningMethodRS256)
// ヘッダーを設定
token.Header = map[string]interface{}{
"alg": "RS256",
"typ": "JWT",
}
// クレームを設定(GitHubのドキュメントに従って)
now := time.Now()
claims := token.Claims.(jwt.MapClaims)
claims["iat"] = now.Add(-60 * time.Second).Unix() // 60秒前(クロックドリフト対策)
claims["exp"] = now.Add(10 * time.Minute).Unix() // 10分後
claims["iss"] = fmt.Sprintf("%d", appID) // GitHub App's ID
log.Printf("JWT headers: %+v", token.Header)
log.Printf("JWT claims: %+v", claims)
// トークンに署名
signedToken, err := token.SignedString(signKey)
if err != nil {
return "", fmt.Errorf("signing token: %v", err)
}
// デバッグ用:生成されたトークンを出力
log.Printf("Generated token: %s", signedToken)
return signedToken, nil
}
func createGithubAppClient(config *Config) (*github.Client, error) {
// JWTトークンを生成
jwtToken, err := createJWT(config.appID, config.privateKey)
if err != nil {
return nil, fmt.Errorf("creating JWT: %v", err)
}
// インストールトークンを取得するためのクライアントを作成
httpClient := &http.Client{}
req, err := http.NewRequest(
"POST",
fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", config.installationID),
nil,
)
if err != nil {
return nil, fmt.Errorf("creating request: %v", err)
}
// 必要なヘッダーを設定
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwtToken))
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
// インストールトークンを取得
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("getting installation token: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status: %d, body: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("decoding response: %v", err)
}
// 取得したインストールトークンを使用してGitHubクライアントを作成
return github.NewClient(&http.Client{
Transport: &github.BasicAuthTransport{
Username: "x-access-token",
Password: tokenResp.Token,
},
}), nil
}
func createPR(client *github.Client, config *Config) (*github.PullRequest, error) {
ctx := context.Background()
// headブランチを owner:branch の形式に設定
head := fmt.Sprintf("%s:%s", config.owner, config.headBranch)
newPR := &github.NewPullRequest{
Title: github.String(config.title),
Head: github.String(head),
Base: github.String(config.baseBranch),
Body: github.String(config.body),
MaintainerCanModify: github.Bool(true),
}
log.Printf("Creating PR with head: %s, base: %s", head, config.baseBranch)
// PRを作成
pr, _, err := client.PullRequests.Create(ctx, config.owner, config.repo, newPR)
if err != nil {
return nil, err
}
// ラベルを追加
labels := []string{"LLM"}
_, _, err = client.Issues.AddLabelsToIssue(ctx, config.owner, config.repo, pr.GetNumber(), labels)
if err != nil {
log.Printf("Warning: Failed to add labels: %v", err)
// ラベル追加の失敗はPR作成自体の失敗とはしない
}
return pr, nil
}
@obutora
Copy link
Author

obutora commented Feb 21, 2025

使い方

app-id, installation-id, owner, repoなどそれぞれ設定する

go run main.go \
  -app-id=app-id \
  -installation-id=installation-id \
  -owner="owner" \
  -repo="repo" \
  -head="test" \
  -title="Add new feature" \
  -body="This PR is created by Bot"

環境変数に以下設定する

GITHUB_PRIVATE_KEY=hoge

@obutora
Copy link
Author

obutora commented Feb 21, 2025

clineで使う場合は、以下のようなプロンプトを入れておくと、
MCPサーバーを作るまでもなく勝手に利用してくれる

コミットをプッシュしたあとにPRを作成する時は 
go run path_to_tool \
  -app-id=1146894 \
  -installation-id=61180787 \
  -owner="owner" \
  -repo="repo" \
  -head="test" \
  -title="Add new feature" \
  -body="This PR is created by Bot"
上記のようなコマンドで作成して

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment