전편을 안보신분은 #1을 먼저 보실것을 추천드립니다.

 

C#으로 JIRA API REST 제어/연동하기 #1

프로젝트를 진행하다 보면 JIRA를 사용하는 경우가 많다. 흔히 우리가 알고있는 아틀라시안 JIRA를 내가 있는 현장에서도 많이 사용하고 있었다. Atlassian | 소프트웨어 개발 및 협업 도구 전 세계

racer42.tistory.com

#2에서는 메인화면의 구성과 내용에 대해서 설명하겠습니다.

필요했던 기능은 크게 두가지입니다.
  1. JIRA 티켓생성기능(Create Ticket)
  2. JRIA 티켓검색기능(Search Ticket)

JIRA 티켓생성기능(Create Ticket)
이 티켓을 어떻게 만들어야할까...
고민하면서 여러가지 자료를 검토해 보았습니다.
우선 먼저 만들어두신 분들이 있기에 JAVA 관련 자료는 아래를 참고하시면 될것 같습니다.

 

Jira Software Rest API 이용한 이슈 등록

"Jira Rest API 이용한 이슈 등록 "● REST API 기존에 사용하는 서비스에서 이슈를...

blog.naver.com

저는 C#으로 만들예정이므로 아래의 페이지를 참고하였습니다.

 

.NET Framework: 780. C# - JIRA REST API 사용 정리

.NET Framework: 780. C# - JIRA REST API 사용 정리 [링크 복사], [링크+제목 복사] 조회: 6293 글쓴 사람 정성태 (techsharer at outlook.com) 홈페이지 첨부 파일 [jira_rest_api_sample.zip]     부모글 보이기/감추기 C# - J

www.sysnet.pe.kr

JIRA REST API 의 샘플은 공식문서를 참고하였습니다.

 

Jira REST API examples

Jira REST API examples This guide contains different examples of how to use the Jira REST API, including how to query issues, create an issue, edit an issue, and others. The reference documentation for the Jira Server platform REST API is here:  Jira Serv

developer.atlassian.com

우선, 사용되는 클래스 정의는 아래와 같습니다.

using System;
using System.Collections.Generic;

public class Progress
{
    public int progress { get; set; }
    public int total { get; set; }
}

public class Issuetype
{
    public string self { get; set; }
    public string id { get; set; }
    public string description { get; set; }
    public string iconUrl { get; set; }
    public string name { get; set; }
    public bool subtask { get; set; }
}

public class Votes
{
    public string self { get; set; }
    public int votes { get; set; }
    public bool hasVoted { get; set; }
}

public class Resolution
{
    public string self { get; set; }
    public string id { get; set; }
    public string description { get; set; }
    public string name { get; set; }
}

public class AvatarUrls
{
    public string __invalid_name__16x16 { get; set; }
    public string __invalid_name__24x24 { get; set; }
    public string __invalid_name__32x32 { get; set; }
    public string __invalid_name__48x48 { get; set; }
}

public class Reporter
{
    public string self { get; set; }
    public string name { get; set; }
    public string emailAddress { get; set; }
    public AvatarUrls avatarUrls { get; set; }
    public string displayName { get; set; }
    public bool active { get; set; }
}

public class Priority
{
    public string self { get; set; }
    public string iconUrl { get; set; }
    public string name { get; set; }
    public string id { get; set; }
}

public class Watches
{
    public string self { get; set; }
    public int watchCount { get; set; }
    public bool isWatching { get; set; }
}

public class Status
{
    public string self { get; set; }
    public string description { get; set; }
    public string iconUrl { get; set; }
    public string name { get; set; }
    public string id { get; set; }
}

public class AvatarUrls2
{
    public string __invalid_name__16x16 { get; set; }
    public string __invalid_name__24x24 { get; set; }
    public string __invalid_name__32x32 { get; set; }
    public string __invalid_name__48x48 { get; set; }
}

public class Assignee
{
    public string self { get; set; }
    public string name { get; set; }
    public string emailAddress { get; set; }
    public AvatarUrls2 avatarUrls { get; set; }
    public string displayName { get; set; }
    public bool active { get; set; }
}

public class AvatarUrls3
{
    public string __invalid_name__16x16 { get; set; }
    public string __invalid_name__24x24 { get; set; }
    public string __invalid_name__32x32 { get; set; }
    public string __invalid_name__48x48 { get; set; }
}

public class Project
{
    public string self { get; set; }
    public string id { get; set; }
    public string key { get; set; }
    public string name { get; set; }
    public AvatarUrls3 avatarUrls { get; set; }
}

