詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

Fork me on GitHub

prchecklist でリリース Pull Request のチェックフローをスムーズに行う

背景

GitHub を使った開発では、

  • master ブランチがいつでも本番に出せる状態として、
  • master から切った develop ブランチを開発のベースとし、
  • 各フィーチャは develop から切って develop にマージし、
  • リリースのタイミングで develop を master にマージ、リリース

……という流れを pull request ベースで行うのがよくあるパターンのひとつだと思います。リリースの際、ステージングや QA という名前のついた本番前環境でそれぞれの機能が正しく動いているか確認するのもよくあるフローです。

このチェックを pull request 本文のチェックボックスを使って行おう、というアイデアを実装したのが git-pr-release で、もともと id:hitode909 がチーム向けにこしらえたものをパクった汎用化したものでした。この仕組はとても便利で、いまも社内で使われているのですが、GitHub 上のチェックボックスを利用するのはすこし困るところがありました:

  • (最近はないけど)Firefox で挙動がおかしいことがあった
  • 誰がチェックしたかわからない
  • 複数人でチェックできない
  • チェックが完了したかどうか、リリース担当者が確認する必要がある

などなど。GitHub の機能追加でチェックボックスまわりが今後リッチになっていく可能性は十分ありますが、それがいつのことかは当然わかりません。チームのプロセスの進歩のためには、自分たちで拡張可能な仕組みにする必要があります。

prchecklist

そういうわけでこのチェックリストをウェブアプリケーション化したのが prchecklist です。

GitHub - motemen/prchecklist: Provides checklists based on release PR

git-pr-release と同様に、複数のフィーチャ PR をまとめて master にマージする「リリース PR」に対してチェックリストを生成します(pull request の作成はおこないません)。チェックリストは、リポジトリへのアクセス権限のある GitHub ユーザによってチェックされ、設定によっては Slack への通知も行えます。

動作を大づかみするには、Heroku にデプロイしているデモを見るのが早そうです。サンプルのリリース PR である https://github.com/motemen/test-repository/pull/2 のチェックリストが 、prchecklist 上では https://prchecklist.herokuapp.com/motemen/test-repository/pull/2 になります(GitHub における URL のパスが prchecklist のパスに対応しています)。ログイン必須で右上から GitHub ログインする必要がありますが、repo 権限が必要なのでちょっと厳しいかも……。という人はあとの説明をみて、手元で立てて確かめてみてください。

f:id:motemen:20170926092823p:plain

設定

先のデモでは、qa と production の2ステージが選択できるようになっていました(右上のドロップダウン)。このような設定は、リポジトリ直下の prchecklist.yml でカスタマイズできます。

例はステージの設定のみですが、通知を設定することもできて、以下はチェックリストのアイテムがチェックされた場合と、チェックリストがすべてチェックされた場合に別々の Slack チャンネルに通知するような例です。

stages:
  - qa
  - production
notification:
  events:
    on_check:
      - ch_check
    on_complete:
      - ch_complete
  channels:
    ch_complete:
      url: https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX
    ch_check:
      url: https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX

通知は、こんな感じのが来ます。

f:id:motemen:20170926093024p:plain

ビルド/デプロイ

https://github.com/motemen/prchecklist/releases にビルド済みのバイナリをアップロードしています。go get github.com/motemen/prchecklist/cmd/prchecklist または git clone して make でも取得可能です。

利用するには GitHub にアプリケーションを登録する必要があります(-github-client-id-github-client-secret)。また、チェック情報の保存に別途ストレージが必要で、デフォルトでは Bolt を利用しますが、-datasource redis://u:<password>@<host> のような形で Redis も指定可能です。

% prchecklist -h
Usage of prchecklist:
  -behind-proxy
        prchecklist is behind a reverse proxy (PRCHECKLIST_BEHIND_PROXY)
  -datasource string
        database source name (default "bolt:./prchecklist.db")
  -github-client-id string
        GitHub client ID (GITHUB_CLIENT_ID)
  -github-client-secret string
        GitHub client secret (GITHUB_CLIENT_SECRET)
  -github-domain string
        GitHub domain (GITHUB_DOMAIN) (default "github.com")
  -licenses
        show license notifications
  -listen address
        address to listen (default "localhost:8080")
  -session-secret string
        session secret (PRCHECKLIST_SESSION_SECRET)
  -version
        show version information

