Redmine Easy Gannt Plugin でスケジュールの可視化が捗った話

PhotosynthRedmine おじさん @ishturnk です

これはAkerun Advent Calendar12日目の記事です。

f:id:photosynth-inc:20191225114312p:plain

進捗どうですか?

耳が痛いどの組織でも課題になる話題

  • スケジュールの可視化
  • 進捗の更新

について運用してみたメソッドを紹介します

ワークフロー

  • 計画

まずはスケジュールを立てます。

  • 作業→進捗ステータスの更新

仕様作成や実装、評価など、実際の作業を実施します。終わったものには完了のフラグをつけていきます。

  • リリース

開発と評価が終わったのでリリースします。

  • 計画の見直し

ほとんどのケースでは計画通りにはいきません。遅延や前倒しをなど、スケジュールをアップデートします。

計画の可視化と進捗更新ツールに Redmine のEasyGantt をつかってみた

エンジニアは僕は面倒な作業が嫌いなので、

  • ひと目でわかる
  • 更新がしやすい

ツールでなければ、運用が回らなくなります。

Redmineプラグインを導入

こちらのプラグインを導入します。

Redmine Gantt プラグイン - Easy Redmine

使用感については公式の動画をご覧ください。

一部の機能を除き、無料で使えます。(弊社は課金しました)

特徴として、

  • ドラッグ操作でスケジュールの変更(移動や期間)が可能
  • 進捗(%)もドラッグ操作で更新可能
  • 後続チケットをバインドできる(テストは実装の後、など)

実際にチケットにあてはめるとこのようになります。

f:id:photosynth-inc:20191225112031p:plain

見やすくてなかなか良いです。

それでも滞る更新

それでも更新が滞ってしまい、期日を過ぎてしまっているチケットがしばしば。

Redmine おじさんの出番です。

期日が過ぎているチケットをメンション付きで slack 通知する

メールで通知する方法はよく見るのですが、弊社はslackでコミュニケーションをしており、メールだと埋もれがち。 なのでslack通知する方法を実装しました。

// User : user の型
type User struct {
        ID    int    `json:"id"`
        Name  string `json:"name"`
        Login string `json:"login"`
}

// Issue : issueの型
type Issue struct {
        ID      int `json:"id"`
        Project struct {
                ID int `json:"id"`
        } `json:"project"`
        Progress int    `json:"done_ratio"`
        Due      string `json:"due_date"`
        Subject  string `json:"subject"`
        Assign   User   `json:"assigned_to"`
        Status   struct {
                ID int `json:"id"`
        } `json:"status"`
}

// Resp : レスポンス
type Resp struct {
        Issues []Issue `json:"issues"`
        User   User    `json:"user"`
}

func post(message string) {

        type Message struct {
                Username string `json:"username"`
                Icon     string `json:"icon_url"`
                Text     string `json:"text"`
                Linkname int    `json:"link_names"`
                Channel  string `json:"channel"`
        }

        mes := Message{
                Username: "Redmineおじさん",
                Icon:     "http://www.redmine.org/attachments/3462/redmine_fluid_icon.png",
                Text:     message,
                Linkname: 1,
                Channel:  "#投稿チャンネル",
        }
        url := "https://hooks.slack.com/services/XXXXXXXXXXXXXXX"  // webhook url
        payload, _ := json.Marshal(&mes)

        req, err := http.NewRequest(
                "POST",
                url,
                bytes.NewBuffer([]byte(payload)),
        )
        if err != nil {
                return
        }

        // Content-Type 設定
        req.Header.Set("Content-Type", "application/json")

        client := &http.Client{}
        resp, err := client.Do(req)
        if err != nil {
                return
        }
        defer resp.Body.Close()
}

func issues() ([]Issue, error) {
        values := url.Values{}
        values.Add("key", {Redmineのアクセストークン})
        values.Add("tracker_id", {指定するトラッカー})
        values.Add("limit", "100")
        res, err := http.Get("https://{RedmineのURL}/issues.json?" + values.Encode())
        if err != nil {
                log.Fatal(err)
        }
        robots, err := ioutil.ReadAll(res.Body)
        res.Body.Close()
        if err != nil {
                log.Fatal(err)
        }
        var r Resp
        err = json.Unmarshal(robots, &r)
        if err != nil {
                log.Fatal(err)
        }
        fmt.Println(len(r.Issues))
        return r.Issues, err
}

func user(id int) (User, error) {
        values := url.Values{}
        values.Add("key", {Redmineのアクセストークン})
        res, err := http.Get("https://redmine.akerun.com/users/" + strconv.Itoa(id) + ".json?" + values.Encode())
        if err != nil {
                log.Fatal(err)
        }
        robots, err := ioutil.ReadAll(res.Body)
        res.Body.Close()
        if err != nil {
                log.Fatal(err)
        }
        var r Resp
        err = json.Unmarshal(robots, &r)
        if err != nil {
                log.Fatal(err)
        }
        return r.User, err
}

func main() {
        issues, err := issues()
        if err != nil {
                log.Fatal(err)
        }
        fmt.Println(issues)
        now := time.Now()
        str := ""
        for _, s := range issues {
                if s.Project.ID != 4 { // sandbox
                        continue
                }
                if s.Status.ID == 3 { // pending
                        continue
                }
                if s.Progress < 100 {
                        d, err := time.Parse("2006-01-02", s.Due)
                        if err != nil {
                                log.Fatal(err)
                        }
                        if now.After(d) {
                                assign, err := user(s.Assign.ID)
                                mention := assign.Login
                                if err != nil {
                                        continue
                                }
                                str += fmt.Sprintf("@%s\n    %s | %3d%% | %s %s%d\n", mention, s.Due, s.Progress, s.Subject, "https://{RedmineのURL}/issues/", s.ID)
                        }
                }
        }
        if str != "" {
                str = "Redmineおじさんです。チケット更新してね\n" + str
                post(str)
        }
}

やっているのは

  • RedmineAPIでチケット一覧を取得
  • チケットのプロジェクトを指定(フィルタ)
  • チケットのステータスを指定(フィルタ)
  • 進捗が100%になってないもの &$ 期日が過ぎているチケットを抽出
  • チケットのアサインユーザーにメンション通知

という流れです。

Redmine のユーザーとslackユーザーの対応ですが、両者を同じにするという運用をしています。メンションで連携しやすくなるので、slack連携している組織ではおすすめの運用です。

おわり

株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 recruit.jobcan.jp

Akerun Proの購入はこちらから akerun.com