はじめに
こんにちは!AgVenture Labの荒井です。
Azure DevOpsでのチケット管理、まだ手作業でやっていますか?
本記事では、Azure DevOps REST APIを使い、Javaでバックログ起票を自動化する方法を徹底解説します。
実践的なコード例や、CSV生成、Blob Storage連携など、チーム開発の効率化に直結するノウハウを余すところなく紹介。
Azure DevOpsを活用する開発者の必見記事です!
本記事のポイント
・Azure DevOps REST APIを活用するメリット
・自動化の具体的な流れ
・実際に使ってみた感想
目次
- Azure DevOps Service REST APIとは
- 目的
- 概要図
- ①CSV作成
- ②CSV取得・読み込み
- ③PBI起票処理
- ④task起票処理
- 完成(コード全量)
- 振り返り
- 最後に : Azure DevOps Service REST APIを活用してみて
Azure DevOps Service REST APIとは
まず、Azure DevOps REST APIが何かを簡単におさらいします。
Azure DevOps REST APIは、Azure DevOps内のさまざまなリソース(Work Items、Pipelines、Repositories など)をプログラムから操作できる非常に強力なAPIです。
これを活用すれば、
・バックログやタスクの自動登録
・ステータス更新の自動化
・作業の可視化の効率化
などが可能になります!
目的
今回の目的は、Azure DevOps REST APIを活用して「手作業でのバックログ登録を自動化する」ことです。
具体的には、毎スプリント手作業で起票していた定型PBI(Product Backlog Item)やtaskを自動で登録できるようにします。
概要図
全体の流れを示した図はこちらです。
次から順を追って解説していきます。
図の番号それぞれについて説明していきます。
①CSV作成
まず、起票するPBI・taskの情報をCSVにまとめ、Azure Blob Storageに格納します。
このCSVが後続の処理に使われます。
<手順>
・Azure DevOpsでCSV作成
DevOpsの「Queries」を使ってCSVにする情報を作成します。
- 自動起票対象となるPBIのIDを下図の通りに設定
- 「Run query」を押下
- 右上の3点リーダ→「Export to CSV」を押下
- blobストレージに格納
②CSV取得・読み込み
次に、BlobストレージからCSVファイルをダウンロードし、内容をプログラムで読み込みます。
ここでは、指定されたパスからCSVデータを読み取り、リストに変換します。
<downloadBlobClient>
/**
* 引数のファイルをBLOBストレージからダウンロードする
*
* @param blobPath ファイルパス
* @param tmpFilePath 一時ファイル格納パス
*/
public String downloadBlobClient(String blobPath, String tmpFilePath) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// BLOBコンテナに接続
BlobContainerClient blobContainerClient = getAutoTicketBlobContainerClient();
BlobClient blobClient = blobContainerClient.getBlobClient(blobPath);
// blobからCSVをダウンロード
blobClient.download(baos);
String csvContent = baos.toString(StandardCharsets.UTF_8);
return csvContent;
} catch (Exception e) {
throw e;
}
}
そして、CSV形式の文字列を「DevOpsWorkItem」というオブジェクトのリストに変換します。
"DevOpsWorkItem"の中身は「完成(コード全量)」の項目で記載しています。
<parseCsvToWorkItemList>
/**
* CSVのデータをList<DevOpsWorkItem>に格納するメソッド。
*
* @param csvData CSV形式の文字列データ
* @return List<DevOpsWorkItem> CSVデータから生成されたDevOpsWorkItemオブジェクトのリスト
* @throws IOException
*/
public List<DevOpsWorkItem> parseCsvToWorkItemList(String csvData) throws IOException {
// 文字列からReaderを作成
try (Reader reader = new StringReader(csvData)) {
// CsvToBeanBuilderを使用してCSVデータをTargetTicketオブジェクトにマッピング
CsvToBean<DevOpsWorkItem> csvToBean = new CsvToBeanBuilder<DevOpsWorkItem>(reader)
.withType(DevOpsWorkItem.class)
.withIgnoreLeadingWhiteSpace(true)
.build();
// パースしてDevOpsWorkItemのリストを取得
List<DevOpsWorkItem> devOpsWorkItemList = csvToBean.parse();
return devOpsWorkItemList;
} catch (Exception e) {
e.printStackTrace();
throw e;
}
}
これでCSVデータの読み込みが完了しました!
③PBI起票処理
読み込んだデータを基に、PBIを作成します。
createPBI メソッドで、必要な情報をリクエストボディとして構築し、Azure DevOpsに送信します。
<createPBI>
/**
* PBIを作成する
*
* @param wi ワークアイテム
* @param iterationPath イテレーションパス
*/
public HttpResponse<String> createPBI(DevOpsWorkItem wi, String iterationPath)
throws Exception {
// リクエストボディを作成
StringBuilder sb = new StringBuilder();
sb.append("[");
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_ITERATIONPATH, iterationPath));
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_DESCRIPTION, wi.getDescription()));
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_TITLE, wi.getTitle()));
sb.append("]");
String body = sb.toString();
return ExternalServiceConnection.sendHttpRequest(getAzureAPIUrlCreatePBI(), body);
}
<sendHttpRequest>
// PBIを作成する
public static HttpResponse<String> sendHttpRequest(String url, String body)
throws Exception {
// PATをエンコード
String encodedPAT = Base64.getEncoder().encodeToString((":" +
{PAT}).getBytes());
// リクエスト作成
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json-patch+json")
.header("Authorization", "Basic " + encodedPAT)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// リクエスト送信
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
<呼び出し元>
String iterationPath = devOpsForm.getIterationPath();
// workItemListの数だけ繰り返す
for (DevOpsWorkItem workItemPBI : workItemList) {
// PBIの場合
if (workItemPBI.getWorkItemType().equals("Product Backlog Item")) {
// PBIを起票する
HttpResponse<String> res = createPBI(workItemPBI, iterationPath);
}
}
④task起票処理
次に、PBIに関連するtaskを作成します。
新たに作成したPBIのIDを使って、関連するtaskを登録します。
<createTask>
/**
* タスクを作成する
*
* @param parentID 親USのID
* @param wi ワークアイテム
* @param iterationPath イテレーションパス
*/
public HttpResponse<String> createTask(int parentID, DevOpsWorkItem wi, String iterationPath)
throws Exception {
// リクエストボディを作成
StringBuilder sb = new StringBuilder();
sb.append("[");
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_TITLE, wi.getTitle()));
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_REMAININGWORK, wi.getRemainingWork()));
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_ITERATIONPATH, iterationPath));
sb.append(createWorkItemRelationJson(parentID));
sb.append("]");
String body = sb.toString();
return ExternalServiceConnection.sendHttpRequest(getAzureAPIUrlCreateTask(), body);
}
<呼び出し元>
// workItemListの数だけ繰り返す
for (DevOpsWorkItem workItemPBI : workItemList) {
// PBIの場合
if (workItemPBI.getWorkItemType().equals("Product Backlog Item")) {
// PBIを起票する
HttpResponse<String> res = createPBI(workItemPBI, iterationPath);
// 新規起票したPBIのIdを取得
JSONObject bodyJson = new JSONObject(res.body());
int newPBIId = bodyJson.getInt("id");
// "Parent"がPBIのIDと一致するもののみtask起票する
String workItemPBIId = workItemPBI.getId();
for (DevOpsWorkItem workItemTask : workItemList) {
if (workItemTask.getParent().equals(workItemPBIId)) {
HttpResponse<String> res2 = createTask(newPBIId, workItemTask, iterationPath);
}
}
}
}
完成(コード全量)
最終的なコードを全てまとめたものがこちらです。
Postmanを使う場合の設定例もありますので、ぜひ参考にしてみてください。
<DevOpsRestController>
/**
*
* DevOpsRestControllerクラス.
*
*/
@RestController
@CrossOrigin
public class DevOpsRestController {
/** DevOps管理インスタンス */
@Autowired
DevOpsService devOpsService;
/**
* blobに格納されているCSVデータをもとにAzureDevOpsのPBIとtaskを起票する<br>
* MappingURI: /devops/workitems<br>
*
* @return
*/
@PostMapping("/devops/workitems")
public void postScrumEvent(DevOpsForm devOpsForm) {
try {
devOpsService.postDevOpsWorkItem(devOpsForm);
} catch (Exception e) {
e.printStackTrace();
}
}
}
<DevOpsService>
/**
* Azureに関する処理を行うServiceクラス.
*
*/
@Service
@Transactional
public class DevOpsService {
@Autowired
ExternalServiceConnection externalServiceConnection;
/** 一時ディレクトリパス. */
private static final String TMP_DIR_PATH = "任意のパス";
/** Blobコンテナのフォルダパス */
private static final String PATH_WORK_ITEM_TEMPLATES = "任意のパス";
/** csv拡張子 .csv */
private static final String EXTENSION_CSV = ".csv";
/** Work item option - add */
private static final String WORK_ITEM_OPTION_ADD = "add";
/** Work item path - IterationPath */
private static final String WORK_ITEM_PATH_ITERATIONPATH = "/fields/System.IterationPath";
/** Work item path - Title */
private static final String WORK_ITEM_PATH_TITLE = "/fields/System.Title";
/** Work item path - Description */
private static final String WORK_ITEM_PATH_DESCRIPTION = "/fields/System.Description";
/** Work item path - RemainingWork */
private static final String WORK_ITEM_PATH_REMAININGWORK = "/fields/Microsoft.VSTS.Scheduling.RemainingWork";
/**
* DevOpsのUSを追加する.
*
* @param devOpsForm
* @throws Exception
*/
public void postDevOpsWorkItem(DevOpsForm devOpsForm) throws Exception {
try {
// 一時ファイル格納フォルダを指定
String uuid = java.util.UUID.randomUUID().toString();
String tmpFilePath = TMP_DIR_PATH + uuid + EXTENSION_CSV;
// 引数のTargetWorkItemNameをもとに取得するCSVを指定
String blobPath = PATH_WORK_ITEM_TEMPLATES + devOpsForm.getTargetWorkItemName() + EXTENSION_CSV;
// blobからCSV取得
String csvContent = externalServiceConnection.downloadBlobClient(blobPath, tmpFilePath);
// CSVデータをtargetTicketListに格納
List<DevOpsWorkItem> workItemList = parseCsvToWorkItemList(csvContent);
String iterationPath = devOpsForm.getIterationPath();
// targetTicketListの数だけ繰り返す
for (DevOpsWorkItem workItemPBI : workItemList) {
// PBIの場合
if (workItemPBI.getWorkItemType().equals("Product Backlog Item")) {
// PBIを起票する
HttpResponse<String> res = createPBI(workItemPBI, iterationPath);
// 新規起票したPBIのIdを取得
JSONObject bodyJson = new JSONObject(res.body());
int newPBIId = bodyJson.getInt("id");
// "Parent"がPBIのIDと一致するもののみtask起票する
String workItemPBIId = workItemPBI.getId();
for (DevOpsWorkItem workItemTask : workItemList) {
if (workItemTask.getParent().equals(workItemPBIId)) {
HttpResponse<String> res2 = createTask(newPBIId, workItemTask, iterationPath);
}
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* CSVのデータをList<DevOpsWorkItem>に格納するメソッド。
*
* @param csvData CSV形式の文字列データ
* @return List<DevOpsWorkItem> CSVデータから生成されたDevOpsWorkItemオブジェクトのリスト
* @throws IOException
*/
public List<DevOpsWorkItem> parseCsvToWorkItemList(String csvData) throws IOException {
// 文字列からReaderを作成
try (Reader reader = new StringReader(csvData)) {
// CsvToBeanBuilderを使用してCSVデータをTargetTicketオブジェクトにマッピング
CsvToBean<DevOpsWorkItem> csvToBean = new CsvToBeanBuilder<DevOpsWorkItem>(reader)
.withType(DevOpsWorkItem.class)
.withIgnoreLeadingWhiteSpace(true)
.build();
// パースしてDevOpsWorkItemのリストを取得
List<DevOpsWorkItem> devOpsWorkItemList = csvToBean.parse();
return devOpsWorkItemList;
} catch (Exception e) {
e.printStackTrace();
throw e;
}
}
/**
* PBIを作成する
*
* @param wi ワークアイテム
* @param iterationPath イテレーションパス
*/
public HttpResponse<String> createPBI(DevOpsWorkItem wi, String iterationPath)
throws Exception {
// リクエストボディを作成
StringBuilder sb = new StringBuilder();
sb.append("[");
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_ITERATIONPATH, iterationPath));
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_DESCRIPTION, wi.getDescription()));
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_TITLE, wi.getTitle()));
sb.append("]");
String body = sb.toString();
return ExternalServiceConnection.sendHttpRequest(getAzureAPIUrlCreatePBI(), body);
}
/**
* タスクを作成する
*
* @param parentID 親USのID
* @param wi ワークアイテム
* @param iterationPath イテレーションパス
*/
public HttpResponse<String> createTask(int parentID, DevOpsWorkItem wi, String iterationPath)
throws Exception {
// リクエストボディを作成
StringBuilder sb = new StringBuilder();
sb.append("[");
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_TITLE, wi.getTitle()));
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_REMAININGWORK, wi.getRemainingWork()));
sb.append(createWorkItemJson(WORK_ITEM_OPTION_ADD, WORK_ITEM_PATH_ITERATIONPATH, iterationPath));
sb.append(createWorkItemRelationJson(parentID));
sb.append("]");
String body = sb.toString();
return ExternalServiceConnection.sendHttpRequest(getAzureAPIUrlCreateTask(), body);
}
/**
* WorkItem JSONオブジェクトを作成するための補助メソッド
*
* @param op オプション
* @param path パス
* @param value 値
*/
private String createWorkItemJson(String op, String path, String value) {
if (value == null || value.isEmpty()) {
return "";
}
return String.format("{\"op\": \"%s\", \"path\": \"%s\", \"value\": \"%s\"},", op, path, escapeJson(value));
}
/**
* WorkItem 関連付けJSONオブジェクトを作成するための補助メソッド
*
* @param parentID ID
*/
private String createWorkItemRelationJson(int parentID) {
String relationValue = String.format(
"{ \"rel\": \"System.LinkTypes.Hierarchy-Reverse\", \"url\": \"https://dev.azure.com/{プロジェクト名}/_apis/wit/workItems/%s\", \"attributes\": { \"comment\": \"Making a new link for the purpose of the bulk add\" } }",
parentID);
return String.format("{\"op\": \"%s\", \"path\": \"%s\", \"value\": %s}", "add", "/relations/-",
relationValue);
}
/**
* AzureDevOpsAPIのURLを返却する.
* PBI新規作成URL
*
* @param parentID
* @return
*/
private String getAzureAPIUrlCreatePBI() {
return "https://dev.azure.com/"
+ {組織名}
+ "/"
+ {プロジェクト名}
+ "/_apis/wit/workitems/$Product%20Backlog%20Item?api-version="
+ {APIバージョン};
}
/**
* AzureDevOpsAPIのURLを返却する.
* Task新規作成URL
*
* @param parentID
* @return
*/
private String getAzureAPIUrlCreateTask() {
return "https://dev.azure.com/"
+ {組織名}
+ "/"
+ {プロジェクト名}
+ "/_apis/wit/workitems/$Task?api-version="
+ {APIバージョン};
}
/**
* Json文字列のエスケープを行う
*
* @param str
* @return エスケープ後の文字列
*/
public static String escapeJson(String str) {
return str.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("\b", "\\b")
.replace("\f", "\\f");
}
}
<ExternalServiceConnection>
@Component
public class ExternalServiceConnection {
/** Environment(プロパティ値保持クラス). */
@Autowired
Environment environment;
/**
* 引数の素材ファイルをBLOBストレージからダウンロードする
*
* @param materialUrl 素材ファイルパス
* @param tmpFilePath 一時ファイル格納パス
*/
public String downloadBlobClient(String blobPath, String tmpFilePath) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// BLOBコンテナに接続
BlobContainerClient blobContainerClient = getAutoTicketBlobContainerClient();
BlobClient blobClient = blobContainerClient.getBlobClient(blobPath);
// blobからCSVをダウンロード
blobClient.download(baos);
String csvContent = baos.toString(StandardCharsets.UTF_8);
return csvContent;
} catch (Exception e) {
throw e;
}
}
/**
* プロパティ値をもとにBlobContainerClientオブジェクトを生成する.
*
* @return BlobContainerClientオブジェクト
*/
private BlobContainerClient getAutoTicketBlobContainerClient() {
return new BlobContainerClientBuilder()
.connectionString({接続文字列}))
.containerName({blobストレージコンテナ名}))
.buildClient();
}
// PBIを作成する
public static HttpResponse<String> sendHttpRequest(String url, String body)
throws Exception {
// PATをエンコード
String encodedPAT = Base64.getEncoder().encodeToString((":" +
{PAT}).getBytes());
// リクエスト作成
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json-patch+json")
.header("Authorization", "Basic " + encodedPAT)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// リクエスト送信
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
}
<DevOpsForm>
/**
* DevOpsformクラス.
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DevOpsForm implements Serializable {
/** serialVersionUID. */
private static final long serialVersionUID = 1L;
/** 自動起票対象の名前. */
@NonNull
private String targetWorkItemName;
/** イテレーションパス. */
private String iterationPath;
}
<DevOpsWorkItem>
/**
* AzureコストCSV行クラス.
*
*/
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DevOpsWorkItem implements Serializable {
/** ステータス */
@CsvBindByName(column = "State")
private String state;
/** ID */
@CsvBindByName(column = "ID")
private String Id;
/** ワークアイテムタイプ */
@CsvBindByName(column = "Work Item Type")
private String workItemType;
/** タグ */
@CsvBindByName(column = "Tags")
private String tag;
/** タイトル */
@CsvBindByName(column = "Title")
private String title;
/** AC */
@CsvBindByName(column = "Acceptance Criteria")
private String acceptanceCriteria;
/** ディスクリプション */
@CsvBindByName(column = "Description")
private String description;
/** リマイニング */
@CsvBindByName(column = "Remaining Work")
private String remainingWork;
/** 見積時間 */
@CsvBindByName(column = "見積時間")
private String estimatedTime;
/** イテレーションパス */
@CsvBindByName(column = "Iteration Path")
private String iterationPath;
/** 親PBIのID */
@CsvBindByName(column = "Parent")
private String Parent;
}
振り返り
今回の取り組みを通じて、Azure DevOps REST APIを活用したバックログ起票の自動化には次のような学びがありました。
最後に : Azure DevOps Service REST APIを活用してみて
Azure DevOps REST APIを活用することで、チケット管理の手間を大きく減らせることが実感できました。
最初はAPIの仕様に慣れるのに時間がかかりましたが、一度仕組みを作ってしまえば、毎スプリントのバックログ起票を完全に自動化でき、開発チームの生産性が大幅に向上しました。
今後は、さらに以下のような改善や拡張も検討できそうです。
- Power AutomateやAzure Functionsとの連携で、より柔軟な自動化が可能に
- Webhookを使ったリアルタイムのワークフロー自動化
この記事が、Azure DevOpsの自動化に挑戦する皆さんの参考になれば嬉しいです。
それでは、良い開発ライフを!