おわり

便利なウェブアプリ、prchecklist の紹介でした。

今のところ社内でも順調に使われています。git-pr-release と組み合わせるとより便利でしょう。

ためしに Gitter ルームも用意してみました。make develop で開発環境も簡単に立ち上がりますし、PR ウェルカムです!

go get gobin.cc/PROGNAME でプログラムをゲットできる URL shortener っぽいサイトを作った

夏休みの自由研究です。

ghr を go get したいんだけど作者の e の数って何個だったっけ……と悩んでいたらそもそも違う ID でした、けどそれも思い出せない! って経験ありませんか? たいていの場合、プログラム名だけは覚えていて、その正確なパスまでは覚えていないものです。

そこでその名前だけから欲しいプログラムを go get できる gobin.cc というサイトを作りました。一種の URL shortener サービスだと思ってもらえればよさそうです。

go get gobin.cc/gore

などとすると、github.com/motemen/gore と同じものが go get されます。おー、便利!

実装

プログラム名からその完全なパッケージパスを自動的に再現するのは難しいのもあり、対応しているプログラムは手動管理のファイルである SOURCES に記載されています。そういう観点では、一種の curated list であるとも言えます。

gobin.cc のソースリポジトリは https://github.com/motemen/gobin.cc です。go get gobin.cc/gorego get github.com/motemen/gobin.cc/gore をしたのと同じソースコードをダウンロードします。

gobin.cc サイト自体は Cloudflare + GitHub Pages で構成されていて、404.html にあとで触れるメタタグを埋め込むことでどのようなリクエストでも github.com を参照するように設定しています。

リポジトリ直下に main パッケージがある場合

github.com/motemen/gore など。

このような場合、GitHub のリポジトリ側で行っているのは、それぞれのプログラムを git submodule として追加することだけです。go get は submodule も含めて clone するので、このように外部リポジトリの内容を取り込むことができるのでした。そのぶん、初回の go get は重くなってしまいますが。

リポジトリのサブディレクトリ以下に main パッケージがある場合

github.com/golang/lint/golint など。

この場合はやや面倒です。Git に外部リポジトリのサブディレクトリを取り込む標準的な方法はないので、git read-tree などを使って gobin.cc のリポジトリに当該プログラムの main パッケージに相当するコードを直接コミットしてしまっています。依存パッケージは go get がダウンロードしてくれるので、main だけ考えておけばよいです。

この際、特別にリポジトリ直下の LICENSE ファイルもあわせてコミットしています。

どちらの場合も本体の更新に自動で追随はしないので、リポジトリを更新していく必要はあります(CI でできる予定)。

その他に検討した実装

go get は、VCS を利用してソースコードをダウンロードします。パッケージの示す先が github.com などの有名なホストでない場合、一旦 HTTP(S) でアクセスし、以下の形の meta タグから利用すべき VCS や URL を判別します(Remote import paths)。

<meta name="go-import" content="import-prefix vcs repo-root">

golang.org/x/ 以下のパッケージが有名ですね。gobin.cc 自体もこの仕組みを使っています。

最初は git submodule など使わずに、すべて go-import メタタグによる一種のリダイレクトのみで実現するつもりで、一見、以下のような方法でうまく行ったのですが、

<meta name="go-import" content="gobin.cc/gore git github.com/motemen/gore">

この仕組みはあくまでリポジトリのホストを go に指し示すもので、パッケージパスを指すものではなく、パッケージ直下にないプログラムへの対応を以下のように実現することはできません。

<!-- github.com/golang/lint/golint はリポジトリの URL ではないので意図通りに動かない -->
<meta name="go-import" content="gobin.cc/golint git github.com/golang/lint/golint">

なのでパッケージ直下にないプログラムの対応をするには結局自前のリポジトリを用意するほかなく、そうすると手元の GOPATH ではリポジトリがネスト(例えば $GOPATH/src/gobin.cc$GOPATH/src/gobin.cc/gore)してしまうことになり、取り回しが困難なので諦めました。

