Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
11
Help us understand the problem. What are the problem?

posted at

updated at

VimでGitHubを操作するプラグインgh.vimの紹介

始めに

最近GitHubのVimプラグインgh.vimというのを作っています。
issueやPR、プロジェクト、GitHub ActionsのステータスなどをVim/Neovim上で確認、操作できます。
よい感じにいろいろな機能が入ったので、あらためてちゃんとプラグインを紹介したいと思います。

機能

現時点では次の機能が実装されています。

カテゴリ 機能
issue 一覧/作成/更新
issue comment 一覧/作成/更新
issue open/close
pull request 一覧/パッチ(差分)
repository 一覧/README
project 一覧/カード・カラム一覧
actions ワークフロー・ジョブステップ一覧/ジョブログ
bookmark 一覧/バッファを開く

gh.vimではgh://xxxといった仮想バッファのみ提供していて、Exコマンドは用意していません。なので一般的なプラグインとちょっと異なる使い方をします。
たとえばgh://golang/go/issues?state=allというバッファ名を開くと、そのバッファにissue一覧が表示され、キーマップが設定されます。

仮想バッファというのは、実際ファイルを作成せず一時的なバッファにデータを表示したり、キーマップを設定したりする手法です。
仮想バッファのみにした理由はこれまでにない形のプラグインを作ってみたかったからです。
あとは実装しやすさがあります。詳細については仕組みの項で解説します。

現在gh.vimが提供している仮想バッファは次になります。大体機能ごとにバッファが別れています。

buffer description
gh://:owner/:repo/issues[?state=open&..] issue一覧
gh://:owner/:repo/issues/:number issue編集
gh://:owner/:repo/:branch/issues/new 新規issue作成
gh://:owner/:repo/issues/comments[?page=1&..] issueコメント一覧
gh://:owner/:repo/issues/comments/new 新規issueコメント作成
gh://:owner/repos レポジトリ一覧
gh://user/repos 認証済みユーザリポジトリ一覧
gh://:owner/:repo/readme リポジトリのREADME
gh://:owner/:repo/pulls[?state=open&...] PR一覧
gh://:owner/:repo/pulls/:number/diff PRの差分
gh://:owner/:repo/projects project一覧
gh://orgs/:org/projects Organazationのproject一覧
gh://projects/:id/columns projectのカラム一覧
gh://:owner/:repo/actions[?status=success&...] actionsステータス一覧
gh://bookmarks ブックマーク一覧

次に、機能の詳細について紹介していきます。

Issue機能

issueの一覧や作成、編集、及びコメントの作成&編集などを行えます。

issue一覧

gh://:owner/:repo/issues[?state=open&...]でissueの一覧を表示できます。
?より後ろはクエリパラメータとして認識されるので、APIで使用できるクエリパラメータをそのまま使えます。
たとえば?state=allをつけるとclosedしたissueも一覧に表示されます。詳細はgh.vimのヘルプを参照下さい。
実行可能なアクションは次になります。

キー 説明
<C-h> 前のページ
<C-l> 次のページ
<C-o> issueをブラウザで開く
ghe issueを編集
ghc issueをclose
gho issueをopen
ghm issueのコメントを開く
ghy issueのURLをコピー

issueの作成

gh://:owner/:repo/:branch/issues/newで新規issueを作成できます。
リポジトリにテンプレートがある場合、テンプレートを選択してissueを作成できます。

issueの編集

gh://:owner/:repo/:branch/issues/:numberでissueの本文を編集&更新できます。
本文を編集後:wで更新されます。更新の際タイトルも変更するか聞かれるので合わせて修正したいときは新しいタイトルを入力します。

issueのコメント一覧

gh://:owner/:repo/issues/:number/comments[?page=1&...]でissueのコメント一覧を表示できます。
実行可能なアクションは次になります。

キー 説明
<C-h> 前のページ
<C-l> 次のページ
<C-o> コメントをブラウザで開く
ghn 新規コメント
ghy コメントのURLをコピー

Pull Request機能

PRの一覧&差分を確認できます。

PR一覧

gh://:owner/:repo/pulls[?page=1&...]でPR一覧を表示できます。

実行可能なアクションは次になります。

キー 説明
<C-h> 前のページ
<C-l> 次のページ
<C-o> PRをブラウザで開く
ghd PRの差分
ghy PRのURLをコピー

PRの差分
gh://:owner/:repo/pull/:number/diffでPRの差分を確認できます。
現時点では差分確認のみですが将来的にはレビューコメントもできるようにする予定です。

Repository機能

リポジトリ一覧&READMEを確認できます。

リポジトリ一覧
gh://:owner/repos[?page=1&...]でリポジトリ一覧を表示できます。
:owneruserの場合、認証されたユーザ(tokenを発行したユーザ)のプライベートやOrganazationのリポジトリも表示されます。

リポジトリのREADME
gh://:owner/:repo/readmeでリポジトリのREADMEを確認できます。