public class Customfield10300
{
    public string self { get; set; }
    public string value { get; set; }
    public string id { get; set; }
}

public class Aggregateprogress
{
    public int progress { get; set; }
    public int total { get; set; }
}

public class Status2
{
    public string self { get; set; }
    public string description { get; set; }
    public string iconUrl { get; set; }
    public string name { get; set; }
    public string id { get; set; }
}

public class Issuetype2
{
    public string self { get; set; }
    public string id { get; set; }
    public string description { get; set; }
    public string iconUrl { get; set; }
    public string name { get; set; }
    public bool subtask { get; set; }
}

public class Fields2
{
    public string summary { get; set; }
    public Status2 status { get; set; }
    public Issuetype2 issuetype { get; set; }
}

public class Parent
{
    public string id { get; set; }
    public string key { get; set; }
    public string self { get; set; }
    public Fields2 fields { get; set; }
}

public class Fields
{
    public string summary { get; set; }
    public Progress progress { get; set; }
    public Issuetype issuetype { get; set; }
    public Votes votes { get; set; }
    public List<object> fixVersions { get; set; }
    public Resolution resolution { get; set; }
    public DateTime? resolutiondate { get; set; }
    public object timespent { get; set; }
    public Reporter reporter { get; set; }
    public object aggregatetimeoriginalestimate { get; set; }
    public DateTime updated { get; set; }
    public DateTime created { get; set; }
    public string description { get; set; }
    public Priority priority { get; set; }
    public string duedate { get; set; }
    public List<object> issuelinks { get; set; }
    public Watches watches { get; set; }
    public object customfield_10600 { get; set; }
    public List<object> subtasks { get; set; }
    public Status status { get; set; }
    public List<object> labels { get; set; }
    public long workratio { get; set; }
    public Assignee assignee { get; set; }
    public string customfield_10500 { get; set; }
    public object aggregatetimeestimate { get; set; }
    public Project project { get; set; }
    public List<object> versions { get; set; }
    public string environment { get; set; }
    public object timeestimate { get; set; }
    public Customfield10300 customfield_10300 { get; set; }
    public Aggregateprogress aggregateprogress { get; set; }
    public DateTime? lastViewed { get; set; }
    public object timeoriginalestimate { get; set; }
    public object aggregatetimespent { get; set; }
    public Parent parent { get; set; }
}

public class Issue
{
    public string expand { get; set; }
    public string id { get; set; }
    public string self { get; set; }
    public string key { get; set; }
    public Fields fields { get; set; }
}

public class SearchResult
{
    public string expand { get; set; }
    public int startAt { get; set; }
    public int maxResults { get; set; }
    public int total { get; set; }
    public List<Issue> issues { get; set; }
}

 

먼저 검색기능을 구현해 보았습니다.

        /// <summary>
        /// 作業中
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private async void btnSearch_Click(object sender, EventArgs e)
        {
            // Grid初期化
            dgDoing.Rows.Clear();

            // 件数カウンター
            lblCount1.Text = 0.ToString();

            // ①.JIRA認証を行う
            Jira jira = new Jira();
            if (await jira.Login(jiraServer, id, password) == false)
            {
                Console.WriteLine("Auth failed: " + id);
                return;
            }

            // ②.アサインを整理する
            assignee = SetAssignMember();

            // ③.fixversionを整理する
            fixversion = SetFixVersion();

            // ③.検索を実施する
            SearchResult resultDoing = await jira.GetIssuesByAssignee(txtDate.Text, fixversion, assignee, setLabels(), 0, true);

            // チケット設定
            ListDoing = resultDoing.issues;

            // ◆◆◆◆◆ DOINGリスト ◆◆◆◆◆
            lblCount1.Text = 0.ToString();
            if (ListDoing != null)
            {
                ListDoing.Sort((a, b) => string.Compare(a.fields.assignee.displayName, b.fields.assignee.displayName));
                ListDoing = resultDoing.issues;
                int count = 1;

                foreach (var issue in ListDoing)
                {
                    dgDoing.Rows.Add(count++, issue.fields.assignee.displayName, jiraUri + issue.key, issue.fields.summary, issue.fields.status.name, issue.fields.updated, issue.fields.duedate);
                }

                if (ListDoing.Count == 51)
                {
                    resultDoing = await jira.GetIssuesByAssignee(txtDate.Text, fixversion, assignee, setLabels(), count, true);
                    ListDoing = resultDoing.issues;
                    ListDoing.Sort((a, b) => string.Compare(a.fields.assignee.displayName, b.fields.assignee.displayName));

                    count = 51;
                    foreach (var issue in ListDoing)
                    {
                        dgDoing.Rows.Add(count++, issue.fields.assignee.displayName, jiraUri + issue.key, issue.fields.summary, issue.fields.status.name, issue.fields.updated, issue.fields.duedate);
                    }
                }

                if (ListDoing.Count == 101)
                {
                    resultDoing = await jira.GetIssuesByAssignee(txtDate.Text, fixversion, assignee, setLabels(), count, true);
                    ListDoing = resultDoing.issues;
                    ListDoing.Sort((a, b) => string.Compare(a.fields.assignee.displayName, b.fields.assignee.displayName));

                    count = 51;
                    foreach (var issue in ListDoing)
                    {
                        dgDoing.Rows.Add(count++, issue.fields.assignee.displayName, jiraUri + issue.key, issue.fields.summary, issue.fields.status.name, issue.fields.updated, issue.fields.duedate);
                    }
                }

                if (ListDoing.Count == 151)
                {
                    resultDoing = await jira.GetIssuesByAssignee(txtDate.Text, fixversion, assignee, setLabels(), count, true);
                    ListDoing = resultDoing.issues;
                    ListDoing.Sort((a, b) => string.Compare(a.fields.assignee.displayName, b.fields.assignee.displayName));

                    count = 51;
                    foreach (var issue in ListDoing)
                    {
                        dgDoing.Rows.Add(count++, issue.fields.assignee.displayName, jiraUri + issue.key, issue.fields.summary, issue.fields.status.name, issue.fields.updated, issue.fields.duedate);
                    }
                }

                // 件数カウンター
                lblCount1.Text = (count - 1).ToString();
            }
        }