ライセンスについて

MIT、Apache、Go 本体のライセンス(BSD 3-clause)のソースコードにおいてはこの配布方法は問題ないという認識ですが、詳しい人の指摘はウェルカムです。

おわりに

PoC 的に gobin.cc という go-gettable な shoturl/curated サイトを作りました。

SOURCES にはさしあたり自分がよく使うものをリストしてますが、ほかに追加したらいいものがあったら教えてくださいね。

GraphQLのレスポンスJSONに対応するstructからクエリを生成できるgo-graphql-query

このあいだ GitHub が公開していた GraphQL API が便利そうだったので使おうと思ったのだけど、求めたライブラリがなかったので作った次第です。

ここで GraphQL についての説明はしませんが、結果の JSON とクエリが同じ形を持っているのが便利で美しいですね。ということは API の結果の JSON を受け取る struct から GraphQL のクエリが生成できるのが自然でしょう。そういうことをやってくれるシンプルなライブラリです。

GitHub - motemen/go-graphql-query

軽い気持ちで書きはじめたところ GraphQL に予想外の表現力があることがわかったのでけっこう無理をしているところもあります。一般的にどんな使い方がなされるのかわかってないので、フィードバックもお待ちしてます。

API

API は今のところ一つだけで、 Build(v interface{}) ([]byte, error) に結果を受け取りたい構造体を渡してやると GraphQL のクエリが返ってくる、というものです。

import "github.com/motemen/go-graphql-query"
// ...
var result someStruct
query, err := graphqlquery.Build(&result)
// ... query を使って Graph QL リクエスト
err := json.Unmarshal(respBody, &result)

以下、いろいろな例をあげていきます。

シンプルな例

以下の型をもつ構造体を Build に渡すと、

type simpleExample struct {
    Hero struct {
        Name string
    }
}

以下のようなクエリが得られます。

query {
  hero {
    name
  }
}

簡単ですね。

構造体のフィールドからクエリ中の名前はさしあたり camelCase への変換で実装してありますが、json タグによってつけられた名前があれば、それが優先されます。

ディレクティブとエイリアス

GraphQL にはもっと複雑な機能がありますが、それらはだいたい graphql をキーとする構造体のタグで表現されます。

タグが @ で始まっていれば、そのフィールドに対するディレクティブになります。

struct {
    Hero struct {
        Name    string
        Friends []struct {
            Name string
        } `graphql:"@include(if: true)"`
    }
}

// query {
//   hero {
//     name
//     friends @include(if: true) {
//       name
//     }
//   }
// }

また、タグが alias= で始まっていれば、そのフィールドがエイリアスであることを示します。

struct {
    JediHero struct {
        Name string
    } `graphql:"alias=hero"`
}

// query {
//   jediHero: hero {
//     name
//   }
// }

カンマで区切って併置することも可能。

struct {
    Hero struct {
        Name        string
        HeroFriends []struct {
            Name string
        } `graphql:"@include(if: true),alias=friends"`
    }
}

// query {
//   hero {
//     name
//     heroFriends: friends @include(if: true) {
//       name
//     }
//   }
// }

引数と変数

引数はタグ中の (...) で表現できます。カッコ内のカンマはまあまあいい感じにサポートされます。

struct {
    Human []struct {
        Name   string
        Height int
    } `graphql:"(after: \"1000\", limit: 5)"`
}

// query {
//   human(after: "1000", limit: 5) {
//     name
//     height
//   }
// }

また、引数は struct 内の GraphQLArguments という特別な名前のフィールドに構造体型をあてることでも表現できます。この構造体の中では、タグの中身は引数として与えるパラメタの値(の文字列)として解釈されます。

struct {
    Human []struct {
        GraphQLArguments struct {
            After string `graphql:"\"1000\""`
            Limit int    `graphql:"5"`
        }
        Name   string
        Height int
    }
}

// query {
//   human(after: "1000", limit: 5) {
//     name
//     height
//   }
// }