Project機能

リポジトリのproject一覧&カード一覧を確認できます。
プロジェクトとカラム、カードはツリーになっていて、折りたたみが可能です。
また、カードの移動も出来ます。

この機能の実装についてはこちらを参照ください。

プロジェクト一覧
gh://:owner/:repo/projectsで指定したリポジトリのproject一覧を表示します。
:ownerorgsの場合、:repoはOrganazationの名前を指定する必要があります。

実行可能なアクションは次になります。

キー 説明
<CR> カード一覧を開く
<C-o> プロジェクトをブラウザで開く
ghy プロジェクトのURLをコピー

カード一覧
gh://projects/:id/columnsでprojectのカラムとカード一覧とカードの操作が出来ます。

キー 説明
<C-o> 選択したカードのURLを開く(現時点issueのみ対応)
gho 選択したカードの詳細を開く(現時点issueのみ対応)
ghm 選択したカードを現在のカラムに移動
ghy 選択したカードのURLをコピー

GitHub Actions機能

gh://:owner/:repo/actions[?status=success&...]でリポジトリのGitHub Actionsのワークフローとジョブを確認できます。
実装の詳細についてはこちらを参照下さい。

キー 説明
<C-o> 選択したワークフロー/ジョブをブラウザで開く
ghy 選択したワークフロー/ジョブのURLをコピー
gho 選択したジョブのログを確認

キーマップ

gh.vimでは各種バッファで使用できるキーマップを用意しています。
詳細なキーマップはヘルプを参照いただければと思いますが、vimrcに次のような設定を書くことで独自のキーマップを設定できます。

function! s:gh_map_add() abort
  if !exists('g:loaded_gh')
    return
  endif
  call gh#map#add('gh-buffer-issue-list', 'nnoremap', 'x', ':bw!<CR>')
  call gh#map#add('gh-buffer-issue-list', 'map', 'h', '<Plug>(gh_issue_list_prev)')
  call gh#map#add('gh-buffer-issue-list', 'map', 'l', '<Plug>(gh_issue_list_next)')
endfunction

augroup gh-maps
  au!
  au VimEnter * call <SID>gh_map_add()
augroup END

仕組み

通信

Vimのjob機能を使って非同期にcurlでGithubのv3 APIを叩いています。取得した結果をいい感じに表示させているだけなので、すごくムズカシイことをやっているわけではないです。
たとえばissue一覧のバッファを開くと、裏では次のコマンドが実行されます。

curl -H "Accept: application/vnd.github.v3+json" -H "Authorization: token xxxxxxxxxxxxxxxxxxx" "https://api.github.com/repos/golang/go/issues"

ただ、生のjob機能を使うのはけっこう面倒なので、それをいい感じに使いやすくしてくれたvital.vimvital.vimの追加モジュールであるvital-Whiskyを使っています。
複雑なVimプラグインを作る上で欠かせないライブラリとなっているので、知らない方は一度使ってみると良いと思います。本当によく出来ています。

仮想バッファ

仮想バッファの仕組みはVimのautocmdBufReadCmdを使って実現しています。
BufReadCmdは新たなバッファが作られたときに何かしらの処理をしたいときに使えます。gh.vimではgh://*と一致したバッファ名が作られたときにgh#gh#init()という関数を呼び、バッファ名をもとに処理を分岐させています。

gh/plugin.vim
augroup gh
  au!
  au BufReadCmd gh://* call gh#gh#init()
augroup END
autoload/gh/gh.vim
function! gh#gh#init() abort
  setlocal nolist
  let bufname = bufname()
  if bufname is# 'gh://user/repos/new'
    call gh#repos#new()
  elseif bufname =~# '^gh:\/\/[^/]\+\/repos$' || bufname =~# '^gh:\/\/[^/]\+\/repos?\+'
    call gh#repos#list()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/readme$'
    call gh#repos#readme()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/issues$'
        \ || bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/issues?\+'
    call gh#issues#list()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/issues\/[0-9]\+$'
    call gh#issues#issue()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/[^/]\+\/issues\/new$'
    call gh#issues#new()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/issues\/\d\+\/comments$'
        \ || bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/issues\/\d\+\/comments?\+'
    call gh#issues#comments()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/issues\/\d\+\/comments\/new$'
    call gh#issues#comment_new()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/pulls$'
        \ || bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/pulls?\+'
    call gh#pulls#list()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/pulls\/\d\+\/diff$'
    call gh#pulls#diff()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/projects$'
        \ || bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/projects?\+'
    call gh#projects#list()
  elseif bufname =~# '^gh:\/\/projects\/[^/]\+\/columns$'
        \ || bufname =~# '^gh:\/\/projects\/[^/]\+\/columns?\+'
    call gh#projects#columns()
  elseif bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/actions$'
        \ || bufname =~# '^gh:\/\/[^/]\+\/[^/]\+\/actions?\+'
    call gh#actions#list()
  elseif bufname =~# '^gh:\/\/bookmarks$'
    call gh#bookmark#list()
  endif
