アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Common Lispで作る日報環境

こんにちは、chibaです!
このブログでは過去に日報を如何に書くかについて度々エントリーが書かれていますが、今回も日報生成の話です。

以前は当日こなしたタスクを眺めたりして振り返る感じでしたが、タスク管理と日報の作成で仕事を二重にしている感があったので、思い切って社内のイシュー管理システムのRedmineの情報から日報を生成することにしてみました。

準備

タスクデータ取得

日報を書くには、現在抱えている仕事と、当日こなした仕事の2つの情報が必要ですが、RedmineAPIだけでは、上手く取得できないようです。
ということでカスタムクエリを設定して、これを呼ぶことにしました。

送信

Yammerや社内メールに送信しますが、Yammerにはメール投稿用のアドレスがあるので、どちらもメールで投稿することにします。

コード

最近念願のLispWorksを購入したので無駄にGUIインターフェイスを付けてみました。
折角なのでRedmineの状態に合せて更新するボタンも付け、弊社標準ウィンドウマネージャーのStumpWMのコマンドも作成します。

daily-report

以上を合わせてこんな感じになります。

(cl:in-package :stumpwm)

(ql:quickload :cl-json :silent T)
(ql:quickload :drakma :silent T)
(ql:quickload :cl+ssl :silent T)
(ql:quickload :cl-smtp :silent T)

(defvar *firefox-path* "/usr/bin/firefox")

(defun browser (uri)
  (run-shell-command
   (format nil "~A ~S" *firefox-path* uri)))

(defun redmine-client (id)
  (let ((drakma:*text-content-types* '(("application" . "json"))))
    (drakma:http-request
     (format nil "https://example.com/issues/~A.json" id)
     :additional-headers '(("X-Redmine-API-Key" . "...")))))

(defun issue-subject-from-id (id)
  (json:json-bind (issue.subject) (redmine-client id)
                  issue.subject))

(defun my-issues ()
  (let ((drakma:*text-content-types* '(("application" . "json"))))
    (drakma:http-request
     "https://example.com/issues.json?query_id=NN"
     :additional-headers
     '(("X-Redmine-API-Key" . "...")))))

(defun my-issues/closed-today ()
  (let ((drakma:*text-content-types* '(("application" . "json"))))
    (drakma:http-request
     "https://example.com/issues.json?query_id=NN"
     :additional-headers
     '(("X-Redmine-API-Key" . "...")))))

(defun iso-date ()
  (multiple-value-bind (s- m- h- d m y)
                       (get-decoded-time)
    (declare (ignore s- m- h-))
    (format nil "~4,'0D-~2,'0D-~2,'0D" y m d)))

(defun daily-report-string ()
  (let ((date (iso-date)))
    (with-output-to-string (out)
      (format out "~2&お疲れ様です、千葉です。")
      (format out "~2&~Aの日報をお送りします。" date)
      (format out "~2&【勤務時間】11:00〜17:00")
      (format out "~2&【本日の作業内容】")
      (json:json-bind (issues)
                      (my-issues/closed-today)
    (when issues
      (format out "~2& 【終了】~2%")
      (dolist (i issues)
        (format out
            "~3,' D% #~A ~A~%"
            (cdr (assoc :done--ratio i))
            (cdr (assoc :id i))
            (cdr (assoc :subject i))))))
      (json:json-bind (issues)
                      (my-issues)
    (setq issues
          (sort issues
            #'>
            :key (lambda (x)
               (cdr (assoc :done--ratio x)))))
    (when issues
      (format out "~2& 【実行中】~2%")
      (dolist (i issues)
        (unless nil ;; (zerop (cdr (assoc :done--ratio i)))
          (format out
              "~3,' D% #~A ~A~%"
              (cdr (assoc :done--ratio i))
              (cdr (assoc :id i))
              (cdr (assoc :subject i)))))))
      (format out "~2&【感想】~2%"))))

(defun send-yammer (sub msg)
  (cl-smtp:send-email "smtp.example.com"
              "送り主のアドレス"
              "Yammerのメール投稿アドレス"
              sub
              msg
              :ssl :tls
              :authentication
              '(:login "foouser" "foopass")))

(defconstant tue 1)

(defun tomorrow-off-p ()
  (= tue (nth 6 (multiple-value-list (get-decoded-time)))))

(defun declare-off-day (offp msg)
  (block nil
    (or offp (return msg))
    (let ((dcl (format nil "~%※明日水曜日はお休みです。~%")))
      (and (search dcl msg) (return msg))
      (concatenate 'string msg dcl))))

(capi:define-interface daily-report ()
  ()
  (:panes
   (editor-pane
    capi:editor-pane
    :flag 'daily-report
    :text (declare-off-day (tomorrow-off-p) (daily-report-string))
    :buffer-name :temp
    :echo-area-pane echo-area
    :visible-min-width '(character 80)
    :visible-min-height '(character 15))

   (buttons
    capi:push-button-panel
    :items '("Send Report" "Update")
    :callback-type :data
    :selection-callback
    (lambda (command)
      (cond ((string= "Send Report" command)
             (when (capi:confirm-yes-or-no "この内容で送信しますか?")
               (send-yammer (format nil
                                    "~1&【日報 システムチーム/千葉】 ~A"
                                    (iso-date))
                            (capi:editor-pane-text editor-pane))
               (capi:apply-in-pane-process editor-pane
                                           #'capi:quit-interface
                                           editor-pane)))
            (T (when (capi:confirm-yes-or-no "タスク内容を更新しますか?")
                 (let* ((text (capi:editor-pane-text editor-pane))
                        (playback (search "【感想】" text))
                        (updated-text (daily-report-string)))
                   (setf (capi:editor-pane-text editor-pane)
                         (declare-off-day (tomorrow-off-p)
                                          (if playback
                                              (concatenate 'string
                                                           (subseq updated-text
                                                                   0
                                                                   (+ (+ -2 (- #.(length "【感想】")))
                                                                      (length updated-text)))
                                                           (subseq text playback nil))
                                              updated-text))
                         )))))))
   (echo-area capi:echo-area-pane :max-height t))
  (:menus (quit "Quit" ("Quit") :callback (lambda (name self)
                                            (declare (ignore name))
                                            (capi:quit-interface self))))
  (:menu-bar quit)
  (:default-initargs :title "Daily Report" :auto-menus nil))

(defcommand daily-report () ()
     ""
  (execute-command-or-raise 'daily-report1
                            '(:title "Daily Report")))

(defcommand daily-report1 () ()
     ""
  (let ((ept (make-instance 'daily-report)))
    (capi:display ept)))

どうもコードに重複が多いですが、まあこれで良いだろうということで完成。
30行程度でGUIが付くなんてLispWorksは素晴しいですね。