これがどういうときに役立つかというと、クエリに変数が登場する場合です。引数の値を $ で始めると変数として解釈され、それがそのままクエリ自体の引数として登場します(query(...) の中)。この際、構造体のフィールドの型情報が利用されます。

struct {
    Human []struct {
        GraphQLArguments struct {
            After string `graphql:"$afterCursor"`
            Limit int    `graphql:"$limit"`
        }
        Name   string
        Height int
    }
}

// query($afterCursor: String, $limit: Int) {
//   human(after: $afterCursor, limit: $limit) {
//     name
//     height
//   }
// }

便利ですが、前に出たディレクティブの場合など、変数が構造体タグの中に登場する場合は、手でクエリの引数を指定する必要があります。この場合は構造体トップレベルに GraphQLArguments フィールドを指定してやります。

struct {
    GraphQLArguments struct {
        WithFriends bool `graphql:"$withFriends"`
    }
    Hero struct {
        Name    string
        Friends []struct {
            Name string
        } `graphql:"@include(if: $withFriends)"`
    }
}

// query($withFriends: Boolean) {
//   hero {
//     name
//     friends @include(if: $withFriends) {
//       name
//     }
//   }
// }

引数の型は、組み込みの型であれば適切に変換され、また、名前の付いた型である場合は、その名前が大文字で始まっていればそのままクエリの中に登場します。

インラインフラグメント

インラインフラグメントは、タグを ... で始めることで表現できます。この際、構造体埋め込みも使えます。

struct {
    Hero []struct {
        Name        string
        Height      int `graphql:"... on Human"`
        DroidFields `graphql:"... on Droid"`
    }
}

type DroidFields struct {
    PrimaryFunction string
}

// query {
//   hero {
//     name
//     ... on Human {
//       height
//     }
//     ... on Droid {
//       primaryFunction
//     }
//   }
// }

複雑な例

最後に、実際に自分が使っている例です。もともとこれが作りたくてライブラリをこしらえたのでした。

type githubPullRequest struct {
    GraphQLArguments struct {
        IsBase bool `graphql:"$isBase,notnull"`
    }
    Repository *struct {
        GraphQLArguments struct {
            Owner string `graphql:"$owner,notnull"`
            Name  string `graphql:"$repo,notnull"`
        }
        IsPrivate   bool
        PullRequest struct {
            GraphQLArguments struct {
                Number int `graphql:"$number,notnull"`
            }
            Title   string
            Number  int
            Body    string
            URL     string
            BaseRef struct {
                Name string
            }
            HeadRef struct {
                Target struct {
                    Tree struct {
                        Entries []struct {
                            Name string
                            Oid  string
                            Type string
                        }
                    } `graphql:"... on Commit"`
                }
            } `graphql:"@include(if: $isBase)"`
            Commits struct {
                GraphQLArguments struct {
                    First int    `graphql:"100"`
                    After string `graphql:"$commitsAfter"`
                }
                Edges []struct {
                    Node struct {
                        Commit struct {
                            Message string
                        }
                    }
                }
                PageInfo struct {
                    HasNextPage bool
                    EndCursor   string
                }
                TotalCount int
            } `graphql:"@include(if: $isBase)"`
        }
    }
    RateLimit struct {
        Remaining int
    }
}

// query($commitsAfter: String, $isBase: Boolean!, $number: Int!, $owner: String!, $repo: String!) {
//   repository(name: $repo, owner: $owner) {
//     isPrivate
//     pullRequest(number: $number) {
//       title
//       number
//       body
//       url
//       baseRef {
//         name
//       }
//       headRef @include(if: $isBase) {
//         target {
//           ... on Commit {
//             tree {
//               entries {
//                 name
//                 oid
//                 type
//               }
//             }
//           }
//         }
//       }
//       commits(after: $commitsAfter, first: 100) @include(if: $isBase) {
//         edges {
//           node {
//             commit {
//               message
//             }
//           }
//         }
//         pageInfo {
//           hasNextPage
//           endCursor
//         }
//         totalCount
//       }
//     }
//   }
//   rateLimit {
//     remaining
//   }
// }

はてなで一緒に働きませんか?