여기서 왜 ??
아래처럼 건수별 분기를 하였는지 설명드리자면, json 형식으로 데이터를 수신하면서 최대 수신 문자열을 초과하는 상황이 발생하므로 카운팅된 건수 다음을 다시 가져오게끔 하기위해서 두번째 세번째 요구를 보내고 있습니다.
지금은 200건까지 데이터를 가져오도록 하였는데 더 확장하는것도 가능합니다.

                foreach (var issue in ListDoing)
                {
                    dgDoing.Rows.Add(count++, issue.fields.assignee.displayName, jiraUri + issue.key, issue.fields.summary, issue.fields.status.name, issue.fields.updated, issue.fields.duedate);
                }

                if (ListDoing.Count == 51)
                {
                    resultDoing = await jira.GetIssuesByAssignee(txtDate.Text, fixversion, assignee, setLabels(), count, true);
                    ListDoing = resultDoing.issues;
                    ListDoing.Sort((a, b) => string.Compare(a.fields.assignee.displayName, b.fields.assignee.displayName));

                    count = 51;
                    foreach (var issue in ListDoing)
                    {
                        dgDoing.Rows.Add(count++, issue.fields.assignee.displayName, jiraUri + issue.key, issue.fields.summary, issue.fields.status.name, issue.fields.updated, issue.fields.duedate);
                    }
                }

                if (ListDoing.Count == 101)
                {
                    resultDoing = await jira.GetIssuesByAssignee(txtDate.Text, fixversion, assignee, setLabels(), count, true);
                    ListDoing = resultDoing.issues;
                    ListDoing.Sort((a, b) => string.Compare(a.fields.assignee.displayName, b.fields.assignee.displayName));

                    count = 51;
                    foreach (var issue in ListDoing)
                    {
                        dgDoing.Rows.Add(count++, issue.fields.assignee.displayName, jiraUri + issue.key, issue.fields.summary, issue.fields.status.name, issue.fields.updated, issue.fields.duedate);
                    }
                }

                if (ListDoing.Count == 151)
                {
                    resultDoing = await jira.GetIssuesByAssignee(txtDate.Text, fixversion, assignee, setLabels(), count, true);
                    ListDoing = resultDoing.issues;
                    ListDoing.Sort((a, b) => string.Compare(a.fields.assignee.displayName, b.fields.assignee.displayName));

                    count = 51;
                    foreach (var issue in ListDoing)
                    {
                        dgDoing.Rows.Add(count++, issue.fields.assignee.displayName, jiraUri + issue.key, issue.fields.summary, issue.fields.status.name, issue.fields.updated, issue.fields.duedate);
                    }
                }

