F#dotnetazure

JIRA work log API with F# and Azure Functions

Published at 2019-02-23

In any software environment that uses the project management system JIRA, I'm sure that copying your work log from JIRA to another work log system is a tidius and manual task. I found myself doing exactly that. Now, in an effort to kill two birds with one stone, I'll write a program to get my work log from JIRA and format to a structure that I desire while learning F# and Azure Functions!

I figured out that JIRA has an API for "search" (/rest/api/3/search). This API allows you to use the JQL (JIRA Query Language) to do filtering. The API returns a list of issues. When you call the API it's important to send an authorization header. JIRA uses basic auth with your JIRA user name and password. An example for getting issues worked on in the month of January: /rest/api/3/search?startIndex=0&jql=worklogDate >= 2019-01-01 and worklogDate <= 2019-01-31 and worklogAuthor = karl.solgard&fields=self,key,summary,worklog,customfield_10600.

{
    "expand": "schema,names",
    "startAt": 0,
    "maxResults": 50,
    "total": 26,
    "issues": [
        {
            "expand": "operations,versionedRepresentations,editmeta,changelog,renderedFields",
            "id": "1000",
            "self": "https://<accountname>.atlassian.net/rest/api/3/issue/1000",
            "key": "HELLO-1000",
            "fields": {
                "summary": "Production is down! God help us!",
                "customfield_10600": 100,
                "worklog": {
                    "startAt": 0,
                    "maxResults": 20,
                    "total": 1,
                    "worklogs": [
                        {
                            "self": "https://<accountname>.atlassian.net/rest/api/3/issue/1000/worklog/1000",
                            "author": {
                                "self": "https://<accountname>.atlassian.net/rest/api/3/user?accountId=XXXXXXX",
                                "name": "karl.solgard",
                                "key": "karl.solgard",
                                "accountId": "XXXXXXX",
                                "emailAddress": "[email protected]",
                                "avatarUrls": {
                                   ...
                                },
                                "displayName": "Karl Solgård",
                                "active": true,
                                "timeZone": "America/Chicago"
                            },
                            "updateAuthor": {
                                "self": "https://<accountname>.atlassian.net/rest/api/3/user?accountId=XXXXXXX",
                                "name": "karl.solgard",
                                "key": "karl.solgard",
                                "accountId": "XXXXXXX",
                                "emailAddress": "[email protected]",
                                "avatarUrls": {
                                   ...
                                },
                                "displayName": "Karl Solgård",
                                "active": true,
                                "timeZone": "America/Chicago"
                            },
                            "comment": {},
                            "created": "2019-01-30T06:18:00.512-0600",
                            "updated": "2019-01-30T06:18:00.512-0600",
                            "started": "2018-01-30T06:14:00.000-0600",
                            "timeSpent": "1h",
                            "timeSpentSeconds": 3600,
                            "id": "62764",
                            "issueId": "1000"
                        },
                        {
                            "self": "https://<accountname>.atlassian.net/rest/api/3/issue/1000/worklog/1000",
                            "author": {
                                "self": "https://<accountname>.atlassian.net/rest/api/3/user?accountId=XXXXXXX",
                                "name": "other.user",
                                "key": "other.user",
                                "accountId": "XXXXXXX",
                                "emailAddress": "[email protected]",
                                "avatarUrls": {
                                   ...
                                },
                                "displayName": "Joe Peterson",
                                "active": true,
                                "timeZone": "America/Chicago"
                            },
                            "updateAuthor": {
                                "self": "https://<accountname>.atlassian.net/rest/api/3/user?accountId=XXXXXXX",
                                "name": "other.user",
                                "key": "other.user",
                                "accountId": "XXXXXXX",
                                "emailAddress": "[email protected]",
                                "avatarUrls": {
                                   ...
                                },
                                "displayName": "Joe Peterson",
                                "active": true,
                                "timeZone": "America/Chicago"
                            },
                            "comment": {},
                            "created": "2019-01-30T06:18:00.512-0600",
                            "updated": "2019-01-30T06:18:00.512-0600",
                            "started": "2018-01-30T06:14:00.000-0600",
                            "timeSpent": "1h",
                            "timeSpentSeconds": 3600,
                            "id": "62765",
                            "issueId": "1000"
                        }
                    ]
                }
            }
        },
        ...
    ]
}

This endpoint will give you a whole lot of data. And it needs some love and care. I even scoped the data a bit with the "fields" parameter and added a field 'customfield_10600' that I need to map by later. 'customfield_10600' is a custom field in our JIRA and it's a project id for our payroll system. Let's massage this data with F#. First of all, I followed this post to get started since F# in v2 of Azure Functions only supports compiled F#.

module MyFunctions

open Microsoft.Azure.WebJobs
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Mvc
open System.Net.Http
open System.Text
open System
open System.Net.Http.Headers
open Newtonsoft.Json

type Author = {
    Name: string;
}

type Worklog = {
    TimeSpentSeconds: int;
    Started: DateTime;
    Author: Author;
}

type WorklogResults = {
    Worklogs: Worklog[];
}

type Fields = {
    Summary: string;
    Worklog: WorklogResults;
    Customfield_10600: string;
}

type Issue = {
    Key: string;
    Fields: Fields;
}

type Issues = {
    Total: int;
    Issues: Issue[];
}

type WorkItem = {
    ProjectNumber: string;
    Started: string;
    Summary: string;
    TimeSpentSeconds: int;
    Key: string;
}

type EntryNode = {
    Logs: seq<string>;
    Sum: string;
    Project: string;
}

