こんにちは、エンジニアのみーや(@miiya387)です。
今回は先日開催された「WP HACK DAY」の第4回でチームで取り組んだ内容のレポートをしようと思います。
チームメンバー全員で終始楽しく取り組んだので、当日の様子からエンジニアの雰囲気などが伝わったら嬉しいです。
また、解決する手段としてSlackワークフローやGoogle Apps Script(以下: GAS), Redmine APIなどを活用したので、Slackワークフローを用いて資料の作成や通知周りを一本化したいと思っている方の参考にもなればと思います。
目次
- WP HACK DAYについて
- 当日の様子
- 成果物
- まとめ
WP HACK DAYについて
WP HACK DAYは、ウエディングパークエンジニアチームの技術力向上と一体感醸成のための1dayハッカソンです。
今回実施した第4回目では「業務課題を解決するプロダクト開発」をテーマに3人1チームのチーム開発形式で実施しました!
取り上げた課題
私たちのチームが取り上げた課題は「障害対応時にやることが多く複雑」ということです。
システム障害が発生した際に、対応者が行うことがたくさんあること、さらに行う順番や方法が複雑化してきていることが課題でした。もう少し掘り下げると、障害対応の中でも対応に入る前に行う「一次報告」に時間と手間がかかってしまうことが課題でした。
障害対応は、復旧までの時間をいかに短くできるかが重要で、時間との勝負な作業である一方で復旧対応以外のタスクに手間がかかってしまうことに着目し、一次報告をする作業を簡潔にすることで本質的な復旧作業に割ける時間を増やそうと考えました。
設定したゴール
3人で話し合う中で、対応に慣れているメンバーもいれば、初めてのメンバーもいることや、エンジニアメンバー以外が一次報告を担当することもあることに気づきました。
課題や実際に使っていただく状況も考慮した上で、「誰でも簡単に障害対応の一次報告までを行える機能」を作ることに決めました。
取り組む内容が決まったところで、話し合った課題や要件、実装方法などをシートにまとめ、当日の役割分担やスケジュールを事前に決めておきました。

当日の様子
今回実装する機能のフロー図と当日のタイムスケジュールは以下のようになっています。


当日は事前に設計しておいた実装方法で実際に機能を作成するところから始まります。
3人のチームなので作業を分担して効率よく進めても良かったのですが、チームになったメンバーが普段一緒に開発をする機会の少ない組み合わせだったので、あえてすべての作業を3人一緒に行うことにしました。一緒に実装することで、ワイワイ話し合いながら楽しく実装すること、実装がうまく行った時の達成感などを共有したかったからです。
実際に一つの画面を3人で囲みながらの開発は、WP HACK DAYならではの贅沢なモブプロ時間になりました。

成果物
今回作成したものは以下の5つです。
- Slackのワークフロー/Webhook URL(手動作成)
- 障害対応した内容が一覧表示されるスプレッドシート(手動作成/GASを書くのはここです)
- Googleドキュメント(GASで作成)
- RedmineのISSUE(GAS上でRedmine APIを使って作成)
- メール(GASでGmail送信)
Slackワークフローの作成
作成手順は公式に載っている通りです。
今回は一覧管理するスプレッドシートと連携してワークフロー申請時にスプレッドシートに入力した内容が出力されるようにしました。

スプレッドシート側にGASの設定をし、Slackのワークフローで入力された内容をもとに、以下の作成をします。
- Googleドキュメント
- RedmineのISSUE
作成された2つをもとにスプレッドシートの行を再度更新して情報を補足します。

最後に作成した2つのURLと一緒に、調査・対応開始の報告としてメールとSlackに通知が来るようにしました。Slackへの通知はincoming Webhookを利用して行いました。


