SalesforceでBacklogチケット状況をプロジェクト横断的に見れるようにしてみた。

目的

弊社はBacklogを利用していますが、Backlogには全体のサマリ機能がなく、
プロジェクトやチケットが増えるほど、担当者が複数のプロジェクトにまたがるほど、
全体の俯瞰がしにくいです。

その為、Backlog API を利用してチケットを Salesforce へ取り込み、
全体をレポートしちゃおうというのが目的です。

システム概要

Salesforce 側にチケット保管用のオブジェクト作成、および、APIコール用のプログラムを作成し、
1日に1度、Backlog チケットを Salesforce へ取り込み、
プロジェクト横断的なチケット状況をレポートで一発で閲覧できるようにします。

Backlog側の設定

Backlog API の詳細は こちら をご参照下さい。

認証方式は API Key を利用しますので、Backlog 管理者でログインし、
ユーザアイコン → 個人設定 → API → 新しいAPIキーを発行/メモ欄に任意に入力 → 登録ボタン
→ APIキーを控えておいて下さい。

Backlog 側の準備はこれで終了。

Salesforce側の構築

リアルタイムに見れる必要はないと思い、1日に1回、チケット取得ジョブが起動する作りにしました。
以下は実際に弊社で利用しているコードです。

1)チケットオブジェクト(ProjectTicket__c)作成

項目の表示ラベル項目名データ型内容
Backlog リンクBacklogLink__c数式 (テキスト)HYPERLINK(“https://3a-inc.backlog.com/view/” & IssueKey__c, IssueKey__c, “_blank”)
カテゴリCategory__cテキスト(80)
チケット作成日CreatedDateTime__c日付/時間
チケット名Nameテキスト(80)
プロジェクト名ProjectName__cテキスト(255)
レコードタイプRecordTypeIdレコードタイプBacklog, GitHub, Jira
予定工数EstimatedHours__c数値(10、2)
優先度Priority__cテキスト(10)
実績工数ActualHours__c数値(10、2)
担当者Assignee__cテキスト(80)
期限日DueDate__c日付
状況Status__cテキスト(10)
社員Member__c参照関係(社員)
課題キーIssueKey__cテキスト(80) (外部 ID)
開始日StartDate__c日付

2)リモートサイトに Backlog ドメインを登録

3)Apex コード作成

  • スケジューラ
public with sharing class BacklogScheduler implements Schedulable {

  // スケジュール実行
  public void execute(SchedulableContext sc) {
    retrieveBacklogToSalesforceAsync();
  }

  @future(callout=true)
  private static void retrieveBacklogToSalesforceAsync() {
    Backlog.retrieveBacklogToSalesforce();
  }
}
  • チケット取得処理
