2
2

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 CLIで自分がApproveしていないPRを検索する

Last updated at Posted at 2025-12-24

はじめに

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言語で実装しました。

全体構成

ざっくりこんな感じで作成しました。(厳密に分けているわけではないです)
実装周りは突貫で作ったので良くはないですが、参考程度に実装コードを載せます。

  1. Presentation : CLIの出力
  2. Controller : アプリケーションの実行周り
  3. Usecase : ビジネスロジック
  4. Domain : 共通コンポーネント
  5. 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と人間の共同レビューも出来そうです。

皆さんもちょっと楽するための拡張機能を作ってはいかがでしょうか?

  1. 参考ですが、私がレビューしたPRは1400(件/年) 程度です。(1ヶ月で約120件、1日で5 ~ 6件のレビューを行っていることになります)

  2. もし出来たら教えて下さい(切実)

2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?