type Entry = {
    Date: string;
    Projects: seq<EntryNode>;
}

type PostData = {
    Username: string;
    Password: string;
    From: string;
    To: string;
}

let ConvertSecondsToHoursAndMinutes (seconds:int) = 
    let mutable minutes = seconds / 60
    let mutable hours = 0
    let mutable reminder = 0
    if minutes >= 60 then
        hours <- minutes / 60
        reminder <- minutes % 60

        if reminder > 0 then
           reminder <- reminder * 100
           reminder <- reminder / 60
    else
        hours <- 0
        reminder <- minutes * 100
        reminder <- reminder / 60
    
    if reminder < 10 then
        sprintf "%d.0%d" hours reminder
    else
        sprintf "%d.%d" hours reminder


let GetJira<'T> (path:string, username:string, password: string) = async {
    let client = new HttpClient()
    let byteArray = Encoding.ASCII.GetBytes(sprintf "%s:%s" username password)
    client.DefaultRequestHeaders.Authorization <- new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray))
    let! response = client.GetStringAsync(path) |> Async.AwaitTask
    let json = JsonConvert.DeserializeObject<'T>(response)
    return json
}

let GetWorkItems (issues: Issue[], fromDate: DateTime, toDate: DateTime, author: string) = seq {
    for issue in issues do
        let worklog = (issue.Fields.Worklog)
        for wl in worklog.Worklogs do
            if wl.Author.Name.Contains(author) && wl.Started > fromDate && wl.Started < toDate then
                yield { 
                    ProjectNumber = issue.Fields.Customfield_10600; 
                    Started = wl.Started.ToShortDateString(); 
                    Summary = issue.Fields.Summary; 
                    TimeSpentSeconds = wl.TimeSpentSeconds; 
                    Key = issue.Key; 
                }
}

[<FunctionName("nafjiralogger")>]
let run([<HttpTrigger(Extensions.Http.AuthorizationLevel.Anonymous, "post", Route = "nafjiralogger")>] req: HttpRequestMessage) =
    let data = Async.AwaitTask <| req.Content.ReadAsStringAsync() |> Async.RunSynchronously
    let s = JsonConvert.DeserializeObject<PostData> data
    let username = s.Username
    let password = s.Password
    let fromTime = s.From
    let toTime = s.To

    let jql = sprintf "https://nafikt.atlassian.net/rest/api/3/search?startIndex=0&jql=worklogDate >= %s and worklogDate <= %s and worklogAuthor = %s&fields=self,key,summary,worklog,customfield_10600" fromTime toTime username
    let issues = GetJira<Issues>(jql, username, password) |> Async.RunSynchronously

    let workitems = GetWorkItems(issues.Issues, DateTime.Parse(fromTime), DateTime.Parse(toTime), username)

    let worklist = workitems 
                |> Seq.groupBy (fun x -> x.Started) 
                |> Seq.map (fun (key, values) -> (key, Seq.groupBy (fun (x:WorkItem) -> x.ProjectNumber) values))
                |> Seq.sortBy (fun date -> DateTime.Parse(fst date))
                |> Seq.map (fun date -> 
                        let projects = snd date |> Seq.map (fun project -> 
                            let sum = snd project 
                                    |> Seq.sumBy (fun (x:WorkItem) -> x.TimeSpentSeconds) 
                                    |> ConvertSecondsToHoursAndMinutes
                            let logs = snd project |> Seq.map (fun log -> 
                                sprintf "%s: %s" log.Key log.Summary
                            )
                            { Logs = logs; Sum = sum; Project = fst project; }
                        )
                        { Date = fst date; Projects = projects; }
                    )

    let serialized = JsonConvert.SerializeObject worklist
    ContentResult(Content = serialized, ContentType = "application/json")

This piece of code explores a couple of different programming concepts. Generics, async and sequences. First we make the initial request to JIRA with posted credentials. JIRA search API finds the issue by the JQL, but doesn't filter by worklog author and when the worklog was made. After getting the response we filter by worklog author and date time, and returning a sequence of WorkItem. Sequence magic consues to sum the worklogs by day and project number. I hope you like Tuples (fst, snd), cause that's what you get from sequences! We deliver this mapped data back as ContentResult. Make a POST call to /api/nafjiralogger with username, password, from and to date.

[
    {
        "Date": "01/6/2019",
        "Projects": [
            {
                "Logs": [
                    "HELLO-1000: Some task",
                    "HELLO-1001: Some other task",
                    "HELLO-1002: Some task 2"
                ],
                "Sum": "7.00",
                "Project": "100"
            },
            {
                "Logs": [
                    "HELLO-1003: Some task 3"
                ],
                "Sum": "1.00",
                "Project": "101"
            }
        ]
    },
    {
        "Date": "01/7/2019",
        "Projects": [
            {
                "Logs": [
                    "HELLO-1000: Some task",
                    "HELLO-1001: Some other task"
                ],
                "Sum": "7.00",
                "Project": "100"
            },
            {
                "Logs": [
                    "HELLO-1003: Some task 5"
                ],
                "Sum": "1.00",
                "Project": "101"
            }
        ]
    }
]

Now this is a better way to see my hours. F# is a fun language and a good way to introduce functional programming from a .NET perspective. I, for sure, learned a lot from this approach and the language enforces a simple, DRY programming style. I wouldn't recommend doing a F# approach for Azure Functions however, as C# has better support. I hope this post helps you in your JIRA and F# adventures.

Avatar of Author

Karl Solgård

Norwegian software developer. Eager to learn and to share knowledge. Sharing is caring! Follow on social: Twitter and LinkedIn.