실제 검색을 날리는 부분을 살펴보면
등장하는 jql 은 
duedate : 기간
assignee : 담당
fixversion : 픽스버젼
labels : 라벨
project = 프로젝트명 <- 삭제하였음
등이 있습니다.
jql 의 사용법에 대해서는 developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ 페이지를 참고하세요.

        /// <summary>
        /// 複合的な自由検索
        /// </summary>
        /// <param name="date"></param>
        /// <param name="fixversion"></param>
        /// <param name="assignee"></param>
        /// <returns></returns>
        public async Task<SearchResult> GetIssuesByAssignee(string date, string fixversion, string assignee, string labels, int startAt, bool doneJudge)
        {
            string url = _baseUrl + "search?jql=duedate < " + date + "d and assignee in (" + assignee + ")";

            string url2 = 0.Equals(startAt) ?
                string.Empty : " and startAt = " + startAt.ToString();

            url2 = !string.IsNullOrEmpty(fixversion) ?
                " and fixVersion in (" + fixversion + ")" : string.Empty;

            if (!string.IsNullOrEmpty(labels))
            {
                url2 += " and labels in (" + labels + ")";
            }

            string url3 = " and status not in (Closed,Resolved)";
            string url4 = " and status in (Closed,Resolved)";

            HttpResponseMessage hrm = doneJudge ? await _httpClient.GetAsync(url + url2 + url3) : await _httpClient.GetAsync(url + url2 + url4);

            //string text = await hrm.Content.ReadAsStringAsync();
            SearchResult result = Newtonsoft.Json.JsonConvert.DeserializeObject<SearchResult>(await hrm.Content.ReadAsStringAsync());
            return result;
        }

이것을 

Newtonsoft.Json.JsonConvert.DeserializeObject<SearchResult>(await hrm.Content.ReadAsStringAsync());

을 통해서 요청하고 검색결과를 SearchResult 클래스 형태로 담아냅니다.

Done검색에서는 Jql만 조금 바뀐형태인데, Status 의 쿼리를 약간 변형한 형태입니다.

await _httpClient.GetAsync(url + url2 + url4)

url / url2 / url4 를 합쳐서 쿼리를 날리고있습니다.

url3 과 url4 의 차이점

여기까지 완료되면, 아래처럼 검색결과가 나오는것을 보실수 있습니다.

검색결과

데이터 그리드의 링크는 클릭하시면, 지정된 브라우져를 통해서 새창으로 보실수있습니다.

 

DataGridView 클래스 (System.Windows.Forms)

사용자 지정 가능한 표에 데이터를 표시합니다.Displays data in a customizable grid.

docs.microsoft.com

#3에서 등록에 대해서 다루어 보겠습니다.
현재 다루고있는 소스의 전체코드는 아래에 있습니다.

 

 

sungmanko/JiraRestAPI

This is a JIRA ticket automatic creation and management project created in C#. - sungmanko/JiraRestAPI

github.com

 

프로젝트를 진행하다 보면 JIRA를 사용하는 경우가 많다.
흔히 우리가 알고있는 아틀라시안 JIRA를 내가 있는 현장에서도 많이 사용하고 있었다.

 

Atlassian | 소프트웨어 개발 및 협업 도구

전 세계 수백 만 사용자가 Atlassian 제품을 이용해 소프트웨어 개발과 프로젝트 관리, 협업, 코드 품질을 개선하고 있습니다.

www.atlassian.com

이번 주제는 이 JIRA를 많이 사용해서 Agile을 진행하면서 과제를 지속적으로 생성하고 관리함에 있어서 팀원들이 불편함을 많이 느끼고 있어서 툴을 하나 만들어보면서 느낀 점과 소스코드 등에 대해서 다루어보려고 한다.

들어가기에 앞서서, Agile(애자일)이 무엇인지 간략히 말하자면, 레드햇에서는 다음과 같이 정의하고 있다.
"애자일은 신속한 반복 작업을 통해 실제 작동 가능한 소프트웨어를 개발하여 지속적으로 제공하기 위한 소프트웨어 개발 방식입니다. "

 

애자일 방법론이란?

애자일은 신속한 반복 작업을 통해 실제 작동 가능한 소프트웨어를 개발하여 지속적으로 제공하기 위한 소프트웨어 개발 방식입니다.

www.redhat.com

신속한 반복 작업.. 이것을 하기 위해서 JIRA의 티켓을 무수하게 만들어가는 과정 중에 
"자동화를 해서 관리할 수 없나?"
"JIRA 페이지를 다음다음 다음~~ 눌러가면서 생성하는 것 자체도 시간낭비가 많다"
등등의 의견이 팀 내에서 많이 발생하였기에
JIRA API를 사용해서 개발하여
프로젝트 / 라벨 / 담당자 등을 자동으로 기입한 상태로 티켓을 만들어주는 툴을 개발하기로 마음먹었다.

