Slackに日報コマンドをつくった

私は日報を忘れてしまう人間です。
もう少し正しく言うとめんどくさく思ってしまう人間です。

今日閉じたIssueと取り組んでいるIssueを全部コピペしてきたりとか。

今働いているチームのSlackには #nippo チャンネルがあり、メンバーはみんなここにその日やったこととかを投稿しています。

今回つくったのはそんな毎日の日報をもう少し楽ちんにして、本質である”振り返る”という行為だけに留めたいと言うものです。
言い換えると何をやったかは自動で出力できるようにしたい。

その名も「浜辺美波」です。

/nippo 今日は足が冷えて冷えて大変でした。

とうつと#nippoにこのように投稿してくれます。

仕組みとしてはSpreadSheetにそれぞれのメンバーのSlackのユーザーIDとGitHubIDを保存してあり、GASでSlack commandから送られてくるユーザー情報と照会してGitHubから関連のIssueを取得してきています。
GitHub GraphQL API初めて使いましたが便利すぎてびっくりしました。Explorerが本当に便利。

メリット

日報を書くのが楽ちんで少し楽しい

明らかに日報にさく時間が減りました。振り返りコメントに全神経を集中させることができるので最高に面白いコメントができるようになりそうです。

Issueを書く文化

私以外のエンジニアメンバーは今の会社が初めてのエンジニアとしての勤務先でなかなかチーム開発に不慣れな人が多いです。その中でひとつ大変なのがIssueをきちんと書いてもらうこと。更新してもらうこと。
今回の/nippoコマンドによってIssueの役に立っている感がすごくあるので多少Issueをきちんと更新する文化に貢献してくれるのではないかと期待しています。

コード Google App Script

function doPost(e) {
  var spreadsheet = SpreadsheetApp.openById('SpreadSheetのID');
  var sheet = spreadsheet.getActiveSheet();
  
  var username = e.parameter.user_name;
  var comment = comment = e.parameter.text;
  var row = findRowForSlackUsername(sheet,username);
  var githubId = sheet.getRange(row, 2).getValue();
  var freeeId = sheet.getRange(row, 3).getValue();
  
  if(comment.length === 0) {
    return ContentService.createTextOutput("⚠️`/nippo 振り返りコメント`の形式でコメントも書いて!");
  }
  
  if(githubId.length === 0 || freeeId.length === 0) {
    return ContentService.createTextOutput("⚠️あなたのアカウント情報がまだちゃんと用意されてないみたい。超絶イケメンなimjnに聞いてみて!");
  }
  
  var resForClosed = fetchIssues(githubId, "closed");
  var resForWIP = fetchIssues(githubId, "open");
  var closedIssues = JSON.parse(resForClosed.getContentText()).data.search.edges;
  var wipIssues = JSON.parse(resForWIP.getContentText()).data.search.edges;
  var issuesClosedToday = getIssuesClosedToday(closedIssues);
  var outputString = `今日もおつかれさまでした。 ${githubId}の日報です💙\n\n`;
  
  outputString += `💪 *今${githubId}が取り組んでいるIssue* ↓\n`
  
  if(wipIssues.length > 0) {
    var wipIssueString = "";
    for(var i=0;i<wipIssues.length;i++){
      var wipIssue = wipIssues[i];
      wipIssueString += `${wipIssue.node.title} (${wipIssue.node.url})\n`;
    }
    outputString += "```" + wipIssueString + "```";
  } else {
    outputString += `> ${githubId}がアサインされているタスクはありません。タスク待ち!`;
  }
  
  outputString += `\n\n🤗 *今日${githubId}がCloseしたIssue* ↓\n`
  
  if(issuesClosedToday.length > 0) {
    var issueString = "";
    for(var i=0;i<issuesClosedToday.length;i++){
      var theIssue = issuesClosedToday[i];
      issueString += `${theIssue.node.title} (${theIssue.node.url})\n`;
    }
    outputString += "```" + issueString + "```";
  } else {
    outputString += `> ${githubId}が今日CloseしたIssueはありません`;
  }
  
  outputString += `\n\n✍️ *振り返りコメント* ↓\n`
  
  outputString += '```' + comment + '```';
  
  const webhookUrl = "Slackのwebhook URL";
  const data = { 
    'attachments': [{
      'color': '#0086CC',
      'text' : outputString,
    }]
  };

  const payload = JSON.stringify(data);
  const options = {
    'method' : 'POST',
    'contentType' : 'application/json',
    'payload' : payload
  };

  UrlFetchApp.fetch(webhookUrl, options);
  return ContentService.createTextOutput("#nippoに投稿したよ!❤️");
}

function findRowForSlackUsername(sheet,val){
  var lastRow=sheet.getDataRange().getLastRow();
 
  for(var i=1;i<=lastRow;i++){
    if(sheet.getRange(i,1).getValue() === val){
      return i;
    }
  }
  return 0;
}

function fetchIssues(githubId, status) {
  const query = 
'query {\
   search(last: 10, query: "org:オーガニゼーションID is:issue is:' + status + ' assignee:' + githubId + ' sort:updated-desc", type: ISSUE) {\
     edges {\
       node {\
         ... on Issue {\
           title\
           url\
           closedAt\
         }\
      }\
    }\
  }\
}';

  const option = buildGraphqlRequest(query);
  return UrlFetchApp.fetch("https://api.github.com/graphql", option);
}

function buildGraphqlRequest(graphql) {
  return {
    method: "post",
    contentType: "application/json",
    headers: {
      Authorization: "bearer GitHubのアクセストークン",
    },
    payload: JSON.stringify({ query: graphql }),
  };
}

function getIssuesClosedToday(issues) {
  var now = new Date();
  var closedIssues = [];
  for(var i=0;i<issues.length;i++){
    var issue = issues[i];
    var closedAt = new Date(issue.node.closedAt);
    if(closedAt.getFullYear() === now.getFullYear() && closedAt.getMonth() === now.getMonth() && closedAt.getDate() === now.getDate()) {
      closedIssues.push(issue);
    }
  }
  return closedIssues;
}

せっかくfreeeも使ってるのでまた時間がある時に勤務時間とかも含められるようにしたいと思います。

今月中に旅行領域で新規プロダクトを出します。こんなご時世ですが、がんばる。