こんばんは!株式会社スマレジ、開発部のmasaです。
また、更新が空いてしまい申し訳ないです。。。
今日はうちのチームのエンジニアがつけている日報を自動化する試みにチャレンジしており、そこで作ったツールの紹介をします。
背景
結論から言えば、「日報とRedmineをリンクさせて、Redmine上のタスクについては日報を自動生成してしまう」というのがこの記事の趣旨です。ただ、それが必要となる背景について簡単にお話しします。
タスクが入り乱れやすい性質のプロジェクトでは、得てして生産性がはっきりせず、チーム内部と外部で作業スピードで温度差が出ることがあります。
タスクの入り乱れの原因は、社内ステークホルダが多様化にあります。
例えばスマレジのようなSaaSの場合、
- 新規機能を推し進めて売上達成をしたい営業
- 品質改善によって問い合わせ負荷を軽減したいCS・開発メンバー
- APIやサードパーティ向け機能を拡充して、パートナー開拓、プラットフォームの活性化、連携コスト低減による導入のスケールメリット拡大を狙いたい技術営業や大手法人担当の営業
大体この辺りの思惑がプロダクトのスケジュールへ向けられます。
例えば弊社の場合だと、スマレジ・POS関係の開発チームが上記のような傾向になりがちで、タスクの種類や性質が多様化するため、開発側でのタスクも多様化します。
こんな風に、多様なタスクを抱えるチームの場合は開発作業とそれ以外の作業をするメンバーを役割わけし、定期的に役割をローテーションさせていくことになるのですが、
実際のところは簡単な話ではありません。
というのも、誰か一人のタスクのバケツが溢れたら他のメンバーでフォローするため、綺麗に役割を分けるのはマンパワーとそれを組織するための体制が安定するまでは、個人のタスクは多様化する傾向にあるためです。
こういった状況で「何にそんなに時間がかかっているか分からないから、もっと生産性を見える化しろ!」といっても、多様なタスクに振り回されている開発メンバーに10分単位のタスクについていちいち記録をつけて行ってもらうことは現実的ではありません。限界のラインが1日の終わりにタスクを振り返って日報をつけることくらいです。
以前のブログでも触れましたが、弊社はプロジェクト管理ツールとしてRedmineを使用しています。
redmine.jp
過去ブログは↓から。
masa2019.hatenablog.com
masa2019.hatenablog.com
masa2019.hatenablog.com
スマレジではRedmineをボトムアップ見積もり型のプロジェクト管理ツールとして利用しています。
(チケット駆動開発はタスクの単位での開発手法に関する議論で、これをベースにプロジェクトを計画するなら、プロジェクト見積もりになるのかなとmasaは考えています)
ボトムアップ見積もりはこちら。
ssaits.jp
チケット駆動開発についてはこちら。
www.atlassian.com
どちらも、タスクレベルで課題を明らかにしてから進行するという意味で共通しています。
このタスクが前述のように多様化するので、記録するのも一手間。とはいえある程度の粒度で残す必要はある。だけど楽はしたい。。。
そこで、毎日つける日報もタスクベースでの記載になることに着目して「日報とRedmineをリンクさせて、Redmine上のタスクについては日報を自動生成してしまう」というのがこの記事の趣旨です。
プログラム
今回はNode.jsのバッチで作成しました。
今回、少しプログラムが長いので、折りたたみにします。
社内ツールとして作ったものなので、コメントがなかったり、メソッドわけが多少雑になっていますがご容赦ください汗
(暇があれば、そのうちgithubにあげておきます。)
const getSmaregiShift = require('./getSmaregiShift');
const apiCommon = require('./apiCommon');
const {getRedmineTimeEntries} = require("./getRedmineTimeEntries");
const {postSmaregiDailyReport} = require("./postSmaregiDailyReport");
async function execute(targetYmd, targetStaffId, redmineUserId) {
let contractId = "スマレジの契約ID(プライベートアプリを想定しています)";
let access_token = await apiCommon.getSmaregiPlatformApiAccessToken(contractId);
let smaregiShift = await getSmaregiShift.getSmaregiShift(access_token, contractId, targetYmd, targetStaffId);
let redmineTimeEntries = await getRedmineTimeEntries(redmineUserId, targetYmd);
let postSmaregiDailyReportResponse = await postSmaregiDailyReport(access_token, contractId, smaregiShift, redmineTimeEntries, targetYmd);
}
async function main() {
if (typeof process.argv[2] !== "string") {
console.log("第一引数: yyyymmdd形式で日付を指定してください");
return;
}
if (process.argv[2].length !== 8) {
console.log("第一引数: 引数の値が不正です。引数にはyyyymmdd形式で日付を指定してください");
return;
}
if (typeof process.argv[3] !== "string") {
console.log("第二引数: 引数が指定されていないか不正な値です。");
return;
}
if (process.argv[3].length < 1) {
console.log("第二引数: 引数が指定されていません。");
return;
}
if (typeof process.argv[4] !== "string") {
console.log("第三引数: 引数が指定されていないか不正な値です。");
return;
}
if (process.argv[4].length < 1) {
console.log("第三引数: 引数が指定されていません。");
return;
}
try {
const targetYmd = parseInt(process.argv[2]);
const targetStaffId = parseInt(process.argv[3]);
const redmineUserId = parseInt(process.argv[4]);
if (targetYmd < 20220701) {
console.log("第一引数: いつの日報を入れようとしているのかね?");
return;
}
if (targetStaffId < 1) {
console.log("第二引数: スタッフIDは1以上で入力してください。");
return;
}
if (redmineUserId < 1) {
console.log("第三引数: redmineのユーザIDは1以上で入力してください。");
return;
}
try {
await execute(targetYmd, targetStaffId, redmineUserId);
} catch (eInternal) {
console.log(eInternal);
console.log("処理に失敗しました。");
}
} catch (e) {
console.log("引数の値が不正です。引数にはyyyymmdd形式で日付を指定してください");
}
}
main().then(function () {});
エントリーポイントとなるjsです。
コマンドライン引数の入力チェックをして、サービスのメインメソッドを呼び出しているだけなので特にいうことはないです。
const axios = require('axios')
exports.executeRedmineApi = function (url) {
const accessToken = 'Redmine apiのアクセストークン';
const headers = {
'X-Redmine-API-Key' : accessToken
};
return executeGetApi(url, headers);
}
exports.executeSmaregiPlatformApi = async function (accessToken, contractId, url, httpMethod, body) {
const headers = {
'Authorization' : 'Bearer ' + accessToken,
'Content-Type': 'application/json'
};
if (httpMethod === 'post') {
return await executePostApi(url, body, headers);
} else {
return await executeGetApi(url, headers);
}
}
async function executeGetApi(url, headers) {
const options = {
headers: headers
}
try {
const response = await axios.get(url, options);
return response.data;
} catch(e) {
console.error("エラー:" + e);
throw new Error(e);
}
}
async function executePostApi(url, data, headers) {
const options = {
headers: headers
}
try {
const response = await axios.post(url, data, options);
return response.data;
} catch(e) {
console.error(JSON.stringify(e));
throw new Error(e);
}
}
exports.getSmaregiPlatformApiAccessToken = async function (contractId) {
const scope = "timecard.shifts:read timecard.daily-reports:read timecard.stores:read timecard.staffs:read timecard.settings:read timecard.daily-reports:write";
const clientId = "PFアプリのクライアントID";
const secret = "PFアプリのクライアントシークレット";
const grantType = "client_credentials";
const url = 'https://id.smaregi.dev/app/' + contractId + '/token';
const headers = {
'Authorization' : 'Basic ' + Buffer.from(clientId + ":" + secret).toString('base64'),
};
const payload = new URLSearchParams();
payload.append('grant_type', grantType);
payload.append('scope', scope);
try {
const resJson = await executePostApi(url, payload, headers);
return resJson.access_token;
} catch(e) {
console.error("エラー:" + e);
throw new Error(e);
}
}
各
APIを呼び出すUtilメソッドが入ったjsファイルです。
ここも過去のブログで書いたことばかりなので、特に書くことはないです。(Node.jsで書いたのはブログだと初めてかも。)
const {executeSmaregiPlatformApi} = require("./apiCommon");
exports.getSmaregiShift = async function (accessToken, contractId, targetYmd, targetStaffId) {
let resJson = await executeSmaregiPlatformApi(accessToken, contractId, getSmaregiShiftListApiUrl(contractId, targetYmd));
return createShiftJSONFromPlatformAPIResponse(resJson, targetYmd, targetStaffId);
}
getSmaregiShiftListApiUrl = function (contractId, targetYmd) {
const targetYmdStr = "" + targetYmd;
let url = 'https://api.smaregi.dev/' + contractId + '/timecard/shifts_summary/1/daily';
url += "?division=result";
url += "&year=" + targetYmdStr.slice(0, 4);
url += "&month=" + targetYmdStr.slice(4, 6);
url += "&date=" + targetYmdStr.slice(6, 8);
return url;
}
createShiftJSONFromPlatformAPIResponse = function (resJson, targetYmd, targetStaffId) {
let shiftStoreDaily = resJson.shiftStoreDaily;
let staffs = "";
let shifts = "";
let result = {};
for (let ymd in shiftStoreDaily) {
staffs = shiftStoreDaily[ymd].staffs;
for (let i = 0; i < staffs.length; i++) {
shifts = staffs[i].shifts;
for (let j = 0; j < shifts.length; j++) {
if (parseInt(shifts[j].staffId) !== targetStaffId) {
continue;
}
result = {
ymd: ymd,
staffId: shifts[j].staffId,
shiftResultId: shifts[j].shiftResultId
};
break;
}
}
}
return result;
}
スマレジ・タイムカードの勤怠実績を取得する
APIです。
タイムカードのプラットフォーム
APIは実は初めて使ったのですが、配列のキーに日付が入るなど、
あまり他の
APIではみたことがないのですが、便利なフォーマットで返してくれるのでありがたいと思いました。
ただ、勤怠のIDがないとタイムカードでは日報をつけることができないのですが、そのIDが現在使用している事業所単位で取得する
APIにしかなかったので、
少々取得データ量が多くなっています。詳しくは下記の仕様書をご覧ください。
timecard1.smaregi.dev
const {executeRedmineApi} = require("./apiCommon");
exports.getRedmineTimeEntries = async function (userId, targetYmd) {
const resJsonPOS = await executeRedmineApi(getRedmineTimeEntriesApiUrl(userId, 1));
let timeEntries = resJsonPOS.time_entries;
const resJsonSupport = await executeRedmineApi(getRedmineTimeEntriesApiUrl(userId, 169));
timeEntries = timeEntries.concat(resJsonSupport.time_entries);
const issueIdList = getIssueIdListFromTimeEntriesResponse(timeEntries);
const issueList = await getTargetIssueFromRedmine(issueIdList);
return createRedmineResultFromResponse(timeEntries, targetYmd, issueList);
}
const getRedmineTimeEntriesApiUrl = function (userId, projectId) {
return 'https://redmine.smaregi.co.jp/time_entries.json?project_id=' + projectId + '&user_id=' + userId;
}
const getRedmineIssueApiUrl = function (issueId) {
return 'https://redmine.smaregi.co.jp/issues/' + issueId + '.json';
}
const getTargetIssueFromRedmine = async function (issueIdList) {
let issueList = [];
let wkIssue = null;
for (let i = 0; i < issueIdList.length; i++) {
wkIssue = await executeRedmineApi(getRedmineIssueApiUrl(issueIdList[i]));
if (!wkIssue.issue.hasOwnProperty('fixed_version')) {
wkIssue.issue.fixed_version = {
'id': 99999,
'name': ''
}
}
issueList[wkIssue.issue.id] = wkIssue;
}
return issueList;
}
const getIssueIdListFromTimeEntriesResponse = function (timeEntries) {
let res = [];
for (let i = 0; i < timeEntries.length; i++) {
res.push(timeEntries[i].issue.id);
}
return res;
}
const createRedmineResultFromResponse = function (timeEntries, targetYmd, issueList) {
let result = [];
let tmpActivityList = [];
let targetYmdStr = "" + targetYmd;
let convertedTargetYmdStr = targetYmdStr.slice(0, 4) + "-";
convertedTargetYmdStr += targetYmdStr.slice(4, 6) + "-";
convertedTargetYmdStr += targetYmdStr.slice(6, 8);
for (let i = 0; i < timeEntries.length; i++) {
if (timeEntries[i].spent_on === convertedTargetYmdStr) {
tmpActivityList.push(
{
'issue': timeEntries[i].issue.id,
'activity': timeEntries[i].activity.name,
'comments': timeEntries[i].comments,
'hours': timeEntries[i].hours,
}
)
}
}
let wkHitFlag = false;
for (let j = 0; j < tmpActivityList.length; j++) {
wkHitFlag = false;
for(let k = 0; k < result.length; k++) {
if (result[k].issue.id === tmpActivityList[j].issue) {
result[k].activityList.push(
{
'activity': tmpActivityList[j].activity,
'comments': tmpActivityList[j].comments,
'hours': tmpActivityList[j].hours
}
)
wkHitFlag = true;
break;
}
}
if (wkHitFlag) {
continue;
}
result.push(
{
'issue': issueList[tmpActivityList[j].issue].issue,
'activityList': [
{
'activity': tmpActivityList[j].activity,
'comments': tmpActivityList[j].comments,
'hours': tmpActivityList[j].hours
}
]
}
)
}
result.sort(function(a, b) {
if (!a.issue.hasOwnProperty('fixed_version')) {
return -1;
}
return a.issue.fixed_version.id - b.issue.fixed_version.id
})
result.sort((a,b) => a.issue.project.id - b.issue.project.id)
return result;
}
Redmineの「作業時間」タブの情報をapiCommon.jsを使って取ってきて、整形するメソッドが入ったjsファイルです。
この作業時間をつける部分が忘れがちなのですが、この辺りはチームミーティング時に相互確認して、忘れていたらその場でつける運用にしています。
ただ、活動時間だけだと、チケットタイトルなどが取れないので、活動時間のissue.idでチケット取得の
APIも叩いて、プログラムでひっつけています。
const {executeSmaregiPlatformApi} = require("./apiCommon");
const fs = require('fs');
exports.postSmaregiDailyReport = async function (accessToken, contractId, smaregiShift, redmineTimeEntries, targetYmd) {
let dailyReportList = {};
let hitFlag = false;
let url = "";
if (parseInt(smaregiShift.ymd) === targetYmd) {
dailyReportList = createDailyReportList(smaregiShift, redmineTimeEntries);
url = getSmaregiDailyReportApiUrl(contractId, smaregiShift.shiftResultId);
hitFlag = true;
}
if (hitFlag) {
return await executeSmaregiPlatformApi(accessToken, contractId, url, 'post', dailyReportList);
} else {
console.log("redmineに対象日の活動がないか、出勤情報がありませんでした。");
}
}
getSmaregiDailyReportApiUrl = function (contractId, shiftResultId) {
return 'https://api.smaregi.dev/' + contractId + '/timecard/daily_reports/' + shiftResultId;
}
createDailyReportList = function (smaregiShift, redmineTimeEntries) {
let wkStringArr = [];
for (let i = 0; i < redmineTimeEntries.length; i++) {
if (i === 0 || redmineTimeEntries[i-1].issue.project.id !== redmineTimeEntries[i].issue.project.id) {
wkStringArr.push(redmineTimeEntries[i].issue.project.name);
}
if (i === 0 || redmineTimeEntries[i-1].issue.fixed_version.id !== redmineTimeEntries[i].issue.fixed_version.id) {
if ( redmineTimeEntries[i].issue.fixed_version.name.length > 0) {
wkStringArr.push(" " + redmineTimeEntries[i].issue.fixed_version.name);
}
}
wkStringArr.push(" #" + redmineTimeEntries[i].issue.id + "(" + redmineTimeEntries[i].issue.tracker.name + ") " + redmineTimeEntries[i].issue.subject);
for (let j = 0; j < redmineTimeEntries[i].activityList.length; j++) {
wkStringArr.push(
" - [" + redmineTimeEntries[i].activityList[j].activity + "] : " +
redmineTimeEntries[i].activityList[j].comments +
"(" + redmineTimeEntries[i].activityList[j].hours + "H)"
);
}
}
let wkTotal = 0;
let wkTrackerInfo = {};
let wkProjectInfo = {};
let wkFixedVersionInfo = {};
let wkTimeCardDailyReportTagId = 0;
let isAsset = false;
let dailyReportTagList = [];
let wkDailyReportTag = {};
const trackerCostAssetInfo = JSON.parse(fs.readFileSync('./src/json/tracker_cost_asset_info.json', 'utf8'));
const reportTagInfo = JSON.parse(fs.readFileSync('./src/json/report_tag.json', 'utf8'));
for (let k = 0; k < redmineTimeEntries.length; k++) {
isAsset = false;
wkTotal = 0;
wkProjectInfo = {};
wkFixedVersionInfo = {};
wkTimeCardDailyReportTagId = 0;
wkDailyReportTag = {};
wkTrackerInfo = trackerCostAssetInfo.find((trackerCostAssetInfo) => trackerCostAssetInfo.id === redmineTimeEntries[k].issue.tracker.id);
if (wkTrackerInfo && wkTrackerInfo.type === "asset") {
isAsset = true;
}
wkProjectInfo = reportTagInfo.find((reportTagInfo) => reportTagInfo.redmine_project.id === redmineTimeEntries[k].issue.project.id);
for (let c = 0; c < wkProjectInfo.redmine_project.redmine_fixed_version.length; c++) {
if(wkProjectInfo.redmine_project.redmine_fixed_version[c].id === redmineTimeEntries[k].issue.fixed_version.id) {
wkFixedVersionInfo = wkProjectInfo.redmine_project.redmine_fixed_version[c];
if (isAsset) {
wkTimeCardDailyReportTagId = wkFixedVersionInfo.timecard_report_tag.assets_id;
} else {
wkTimeCardDailyReportTagId = wkFixedVersionInfo.timecard_report_tag.cost_id;
}
}
}
for (let l = 0; l < redmineTimeEntries[k].activityList.length; l++) {
wkTotal += redmineTimeEntries[k].activityList[l].hours;
}
wkDailyReportTag = dailyReportTagList.find((dailyReportTag) => dailyReportTag.dailyReportTagId === wkTimeCardDailyReportTagId);
if (typeof wkDailyReportTag !== "undefined") {
for (let m = 0; m < dailyReportTagList.length; m++) {
if (dailyReportTagList[m].dailyReportTagId === wkDailyReportTag.dailyReportTagId) {
dailyReportTagList[m].time += wkTotal;
}
}
} else {
dailyReportTagList.push({
"dailyReportTagId": wkTimeCardDailyReportTagId,
"time": wkTotal
});
}
}
let result = {
"report": wkStringArr.join("\n")
}
if (dailyReportTagList.length > 0) {
result.dailyReportTagList = dailyReportTagList;
}
return result;
}
Redmineの作業時間情報から、日報とタイムカードの日報タグをつける処理が書かれたjsファイルになります。
本当はテンプレートを用意して埋め込む形式にしたほうが拡張性はあるんですが、時間優先で今回はループ文の中で日報の文章を整形しました。
これを運用してみて、うまく行ったかどうか、課題点などがあるかについては、また後日報告させてもらえればと思います。