1
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?

GitHubのREADMEをAIで要約してポートフォリオに表示する機能を作った話

1
Last updated at Posted at 2026-03-26

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のスコープ問題は、ドキュメントをよく読まないとハマりやすいポイントです。

同じ機能を実装しようとしている方の参考になれば幸いです。

1
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
1
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?