はじめに
HRBrainでエンジニアをしているNubです。
チームで開発するようになり、Pull Requestにレビューをする機会が多くなりました。
皆さんはチーム開発をする上で、自分の見るべきPRをどのように検索・レビューしていますか?
GitHubをブラウザで開いて、自分がレビューすべきPRを探しているかもしれません。
VS Code拡張に好きなクエリを入れておいて、そこから見ているかもしれません。
GitHub CLIなどのCLIツールを利用して検索していることも考えられます。
今回はGitHub CLIでユーザー拡張機能を作成し、自分のレビューすべきPull Requestを検索する機能を作ってみたので、その内容を書いていこうと思います。
この記事は、HRBrain Advent Calendar 2025の24日目の記事です。
拡張機能を作成したいと思ったきっかけ
チームで開発を行っていくにあたり、自分以外のPull Requestのレビューを行うことは自他のタスクを進めるにあたり大切な作業になります。
タスクは並行で進む場合も多く、複数人のエンジニアが共同で作業を行う以上、1日で複数件のPull Requestが上がってくることがしばしばあります。1
私はバックエンド側のPull Request全てに目を通していたため、「あれ?自分が見てないPRなんやったっけ...?」となりました。
一旦の対応で「上がってきたやつはすぐ見る」「残りがないかもう一回全部見直す」といったことをやっていましたが良い方法とは言えませんでした。
そこで、自分がレビューすべきPull Requestをもっと絞り込むための方法の1つとして、GitHub Extensionを利用した絞り込み機能を作成し、利用することを考えました。
GitHub CLI(とGitHub Extension) とは
GitHub CLIは、コマンドライン上でGitHubの機能を利用する事ができるツールの一つで、GitHub公式がサポートしています。
GitHub CLIには様々な機能があり、公式の説明ページでは以下のように書かれています。
GitHub CLI には、次のような GitHub 機能が含まれています。
- リポジトリの表示、作成、複製、フォーク
- Issue と pull request の作成、クローズ、編集、一覧表示
- プルリクエストのレビュー、diff、マージ
- ワークフローの実行、表示、一覧表示
- リリースの作成、一覧表示、表示、削除
- gist の作成、編集、一覧表示、表示、削除
- codespace の一覧表示、作成、削除、接続
- GitHub API から情報を取得します
GitHub ExtensionはGitHub CLI 2.0から追加された、ユーザーが作成した拡張機能をGitHub CLIのコマンドのように利用できる機能です。
(2021年にリリースされており、大分前の話です)
拡張機能は単純にシェルスクリプトを実行するものから、Goやその他の言語で実装することもできます。
さらに、公式でリリースワークフローまでサポートされているのでチームやプロジェクト全体の共通資産することも、全体公開することも可能です。
作成した拡張機能
GitHubのPR検索機能は非常に強力で、ユーザー・ブランチ、PRの状況など様々な軸で絞り込むことが出来ます。
しかし、「PRの誰がapproveしたか」を取得することが出来ません。2
そのため、そのフィルタリングをするための拡張機能をGo言語で実装しました。
全体構成
ざっくりこんな感じで作成しました。(厳密に分けているわけではないです)
実装周りは突貫で作ったので良くはないですが、参考程度に実装コードを載せます。
- Presentation : CLIの出力
- Controller : アプリケーションの実行周り
- Usecase : ビジネスロジック
- Domain : 共通コンポーネント
- Repository : GitHubと通信を行う部分(APIクライアント)
.
├── domain
│ ├── pr.go # Pull Request用のドメインモデル
│ └── review.go # Review 用のドメインモデル
├── gh-no-review-prs # バイナリファイル
├── go.mod
├── go.sum
├── infrastructure # Repository
│ └── github
│ ├── client.go # GitHub APIを利用するためのクライアント
│ ├── pull_request.go # GitHub Search APIを利用するためのRepository
│ ├── types.go # Repository層だけで使う構造体
│ └── user.go # ユーザー情報を取得するためのRepository
├── interface
│ └── cli
│ ├── controller.go # 実行制御系
│ └── presenter.go # 表示系
├── main.go # main関数
└── usecase
└── execute.go # ビジネスロジック部分
Presentation
プレゼンテーション層ではCLIの出力周りを実装しています。
自分がレビューすべきPull Requestを表示します。
presenter.go
package cli
import (
"fmt"
"time"
"github.com/<MYNAME>/gh-no-review-prs/domain"
)
const (
// ANSI color codes
colorTitle = "\033[4m" // 下線
colorApproved = "\033[32m" // 緑 (APPROVED)
colorChangesReq = "\033[31m" // 赤 (CHANGES_REQUESTED)
colorCommented = "\033[36m" // シアン (COMMENTED)
colorDismissed = "\033[90m" // グレー (DISMISSED)
colorReset = "\033[0m"
)
type Presenter struct{}
func NewPresenter() *Presenter {
return &Presenter{}
}
func (p *Presenter) DisplayResults(prs []domain.PullRequest) {
if len(prs) == 0 {
fmt.Println("No PRs waiting for review")
return
}
for _, pr := range prs {
p.display(pr)
}
fmt.Printf("\nTotal: %d PRs\n", len(prs))
}
func (p *Presenter) display(pr domain.PullRequest) {
fmt.Printf("%s#%d - %s%s\n", colorTitle, pr.Number, pr.Title, colorReset)
fmt.Printf(" Author : %s\n", pr.Author.Name)
p.displayReviewers(pr.LatestReviews)
fmt.Println(" Head :", pr.HeadRefName)
fmt.Println(" Base :", pr.BaseRefName)
fmt.Println(" Updated :", pr.UpdatedAt.Format(time.RFC3339))
fmt.Println(" URL :", pr.URL)
fmt.Println()
}
func (p *Presenter) displayReviewers(reviews domain.Reviews) {
if len(reviews) == 0 {
fmt.Println(" Reviewers : なし")
return
}
fmt.Print(" Reviewers : ")
for i, review := range reviews {
if i > 0 {
fmt.Print(", ")
}
color, label := p.getReviewStateDisplay(review.State)
fmt.Printf("%s%s(%s)%s", color, review.Author.Login, label, colorReset)
}
fmt.Println()
}
func (p *Presenter) getReviewStateDisplay(state domain.ReviewState) (color string, label string) {
switch state {
case domain.ReviewStateApproved:
return colorApproved, "承認"
case domain.ReviewStateChangesRequested:
return colorChangesReq, "変更要求"
case domain.ReviewStateCommented:
return colorCommented, "コメント"
case domain.ReviewStateDismissed:
return colorDismissed, "却下"
default:
return colorReset, string(state)
}
}
Controller
Controllerは拡張機能の実行周りを実装しています。
controller.go
package cli
import (
"context"
"fmt"
"os"
"github.com/<MYNAME>/gh-no-review-prs/usecase"
)
type Controller struct {
filterPRsUseCase usecase.PullRequestUsecase
presenter *Presenter
}
func NewController(pullRequestUsecase usecase.PullRequestUsecase, presenter *Presenter) *Controller {
return &Controller{
filterPRsUseCase: pullRequestUsecase,
presenter: presenter,
}
}
func (c *Controller) Run(ctx context.Context) error {
// Display progress
fmt.Fprintln(os.Stderr, "Fetching user info...")
fmt.Fprintln(os.Stderr, "Fetching PR list from GitHub...")
filteredPRs, err := c.filterPRsUseCase.Execute(ctx)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "Filtering PRs...")
c.presenter.DisplayResults(filteredPRs)
return nil
}
Usecase
全体的な動作周りを実装しています。
execute.go
package usecase
import (
"context"
"sort"
"github.com/<MYNAME>/gh-no-review-prs/domain"
"github.com/<MYNAME>/gh-no-review-prs/infrastructure/github"
)
type PullRequestUsecase interface {
Execute(ctx context.Context) ([]domain.PullRequest, error)
}
type pullRequestUsecase struct {
pullRequestRepo github.PullRequestRepository
userRepo github.UserRepository
}
func NewPullRequestUseCase(pullRequestRepo github.PullRequestRepository, userRepo github.UserRepository) PullRequestUsecase {
return &pullRequestUsecase{
pullRequestRepo: pullRequestRepo,
userRepo: userRepo,
}
}
func (uc *pullRequestUsecase) Execute(ctx context.Context) ([]domain.PullRequest, error) {
currentUser, err := uc.userRepo.GetCurrentUser(ctx)
if err != nil {
return nil, err
}
prs, err := uc.pullRequestRepo.GetPullRequests(ctx)
if err != nil {
return nil, err
}
shouldReviewPullRequests := make([]domain.PullRequest, 0, len(prs))
for _, pr := range prs {
if pr.Author.Login == currentUser {
continue
}
if pr.IsDraft {
continue
}
if !pr.IsTeamRequested() {
continue
}
if pr.LatestReviews.IsApproved(currentUser) {
continue
}
shouldReviewPullRequests = append(shouldReviewPullRequests, pr)
}
sort.Slice(prs, func(i, j int) bool {
return prs[i].UpdatedAt.After(prs[j].UpdatedAt)
})
return shouldReviewPullRequests, nil
}
Domain
拡張機能共通で参照できるデータ型を定義しています。
pr.go
package domain
import (
"strings"
"time"
)
type PullRequest struct {
Number int
Title string
Author Author
UpdatedAt time.Time
LatestReviews Reviews
ReviewRequests []ReviewRequest
IsDraft bool
URL string
BaseRefName string
HeadRefName string
}
func (pr *PullRequest) IsTeamRequested() bool {
containsTeam := func(s string) bool { return strings.Contains(strings.ToLower(s), "<TEAM NAME>") }
for _, reviewer := range pr.ReviewRequests {
if containsTeam(reviewer.Name) || containsTeam(reviewer.Slug) {
return true
}
}
return false
}
type Author struct {
Login string
Name string
}
type ReviewRequest struct {
Login string // for User
Name string // for Team
Slug string // for Team
}
review.go
package domain
import (
"slices"
)
type Reviews []Review
func (rs Reviews) IsApproved(author string) bool {
return slices.ContainsFunc(rs, func(r Review) bool { return r.HasReviewed(author) && r.IsApproved() })
}
type ReviewState string
const (
ReviewStateNone ReviewState = ""
ReviewStateApproved ReviewState = "APPROVED"
ReviewStateChangesRequested ReviewState = "CHANGES_REQUESTED"
ReviewStateCommented ReviewState = "COMMENTED"
ReviewStateDismissed ReviewState = "DISMISSED"
)
type Review struct {
Author ReviewAuthor
State ReviewState
}
func (r Review) HasReviewed(author string) bool {
return r.Author.Login == author
}
func (r Review) IsApproved() bool {
return r.State == ReviewStateApproved
}
type ReviewAuthor struct {
Login string
}
Repository
Repository層ではGitHubとの通信を行っています。
GitHub CLIではRest APIとGraphQLの2種類のクエリをサポートしています。
今回はネットワークのIOを減らすために、GraphQLを利用して1回の通信で必要な情報を取得するようにしています。
また、デバッグ用に環境変数を設定してクエリをログに表示する実装も加えました。
client.go
package github
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"github.com/cli/go-gh/v2/pkg/api"
"github.com/shurcooL/githubv4"
)
type Client struct {
GraphQL *githubv4.Client
REST *api.RESTClient
}
type debugTransport struct {
transport http.RoundTripper
}
func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.Body != nil {
body, _ := io.ReadAll(req.Body)
fmt.Fprintf(os.Stderr, "\n=== GraphQL Request ===\n%s\n=======================\n\n", string(body))
req.Body = io.NopCloser(bytes.NewBuffer(body))
}
resp, err := t.transport.RoundTrip(req)
if err != nil {
return resp, err
}
if resp.Body != nil {
body, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "=== GraphQL Response ===\n%s\n========================\n\n", string(body))
resp.Body = io.NopCloser(bytes.NewBuffer(body))
}
return resp, nil
}
func NewClient() (*Client, error) {
httpClient, err := api.DefaultHTTPClient()
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
}
if os.Getenv("DEBUG_GRAPHQL") != "" {
httpClient.Transport = &debugTransport{
transport: httpClient.Transport,
}
}
gqlClient := githubv4.NewClient(httpClient)
restClient, err := api.DefaultRESTClient()
if err != nil {
return nil, fmt.Errorf("failed to create REST client: %w", err)
}
return &Client{
GraphQL: gqlClient,
REST: restClient,
}, nil
}
pull_request.go
package github
import (
"context"
"fmt"
"maps"
"slices"
"github.com/<MYNAME>/gh-no-review-prs/domain"
"github.com/shurcooL/githubv4"
)
type PullRequestRepository interface {
GetPullRequests(ctx context.Context) ([]domain.PullRequest, error)
}
type pullRequestRepository struct {
client *Client
}
func NewPullRequestRepository(client *Client) PullRequestRepository {
return &pullRequestRepository{client: client}
}
func (r *pullRequestRepository) GetPullRequests(ctx context.Context) ([]domain.PullRequest, error) {
var query struct {
Search struct {
Nodes []struct {
PullRequest pullRequest `graphql:"... on PullRequest"`
}
} `graphql:"search(query: $query, type: ISSUE, first: 100)"`
}
// AND検索をしたいのでSearch Queryを利用する
searchQuery := "<ここにGitHubで利用しているクエリを入れる>"
variables := map[string]any{
"query": githubv4.String(searchQuery),
}
err := r.client.GraphQL.Query(ctx, &query, variables)
if err != nil {
return nil, fmt.Errorf("failed to query pull requests: %w", err)
}
// (念の為)重複除去
m := make(map[int]domain.PullRequest, len(query.Search.Nodes))
for _, pr := range query.Search.Nodes {
p := pr.PullRequest.ToDomain()
if _, exists := m[p.Number]; exists {
continue
}
m[p.Number] = p
}
return slices.Collect(maps.Values(m)), nil
}
types.go
package github
import (
"github.com/haruka-hosokawa/gh-no-review-prs/domain"
"github.com/shurcooL/githubv4"
)
type user struct {
Login string `json:"login"`
}
type author struct {
Login githubv4.String
User struct {
Name githubv4.String
} `graphql:"... on User"`
}
func (g author) ToDomain() domain.Author {
login := string(g.Login)
name := string(g.User.Name)
if name == "" {
name = login
}
return domain.Author{Login: login, Name: name}
}
type reviews []review
func (gs reviews) ToDomain() []domain.Review {
reviews := make([]domain.Review, 0, len(gs))
for _, g := range gs {
reviews = append(reviews, g.ToDomain())
}
return reviews
}
type review struct {
Author struct {
Login githubv4.String
}
State githubv4.String
}
func (g review) ToDomain() domain.Review {
return domain.Review{
Author: domain.ReviewAuthor{
Login: string(g.Author.Login),
},
State: domain.ReviewState(g.State),
}
}
type reviewRequests []reviewRequest
func (g reviewRequests) ToDomain() []domain.ReviewRequest {
reviewRequests := make([]domain.ReviewRequest, 0, len(g))
for _, reviewRequest := range g {
reviewRequests = append(reviewRequests, reviewRequest.ToDomain())
}
return reviewRequests
}
type reviewRequest struct {
RequestedReviewer struct {
User struct {
Login githubv4.String
} `graphql:"... on User"`
Team struct {
Name githubv4.String
Slug githubv4.String
} `graphql:"... on Team"`
}
}
func (g reviewRequest) ToDomain() domain.ReviewRequest {
return domain.ReviewRequest{
Login: string(g.RequestedReviewer.User.Login),
Name: string(g.RequestedReviewer.Team.Name),
Slug: string(g.RequestedReviewer.Team.Slug),
}
}
type pullRequest struct {
Number githubv4.Int
Title githubv4.String
UpdatedAt githubv4.DateTime
IsDraft githubv4.Boolean
URL githubv4.URI
BaseRefName githubv4.String
HeadRefName githubv4.String
Author author
Labels struct {
Nodes []struct {
Name githubv4.String
}
} `graphql:"labels(first: 10)"`
ReviewRequests struct {
Nodes reviewRequests
} `graphql:"reviewRequests(first: 10)"`
LatestReviews struct {
Nodes reviews
} `graphql:"latestReviews(first: 100)"`
}
func (g pullRequest) ToDomain() domain.PullRequest {
return domain.PullRequest{
Number: int(g.Number),
Title: string(g.Title),
Author: g.Author.ToDomain(),
UpdatedAt: g.UpdatedAt.Time,
LatestReviews: g.LatestReviews.Nodes.ToDomain(),
ReviewRequests: g.ReviewRequests.Nodes.ToDomain(),
IsDraft: bool(g.IsDraft),
URL: g.URL.String(),
BaseRefName: string(g.BaseRefName),
HeadRefName: string(g.HeadRefName),
}
}
user.go
package github
import (
"context"
"fmt"
)
type UserRepository interface {
GetCurrentUser(ctx context.Context) (string, error)
}
type userRepository struct {
client *Client
}
func NewUserRepository(client *Client) UserRepository {
return &userRepository{client: client}
}
func (r *userRepository) GetCurrentUser(ctx context.Context) (string, error) {
var user user
err := r.client.REST.Get("user", &user)
if err != nil {
return "", fmt.Errorf("failed to fetch user info: %w", err)
}
return user.Login, nil
}
実際に使ってみる
実行例はこんな感じになります。
~/gh-extensions ───────────────────────────────────────────────────────────────────────────────────────
❯ gh extensions list
NAME REPO VERSION
gh no-review-prs
~/gh-extensions ───────────────────────────────────────────────────────────────────────────────────────
❯ gh no-review-prs
Fetching user info...
Fetching PR list from GitHub...
Filtering PRs...
#XXX - PR TITLE
Author : XXXXXXXXXXXXX
Reviewers : XXXXXXXXXXXXX (コメント)
Head : XXXXXXXXXXXXX
Base : main
Updated : 2025-12-18T09:40:59Z
URL : https://github.com/XXXXXXXXXX
Total: 1 PRs
おわりに
当初はRestAPIをちょっと使ってフィルターすれば良いかと思っていましたが、GraphQLを利用するとは思いませんでした...
これを作成することで、自分が確認していないPRをすぐに探すことが出来るようになりました。
CLIツールにすることで、ClaudeなどのAI Agentなどに簡単に利用させることが可能なので、「ざっとレビューして!」とAIと人間の共同レビューも出来そうです。
皆さんもちょっと楽するための拡張機能を作ってはいかがでしょうか?
