LoginSignup
30
19

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-01-15

モチベーション

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

付録

ローカルテスト

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

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

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

$ node test.js

GitHub API

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

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

機能 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

機能 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 の機能として、無いの?

30
19
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
19