見出し画像

Notion APIを使ってGitHubのissueをお引越ししてみました

こんにちは。kubopです。
Notionでタスク管理をする際に、GitHubの既存のissueをどうするか、どう移行すれば良いかという問題にあたり、その備忘として記事を書きます。
(備忘録故、綺麗なコードじゃなくて申し訳ないのですが…!

要件

  • issueにある情報をそのままNotionに引き継ぐこと。

    • ラベル

    • アサイン

    • issueのURL

    • issueの中身(description)

    • issueに投稿されたコメント

  • descriptionはNotionのサブページに記載すること。

  • notionレコードに新規プロパティを追加してデータとして扱えるようにする。

手段の候補

GitHubのissueをお引っ越しするにあたり、いくつか候補がありました。

  1. NotionにGitHubを連携する。

  2. NotionのCSVインポート機能を利用する。

  3. Notion APIを利用してページを作成する。

NotionにGitHubを連携する。

こちらに関しては、以下の問題があり断念しました。

  • GitHubのorg単位でしか権限管理ができなく、repo単位で権限管理が出来ない。

  • 連携したissueはread onlyであり、notion側では編集できない。

  • 連携したissueに新しくプロパティを追加できない。

NotionのCSVインポート機能を利用する。

こちらに関しては、以下の問題があり断念しました。

  • CSVインポートした場合は、issueのdescriptionが移行できない。

  • CSVの1カラムが1つのプロパティとして判定されてしまい、データとして扱うには難しい。

Notion APIを利用してページを作成する。

これは、GraphQLを用いてGitHubのissueを引き抜き、CSVに変換した上でNotion 公式のAPI SDKを利用してNotionにページを作成する方法です。

この方法であると、以上の問題点は全て解消できますが、デメリットとしてNotionからGitHubに戻ることが出来なくなります。(頑張って手動移行すれば戻れるかも・・・?)

結果的にGitHubのissueをGraphQLで引き抜き、NotionのAPIを用いてNotionにページを作成するという形で解決となりました。

実際に行ったこと

GitHubのissueをGraphQLで引き抜く

今回はRubyとgraphql-clientのgemを利用しています。
何回も実行するものではないので、全て同一ファイルにまとめています。
元々別案件でRubyからGraphQLを利用してCSVに変換していたため、そのままRubyを使っています。
100件を超える場合、ページネーション処理を追加する必要があります。

GitHubのAPIを利用する際に用いる設定部分です。

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'graphql-client'
end

require 'graphql/client'
require 'graphql/client/http'
require "csv"

module GitHubAPI
  HTTP = GraphQL::Client::HTTP.new('https://api.github.com/graphql') do
    def headers(context)
      {
        "Authorization" => "Bearer #{ENV['GITHUB_PAT']}"
      }
    end
  end

  Schema = GraphQL::Client.load_schema(HTTP)
  Client = GraphQL::Client.new(schema: Schema, execute: HTTP)
end

Issuesクラスを定義します。
クエリについては、Explorerを参考に作っています。

class Issues
  QUERY = GitHubAPI::Client.parse <<-GraphQL
    query($query: String!) {
      search(type: ISSUE, query: $query, first: 100) {
        pageInfo {
          startCursor
          hasNextPage
          endCursor
        },
        nodes {
          ... on Issue {
           number,
           title,
           url,
           labels(first: 100) {
             nodes { name }
           },
          bodyText,
          comments(first: 100) {
            nodes { body }
            }
          }
        }
      }
    }
  GraphQL

  def initialize(args)
    @org = args[:org]
    @repo = args[:repo]
  end

  def data
    res = exec_query
    result = []

    res.data.search.nodes.each { result << _1 }
    result
  end

  def query
    "is:issue is:open repo:#{@org}/#{@repo}" #今回はopenしてあるissueのみ対象にしています。
  end

  def exec_query
    GitHubAPI::Client.query(QUERY, variables: { query: query })
  end
end

CSVを生成します。
issues.csvは任意の名前にします。

issues = Issues.new(repo: ENV['REPO'], org: ENV['ORG']).data

CSV.open('issues.csv', 'w') do |csv|
  issues.each do |issue|
    row = [
      issue.number,
      issue.state,
      issue.title,
      issue.url,
      issue.labels.nodes.map { |label| label.name }.join('/'),
      issue.body_text,
      issue.comments.nodes.map { |comment| comment.body }
    ]
    csv << row
  end
end

Notion APIのトークンを作成する

公式リファレンスはこちら

こちらからAPIのトークンを作成してください。
今回はページの作成のみ行うのでコンテンツの更新・挿入に権限を絞ります。

トークンの権限管理

Notion SDKを導入する

今回はNotionが公式で開発しているSDKを利用しました。

SDKをインストールします。

npm install @notionhq/client

Notion APIでページを作成する

先ほど設定したトークンを使って疎通部分のコードを作成していきます。

const { Client, LogLevel } = require("@notionhq/client")
// 先ほど作成したトークンを入力してください。↓
const client = new Client({ auth: 'secret_xxxxxxxxxxxxx', logLevel: LogLevel.DEBUG, })
const fs = require('fs');
const csv = require('csv');

CSVを読み込み、それぞれのデータをJson形式に変換した上でAPIを投げます。
ページ作成時のJsonの形式については、以下のリファレンスを参照してください。

;(async () => {
    fs.createReadStream(__dirname + '/issues.csv')
        .pipe(csv.parse(function(err, arr) {
            arr.forEach(data => {
                // CSVのデータを読み込み、カラムをそれぞれ変数に突っ込みます。
                const title = data[2].toString();
                const status = data[1].toString();
                const body = data[5];
                const url = data[3].toString();
                const labels = data[4] ? data[4].toString().split('/') : '';
                let labelMultiSelects = [];
                if(labels){
                    labelMultiSelects = labels.map(label => ({'name': label}));
                }

                let comments = [];
                const jsonComment = JSON.parse(data[6]);
                if(jsonComment) {
                 comments =  jsonComment.map(comment => ({
                     "object": "block",
                     "paragraph": {
                         "rich_text": [
                             {
                                 "text": {
                                     "content": comment
                                 }
                             }
                         ]
                     }}
                 ));
                }

                // ページのプロパティ部分です。DBを作成する際に事前にプロパティは作成しておく必要あり。
                const properties = {
                    "タイトル": {
                        "title": [
                            {
                                "text": {
                                    "content": title
                                }
                            }
                        ]
                    },
                    "ステータス": {
                        "select": {
                            "name": status
                        }
                    },
                    "ISSUE URL": {
                        "rich_text": [
                            {
                                "text": {
                                    "content": url
                                }
                            }
                        ]
                    },
                    "ラベル": {
                        "multi_select": labelMultiSelects
                    }
                }

                // ページのサブページです。ここにはissueのdescriptionとコメントを入れます。
                const children = [
                    {
                        "object": "block",
                        "paragraph": {
                            "rich_text": [
                                {
                                    "text": {
                                        "content": body
                                    }
                                }
                            ]
                        }
                    }
                ]

                const bodyAndComments = children.concat(comments);

                const d1 = new Date();
                // 連続でポストすると失敗するので2秒おいてます。
                while (true) {
                    const d2 = new Date();
                    if (d2 - d1 > 2000) {
                        break;
                    }
                }
                client.pages.create({
                    parent: { database_id: {挿入したいデータベースのIDを入力してください。} },
                    properties,
                    children: bodyAndComments
                });
            })
        }));
})()

データベースIDはURLから取得できるため、アプリを使用している場合は画面右上の「Share」>「Copy link」から、ブラウザからアクセスしている場合はURLをコピーし、www.notion.so/ と ?v= の間の32文字を取得します。この32文字がデータベースIDです。

https://qiita.com/thomi40/items/fe2a828746f31ad827ba
こんな感じでインサートできました
ラベル、ステータスやdescription、コメントがしっかり移行できてる!

スプリント・エピックを作成して紐づける

ER図

スプリントを1週間の単位にして別DBにレコードとして作成し、それを紐づけた上でEstimateをつかって1週間のプランニングをしています。

スプリントビュー
タイムラインビュー

日時のタスクはタイムラインビューで一覧でどのissueが走っているかがわかるようになっています。

メリット・デメリット

メリット

  • issueをさまざまなビューで確認することが容易になった。

    • 特にガントチャートが非常に便利。

  • 基本的にNotionさえ開いていれば、ドキュメントもタスクも一瞥出来るようになっているので、Chromeのタブが渋滞することがなくなった。

    • MTG時に共有される資料などは、忘れないようにNotionに置くことで「あれ、どこいった?」がなくなった。

  • スプリントごとに達成率が計算出来、ベロシティが計算可能になる。

  • Notion上で議論出来ることはissueに紐づけてDiscussionとしておき、メンションしておくことで、非同期コミュニケーションが活性化して時間に依存しなくなった。

Notionにリンク集を作っているので、ここさえあればどのドキュメントにもアクセスできる

デメリット

  • NotionからGitHubに戻ることが難しい。

  • PRなど、GitHubに依存している部分はやはりGitHub issueのほうが便利。

  • Notionの利用方法がやや複雑で、若干学習コストが高い。

  • DBやページ作成のルールを定めておかないとカオスになる。

  • issueのdescription、コメントにあるマークダウンは全て消える。


この記事が気に入ったらサポートをしてみませんか?