1. Qiita
  2. 投稿
  3. GitHub

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

  • 10
    いいね
  • 0
    コメント

モチベーション

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

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

完成イメージ
スクリーンショット 2017-01-15 12.16.47.png

スクリーンショット_2017-01-15_9_57_33.png

設定

GitHub

最初に、図の token A を取得します。

スクリーンショット 2017-01-15 12.24.39.png

GitHub の API を実行するアカウントの、Personal Access Token を取得します。
repo の権限を ON にしてください。
screencapture-github-settings-tokens-new-1484453943940.png

token A が生成されたので、メモしておきます。
screencapture-github-settings-tokens-1484454286388.png

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

AWS Lambda

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

Node 4.3 「Blank Function」 を選択します。
スクリーンショット_2017-01-15_11_19_31.png

トリガーの設定で、API Gateway を選択します。
名前はお好みで。
Security は トークンを使って接続制限するので、Open でも大丈夫です。
screencapture-ap-northeast-1-console-aws-amazon-lambda-home-1484448339516.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

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

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

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

'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

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

動作確認

Issue を登録します。
screencapture-github-DreamArts-SonarTest-issues-new-1484457757976.png

マイルストーンと同名の Project に、カードが追加されました!
screencapture-github-DreamArts-SonarTest-projects-45-1484458000795.png

Issue を クローズします。
screencapture-github-DreamArts-SonarTest-issues-26-1484458220168.png

カードが削除されました!
screencapture-github-DreamArts-SonarTest-projects-45-1484458247040.png

GitHub API

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

Projects

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

機能 API
List repository projects GET /repos/:owner/:repo/projects
List organization projects GET /orgs/:org/projects
Get a project GET /projects/:id
Create a repository project POST /repos/:owner/:repo/projects
Create an organization project POST /orgs/:org/projects
Update a project PATCH /projects/:id
Delete a project DELETE /projects/:id

Project columns

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

機能 API
List project columns GET /projects/:project_id/columns
Get a project column GET /projects/columns/:id
Create a project column POST /projects/:project_id/columns
Update a project column PATCH /projects/columns/:id
Delete a project column DELETE /projects/columns/:id
Move a project column POST /projects/columns/:id/moves

Project cards

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

機能 API
List project cards GET /projects/columns/:column_id/cards
Get a project card GET /projects/columns/cards/:id
Create a project card POST /projects/columns/:column_id/cards
Update a project card PATCH /projects/columns/cards/:id
Delete a project card DELETE /projects/columns/cards/:id
Move a project card POST /projects/columns/cards/:id/moves

おわりに

そもそも、なんで GitHub の機能として、無いの?