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

始めに

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

機能

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

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

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

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

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

bufferdescription
gh://:owner/:repo/issues[?state=open&..]issue一覧
gh://:owner/:repo/issues/:numberissue編集
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/diffPRの差分
gh://:owner/:repo/projectsproject一覧
gh://orgs/:org/projectsOrganazationのproject一覧
gh://projects/:id/columnsprojectのカラム一覧
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をブラウザで開く
gheissueを編集
ghcissueをclose
ghoissueをopen
ghmissueのコメントを開く
ghyissueの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をブラウザで開く
ghdPRの差分
ghyPRの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()という関数を呼び、バッファ名をもとに処理を分岐させています。

augroup gh
  au!
  au BufReadCmd gh://* call gh#gh#init()
augroup END
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.vimhttp通信を提供する
autoload/gh/tree.vimtreeバッファを提供する
autoload/gh/github/*.vimhttp通信をラップしてわかりやすくした
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一覧を取得しています。 大体どの機能も、バッファの処理と通信の処理に分かれているのでファイル単位で分割しました。

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の大体の機能について紹介しました。 このプラグインはまだまだ未完成なので、今後もコツコツと作っていきたいと思います。

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


Vim

2020/12/03 00:00