GitHubのREADMEをAIで要約してポートフォリオに表示する機能を作った話
はじめに
「GitHubのプロフィールを見ても、何を作ったのかよくわからない」という声を聞いたことはありませんか?
リポジトリ名と星の数だけでは、その人の技術力や取り組みがなかなか伝わりません。そこで、READMEをAIに読ませて、3つの観点で技術的なサマリーを自動生成する機能を実装しました。
この記事では、実装で詰まったポイントを中心に、Go + Next.js + OpenAIを使った実装の流れを解説します。
作ったもの
GitHubのリポジトリを選ぶと、AIが以下の3軸でサマリーを生成します。
| 項目 | 内容 |
|---|---|
| 技術選定の理由 | なぜその技術スタックを選んだか |
| 技術的チャレンジ | 難しかった部分や工夫した点 |
| 実績・学び | 得られた成果や学習内容 |
システムの流れ
ユーザーがリポジトリを選択
↓
バックエンドがGitHub APIでREADMEを取得(base64デコード)
↓
OpenAI APIにREADMEを渡して構造化サマリーを生成
↓
DBにキャッシュ(同じリポジトリは再生成しない)
↓
フロントエンドに返却して表示
技術スタック
- バックエンド: Go + GORM + net/http
- フロントエンド: Next.js (App Router) + TypeScript + MUI
-
AI: OpenAI API (
gpt-4o-mini) - DB: PostgreSQL
- 認証: GitHub OAuth
実装のポイント
1. GitHub READMEの取得
GitHub APIは、READMEの内容を base64エンコード して返してきます。
type readmeResponse struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
}
func (s *GitHubService) fetchREADME(token, fullName string) (string, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/readme", fullName)
var result readmeResponse
if err := s.doGet(token, url, &result); err != nil {
return "", err
}
// base64デコードして文字列に変換
content := strings.ReplaceAll(result.Content, "\n", "")
decoded, err := base64.StdEncoding.DecodeString(content)
if err != nil {
return "", fmt.Errorf("failed to decode readme: %w", err)
}
return string(decoded), nil
}
ハマりポイント: contentフィールドには改行コード(\n)が含まれているため、デコード前に除去が必要です。これを忘れるとデコードエラーになります。
2. OpenAIでJSON形式の構造化出力
AIに「JSON形式で返して」と指示するときは、プロンプトの中に必ず"JSON"という単語を入れる必要があります(response_format: json_objectの要件)。
func (s *GitHubService) generateRepoSummary(readme string) (*RepoSummaryContent, error) {
prompt := fmt.Sprintf(`以下のREADMEを読み、JSON形式で技術サマリーを生成してください。
README:
%s
以下のJSON形式で回答してください:
{
"tech_reason": "技術選定の理由(200字以内)",
"challenge": "技術的チャレンジ(200字以内)",
"achievement": "実績・学び(200字以内)"
}`, readme)
resp, err := s.openaiClient.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Model: openai.F(openai.ChatModelGPT4oMini),
Messages: openai.F([]openai.ChatCompletionMessageParamUnion{
openai.UserMessage(prompt),
}),
ResponseFormat: openai.F[openai.ChatCompletionNewParamsResponseFormatUnion](
openai.ResponseFormatJSONObjectParam{
Type: openai.F(openai.ResponseFormatJSONObjectTypeJSONObject),
},
),
})
// ...
}
ハマりポイント: response_format: json_objectを指定しても、プロンプトに"JSON"という単語がないとAPIエラーになります。必ずプロンプトに"JSON形式で"などの記述を含めてください。
3. DBへのUpsert(重複回避)
同じリポジトリを何度もAIに問い合わせないよう、DBにキャッシュします。GORMのclause.OnConflictを使うと、既存レコードがあれば更新、なければ挿入という処理を一行で書けます。
func (r *GitHubRepository) UpsertRepoSummary(summary *models.GitHubRepoSummary) error {
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "full_name"}},
DoUpdates: clause.AssignmentColumns([]string{
"tech_reason", "challenge", "achievement", "updated_at",
}),
}).Create(summary).Error
}
// DBモデルの定義(複合ユニーク制約)
type GitHubRepoSummary struct {
gorm.Model
UserID uint `gorm:"uniqueIndex:idx_user_repo"`
FullName string `gorm:"uniqueIndex:idx_user_repo"`
TechReason string `gorm:"type:text"`
Challenge string `gorm:"type:text"`
Achievement string `gorm:"type:text"`
}
4. 組織リポジトリの取得
自分のリポジトリだけでなく、所属組織のリポジトリも取得するには、段階的に取得する必要があります。
func (s *GitHubService) fetchRepositories(token, login string) ([]models.GitHubRepository, error) {
// Step1: 自分のリポジトリを取得
repos, err := s.fetchRepoPages(token, "https://api.github.com/user/repos?type=all&per_page=100")
if err != nil {
return nil, err
}
// Step2: 所属組織を取得
orgs, err := s.fetchOrgs(token)
if err != nil {
// 組織取得失敗は無視して続行(スコープなしトークンでも動作させる)
fmt.Printf("[GitHub] failed to fetch orgs: %v\n", err)
return repos, nil
}
// Step3: 各組織のリポジトリを取得してマージ
seen := make(map[string]bool)
for _, r := range repos {
seen[r.FullName] = true
}
for _, org := range orgs {
orgRepos, err := s.fetchRepoPages(token,
fmt.Sprintf("https://api.github.com/orgs/%s/repos?per_page=100", org.Login))
if err != nil {
continue
}
for _, r := range orgRepos {
if !seen[r.FullName] {
repos = append(repos, r)
seen[r.FullName] = true
}
}
}
return repos, nil
}
ハマりポイント: 組織リポジトリを取得するには、GitHub OAuthのスコープにread:orgが必要です。既存のトークンにこのスコープがない場合は、GitHub設定ページでOAuthアプリを失効させてから再ログインしてください。
5. フロントエンド側の実装(Next.js + TypeScript)
// リポジトリを選択してサマリーを生成するボタン
const handleSummarizeRepo = async (fullName: string) => {
setSummarizingRepo(fullName)
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/github/repo/summarize?user_id=${userId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ full_name: fullName, force_refresh: false }),
}
)
if (!res.ok) {
const errText = await res.text()
throw new Error(errText)
}
const summary: RepoSummary = await res.json()
setSummaries(prev => ({ ...prev, [fullName]: summary }))
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setSummarizingRepo(null)
}
}
まとめ:詰まったポイント一覧
| 問題 | 原因 | 解決策 |
|---|---|---|
| base64デコードエラー | content内の改行\nを除去していなかった |
strings.ReplaceAllで除去してからデコード |
| OpenAI APIエラー |
response_format: json_object指定時にプロンプトに"JSON"がなかった |
プロンプトに"JSON形式で"と明記 |
| 同一リポジトリが重複登録 | Upsertではなく毎回Insertしていた |
clause.OnConflictでUpsert化 |
| 組織リポジトリが表示されない | OAuthトークンにread:orgスコープがなかった |
スコープ追加 + OAuthアプリを失効させて再ログイン |
UserIDが0になってUpsert失敗 |
サマリー生成後にUserIDをセットし忘れた |
summary.UserID = userIDを明示的にセット |
おわりに
「READMEを読んでもらう」というシンプルなアイデアでも、実装してみると意外なところで詰まることがわかりました。特にOpenAIのJSON出力とGitHub OAuthのスコープ問題は、ドキュメントをよく読まないとハマりやすいポイントです。
同じ機能を実装しようとしている方の参考になれば幸いです。