endfunction

仮想バッファのメリットは簡単にいうとバッファを開く処理とバッファ初期化の処理を分断できる、ということです。
分断することで、たとえばissue編集バッファをissue一覧やprojectバッファから開くときは以下を実行するだけで済みます。

exe 'gh://:owner/:repo/issues/:number'

しかし分断されていない場合、バッファ作成したあとに初期化の処理関数を呼び出す必要があります。
そして関数名が変わったら修正範囲も広がってしまいます。

exe 'gh://:owner/:repo/issues/:number'
call gh#issue#edit()

このように、結合度を低く保てることにメリットがあるためgh.vimは積極的に仮想バッファを採用しています。

モジュール

gh.vimでは大きく分けて次のモジュール群があります。

autoload/gh/http.vim http通信を提供する
autoload/gh/tree.vim treeバッファを提供する
autoload/gh/github/*.vim http通信をラップしてわかりやすくした
autoload/gh/*.vim 各種バッファを提供している
autoload/gh/gh.vim 全バッファ共通のutil関数などを提供している

ディレクトリ構成は次になっています。
他のプラグインと名前空間がかぶらないように、ghというディレクトリ配下にコードを置くようにしています。

 gh.vim
 |- autoload/
  |- gh/
   |- github/
    |  actions.vim
    |  issues.vim
    |  projects.vim
    |  pulls.vim
    |  repos.vim
   |  actions.vim
   |  bookmark.vim
   |  buffer.vim
   |  gh.vim
   |  http.vim
   |  issues.vim
   |  map.vim
   |  projects.vim
   |  pulls.vim
   |  repos.vim
   |  tree.vim

基本、各種バッファからgh#http#xxx()またはgh#github#xxx()を呼び出して、結果を受け取って画面描画とキーマップの設定を行っています。
次はissue一覧バッファを開いたときに実行される関数です。中でgh#github#issues#list()を呼び出していてissue一覧を取得しています。
大体どの機能も、バッファの処理と通信の処理に分かれているのでファイル単位で分割しました。

autoload/gh/issue.vim
function! gh#issues#list() abort
  setlocal ft=gh-issues
  let m = matchlist(bufname(), 'gh://\(.*\)/\(.*\)/issues?*\(.*\)')

  call gh#gh#delete_buffer(s:, 'gh_issues_list_bufid')
  let s:gh_issues_list_bufid = bufnr()

  let param = gh#http#decode_param(m[3])
  if !has_key(param, 'page')
    let param['page'] = 1
  endif

  let s:issue_list = {
        \ 'repo': {
        \   'owner': m[1],
        \   'name': m[2],
        \ },
        \ 'param': param,
        \ }

  call gh#gh#init_buffer()
  call gh#gh#set_message_buf('loading')

  call gh#github#issues#list(s:issue_list.repo.owner, s:issue_list.repo.name, s:issue_list.param)
        \.then(function('s:issue_list'))
        \.then({-> gh#map#apply('gh-buffer-issue-list', s:gh_issues_list_bufid)})
        \.catch({err -> execute('call gh#gh#error_message(err.body)', '')})
        \.finally(function('gh#gh#global_buf_settings'))
endfunction

以上がgh.vimの大まかな仕組みです。
まだまだリファクタリングしないといけない箇所がたくさんありますが、より詳細な実装を知りたい方はコードを覗いてみて下さい。

課題

一覧バッファの共通化
現在、一覧バッファは大体どれも同じ仕組みですが、共通の部分を分けられていない状態です。
tree.vimのように、list.vimを作って一覧の処理の共通化をする必要があります。
共通化しておかないと、今後一覧画面が増えるたびに似通った処理が増えてメンテナンスが大変なので、いまのうちに手を付けておきたいと思っています。

API通信量の削減
v3のAPIを使っているため、通信量が結構多いです。
たとえばプロジェクトのカード一覧を取得するAPIがありますが、こちらにはカードの種類とURLの情報くらいしかなくて、種類がissueやPRの場合は別途APIを叩く必要があります。
カードがN百枚の場合、Vimが死ぬのでv4のGraphQLを使って通信量と回数を減らすのが直近一番やらないと行けない課題です。

エラーハンドリング
vital.vimpromiseを使っていることによる原因かわからないんですが、処理でエラーが起きたときに握りつぶされることがあります。
実装時結構大変なのでこれも早い段階で解決しなければ行けないんですが、良い解決策が浮かばずという状態です。
知見がある方はアドバイスをお願いします。

最後

少し長くなりましたが、gh.vimの大体の機能について紹介しました。
このプラグインはまだまだ未完成なので、今後もコツコツと作っていきたいと思います。

プラグイン気になる方は、ぜひ一度使ってみて下さい。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
11
Help us understand the problem. What are the problem?