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!
- VS code
- Ionide-fsharp extension
- Azure Functions extension
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": "user@test.com",
"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": "user@test.com",
"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": "other.user@test.com",
"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": "other.user@test.com",
"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.