AWS Lambda で GitHub の Projects 機能を Issue と連動させて Trello の代替とする

## モチベーション

Trello が買収されたので、代替を探す。
昨年 9 月頃? リリースされた、GitHub の Projects 機能がよく出来ているが、カードの登録が手動で面倒臭い。

GitHub に Issue を登録したら、Projects のカードとして自動で登録して欲しい。
また、Issue がクローズされたら、Projects からは取り除いてくれると、カンバン運用が捗りそうだ。

**完成イメージ**
![スクリーンショット 2017-01-15 12.16.47.png](https://qiita-image-store.s3.amazonaws.com/0/69410/cc1e8d57-4791-4c98-e4fd-299fbdb3168e.png)


![スクリーンショット_2017-01-15_9_57_33.png](https://qiita-image-store.s3.amazonaws.com/0/69410/27ea45ce-76df-5b6f-4f24-da58fa6af4b6.png)

## 設定
### GitHub
最初に、図の token A を取得します。

![スクリーンショット 2017-01-15 12.24.39.png](https://qiita-image-store.s3.amazonaws.com/0/69410/f0d09827-697c-076e-218d-9caa89cac005.png)

GitHub の API を実行するアカウントの、Personal Access Token を取得します。
repo の権限を ON にしてください。
![screencapture-github-settings-tokens-new-1484453943940.png](https://qiita-image-store.s3.amazonaws.com/0/69410/7b7aebbf-fdf6-bc8f-624b-0e762f9bd80b.png)

token A が生成されたので、メモしておきます。
![screencapture-github-settings-tokens-1484454286388.png](https://qiita-image-store.s3.amazonaws.com/0/69410/89969d68-ceb3-96c7-3509-1938c1c52dc1.png)

**今回は、token A と token B は、同じトークンを使うことにします。**

### AWS Lambda
順番に、設定していきます。次は、Lambda 。

Node 4.3 「Blank Function」 を選択します。
![スクリーンショット_2017-01-15_11_19_31.png](https://qiita-image-store.s3.amazonaws.com/0/69410/5e035f3f-b990-f0b4-453f-c3ac500b73d8.png)

トリガーの設定で、API Gateway を選択します。
名前はお好みで。
Security は トークンを使って接続制限するので、Open でも大丈夫です。
![screencapture-ap-northeast-1-console-aws-amazon-lambda-home-1484448339516.png](https://qiita-image-store.s3.amazonaws.com/0/69410/021460f0-10ca-4057-5c6d-a3c78fd5e306.png)

名前はお好みで。
Edit Code Inline を選択して、下記の Lambda コードをコピペすればOK。
Environment Variables (環境変数) に、token というキーで、先に取得した GitHub の Private Access Token を設定して下さい。
(Lambda 処理の中で、token A / token B の両方のトークンとして使用します。)
ロールは、Simple Microservice テンプレートで、新規作成しました。
タイムアウトは、デフォルトの3秒だと微妙に足らないので、10秒程度に設定します。
![screencapture-ap-northeast-1-console-aws-amazon-lambda-home-1484447617614__1_.png](https://qiita-image-store.s3.amazonaws.com/0/69410/4a342b1e-c1b1-9659-50a0-3face60afb34.png)

Lambda の処理概要は、以下になります。

 1. Issue が更新されると、GitHub Webhook によって、ハンドラーが起動される
 2. Issue がマイルストーンにひもづいていない場合は、何もしない。
 1. マイルストーンにひもづくProjectを見つける  
    存在しない場合は、新規作成  
    存在する場合は、名前が変更されていればマイルストーン名に合わせる
 2. ToDo / Doing / Done のカラムが、なければ生成する
 3. Issue にひもづく Card を見つける
 4. Issue が  
    Open なら、ToDo カラムに Card を追加。既に存在すれば何もしない。  
    Close なら、カラムから Card を削除

Edit Code Inline に貼り付ける Lambda コードは、下記になります。
(GitHub はこちら https://github.com/exabugs/GitHubIssueProjectSync )

```js
'use strict';

const crypto = require('crypto');
const https = require('https');

exports.handler = function(event, context) {

  console.log(JSON.stringify(event));
  console.log(JSON.stringify(event.headers));
  const headers = event.headers;

  // 認証
  const hmac = crypto.createHmac('sha1', process.env.token);
  hmac.update(event.body, 'utf8');
  const calculatedSignature = 'sha1=' + hmac.digest('hex');

  if (headers['X-Hub-Signature'] !== calculatedSignature) {
    console.log(`calculatedSignature : ${calculatedSignature}`);
    console.log(`req.X-Hub-Signature : ${headers['X-Hub-Signature']}`);
    return context.succeed({statusCode: 403});
  }

  const payload = JSON.parse(event.body);

  const REPO = payload.repository;
  if (!REPO) {
    console.log('Not exists Repository.');
    return context.succeed({statusCode: 200});
  }
  const REPO_OWNER = REPO.owner.login;
  const REPO_NAME = REPO.name;
  console.log(`REPOSITORY_OWNER : ${REPO_OWNER}`);
  console.log(`REPOSITORY_NAME : ${REPO_NAME}`);

  const ISSUE = payload.issue;
  if (!ISSUE) {
    console.log('Not exists Issue.');
    return context.succeed({statusCode: 200});
  }
  const ISSUE_STATE = ISSUE.state;
  const ISSUE_TITLE = ISSUE.title;
  const ISSUE_ID = ISSUE.id;
  const ISSUE_URL = ISSUE.url;
  console.log(`ISSUE_STATE : ${ISSUE_STATE}`);
  console.log(`ISSUE_TITLE : ${ISSUE_TITLE}`);
  console.log(`ISSUE_ID : ${ISSUE_ID}`);
  console.log(`ISSUE_URL : ${ISSUE_URL}`);

  const MILESTONE = ISSUE.milestone;
  if (!MILESTONE) {
    console.log('This issue is not assinged to milestone.');
    return context.succeed({statusCode: 200});
  }
  const MILESTONE_TITLE = MILESTONE.title;
  const MILESTONE_ID = MILESTONE.id;
  const MILESTONE_URL = MILESTONE.html_url;
  console.log(`MILESTONE_TITLE : ${MILESTONE_TITLE}`);
  console.log(`MILESTONE_ID : ${MILESTONE_ID}`);
  console.log(`MILESTONE_URL : ${MILESTONE_URL}`);

  request('GET', `/repos/${REPO_OWNER}/${REPO_NAME}/projects`).then(data => {
    console.log('Stage 1');
    return data.filter((milestone) => {
      // Description を編集できるように、最終行にIDが含まれていればよいことにする。
      if (!milestone.body) return false;
      const body = milestone.body.split('\n');
      return body[body.length - 1].indexOf(MILESTONE_ID) !== -1;
    })[0];
  }).then(project => {
    console.log('Stage 2');
    if (!project) {
      // Project 新規追加
      console.log('Create Project.');
      const path = MILESTONE_URL.split('/').slice(3).join('/');
      const url = `Milestone : <a href='/${path}'>${MILESTONE_ID}</a>`;
      const json = {name: MILESTONE_TITLE, body: url};
      return request('POST', `/repos/${REPO_OWNER}/${REPO_NAME}/projects`, json);
    } else if (project.name !== MILESTONE_TITLE) {
      // 名前だけアップデートする。(Description はそのまま)
      console.log(`Update Project Name.`);
      const json = {name: MILESTONE_TITLE, body: project.body};
      return request('PATCH', `/projects/${project.id}`, json);
    } else {
      console.log('Project exists.');
      return project;
    }
  }).then(project => {
    console.log('Stage 3');
    // ToDo / Doing / Done のカラム生成
    return request('GET', `/projects/${project.id}/columns`).then(columns => {
      return Promise.all(['ToDo', 'Doing', 'Done'].map(name => {
        const column = columns.filter(column => column.name === name);
        if (column.length) {
          // カードの配列を返す
          console.log(`Column : ${name} exists. Search cards.`);
          return request('GET', `/projects/columns/${column[0].id}/cards`).then(cards => {
            return {column: column[0], card: cards.filter(card => card.content_url === ISSUE_URL)[0]};
          });
        } else {
          // カラムを生成して、空配列を返す
          console.log(`Column : ${name} not exists. Create column.`);
          return request('POST', `/projects/${project.id}/columns`, {name: name}).then((column) => {
            return {column: column, card: undefined};
          });
        }
      }));
    });
  }).then(columns => {
    console.log('Stage 4');
    if (ISSUE_STATE === 'closed') {
      // 削除
      return Promise.all(columns.map(column => {
        console.log(`Find closed card in column ${column.column.name}.`);
        const card = column.card;
        if (card) {
          console.log('Remove card.');
          return request('DELETE', `/projects/columns/cards/${card.id}`);
        }
      }));
    } else {
      // Todo に追加 (他のカラムに存在した場合は何も変化なし)
      console.log(`Card is not closed.`);
      if (!columns.filter(column => column.card).length) {
        console.log('Card create.');
        const column = columns[0].column;
        const json = {content_id: ISSUE_ID, content_type: 'Issue'};
        return request('POST', `/projects/columns/${column.id}/cards`, json);
      }
    }
  }).then(() => {
    console.log('Success. Return 200.');
    return context.succeed({statusCode: 200});
  }).catch(e => {
    console.log(e);
    return context.fail(e);
  });
};

function request (method, path, json) {
  return new Promise(function(resolve, reject) {

    const options = {
      hostname: 'api.github.com',
      port: 443,
      path: path,
      method: method,
      headers: {
        'User-Agent': 'Awesome-Octocat-App',
        'Authorization': `token ${process.env.token}`,
        'Accept': 'application/vnd.github.inertia-preview+json'
      }
    };
    let data = undefined;
    if (json !== undefined) {
      data = JSON.stringify(json);
      options.headers['Content-Type'] = 'application/json; charser=UTF-8';
      options.headers['Content-Length'] = Buffer.byteLength(data);
    }
    const req = https.request(options, res => {
        let data = '';
        res.on('data', d => {
          data += d;
        });
        res.on('end', () => {
          console.log(`Status-Code : ${res.statusCode}.`);
          switch (res.statusCode) {
            case 200:
            case 201: // POST
              resolve(JSON.parse(data));
              break;
            case 204: // DELETE
            case 422: // Duplicate Insert
              resolve();
              break;
            default:
              reject(data);
              break;
          }
        });
      }
    );
    req.on('error', (e) => {
      console.error(e);
      reject(e);
    });
    req.end(data);
  });
}
```

Triggers タブで、API Gateway の URL を調べてメモしておきます。
後で、GitHub の Webhook に設定します。

![screencapture-ap-northeast-1-console-aws-amazon-lambda-home-1484453085382.png](https://qiita-image-store.s3.amazonaws.com/0/69410/c0fce475-06c1-79d7-9bc7-4ccd3460b231.png)


### GitHub
最後に、GitHub の Webhook を設定します。
リポジトリ毎の設定ではなく、アカウントの設定で Webhook を設定すれば、全てのリポジトリが対象となります。
Payload URL は、先ほどの API Gateway の URL を設定。
Content-type は、application/json を指定。
Secret は、先ほど取得した Private Access Token を設定。


![screencapture-github-organizations-DreamArts-settings-hooks-new-1484453509556.png](https://qiita-image-store.s3.amazonaws.com/0/69410/4225f67b-afd9-a83a-b112-030da3711450.png)

## 動作確認

Issue を登録します。
![screencapture-github-DreamArts-SonarTest-issues-new-1484457757976.png](https://qiita-image-store.s3.amazonaws.com/0/69410/8ce431e6-0bac-8622-da74-575f2ef03656.png)

マイルストーンと同名の Project に、カードが追加されました！
![screencapture-github-DreamArts-SonarTest-projects-45-1484458000795.png](https://qiita-image-store.s3.amazonaws.com/0/69410/fe10b607-3cf3-498a-2c08-bef196e713fd.png)

Issue を クローズします。
![screencapture-github-DreamArts-SonarTest-issues-26-1484458220168.png](https://qiita-image-store.s3.amazonaws.com/0/69410/a867fc01-fc8a-ef10-697f-720b2131966e.png)

カードが削除されました！
![screencapture-github-DreamArts-SonarTest-projects-45-1484458247040.png](https://qiita-image-store.s3.amazonaws.com/0/69410/7c774c2c-ac3a-9aaf-eccd-a6d1c105047b.png)

## 付録
### ローカルテスト

ローカルで Lambda 関数を動作させて、挙動を確認できます。

```js:test.js
// For Local Test
// Node.js 4.3.2
const lambda = require("./lambda");

process.env.token = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

// 下記 X-Hub-Signature の値 sha1= は、GitHub Webhooks の Recent Deliveries 参照
const event = {
  headers: {
    'X-Hub-Signature': 'sha1=127ad3ca94f4e3520fb30fb18b6d36d0b6de28d'
  },
  body: JSON.stringify(require("./payload.json"))
};

const context = {
  succeed: response => {
    console.log(response);
  },
  fail: e => {
    console.log(e);
  }
};

// Lambda 実行
lambda.handler(event, context);
```

 - process.env.token は、Private Access Token を記載。
 - X-Hub-Signature は Headers の該当箇所をコピペ
 - paload.json は、Payload 部分をコピペしてファイル(payload.json)として保存

![screencapture-github-organizations-DreamArts-settings-hooks-11536808-1484529851600.png](https://qiita-image-store.s3.amazonaws.com/0/69410/3bd19228-995a-fb2e-b065-d0b5bee650db.png)

Node.js 4.3 で test.js を実行します

```
$ node test.js
```



### GitHub API

今回使用した、Project関連のAPIをリストしておきます。
GitHub の REST API の特徴は、オブジェクト類は全ユーザでの通し番号になっており、Get と Create での URL のパスが微妙に違う、ということですかね。(少しハマりました。)

#### Projects
https://developer.github.com/v3/projects/

|機能|API|
|----|----|
|[List repository projects](https://developer.github.com/v3/projects/#list-repository-projects)|GET /repos/:owner/:repo/projects|
|[List organization projects](https://developer.github.com/v3/projects/#list-organization-projects)|GET /orgs/:org/projects|
|[Get a project](https://developer.github.com/v3/projects/#get-a-project)|GET /projects/:id|
|[Create a repository project](https://developer.github.com/v3/projects/#create-a-repository-project)|POST /repos/:owner/:repo/projects
|[Create an organization project]()|POST /orgs/:org/projects|
|[Update a project](https://developer.github.com/v3/projects/#update-a-project)|PATCH /projects/:id|
|[Delete a project](https://developer.github.com/v3/projects/#delete-a-project)|DELETE /projects/:id|

#### Project columns
https://developer.github.com/v3/projects/columns/

|機能|API|
|----|----|
|[List project columns](https://developer.github.com/v3/projects/columns/#list-project-columns)|GET /projects/:project_id/columns|
|[Get a project column](https://developer.github.com/v3/projects/columns/#get-a-project-column)|GET /projects/columns/:id|
|[Create a project column](https://developer.github.com/v3/projects/columns/#create-a-project-column)|POST /projects/:project_id/columns|
|[Update a project column](https://developer.github.com/v3/projects/columns/#update-a-project-column)|PATCH /projects/columns/:id|
|[Delete a project column](https://developer.github.com/v3/projects/columns/#delete-a-project-column)|DELETE /projects/columns/:id|
|[Move a project column](https://developer.github.com/v3/projects/columns/#move-a-project-column)|POST /projects/columns/:id/moves|


#### Project cards
https://developer.github.com/v3/projects/cards/

|機能|API|
|----|----|
|[List project cards](https://developer.github.com/v3/projects/cards/#list-project-cards)|GET /projects/columns/:column_id/cards|
|[Get a project card](https://developer.github.com/v3/projects/cards/#get-a-project-card)|GET /projects/columns/cards/:id|
|[Create a project card](https://developer.github.com/v3/projects/cards/#create-a-project-card)|POST /projects/columns/:column_id/cards|
|[Update a project card](https://developer.github.com/v3/projects/cards/#update-a-project-card)|PATCH /projects/columns/cards/:id|
|[Delete a project card](https://developer.github.com/v3/projects/cards/#delete-a-project-card)|DELETE /projects/columns/cards/:id|
|[Move a project card](https://developer.github.com/v3/projects/cards/#move-a-project-card)|POST /projects/columns/cards/:id/moves|

## おわりに
そもそも、なんで GitHub の機能として、無いの？