これで、一次報告者はSlackの指定のチャネルからワークフローを出すだけで必要な資料や通知が済まされる状態になりました。今までは手作業ですべて行なっていたので時間にすると15分ほどかかっていた作業が2分ほどで完了する改善となりました。
今回作成したGASの中身は以下になります。対象のスプレッドシートに行が追加されたタイミング(変更)をトリガーに動作するように設定して使っています。
"メディア1": ['test1@gmail.com', 'test2@gmail.com'],
"メディア2": ['test3@gmail.com', 'test4@gmail.com'],
"メディア3": ['test5@gmail.com', 'test6@gmail.com'],
// RedmineのISSUEがすでにある場合は何もしない
let {document, docUrl} = createDoc(data);
let {ticketId, ticketUrl} = createTicket(data, docUrl);
let ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName('障害管理一覧');
sheet.getRange(data['target'], 9).setFormula('=HYPERLINK("' + docUrl + '","' + data['title'] + '")');
sheet.getRange(data['target'], 10).setFormula('=HYPERLINK("' + ticketUrl + '","' + ticketUrl + '")');
sheet.getRange(data['target'], 3).setValue(data['name']);
sheet.getRange(data['target'], 1).setValue('=ROW()-4');
document.setName('【#' + ticketId + '】' + data['title']);
sendGmail(data, docUrl, ticketUrl);
post_slack(data, docUrl, ticketUrl);
let ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName('障害管理一覧');
let lastRow = sheet.getLastRow(); // 最終行
response['target'] = lastRow;
const realName = sheet.getRange(lastRow, 3).getValue()
response['name'] = realName.replace(/^.*-/, '');
response['discoverer'] = sheet.getRange(lastRow, 7).getValue()
response['quarter'] = sheet.getRange(lastRow, 2).getValue();
response['email'] = sheet.getRange(lastRow, 4).getValue();
response['date'] = sheet.getRange(lastRow, 6).getValue();
response['media'] = sheet.getRange(lastRow, 8).getValue();
response['title'] = sheet.getRange(lastRow, 9).getValue();
response['ticketUrl'] = sheet.getRange(lastRow, 10).getValue();
function createDoc(data) {
let fileName = '【#チケット番号】障害報告書';
// IDはドキュメントを開いたときのURLから取得可能
// 例:https://docs.google.com/document/d/AAAAAA/edit
fileName = fileName.replace(/[A-Za-z0-9]/g, function(s) {
return String.fromCharCode(s.charCodeAt(0) + 0xFEE0);
let f = DriveApp.getFileById(fileId);
let folder = f.getParents().next();
const targetQFolder = data['quarter'].replace(' ', '');
let quaterFolder = folder.getFoldersByName(targetQFolder);
if (! quaterFolder.hasNext()) {
quaterFolder = folder.createFolder(targetQFolder);
quaterFolder = quaterFolder.next();
let targetMonthFolder = Utilities.formatDate(data['date'], "JST", "M月");
let monthFolder = quaterFolder.getFoldersByName(targetMonthFolder);
if (! monthFolder.hasNext()) {
monthFolder = quaterFolder.createFolder(targetMonthFolder);
monthFolder = monthFolder.next();
f = f.makeCopy(fileName, monthFolder);
let body = DocumentApp.openById(f.getId()).getBody();
body.replaceText('報告日:yyyy年MM月dd日', '報告日:' + Utilities.formatDate(data['date'], "JST", "yyyy年MM月dd日"));
body.replaceText('所 属:メディア1', '所 属:' + data['media']);
body.replaceText('氏 名:担当者名を記載', '氏 名:' + data['name']);
body.replaceText('XXXXXXXXXXX障害', data['title']);
return {document: f, docUrl: f.getUrl()};
function createTicket(data, url) {
"subject": data['title'],
"56": data['discoverer'],
"22": Utilities.formatDate(data['date'], "JST", "yyyy-MM-dd"),
let redmine_url = 'https://{所有グループのRedmineURL}/issues.json';
let api_key = 'abcdefg'; // RedmineのAPI key を入力
'X-Redmine-API-Key': api_key,
'Content-Type': 'application/json',
'contentType': 'application/json',
'payload': JSON.stringify(payload),
let response = UrlFetchApp.fetch(redmine_url, options);
let id = JSON.parse(response).issue.id;
return { ticketId: id, ticketUrl: 'https://{所有グループのRedmineURL}/issues/' + id };
function sendGmail(data, docUrl, ticketUrl) {
const recipient = mailLists[data['media']].join(',');
const subject = '【障害報告】' + data['media'] + '_' + data['title'];
const body = '障害が発覚したので調査・対応を始めます。\n\n障害報告書: ' + docUrl + '\nチケット: ' + ticketUrl;
from: 'test_user@gmail.com' // fromアドレス入力
GmailApp.sendEmail(recipient, subject, body, options);
function post_slack(data, docUrl, ticketUrl) {
const slack_webhook_url = ''; // webhook url入力
// SlackのWebhook URLに投稿するデータをまとめる
'icon_emoji': ':smiley:',
'text': '<!here>障害が発覚したので調査・対応を始めます。\n\n報告者: ' + data['name'] + '\nメディア: ' + data['media'] + '\n題名: ' + data['title'] + '\n報告書: ' + docUrl + '\nチケット: ' + ticketUrl
// SlackのWebhook URLに送信するデータをJSONに変換する
const payload = JSON.stringify(json);
// UrlFetchAppで使用するメソッドやコンテントタイプを指定
'contentType': 'application/json',
UrlFetchApp.fetch(slack_webhook_url, options);
// メディア別関係者アドレス一覧
const mailLists = {
"メディア1": ['test1@gmail.com', 'test2@gmail.com'],
"メディア2": ['test3@gmail.com', 'test4@gmail.com'],
"メディア3": ['test5@gmail.com', 'test6@gmail.com'],
}
function main() {
let data = formatData();
// RedmineのISSUEがすでにある場合は何もしない
if (data['ticketUrl']) {
return
}
let {document, docUrl} = createDoc(data);
let {ticketId, ticketUrl} = createTicket(data, docUrl);
// スプレッドシートのデータ整形
let ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName('障害管理一覧');
// 報告書URL追加
sheet.getRange(data['target'], 9).setFormula('=HYPERLINK("' + docUrl + '","' + data['title'] + '")');
// チケットURL追加
sheet.getRange(data['target'], 10).setFormula('=HYPERLINK("' + ticketUrl + '","' + ticketUrl + '")');
// 報告者名整形
sheet.getRange(data['target'], 3).setValue(data['name']);
// No追加
sheet.getRange(data['target'], 1).setValue('=ROW()-4');
// タイトルを更新
document.setName('【#' + ticketId + '】' + data['title']);
// メール通知
sendGmail(data, docUrl, ticketUrl);
// slack通知
post_slack(data, docUrl, ticketUrl);
}
/**
* データ整形
*/
function formatData() {
// シートから題名取得
let ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName('障害管理一覧');
let lastRow = sheet.getLastRow(); // 最終行
let response = [];
// 対象行
response['target'] = lastRow;
// 報告者
const realName = sheet.getRange(lastRow, 3).getValue()
response['name'] = realName.replace(/^.*-/, '');
// 発見者
response['discoverer'] = sheet.getRange(lastRow, 7).getValue()
// 報告Q
response['quarter'] = sheet.getRange(lastRow, 2).getValue();
// メールアドレス
response['email'] = sheet.getRange(lastRow, 4).getValue();
// 報告日
response['date'] = sheet.getRange(lastRow, 6).getValue();
// メディア
response['media'] = sheet.getRange(lastRow, 8).getValue();
// 題名
response['title'] = sheet.getRange(lastRow, 9).getValue();
// チケット
response['ticketUrl'] = sheet.getRange(lastRow, 10).getValue();
return response;
}
/**
* 報告書作成
*/
function createDoc(data) {
let fileName = '【#チケット番号】障害報告書';
// コピー元となるドキュメントファイルのID
// IDはドキュメントを開いたときのURLから取得可能
// 例:https://docs.google.com/document/d/AAAAAA/edit
// この場合は"AAAAAA"がID
let fileId = 'AAAAAA';
// ファイル名が全角で打てないので全角変換
// ファイル名を全角にする必要がなければ削除
fileName = fileName.replace(/[A-Za-z0-9]/g, function(s) {
return String.fromCharCode(s.charCodeAt(0) + 0xFEE0);
});
// コピー元のファイルを開く
let f = DriveApp.getFileById(fileId);
let folder = f.getParents().next();
// 格納先ディレクトリがあるか(Q)
const targetQFolder = data['quarter'].replace(' ', '');
let quaterFolder = folder.getFoldersByName(targetQFolder);
if (! quaterFolder.hasNext()) {
// ない場合は作成する
quaterFolder = folder.createFolder(targetQFolder);
} else {
quaterFolder = quaterFolder.next();
}
// 格納先ディレクトリがあるか(月)
let targetMonthFolder = Utilities.formatDate(data['date'], "JST", "M月");
let monthFolder = quaterFolder.getFoldersByName(targetMonthFolder);
if (! monthFolder.hasNext()) {
// ない場合は作成する
monthFolder = quaterFolder.createFolder(targetMonthFolder);
} else {
monthFolder = monthFolder.next();
}
// コピーを作成。作成したコピーを参照。
f = f.makeCopy(fileName, monthFolder);
// コピー後のファイルの中身を書き換える
// ドキュメントの中身を取得
let body = DocumentApp.openById(f.getId()).getBody();
// 報告日
body.replaceText('報告日:yyyy年MM月dd日', '報告日:' + Utilities.formatDate(data['date'], "JST", "yyyy年MM月dd日"));
// メディア
body.replaceText('所 属:メディア1', '所 属:' + data['media']);
// 氏名
body.replaceText('氏 名:担当者名を記載', '氏 名:' + data['name']);
// 題名
body.replaceText('XXXXXXXXXXX障害', data['title']);
return {document: f, docUrl: f.getUrl()};
}
/**
* チケット作成
*/
function createTicket(data, url) {
// チケット内容
const payload = {
"issue": {
"project_id": "XXX",
"subject": data['title'],
"custom_field_values": {
"20": data['quarter'],
"56": data['discoverer'],
"22": Utilities.formatDate(data['date'], "JST", "yyyy-MM-dd"),
"23": data['media'],
"55": url
}
}
};
let redmine_url = 'https://{所有グループのRedmineURL}/issues.json';
let api_key = 'abcdefg'; // RedmineのAPI key を入力
let headers = {
'X-Redmine-API-Key': api_key,
'Content-Type': 'application/json',
};
let options = {
'method': 'POST',
'contentType': 'application/json',
'headers': headers,
'payload': JSON.stringify(payload),
};
let response = UrlFetchApp.fetch(redmine_url, options);
let id = JSON.parse(response).issue.id;
return { ticketId: id, ticketUrl: 'https://{所有グループのRedmineURL}/issues/' + id };
}
/**
* 関係者に一次報告メール送信
*/
function sendGmail(data, docUrl, ticketUrl) {
const recipient = mailLists[data['media']].join(',');
const subject = '【障害報告】' + data['media'] + '_' + data['title'];
const body = '障害が発覚したので調査・対応を始めます。\n\n障害報告書: ' + docUrl + '\nチケット: ' + ticketUrl;
const options = {
name: data['name'],
from: 'test_user@gmail.com' // fromアドレス入力
};
GmailApp.sendEmail(recipient, subject, body, options);
}
/**
* チャネルに一次報告チャット送信
*/
function post_slack(data, docUrl, ticketUrl) {
// SlackのWebhookURL
const slack_webhook_url = ''; // webhook url入力
// SlackのWebhook URLに投稿するデータをまとめる
const json =
{
'username': '障害報告',
'icon_emoji': ':smiley:',
'text': '<!here>障害が発覚したので調査・対応を始めます。\n\n報告者: ' + data['name'] + '\nメディア: ' + data['media'] + '\n題名: ' + data['title'] + '\n報告書: ' + docUrl + '\nチケット: ' + ticketUrl
};
// SlackのWebhook URLに送信するデータをJSONに変換する
const payload = JSON.stringify(json);
// UrlFetchAppで使用するメソッドやコンテントタイプを指定
const options =
{
'method': 'post',
'contentType': 'application/json',
'payload': payload
};
// Slackに送信
UrlFetchApp.fetch(slack_webhook_url, options);
}
// メディア別関係者アドレス一覧
const mailLists = {
"メディア1": ['test1@gmail.com', 'test2@gmail.com'],
"メディア2": ['test3@gmail.com', 'test4@gmail.com'],
"メディア3": ['test5@gmail.com', 'test6@gmail.com'],
}
function main() {
let data = formatData();
// RedmineのISSUEがすでにある場合は何もしない
if (data['ticketUrl']) {
return
}
let {document, docUrl} = createDoc(data);
let {ticketId, ticketUrl} = createTicket(data, docUrl);
// スプレッドシートのデータ整形
let ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName('障害管理一覧');
// 報告書URL追加
sheet.getRange(data['target'], 9).setFormula('=HYPERLINK("' + docUrl + '","' + data['title'] + '")');
// チケットURL追加
sheet.getRange(data['target'], 10).setFormula('=HYPERLINK("' + ticketUrl + '","' + ticketUrl + '")');
// 報告者名整形
sheet.getRange(data['target'], 3).setValue(data['name']);
// No追加
sheet.getRange(data['target'], 1).setValue('=ROW()-4');
// タイトルを更新
document.setName('【#' + ticketId + '】' + data['title']);
// メール通知
sendGmail(data, docUrl, ticketUrl);
// slack通知
post_slack(data, docUrl, ticketUrl);
}
/**
* データ整形
*/
function formatData() {
// シートから題名取得
let ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName('障害管理一覧');
let lastRow = sheet.getLastRow(); // 最終行
let response = [];
// 対象行
response['target'] = lastRow;
// 報告者
const realName = sheet.getRange(lastRow, 3).getValue()
response['name'] = realName.replace(/^.*-/, '');
// 発見者
response['discoverer'] = sheet.getRange(lastRow, 7).getValue()
// 報告Q
response['quarter'] = sheet.getRange(lastRow, 2).getValue();
// メールアドレス
response['email'] = sheet.getRange(lastRow, 4).getValue();
// 報告日
response['date'] = sheet.getRange(lastRow, 6).getValue();
// メディア
response['media'] = sheet.getRange(lastRow, 8).getValue();
// 題名
response['title'] = sheet.getRange(lastRow, 9).getValue();
// チケット
response['ticketUrl'] = sheet.getRange(lastRow, 10).getValue();
return response;
}
/**
* 報告書作成
*/
function createDoc(data) {
let fileName = '【#チケット番号】障害報告書';
// コピー元となるドキュメントファイルのID
// IDはドキュメントを開いたときのURLから取得可能
// 例:https://docs.google.com/document/d/AAAAAA/edit
// この場合は"AAAAAA"がID
let fileId = 'AAAAAA';
// ファイル名が全角で打てないので全角変換
// ファイル名を全角にする必要がなければ削除
fileName = fileName.replace(/[A-Za-z0-9]/g, function(s) {
return String.fromCharCode(s.charCodeAt(0) + 0xFEE0);
});
// コピー元のファイルを開く
let f = DriveApp.getFileById(fileId);
let folder = f.getParents().next();
// 格納先ディレクトリがあるか(Q)
const targetQFolder = data['quarter'].replace(' ', '');
let quaterFolder = folder.getFoldersByName(targetQFolder);
if (! quaterFolder.hasNext()) {
// ない場合は作成する
quaterFolder = folder.createFolder(targetQFolder);
} else {
quaterFolder = quaterFolder.next();
}
// 格納先ディレクトリがあるか(月)
let targetMonthFolder = Utilities.formatDate(data['date'], "JST", "M月");
let monthFolder = quaterFolder.getFoldersByName(targetMonthFolder);
if (! monthFolder.hasNext()) {
// ない場合は作成する
monthFolder = quaterFolder.createFolder(targetMonthFolder);
} else {
monthFolder = monthFolder.next();
}
// コピーを作成。作成したコピーを参照。
f = f.makeCopy(fileName, monthFolder);
// コピー後のファイルの中身を書き換える
// ドキュメントの中身を取得
let body = DocumentApp.openById(f.getId()).getBody();
// 報告日
body.replaceText('報告日:yyyy年MM月dd日', '報告日:' + Utilities.formatDate(data['date'], "JST", "yyyy年MM月dd日"));
// メディア
body.replaceText('所 属:メディア1', '所 属:' + data['media']);
// 氏名
body.replaceText('氏 名:担当者名を記載', '氏 名:' + data['name']);
// 題名
body.replaceText('XXXXXXXXXXX障害', data['title']);
return {document: f, docUrl: f.getUrl()};
}
/**
* チケット作成
*/
function createTicket(data, url) {
// チケット内容
const payload = {
"issue": {
"project_id": "XXX",
"subject": data['title'],
"custom_field_values": {
"20": data['quarter'],
"56": data['discoverer'],
"22": Utilities.formatDate(data['date'], "JST", "yyyy-MM-dd"),
"23": data['media'],
"55": url
}
}
};
let redmine_url = 'https://{所有グループのRedmineURL}/issues.json';
let api_key = 'abcdefg'; // RedmineのAPI key を入力
let headers = {
'X-Redmine-API-Key': api_key,
'Content-Type': 'application/json',
};
let options = {
'method': 'POST',
'contentType': 'application/json',
'headers': headers,
'payload': JSON.stringify(payload),
};
let response = UrlFetchApp.fetch(redmine_url, options);
let id = JSON.parse(response).issue.id;
return { ticketId: id, ticketUrl: 'https://{所有グループのRedmineURL}/issues/' + id };
}
/**
* 関係者に一次報告メール送信
*/
function sendGmail(data, docUrl, ticketUrl) {
const recipient = mailLists[data['media']].join(',');
const subject = '【障害報告】' + data['media'] + '_' + data['title'];
const body = '障害が発覚したので調査・対応を始めます。\n\n障害報告書: ' + docUrl + '\nチケット: ' + ticketUrl;
const options = {
name: data['name'],
from: 'test_user@gmail.com' // fromアドレス入力
};
GmailApp.sendEmail(recipient, subject, body, options);
}
/**
* チャネルに一次報告チャット送信
*/
function post_slack(data, docUrl, ticketUrl) {
// SlackのWebhookURL
const slack_webhook_url = ''; // webhook url入力
// SlackのWebhook URLに投稿するデータをまとめる
const json =
{
'username': '障害報告',
'icon_emoji': ':smiley:',
'text': '<!here>障害が発覚したので調査・対応を始めます。\n\n報告者: ' + data['name'] + '\nメディア: ' + data['media'] + '\n題名: ' + data['title'] + '\n報告書: ' + docUrl + '\nチケット: ' + ticketUrl
};
// SlackのWebhook URLに送信するデータをJSONに変換する
const payload = JSON.stringify(json);
// UrlFetchAppで使用するメソッドやコンテントタイプを指定
const options =
{
'method': 'post',
'contentType': 'application/json',
'payload': payload
};
// Slackに送信
UrlFetchApp.fetch(slack_webhook_url, options);
}
まとめ
今回は、先日開催された「WP HACK DAY」の第4回にチームで行った取り組みを紹介させていただきました。普段一緒に開発をする機会の少ないメンバーとの開発はとても楽しく、1日という短い時間であっても互いに学び合える時間となりました。
今回作成した機能は現段階ではプロトタイプレベルなので、これから本運用で日々の障害対応時に使えるように調整していこうと思います。
Join Us !
ウエディングパークでは、一緒に働く仲間を募集しています!
ご興味ある方は、お気軽にお問合せください(カジュアル面談から可)
採用情報を見る