여기서 다루고자 하는 JIRA API는 아래의 페이지를 참고하였습니다.

 

REST APIs

REST APIs The Jira REST APIs are used to interact with the Jira Server applications remotely, for example, when configuring webhooks. The Jira Server platform provides the REST API for common features, like issues and workflows. To get started, read the re

developer.atlassian.com

우선 현재까지 완성된 동작 화면을 보여드리자면, 다음과 같습니다.

로그인화면
로그인인증성공
메인화면

실제로 현장에서 사용 중이고, 이 툴로 인해서 개선된 점은 크게 두 가지가 있다.
1. 티켓을 생성함에 있어서 스트레스가 줄었다.
2. 현재 티켓의 현황을 간략하게 볼 수 있어서 DailyScrum에서 활용하면서 서로의 상황을 이야기하게 되었다.

우선, #1 에서는 로그인 기능에 대해서 다루어보고자 한다.
모든 소스는 깃허브에 등록해두었으니, 더 나은 기능이 있다면, 추가/제어를 해 나가면 좋을 것 같습니다.
느낀 점 피드백 등이 있으면 언제든지 추가할 예정이니 많은 참여 부탁드립니다.

그럼, 시작해보겠습니다.

로그인화면>

Server : 도메인
URL :  도메인/jira/browse/

도메인부분에 사용하시는 JIRA 서버의 URL을 적어주세요.

ID : 실제사용되고있는 유져의 아이디
PW : 실제사용되고있는 유져의 패스워드

사용된 소스는 아래와 같습니다.

using System;
using System.Windows.Forms;

namespace JiraApiControl
{
    public partial class frmLogin : Form
    {
        string id = string.Empty;
        string password = string.Empty;
        string jiraServer = string.Empty;
        string jiraUri = string.Empty;

        public frmLogin()
        {
            InitializeComponent();
        }

        private async void btnLogin_Click(object sender, EventArgs e)
        {
            Jira jira = new Jira();
            if (await jira.Login(txtServer.Text, txtID.Text, txtPW.Text) == false)
            {
                lblResult.Text = "AuthFailed:" + txtID.Text;
                btnRun.Visible = false;
                return;
            }
            else
            {
                jiraServer = txtServer.Text;
                jiraUri = txtURL.Text;
                id = txtID.Text;
                password = txtPW.Text;
                btnRun.Visible = true;

                lblResult.Text = string.Empty;
            }
        }

        /// <summary>
        /// 確認ボタンイベント処理
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void btnRun_Click(object sender, EventArgs e)
        {
            this.Hide();

            frmMain frmMain = new frmMain(jiraServer, jiraUri, id, password);
            frmMain.Show();
        }

        private void txtPW_KeyDown(object sender, KeyEventArgs e)
        {
            if (e.KeyCode == Keys.Enter)
            {
                btnLogin.PerformClick();
            }
        }
    }
}

btnLogin_Click
 -> 인증버튼 클릭이벤트

btnRun_Click
 -> 확인버튼 클릭이벤트

txtPW_KeyDown
 -> 엔터버튼 눌러졌을때 인증버튼을 대신 눌러주는 이벤트

도메인 정보에 대해서는 외부파일로 구성을 하면 좋았을것 같다는 생각도 들었지만,
제가 근무하는 JIRA의 경우에는 5번 패스워드를 틀리면 계정이 Lock 걸리는 문제가 있기때문에
인증정보를 눈으로 봐가면서 진행할 필요가 있어서 부득이하게 로그인 화면을 구성하였습니다.

편의에 따라서 이 화면을 생략하고 사용하셔도 좋을것 같습니다.

위에서 사용된 KeyEventArgs는 아래의 문서에서 상세한 정보를 얻으실수 있습니다.

 

KeyEventArgs 클래스 (System.Windows.Forms)

KeyDown 또는 KeyUp 이벤트에 데이터를 제공합니다.Provides data for the KeyDown or KeyUp event.

docs.microsoft.com

 

C# JIRA REST API 컨트롤에 대한 소스는 아래에 올려두었습니다.

 

sungmanko/JiraRestAPI

This is a JIRA ticket automatic creation and management project created in C#. - sungmanko/JiraRestAPI

github.com

궁금한것이 있으시면 댓글이나, 깃허브에 코멘트 주세요~
#2 메인화면으로 이어집니다.

즐프합시다~

+ Recent posts