Last active
February 21, 2025 09:41
-
-
Save obutora/3fce83ef05dcf272dbe7c54bbdba6f99 to your computer and use it in GitHub Desktop.
GitHub Apps の認証情報を用いてPRを作成するTool.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
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
使い方
app-id, installation-id, owner, repoなどそれぞれ設定する
環境変数に以下設定する