public with sharing class Backlog {

  private static final String BASIC_ENDPOINT = 'https://{ワークスペース名}.backlog.com';
  private static final String API_KEY = '{上記で控えたAPI Key}';
  // 課題一覧: 100件単位で取得
  private static final Integer TICKET_UNIT = 100;

  public class BacklogException extends Exception {}

  // 課題情報をSFDCへ保管
  public static void retrieveBacklogToSalesforce() {
    Id backlogTypeId = sObjectType.ProjectTicket__c.getRecordTypeInfosByDeveloperName().get('Backlog').getRecordTypeId();
    // Map<nulabId, 社員Id>
    Map<String, Id> nulabMemberMap = new Map<String, Id>();
    for (Member__c member : [select Id, nulabId__c from Member__c]) {
      if (String.isNotBlank(member.nulabId__c)) {
        nulabMemberMap.put(member.nulabId__c, member.Id);
      }
    }

    Map<String, Project> projects = retrieveProjects();
    List<Issue> issues = retrieveIssues();

    List<ProjectTicket__c> tickets = new List<ProjectTicket__c>();
    Set<String> issueKeys = new Set<String>();
    for (Issue issue : issues) {
      Project p = projects.get(issue.projectId);
      String subject = issue.summary;
      if (subject.length() > 80) {
        subject = subject.substring(0, 80);
      }
      issueKeys.add(issue.issueKey);
      // 社員Id
      Id memberId = (issue.assignee != null && issue.assignee.nulabAccount != null && issue.assignee.nulabAccount.nulabId != null) ?
        nulabMemberMap.get(issue.assignee.nulabAccount.nulabId) : null;
      // カテゴリ
      String category = (issue.category != null && issue.category.size() > 0) ? issue.category[0].name : null;

      tickets.add(new ProjectTicket__c(
        Name = subject,
        RecordTypeId = backlogTypeId,
        ProjectName__c = p.name,
        IssueKey__c = issue.issueKey,
        Priority__c = (issue.priority == null) ? null : issue.priority.name,
        Category__c = category,
        EstimatedHours__c = issue.estimatedHours,
        ActualHours__c = issue.actualHours,
        Assignee__c = (issue.assignee == null) ? null : issue.assignee.name,
        Member__c = memberId,
        Status__c = issue.status.name,
        CreatedDateTime__c = (issue.created == null) ? null : issue.created.date(),
        StartDate__c = (issue.startDate == null) ? null : issue.startDate.date(),
        DueDate__c = (issue.dueDate == null) ? null : issue.dueDate.date()
      ));
    }
    upsert tickets IssueKey__c;
    delete [select Id from ProjectTicket__c where IssueKey__c not in :issueKeys];
  }

  // Map<プロジェクトId, プロジェクト>
  private static Map<String, Project> retrieveProjects() {
    Map<String, Project> result = new Map<String, Project>();

    // callout
    HttpResponse res = calloutGetRequest('/api/v2/projects', null);
    if (res.getStatusCode() != 200) {
      throw new BacklogException('プロジェクト一覧の取得に失敗しました。');
    }

    List<Project> projectList = (List<Project>)JSON.deserialize(res.getBody(), List<Project>.class);
    for (Project p : projectList) {
      String key = p.id;
      result.put(key, p);
    }
    return result;
  }

  // List<課題情報>
  private static List<Issue> retrieveIssues() {
    List<Issue> result = new List<Issue>();

    Boolean hasNext = true;
    Integer offSet = 0;

    while(hasNext) {
      // callout
      HttpResponse res = calloutGetRequest('/api/v2/issues', new Map<String, String> {
        'count' => String.valueOf(TICKET_UNIT),
        'offset' => String.valueOf(offSet)
      });
      if (res.getStatusCode() != 200) {
        throw new BacklogException('課題一覧の取得に失敗しました。');
      }

      List<Issue> issues = (List<Issue>)JSON.deserialize(res.getBody(), List<Issue>.class);
      result.addAll(issues);

      if (issues.size() < TICKET_UNIT) {
        hasNext = false;
      }
      offSet += TICKET_UNIT;
    }
    return result;
  }

  private static HttpResponse calloutGetRequest(String path, Map<String, String> queryParams) {
    // apiKey は常に必要
    List<String> params = new List<String> {
      'apiKey=' + API_KEY
    };

    if (queryParams != null) {
      for (String key : queryParams.keySet()) {
        params.add(key + '=' + queryParams.get(key));
      }
    }
    Http h = new Http();
    HttpRequest req = new HttpRequest();
    req.setMethod('GET');
    req.setEndPoint(BASIC_ENDPOINT + path + '?' + String.join(params, '&'));
    return h.send(req);
  }

  // プロジェクト
  public class Project {
    public String id {get; set;}
    public String projectKey {get; set;}
    public String name {get; set;}
    public Boolean archived {get; set;}
  }

  // 課題
  public class Issue {
    public String projectId {get; set;}
    public String issueKey {get; set;}
    public String summary {get; set;}
    public IssuePriority priority {get; set;}
    public IssueStatus status {get; set;}
    public IssueAssignee assignee {get; set;}
    public List<IssueCategory> category {get; set;}
    public DateTime startDate {get; set;}
    public DateTime dueDate {get; set;}
    public Decimal estimatedHours {get; set;}
    public Decimal actualHours {get; set;}
    public String parentIssueId {get; set;}
    public DateTime created {get; set;}
  }
  public class IssuePriority {
    public String name {get; set;}
  }
  public class IssueStatus {
    public String name {get; set;}
  }
  public class IssueAssignee {
    public String userId {get; set;}
    public String name {get; set;}
    public UserNulabAccount nulabAccount {get; set;} 
  }
  public class IssueCategory {
    public String name {get; set;}
  }
  public class UserNulabAccount {
    public String nulabId {get; set;}
  }
}
  • Apex のスケジュール設定

Apex クラス一覧から「Apex をスケジュール」ボタンをクリックし、以下のように任意にスケジュール設定。

4)レポート作成

レポートの作り方は割愛しますが、弊社は担当者ごと、プロジェクトごと、状況ごとにチケット状況を俯瞰できるようにしました!

※ 以下のように、特定の担当者・特定のプロジェクトの処理中のチケット一覧、
  と言った閲覧も可能です。

総括

全体を俯瞰しやすくなりました!
期限切れフラグを数式で作成して、期限切れチケットを一発で抽出できるようにしたりとかもできるかも。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です