WakaTimeで前日のプログラミング時間の集計をGASを使ってslackに通知する
こんにちは。
年末年始に 『さくら荘のペットな彼女』を見て、主人公空太の以下の名言にいまだあてられております、kgmyshinです。
やばいよなあ、本気になるって。
帰ってきて、椎名が連載が決まったって聞いたとき、自分が否定されているような気がした。
心がくじけそうだった。本気だったから。後悔からも、悔しさからも、逃げも隠れも出来なかった。
でも、だから簡単なんだ。やるしかない!
この気持ちをぬぐうにはやるしかない! ダメでもダメでもやるしかない!
そのため、それなりにやる気はあったのですが、以下のツイートが回ってきてハッとしました。
どうやったらギター上達するの?って時々聞かれるけどその度にslipknotのMick先生のこの言葉を思い出す pic.twitter.com/MYaVca9T1f
— SeiyaIto伊藤聖也 (@tephanie_oid) 2015年3月15日
自分は一日に何時間プログラミングをして、何時間本を読んで(インプットに費やして)、何時間アウトプットに費やしているのかということが無性に気になりました。
その中の 何時間プログラミングをして に関しては、年始にWakaTimeを導入することによってうまく集計できてました。
ただ、それでも毎日WakaTimeを見に行くのはしんどい。ということで、今回botを作ってslackに通知することにしました。
WakaTimeにはslackインテグレーションが初めから用意されてますが、有料プランでのみ使用できます。ただAPIは無料ユーザーでも使えます。
Google Apps Scripts
はじめはhubotあたりで作ってHerokuやBluemixとかにあげる感じかなぁと思ってたのですが、どうやらGASで無料かつcron的なこともできるようなので下記らへんを参考に触ってみることにしました。
実装
まず WakaTime APIから前日の結果を取得して
var date = new Date(); date.setDate(date.getDate() - 1); var dateStr = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); var apiKey = PropertiesService.getScriptProperties().getProperty("WAKATIME_API_KEY"); var url = "https://wakatime.com/api/v1/users/@kgmyshin/durations?api_key=" + apiKey + "&date=" + dateStr; var urlFetchOption = { 'method': 'get', 'contentType' : 'application/json; charset=utf-8', 'muteHttpExceptions' : true }; var response = UrlFetchApp.fetch(url, urlFetchOption); var body = JSON.parse(response.getContentText()); :
前日の総プログラミング時間を算出して
: var body = JSON.parse(response.getContentText()); var totalDuration = 0; for each(var item in body.data) { totalDuration = totalDuration + item.duration; } :
Slackにポストする。
: var message = dateStr + "のプログラミングに費やした時間は " + Math.floor((totalDuration / 3600)) + "時間" + Math.floor(((totalDuration % 3600) / 60)) + "分 でした。" var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN'); var slackApp = SlackApp.create(token); slackApp.postMessage("#general", message, {username: "えま", icon_url: "http://i.imgur.com/TdUStmi.png"});
下記がコードの全容です。(25行)
function postSlackMessage() { var date = new Date(); date.setDate(date.getDate() - 1); var dateStr = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate(); var apiKey = PropertiesService.getScriptProperties().getProperty("WAKATIME_API_KEY"); var url = "https://wakatime.com/api/v1/users/@kgmyshin/durations?api_key=" + apiKey + "&date=" + dateStr; var urlFetchOption = { 'method': 'get', 'contentType' : 'application/json; charset=utf-8', 'muteHttpExceptions' : true }; var response = UrlFetchApp.fetch(url, urlFetchOption); var body = JSON.parse(response.getContentText()); var totalDuration = 0; for each(var item in body.data) { totalDuration = totalDuration + item.duration; } var message = dateStr + "のプログラミングに費やした時間は " + Math.floor((totalDuration / 3600)) + "時間" + Math.floor(((totalDuration % 3600) / 60)) + "分 でした。" var token = PropertiesService.getScriptProperties().getProperty('SLACK_ACCESS_TOKEN'); var slackApp = SlackApp.create(token); slackApp.postMessage("#general", message, {username: "えま", icon_url: "http://i.imgur.com/TdUStmi.png"}); }
これで下記のようにSlackに通知してくれます。
Triggerの設定
Resources -> All your triggers を選択し、下記のように設定することで、毎日深夜1時に送信してくれるようになります。
所感
楽。
テスト書いてないとエラーになるCustom Lint書いた
作り方とかは基本的には下記の記事と同じ流れです。
なのでDetectorだけ紹介。
public class MustWriteTestDetector extends Detector implements Detector.JavaScanner { : @Override public AstVisitor createJavaVisitor(@NonNull JavaContext context) { return new TestChecker(context); } private static class TestChecker extends ForwardingAstVisitor { private final JavaContext context; private TestChecker(JavaContext context) { this.context = context; } @Override public boolean visitClassDeclaration(ClassDeclaration node) { String name = node.astName().astValue(); if (node.getParent() instanceof CompilationUnit) { CompilationUnit parent = (CompilationUnit) node.getParent(); if (parent.astPackageDeclaration() != null && parent.astPackageDeclaration().getPackageName().startsWith("YOUR_PACKAGE_NAME)) { boolean isTarget = false; // テストを書きたいターゲットにかどうかのロジックをここに書く : if (isTarget) { String packageName = parent.astPackageDeclaration().getPackageName(); String testFilePath = context.getProject().getDir().getAbsolutePath() + File.separator + "src" + File.separator + "test" + File.separator + "java" + File.separator + packageName.replace(".", File.separator) + File.separator + name + "Test.java"; if (!new File(testFilePath).exists()) { context.report(ISSUE, context.getLocation(node), "not found test code at \"" + testFilePath + "\""); } } } } return super.visitClassDeclaration(node); } } }
- AstVisitorを作る
- クラス宣言時に、自分のアプリで、かつ諸々自分がテストを書くべきと思ってるがテストファイルがないものを検知
ということをやっています。
即席なので自分のアプリのパッケージ名なんかを条件に入れちゃってます。ごめんなさい。
もう少し真面目にやるなら、設定ファイルとかをプロジェクトルートにおいて、それの有無で動作するしない、テスト対象かどうかはその設定ファイルに、とするといいかもしれない。
RxJava2でonErrorをsubscribeしてないのを検知するCustom Lintを作る
深夜に眠れなかったので、男もすなるCustom Lint作りというものをやってみようかと。
RxJava2でonErrorをsubscribeしてないものを検知するというものを作りました。
きっと探せばあるんでしょうが、始める題材としては良さそうだと思ったのでここはあえて探さずに。
参考
下記を参考に作りました
関係ない話な上たまたまなんですが、上記qiitaのhotchemiさんもamyu君も今同じフロアで働いてたりします。
作り方
上記qiitaにも載ってますが、ここでも。下記手順で作ります。
- build.gradleにdependeicies追加
- Detector及びIssueを作成
- Registoryを作成
- Test作成
一つ一つ説明します。
build.gradleにdependeicies追加
build.gradleのdependeiciesに下記を追加しておきます。
compile 'com.android.tools.lint:lint-api:24.3.1' compile 'com.android.tools.lint:lint-checks:24.3.1' compile 'com.android.tools.lint:lint:24.3.1' testCompile 'junit:junit:4.11' testCompile 'com.android.tools.lint:lint-tests:24.3.1' testCompile 'com.android.tools:testutils:24.3.1'
Detector及びIssueを作成
Observable
, Flowable
, Maybe
, Single
, Completable
が subscribe
された時にsusbscribe()
もしくは subscribe(onNext)
の時はNGとします。
まずIssueというものを作っておきます。このlintの説明みたいなものです。
public static final Issue ISSUE = Issue.create( "RxJava2SubscribeOnError", "not subscribe onError", "must subscribe onError", Category.PERFORMANCE, 7, Severity.ERROR, new Implementation( RxJava2SubscribeOnErrorDetector.class, Scope.JAVA_FILE_SCOPE ) );
そしてDetectorを作っていきます。
public class RxJava2SubscribeOnErrorDetector extends Detector implements Detector.JavaScanner { : private static final String CLS_FLOWABLE = "io.reactivex.Flowable"; private static final String CLS_OBSERAVLE = "io.reactivex.Observable"; private static final String CLS_SINGLE = "io.reactivex.Single"; private static final String CLS_COMPLETABLE = "io.reactivex.Completable"; private static final String CLS_OBJECT = "java.lang.Object"; private static final String CLS_CONSUMER = "io.reactivex.functions.Consumer"; private static final String CLS_ACTION = "io.reactivex.functions.Action"; private static final String CLS_CONSUMER_SIMPLE = "Consumer"; private static final String CLS_ACTION_SIMPLE = "Action"; private static final String ANONYMOUS_NEW_CONSUMER = "new io.reactivex.functions.Consumer(){}"; private static final String ANONYMOUS_NEW_ACTION = "new io.reactivex.functions.Action(){}"; private static final String METHOD_SUBSCRIBE = "subscribe"; // ---- Implements JavaScanner ---- @Override public List<String> getApplicableConstructorTypes() { return Arrays.asList(CLS_FLOWABLE, CLS_OBSERAVLE, CLS_SINGLE, CLS_COMPLETABLE); } @Override public List<String> getApplicableMethodNames() { return Collections.singletonList(METHOD_SUBSCRIBE); } @Override public void visitMethod( @NonNull JavaContext context, @Nullable AstVisitor visitor, @NonNull MethodInvocation node ) { boolean isNG = false; StrictListAccessor<Expression, MethodInvocation> expressions = node.astArguments(); int size = expressions.size(); if (size == 0) { // Any.subscribe() -> NG isNG = true; } else if (size == 1) { // Any.subscribe(Consumer<T> onNext) -> NG Expression expression = expressions.first(); JavaParser.ResolvedClass param1Class = context.getType(expression).getTypeClass(); while (!param1Class.getName().equals(CLS_OBJECT)) { if (param1Class.getName().startsWith(CLS_CONSUMER) || param1Class.getName().startsWith(CLS_CONSUMER_SIMPLE) || param1Class.getName().startsWith(ANONYMOUS_NEW_CONSUMER) || param1Class.getName().startsWith(CLS_ACTION) || param1Class.getName().startsWith(CLS_ACTION_SIMPLE) || param1Class.getName().startsWith(ANONYMOUS_NEW_ACTION)) { isNG = true; break; } param1Class = param1Class.getSuperClass(); } } if (isNG) { context.report(ISSUE, node, context.getLocation(node), "you must subscribe onError"); } } }
getApplicableConstructorTypes
で上記の5クラスに絞るgetApplicableMethodNames
でsubscribe
メソッドに絞るvisitMethod
にて、先の条件に当てはまるものをNGとしてcontext.report
する
ということをやっています。
Registoryを作成
こういうものを作って
public class LintIssueRegistry extends IssueRegistry { public LintIssueRegistry() { } @Override public List<Issue> getIssues() { return Collections.singletonList(RxJava2SubscribeOnErrorDetector.ISSUE); } }
build.gradleに下記を追記しておきます。
jar { manifest { attributes("Lint-Registry": "com.kgmshin.lint.LintIssueRegistry") } }
Test作成
LintDetectorTest
を継承してテストを作成します。
public class RxJava2SubscribeOnErrorDetectorTest extends LintDetectorTest { : @Override protected Detector getDetector() { return new RxJava2SubscribeOnErrorDetector(); } @Override protected List<Issue> getIssues() { return ImmutableList.of(RxJava2SubscribeOnErrorDetector.ISSUE); } @Test public void testSingleSubscribeNoParam() throws Exception { @Language("JAVA") String foo = "" + PACKAGE + IMPORT + "public class Foo {\n" + "public void test() {\n" + "Single.just(\"test\").subscribe()" + "}\n" + "}"; String result = lintProject(java(SOURCE_PATH + "Foo.java", foo)); assertEquals( "src/com/kgmyshin/lint/Foo.java:16: Error: you must subscribe onError [RxJava2SubscribeOnError]\n" + "Single.just(\"test\").subscribe()}\n" + "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n" + "1 errors, 0 warnings\n", result ); } : }
getDetector()
とgetIssues()
で先ほどのDetectorとIssueを返すようにしてあとはゴリゴリテストを書いていくだけです。
gist
途中で何回も貼ってますが、gistにソースを載せています。
追記 2017/1/5 17:36
Testだと動いていたのですが、実際のプロジェクトで使ってみると一部動かなかったので修正しました。上記に紹介しているコードは最新です。
Single.just("test").subscribe(new Consumer<String>() { @Override public void accept(String s) throws Exception { : } });
みたいなコードがある時に、
@Override public void visitMethod( @NonNull JavaContext context, @Nullable AstVisitor visitor, @NonNull MethodInvocation node ) { : Expression expression = expressions.first(); JavaParser.ResolvedClass param1Class = context.getType(expression).getTypeClass(); // ★ : }
の★にたいして getSuperClass()
した挙動がTest時と実際の時で違うみたいです。
うろ覚えですがTest時はgetName()
とgetSuperClass()
を繰り返した時
new io.reactivex.functions.Consumer(){}
Consumer
java.lang.Object
みたいになってたんですが、実際は
new io.reactivex.functions.Consumer(){}
java.lang.Object
ってなってたみたいです。
と、時間がなかったのでなんでそうなるかまでは今回は調べてないですが、以上です。
ちなみに
作ったlintのjarファイルは
~/.android/lint/
に格納されるのですが、module名を lint
としてしまうと lint.jar
が作られてしまいます。
何が言いたいかというと、結構どこのプロジェクトもmodule名をlint
としているっぽくて、同名のjarファイルができてしまうのでコマンドなどで上書きされないように名前を変えてコピペするようにするか、もしくは最初からモジュール名を変えておくと良いと思いました。
Androidでファイル群テンプレートを作る話
File TemplateとかProject Templateは作れるのは知っていたのですが、
- 【Android】もっと先へ「加速」したくはないか、少年 〜File Template編〜 - Qiita
- 【Android】もっと先へ「加速」したくはないか、少年 〜Project Template編〜 - Qiita
Files Templateって作れないのかなーと思ってました。
設計によってはある機能や画面を作る度に似たようなクラス群を作ったりしますしね。
ちょっと調べてみたら Project Templateと同じ要領で作れたので紹介します。
(Macでの開発を前提として書いていきます)
デフォルトのテンプレートの置き場所
Project Template同様、下記に置いてあります。
/Applications/Android Studio.app/Contents/plugins/android/lib/templates/
作り方
下記を用意します。
template.xml
このFiles Templateの概要をtemplate.xml
に載せます。例はこんな感じ。
<?xml version="1.0"?> <template name="Template Name" description="make screen template" revision="1" format="5"> <category value="MyCategory"/> <parameter name="Class Name" constraints="unique|nonempty" default="ClassName" id="className" type="string"/> <parameter name="Resource Name" constraints="unique|nonempty" default="resName" id="resName" type="string"/> <globals file="globals.xml.ftl"/> <execute file="recipe.xml.ftl"/> </template>
template
タグにこのFiles templateの説明を。
category
にカテゴリ名を。
上のOther
や Service
がカテゴリ名です。自分の場合は、汎用的でないがあるプロジェクトでやたら使うテンプレートとか作るときにプロジェクト名をカテゴリ名としてたりします。
parameter
には自分が入力したいパラメーターを。上のxmlだと下記のようになります。
globals.xml.ftl
ここには様々なファイルパスを定義しておきます。必要なものだけで良いです。
<?xml version="1.0"?> <globals> <global id="resOut" value="${resDir}" /> <global id="manifestOut" value="${manifestDir}" /> <global id="srcOut" value="${srcDir}/${slashedPackageName(packageName)}" /> <global id="unitTestOut" value="${escapeXmlAttribute(projectOut)}/src/test/java/${slashedPackageName(packageName)}" /> <global id="relativePackage" value="<#if relativePackage?has_content>${relativePackage}<#else>${packageName}</#if>" /> </globals>
recipe.xml.ftl
root
ディレクトリを作ってそこにテンプレートを作っておきます。 (下記を参考)
recipe.xml.ftl
ではディレクトリを作ったり、テンプレートからファイルを作成したり、コードをマージしたりできます。
<?xml version="1.0"?> <recipe> <instantiate from="root/src/app_package/Activity.java.ftl" to="${escapeXmlAttribute(srcOut)}/${className}Activity.java" /> <instantiate from="root/src/app_package/Contract.java.ftl" to="${escapeXmlAttribute(srcOut)}/${className}Contract.java" /> <instantiate from="root/src/app_package/Fragment.java.ftl" to="${escapeXmlAttribute(srcOut)}/${className}Fragment.java" /> <instantiate from="root/src/app_package/Presenter.java.ftl" to="${escapeXmlAttribute(srcOut)}/${className}Presenter.java" /> <instantiate from="root/res/layout/activity.xml.ftl" to="${escapeXmlAttribute(resOut)}/layout/activity_${resName}.xml" /> <instantiate from="root/res/layout/fragment.xml.ftl" to="${escapeXmlAttribute(resOut)}/layout/fragment_${resName}.xml" /> <merge from="root/AndroidManifest.xml.ftl" to="${escapeXmlAttribute(manifestOut)}/AndroidManifest.xml" /> </recipe>
上記では
HogeActivity
,HogeContract
,HogeFragment
,HogePresenter
クラスを作成activity_hoge.xml
とfragment_hoge.xml
を作成AndroidManifest.xml
にテンプレートの内容をマージ
ということをやってます。
使ってみる
Cmd + N を押して「New」を開いて下の方に行くと
こういうのが出てくるのであとはパラメータを入力して実行です。
共有方法とか
汎用的なテンプレートならgithubに全公開とかでいいと思いますが、プロジェクトに閉じたものならプロジェクトのgit管理下に置いて運用するのがいいと思います。
/Applications/Android Studio.app/Contents/plugins/android/lib/templates/
に、コピーしてくださいというのも面倒なので、スクリプトを用意してそれもセットにして管理しておくのがオススメ。
注意
/Applications/Android Studio.app/Contents/plugins/android/lib/templates/
配下にテンプレートを置いておくとアップデート時にvalidation errorになります。
アップデートの時は削除しましょう。
終わりに
次回のkyobashi.dexでこのことも話そうと思います。
多分これに関してはこっちの方が詳しくなってるかもですが、それ以外のことも似たようなことを話そうかと。
何か聞きたいこととかあれば是非来てください。m m
振り返りと今年の抱負
2016年やったこと
仕事
リクルートマーケティングパートナーズに2015年の5月に入社したので、2016年は入社して1~2年目ってことになる。
2016年が始まってからの4月くらいまでは、新卒の子とiOSのエンジニアとスタディサプリ EnglishのAndroid版を作っていた。ここで学んだチーム開発のことについては、shibuya.apkで話したし、 dexfmでも話した。ただ、話した内容は全然氷山の一角なのと、アップデートもあるので次回のDroidKaigiでも話そうと思う(なんども同じこと話してごめんなさい)。
リリース後は、Google IOに行かせてもらった。一応ブログも頑張った。
Google IOから帰ってきては、 kidsly というサービスのAndroidで kotlinにリプレースするプロジェクトに入った。
その後はそれが終わって、新しいプロジェクトに関わっている。
登壇とか
羅列。
1/22 kyobashi.dex #2 (開催)
2/5 shinobu.apk #1
shinobu.apk #1 のパネルディスカッション録音データとShow Notesを公開しました! #shinobuapk - I'm knowledge worker
4/20 potatotips #28
CQRSをモバイルに適用してみる // Speaker Deck
5/9 shibuya.apk #7
unskilled team development for android // Speaker Deck
5/27 kyobashi.dex #3 (開催)
advanced data binding // Speaker Deck
7/6 retty tech cafe #6
8/8 Androidオールスターズ2
memorandum/my_architecture.pdf at master · kgmyshin/memorandum · GitHub
9/29 potatotips #34
layered arch with swinject // Speaker Deck
その他
- 10キロ痩せた
- 柗亭に20回近く行った
- 引っ越した
2016年振り返り
登壇回数の減少
2016年の後半は登壇回数が減っている件。
これについては、狙ったというか自分で意図的に減らした。発表のためにインプットをしてたんだが、発表に慣れてしまってアウトプットの質が低くなってきたように感じたのが理由。
大きな舞台や頻度を減らしてしっかりアウトプットする方向に切り替えたので減ったこと自体は悪くない。数ヶ月に一回できれば大きな舞台で登壇できれば良しとする。
エンジニアとしての成長
やりたいことはいっぱいあったはず。基礎、機械学習、インフラ、etc...
仕事の中でScalaやcycle.jsに触れたことは幸いだったけど、それ以外が良くなかった。
サービス作りやOSS
2016年はOSSに関わる時間をサービス作りに充てようと考えていた。結果、サービス作ろうとして結局一つも仕上がらなかった。良くない。
痩せれた
これはグッドポイント。痩せれたのを機に中学高校に部活でやっていたバスケをまた始めた。
葛藤
キャリアパスについて悩みができてきた。これはまだ答えが出てない。
2017年の抱負
やりたいことベースに2016年の振り返りを意識しつつ抱負を。
なによりもサービス作りを
仕事でもしっかり。だが、ちゃんと個人でもリリースまで。 場合によってはどこかを手伝うという選択肢もありかもしれない。 ただ必ず個人で何か作ってリリースする。
エンジニアとしての成長
2016年で痩せれたのは何よりも習慣づけというのが上手くなったからだと思う。 学習の習慣づけをして、仕事が忙しいときにでもしっかりと。週一で優先順位つけてタスク分解及び検証。
登壇
数ヶ月に一回できれば大きな舞台で、を継続。
キャリアパス
「本当にやりたいことは?」をしっかり意識して、ちょっと考え直す。今月中に。
もうちょい痩せる。
痩せる。
バスケ
週一はやる。みんなで大会出て優勝。
締め
今年もよろしくお願い致します。
ちなみに
今年のおみくじは吉で要約すると「筋を通せ。筋を通しさえしていれば大丈夫。」みたいな感じだったので、しっかり筋を通していきたいと思います。