プロジェクト管理ツールのちょっとしたカスタマイズでチケット駆動開発を効率化している話
こんにちは。開発チームのやすだです。
4月ということで新しい環境にアサインされたり、新メンバーを受入れたり、様々な変化がある時期ですね。
それぞれの環境毎に業務ルールや運用フローなども異なり、覚えたり教えたりする負荷も徐々に出てくるかなと思います。
弊社でも新メンバーから開発フローが覚えにくいといった話やその後のステータス管理が手間という話がちょくちょくありました。
以前担当していたサービス(Paid)で、「面倒なら自動化したり負荷を下げよう!」と自動化や効率化の仕組みを導入していたところ、良い評価を聞く機会がありました。
今回はPaidでのチケット運用効率化についての取り組みを書こうかなと思います。
過去の運用フロー
過去の運用フローはザックリと下記のようなものでした。
Paidではプロジェクト管理ツールとしてBacklogを利用しており、チケットの起票や担当者変更・状態変更といったチケット管理の主な部分はデフォルト機能でカバーできていました。
ただし、下記の課題列にあるような作業漏れや作業状況の見えにくさを解消しようとするとカスタマイズが必要という状況でした。
フェーズ | 担当 | Backlog上でのタスク | 課題 |
---|---|---|---|
要望・要求の起票 | ユーザー部門 | チケット起票 | ー |
承認 | 決裁権者 | コメント欄に「承認」の文字列を入力 | ー |
開発(設計・開発・テスト) | 開発担当 | チケットの状態を[処理中]に変更 コメント欄に進捗を入力 |
「承認」コメントが漏れた状態で着手してしまう 成果物が漏れてしまう |
ユーザーテスト | ユーザー部門 | [任意]コメント欄に進捗を入力 | 作業状況が見えにくい |
QA | QA担当 | [任意]コメント欄に進捗を入力 | 作業状況が見えにくい |
リリース | 開発担当 | コメント欄にリリースした旨を入力 チケットの状態を[処理済み]に変更 |
作業状況が見えにくい |
検収 | 決裁権者 | コメント欄に「検収」の文字列を入力 | 「検収」コメントが漏れた状態で放置してしまう |
完了 | 決裁権者 | チケットの状態を[完了]に変更 | 「検収」コメントが漏れた状態で完了してしまう |
課題と対応
Paidを運営しているラクーンフィナンシャルは、ラクーンホールディングスの事業会社でありIT統制の対象となっています。
開発フローはIT統制の一環として全社共通で定められており、Paidの開発フローもそれに則った形である必要があります。
そのためフロー自体を根本的に変更しようとするとかなり手間がかかってしまいます。
効率化の作業自体もなるべく手間を掛けたくない!ということで開発フロー以外に手をいれることで対応する方針になりました。
作業状況が見えにくい->作業のフェーズ毎にチケット起票
過去の運用では1チケットで全フェーズの進捗を管理しようとしていたため、フェーズに応じて都度担当者を変更していました。
この辺りは少しイメージしにくい内容かと思いますので具体例でお伝えします。
- 登場人物
- Aさん:開発担当
- Bさん:QA担当
- Cさん:ユーザーテスト担当
- Zさん:PO
フェーズ | 主担当 | 完了後の処理 |
---|---|---|
チケット化 | 未設定 | ー |
要件定義・実装・テスト | A | チケットの担当者変更 A→C |
ユーザーテスト | C | チケットの担当者変更 C→B |
QA | B | チケットの担当者変更 B→A |
リリース | A | チケットの担当者変更 A→C |
完了 | C | ー |
Backlogではチケットに対して担当者を設定できますが、1名のみ設定可能という仕様になっています。
過去の運用では、全フェーズのタスクを1チケットで管理していたため、上記のようにフェーズが進むタイミングで担当者を変更していました。
Zさんからするとどのフェーズまで進んでいるのか一覧で確認することができず作業状況が見えにくい状態でした。
また、Aさんとしても完了後には自身が担当したチケットを一覧から確認することができないため、コメントを一通り確認することが必要でした。
対応としては、複数担当者が発生しない程度にチケットを分割することで解消できそうです。
ただしチケットの分割起票は手間になり漏れが発生しやすいところなので、起票作業は自動化する方針で対応します。
また、Backlogではチケットの親子関係を設定することができます。
最初に起票されたチケットと分割したチケットに対して、親子関係を設定することで関係性も表現できそうです。
成果物が漏れる->最低限の成果物をテンプレート化
実装・テストフェーズでは成果物として下記3点が必要です。
- ソース(・DDL・DML)
- 単体テスト仕様書
- リリース手順
QAフェーズで成果物をチェックするものの、成果物自体が無いといったケースがしばしばありました。
原因について調査・ヒアリングしたところ、リリース手順など定型化されやすい成果物は記載する手間がかかるので端折ることが原因の様子。
定型化されやすいならテンプレート化できるはず。ということでチケットの起票時に成果物の入力欄をテンプレートとして設定する形で記載の手間や見逃しを避けられるようにします。
承認が漏れる->スクリプトによる整合性チェック
IT統制で定めている開発フロー上、着手前に決裁権者による承認が必要になります。
このあたりはよくある話かと思いますが、口頭で承認されたのみでコメントに残しそびれてしまうというケースが多々ありました。
開発フロー上の「決裁権限者による承認」をそのまま実現しようとすると、
Backlogでの権限者管理やステータス更新制限を行うことになりますが、そこまでの機能はBacklog上で提供されていません。
ただし今回の問題はあくまで「コメントに残しそびれてしまう」点にあります。
そのため、下記のような対応で解消できそうです。
- Backlogのカスタム属性として「承認」(ON/OFF)を追加
- バッチで定期的にBacklogのチケットを監視し、カスタム属性[承認]がOFFかつ状態が[処理中]のチケットがあれば、コメントで通知&状態を[未処理]に更新
開発フローの遵守が業務の健全性につながるため、徹底的に対応するという方針を採るケースもありうるかと思います。
ただし、今回はまず目の前の課題を解消しつつ効率化を進めたいということもあり、上記のような割とシンプルな対応を採ることにしました。
リリース後に放置される&検収・完了が漏れる->定例時のチェック&スクリプトによる整合性チェック
リリース後に検収を行いチケットの状態を完了にすることで、開発が完了します。
検収の場合、承認とは若干異なりリリース後~完了まではチケット自体が放置されるというケースがありました。
実態としてリリースしたタイミングでユーザーへの機能提供が実現できていることもあり、チケットへの反映が漏れてしまうという流れになります。
開発フローを考えると、検収のタイミングが多少遅延したとしても実害はありません。
そのためリリース後に放置されるケースは、決裁権者同席の定例会議を行う際に確認する程度で解消できそうです。
リリース後に放置されるケースは上記で解消できるものの、検収は承認と同様にコメントを残す運用となっていたため、
コメント漏れ防止として下記対応も同時に行う方針としました。
- Backlogのカスタム属性として「検収」(ON/OFF)を追加
- バッチで定期的にBacklogのチケットを監視し、カスタム属性[検収]がOFFかつ状態が[完了]のチケットがあれば、コメントで通知&状態を[処理済み]に更新
現在の運用
最後に、効率化した結果である現在の運用を紹介します。
Backlogの設定変更
開発フロー上の決裁権者による[承認]と[検収]に対応するカスタム属性を追加します。
[状態]として追加する案もありましたが、[状態]はフェーズに応じて変更する項目のため、監査などによる後日確認が実施しにくいという点がありました。
そのため、スナップショットとして利用しやすいカスタム属性を利用しています。
- カスタム属性として[承認]を追加
- ラジオボタン形式
- カスタム属性として[検収]を追加
- ラジオボタン形式
Backlog用プログラム
BacklogではAPIが提供されており、Backlog上のカスタマイズでは対応しきれない内容についてはプログラムを作成して対応しています。
- 子チケット作成
- 開発フロー上の開発~リリースまでに対応するチケットを自動生成します
- 内容
- チケットの状態が[処理中]の場合、子チケットとして下記種別のチケットを追加
- 開発
- ユーザーテスト
- QA
- リリース
- チケットの状態が[処理中]の場合、子チケットとして下記種別のチケットを追加
- 承認整合性チェック
- 開発フローに合わせて、開発に着手したチケットが承認済みであることをチェックします
- 内容
- カスタム属性[承認]がOFFかつチケットの状態が[処理中]の場合、コメントで通知&状態を[未処理]に更新
- 検収整合性チェック
- 開発フローに合わせて、開発が完了したチケットが検収済みであることをチェックします
- 内容
- カスタム属性[検収]がOFFかつチケットの状態が[完了]の場合、コメントで通知&状態を[処理済み]に更新
定例会議
- 処理済みチケットを確認
- リリースが完了したチケットを確認します
Backlog運用フロー
フェーズ | 担当 | Backlog上でのタスク |
---|---|---|
要望・要求の起票 | ユーザー部門 | チケット起票(以下、親チケット) |
承認 | 決裁権者 | 親チケットのカスタム属性[承認]をON |
開発(設計・開発・テスト) | 開発担当 | 親チケットの状態を[処理中]に変更 子チケット[開発]を、進捗に応じて[処理中]~[完了]へ変更 |
ユーザーテスト | ユーザー部門 | 子チケット[ユーザーテスト]を、進捗に応じて[処理中]~[完了]へ変更 |
QA | QA担当 | 子チケット[QA]を、進捗に応じて[処理中]~[完了]へ変更 |
リリース | 開発担当 | 親チケットの状態を[処理済み]に変更 子チケット[リリース]を、進捗に応じて[処理中]~[完了]へ変更 |
検収 | 決裁権者 | 親チケットのカスタム属性[検収]をON |
完了 | 決裁権者 | チケットの状態を[完了]に変更 |
プログラム対応についての補足
当社の開発方針では、マイクロサービスや利用範囲が局所的なケースの場合、言語選定は担当者の自由裁量となっています。
今回のような社内業務の効率化は上記でいうところの局所的なケースにあたります。
運用の効率化作業は異なる担当者が随時進めてきたこともあり、子チケット作成プログラムはRubyで、整合性チェックプログラムはPythonでそれぞれ作成されています。
子チケット作成スクリプト(Ruby)
各フェーズに対応した子チケットを作成するスクリプトです。
# encoding: utf-8
require 'backlog_kit'
require 'date'
# backLog space_id
spaceId = 'XXX'
# backLog project_id
projectId = 'XXX'
# backlog_user(publisher) apikey
apiKey = 'XXX'
# status id
# 処理中
statusIdProcessing = 2
# parent isssue id
#親課題でも子課題でもない
parentTypeId = 3
#子課題以外
#parentTypeId = 1
# issue type ids
issueTypes = {
"dev" => "111111",
"bug" => "222222",
"refactor" => "333333",
"task" => "44444"
}
# category ids
categoryTypes = {
"dev" => 100001,
"ut" => 100002,
"qa" => 100003,
"release" => 100004,
}
# qa template
qa_template = <<QA_DETAIL
** 親チケット
%%parentIssueKey%% %%parentIssueSummary%%
** 成果物
*** コミットログ
*** 単体テスト仕様書
*** リリース手順
|実施内容|補足|h
|capistranoでデプロイ|-|
*** 障害発生時リカバリ手順
切り戻し
*** 環境設定変更点
なし
*** 対象機能など
ログイン
** QA
*** マージログ(develop)
QA_DETAIL
# release template
release_template = <<RELEASE_DETAIL
** 親チケット
%%parentIssueKey%% %%parentIssueSummary%%
*** リリース期間
YYYY/MM/DD~YYYY/MM/DD
*** リリース担当者/監査立会
|リリース担当|監査立合|h
|||
*** リリース手順
|実施内容|補足|h
|capistranoでデプロイ|-|
*** 障害発生時リカバリ手順
切り戻し
*** 環境設定変更点
なし
*** 対象機能など
ログイン
*** リリース対象
|リポジトリ|コミットログ|commit hash|h
|tmp_repo|DEV-XXX IPアドレス制限を追加|hoge1234|
RELEASE_DETAIL
# ignoreList
ignoreTicketKeys = [
]
# api client
client = BacklogKit::Client.new(
space_id: spaceId,
api_key: apiKey
)
ret = client.get_issues({
project_id: [projectId],
status_id: [statusIdProcessing],
parentChild: parentTypeId,
issue_type_id: [
issueTypes["dev"],
issueTypes["bug"],
issueTypes["refactor"],
]
})
ret.body.each do |issue|
if issue.assignee.nil?
puts "担当者未設定のためスキップします" + issue.summary
next
end
# todo 対象チケットのリリース完了時点で下記を削除する
next if ignoreTicketKeys.include?(issue.issueKey)
# 親チケット情報を記載
dev_summary = "[開発]" + issue.issueKey + issue.summary
client.create_issue(dev_summary,
{
project_id: projectId,
summary: dev_summary,
parent_issue_id: issue.id,
description: "",
issue_type_id: issueTypes["task"],
category_id: [categoryTypes["dev"]],
priority_id: 3,
assigneeId: issue.assignee.id
})
# 親チケット情報を記載
ut_summary = "[ユーザーテスト]" + issue.issueKey + issue.summary
client.create_issue(ut_summary,
{
project_id: projectId,
summary: ut_summary,
parent_issue_id: issue.id,
description: "",
issue_type_id: issueTypes["task"],
category_id: [categoryTypes["ut"]],
priority_id: 3,
assigneeId: issue.assignee.id
})
# 親チケット情報を記載
qa_summary = "[QA]" + issue.issueKey + issue.summary
qa_template.gsub!('%%parentIssueKey%%', issue.issueKey)
qa_template.gsub!('%%parentIssueSummary%%', issue.summary)
client.create_issue(qa_summary,
{
project_id: projectId,
summary: qa_summary,
parent_issue_id: issue.id,
description: qa_template,
issue_type_id: issueTypes["task"],
category_id: [categoryTypes["qa"]],
priority_id: 3,
assigneeId: issue.assignee.id
})
# 親チケット情報を記載
release_summary = "[リリース]" + issue.issueKey + issue.summary
release_template.gsub!('%%parentIssueKey%%', issue.issueKey)
release_template.gsub!('%%parentIssueSummary%%', issue.summary)
client.create_issue(release_summary,
{
project_id: projectId,
summary: release_summary,
parent_issue_id: issue.id,
description: release_template,
issue_type_id: issueTypes["task"],
category_id: [categoryTypes["release"]],
priority_id: 3,
assigneeId: issue.assignee.id
})
end
承認・検収整合性チェックスクリプト(Python)
承認が無い状態で処理中になっているチケットや検収が無い状態で完了になっているチケットがあれば、
チケットの状態を更新&コメント通知するスクリプトです。
import json
import logging
import datetime as dt
import requests
logger = logging.getLogger(__name__)
# 対象とするBacklogスペース
BACKLOG_SPACE = "BacklogURL"
class AccessFunction(object):
def __init__(self, AppUserConf, AppDeveloperConf):
user_apiKey = AppUserConf.api_key
self.target_projects = AppUserConf.backlog_projects
self.update_interval = AppUserConf.update_interval
self.created_since = self.convert_string_to_datetime(AppUserConf.created_since,) #いつから
self.created_until = self.convert_string_to_datetime(AppUserConf.created_until,) #いつまで
self.target_issue_types = AppDeveloperConf.issue_types
self.target_reverting_detail = AppDeveloperConf.reverting_detail
self.backlog_params = {
'apiKey' : user_apiKey,
}
def convert_string_to_datetime(self, string):
date_list = list(map(int, string.split(',')))
datetime_object = dt.date(
date_list[0],
date_list[1],
date_list[2],
)
return datetime_object
def revert_issue(self, undo_status_id, list_issues_id, additional_comment):
url = "/api/v2/issues/{issue_id}"
params = {
'statusId' : undo_status_id,
'comment' : additional_comment,
}
params.update(self.backlog_params)
for issue_id in list_issues_id:
request = requests.patch(
BACKLOG_SPACE + url.format(issue_id = str(issue_id)),
params = params,
)
if request.status_code == 409:
raise Exception("パッチしようとしたリソースがロック中です")
def get_list_issues_id(self, project_id, issue_type_id, status_id, custum_field_id, required_custum_field, status):
url = "/api/v2/issues"
list_issues_id = []
customField_id = 'customField_' + str(project_id) + '[]'
params = {
'projectId[]' : project_id,
'issueTypeId[]' : issue_type_id,
'statusId[]' : status_id,
'parentChild' : 1,
'createdSince' : self.created_since.strftime('%Y-%m-%d'),
'createdUntil' : self.created_until.strftime('%Y-%m-%d'),
customField_id : custum_field_id,
}
params.update(self.backlog_params)
response = requests.get(
BACKLOG_SPACE + url,
params = params,
)
if response.status_code != 200:
raise Exception("例外が発生しました" + str(response.status_code))
response_json = response.json()
# 要求されるカスタム属性の値を持たない課題のidを集める
# ex : カスタム属性「承認」が「承認済」ではない課題
for target_issue in response_json:
# 状態が完了の課題で、完了理由が「対応しない」の場合は対象に含まない
if target_issue['resolution']:
if target_issue['resolution']['id'] == 1 and status == "完了":
continue
# カスタム属性
for field in target_issue['customFields']:
# 1つの課題に複数のカスタム属性がある場合に注意
if field['id'] == custum_field_id:
# カスタム属性の値がNoneの場合もあることに注意
if field['value']:
if field['value']['name'] != required_custum_field:
list_issues_id.append(target_issue['id'])
else:
list_issues_id.append(target_issue['id'])
return list_issues_id
def get_custum_fields_dict(self, project_id):
url = "/api/v2/projects/{projectIdOrKey}/customFields".format(projectIdOrKey = str(project_id))
custum_fields_dict = {}
response = requests.get(
BACKLOG_SPACE + url,
params = self.backlog_params,
)
if response.status_code != 200:
raise Exception("例外が発生しました" + str(response.status_code))
response_json = response.json()
if type(response_json) is list:
for field in response_json:
custum_fields_dict[field['name']] = field['id']
else:
raise Exception('データがリストではありません')
return custum_fields_dict
def get_statuses_dict(self, project_id):
url = "/api/v2/projects/{projectIdOrKey}/statuses".format(projectIdOrKey = str(project_id))
response = requests.get(
BACKLOG_SPACE + url,
params = self.backlog_params,
)
if response.status_code != 200:
raise Exception("例外が発生しました" + str(response.status_code))
response_json = response.json()
statuses_dict = {}
if type(response_json) is list:
for status in response_json:
statuses_dict[status['name']] = status['id']
else:
raise Exception('データがリストではありません')
return statuses_dict
def get_issue_types_dict(self, project_id):
url = "/api/v2/projects/{projectIdOrKey}/issueTypes".format(projectIdOrKey = str(project_id))
response = requests.get(
BACKLOG_SPACE + url,
params = self.backlog_params,
)
if response.status_code != 200:
raise Exception("例外が発生しました" + str(response.status_code))
response_json = response.json()
issue_types_dict = {}
if type(response_json) is list:
for issue_type in response_json:
if issue_type['name'] in self.target_issue_types:
issue_types_dict[issue_type['name']] = issue_type['id']
else:
raise Exception('データがリストではありません')
return issue_types_dict
def get_projects_dict(self):
url = "/api/v2/projects"
response = requests.get(
BACKLOG_SPACE + url,
params=self.backlog_params,
)
if response.status_code != 200:
raise Exception("例外が発生しました" + str(response.status_code))
response_json = response.json()
projects_dict = {}
for project in response_json:
if project['id'] in self.target_projects:
projects_dict[project['id']] = project['id']
return projects_dict
class AppUserConf(object):
# ---------must----------
# 個人設定 -> API からapiKeyをコピペしてください。
# ex : value = '8K0pVMPrGecDtD9WyI9KIswv45xdg'
api_key = ''
# backlogプロジェクトidを記載してください。プロジェクト設定を開いた時のURLの末尾に書いてあります。
# ex : name = [0000000000, 1111111111, ...]
# sandbox, sandbox2
backlog_projects = []
# いつからいつまでの課題を取得するか、登録日をカンマ区切りの文字列で指定してください
# ex : createdSince = "2020,8,30"
created_since = "2020,8,28" # いつから
created_until = "2021,9,30" # いつまで
# 更新間隔を正の整数かつ分単位で設定してください
update_interval = 15
class AppDeveloperConf(object):
issue_types = ['タスク', 'バグ', ]
reverting_detail = [
{
'status' : "処理中",
'beforeStatus' : "未対応",
'custumField' : "承認",
'requiredCustumField' : "承認済",
'comment' : '承認を行ってから作業を開始して下さい'
},
{
'status' : "完了",
'beforeStatus' : "処理済み",
'custumField' : "検収",
'requiredCustumField' : "検収済",
'comment' : "検収を行ってから完了にして下さい"
},
]
# coding: utf-8
import os
import sys
import logging
import time
import schedule
from lib.backlog_api_access import AccessFunction
from conf.app_user_conf import AppUserConf
from conf.app_developer_conf import AppDeveloperConf
MINUTE = 60
def set_logging_conf():
# 基本設定
logging_detail_formatting = "%(asctime)s : %(levelname)s : %(message)s"
logging.basicConfig(
level = logging.DEBUG,
format = logging_detail_formatting,
filename = "log/app.log"
)
# 標準エラー出力はINFOレベルです
console = logging.StreamHandler(sys.stderr)
console.setLevel(level = logging.INFO)
console_formatter = logging.Formatter("%(asctime)s : %(message)s")
console.setFormatter(console_formatter)
logging.getLogger().addHandler(console)
def backlog_revert_task():
logging.info('Started')
try:
# backlogプロジェクトの一覧を取得
projects_dict = backlog.get_projects_dict()
if not projects_dict:
raise Exception('該当プロジェクトが存在しません')
for project_name in projects_dict.keys():
project_id = projects_dict[project_name]
# 種別・状態・カスタム属性で課題を絞り込み
# 注意:課題種別・状態・カスタム属性はそれぞれ同名であってもIDは異なります。
issue_types_dict = backlog.get_issue_types_dict(project_id)
statuses_dict = backlog.get_statuses_dict(project_id)
custum_fields_dict = backlog.get_custum_fields_dict(project_id)
if not issue_types_dict.keys() or not statuses_dict.keys() or not custum_fields_dict.keys():
raise Exception('dictionary is empty')
# 修正条件に該当する課題を修正
for issue_type in issue_types_dict.keys():
for reverting_detail in backlog.target_reverting_detail:
# 修正前のパラメータ。課題の検索に使用。
issue_type_id = issue_types_dict[issue_type]
status_id = statuses_dict[reverting_detail['status']]
custum_field_id = custum_fields_dict[reverting_detail['custumField']]
required_custum_field = reverting_detail['requiredCustumField']
# 該当の課題
list_issues_id = backlog.get_list_issues_id(
project_id,
issue_type_id,
status_id, custum_field_id,
required_custum_field,
reverting_detail['status'],
)
# 修正後のパラメータ
undo_status_id = statuses_dict[reverting_detail['beforeStatus']]
additional_comment = reverting_detail['comment']
# 該当した課題を修正
backlog.revert_issue(
undo_status_id,
list_issues_id,
additional_comment,
)
logging.info('Finished\n')
except Exception as e:
logging.exception(e)
if __name__ == "__main__":
set_logging_conf()
# apiにアクセスするためのインスタンスを作成
backlog = AccessFunction(
AppUserConf,
AppDeveloperConf,
)
# 設定した時間間隔ごとに実行
schedule.every().seconds.do(backlog_revert_task)
while True:
schedule.run_all(backlog.update_interval * MINUTE)
まとめ
何かしらの業務ルールやフローがある場合、それを覚えたりチェックする負荷が出てきます。
効率化する上で最も効果的なのはルールやフローを無くす/簡略化するといった対応ですが、
様々な制限などからそのあたりができないケースがあります。そんな時でもなるべく負荷は下げたいものですよね。
また、日常的に使うものだと負荷に慣れてしまっていたりして、何が課題なのかわかりにくいことがよくあります。
そんな時に課題に気づけるのは新たに環境に入った人ならではですし、
日常のちょっとした業務の効率化をするタイミングとして、今のような時期はちょうどいいのではないでしょうか。
さて、ラクーングループでは社内業務やユーザー業務の効率化など、一緒に進める仲間を絶賛大募集中です。
もしご興味を持っていただけましたら、こちらからエントリーお待ちしています!