다음을 통해 공유


How to build a VSTS assistant - The Life and Times of a Kanban bot - Part 1

Greetings everyone,

If you're working as a developer, chances are you've heard about Visual Studio Team Services, and you're a master of Kanban boards, iterations, work items, code management, continuous integration pipelines, and you probably know the Agile manifesto by heart... ;)

We live in an era of thousands of tools and workflows that we have to use and pay attention to every time (or maybe I could do better at organizing myself...) Whatever the case may be, I always wondered how it would be like to have an assistant manage tasks for me.

If you relate to anything that I wrote above, then this article is for you. You probably even had moments like these:

[caption id="" align="alignnone" width="406"] Source: https://boingboing.net/2013/04/07/great-do-not-disturb-statu.html\[/caption\]

and you are constantly looking for solutions to automate tasks and make you stay in "the zone".

Having to alt-tab constantly between Visual Studio and the task board all the time may make you go out of focus. :) And just before a project manager or a scrum master wants to fill up the comment section with a lot of arguments against this, remember - this is supposed to be a fun post, and real world may differ significantly .

As we live in a world that's getting filled up with voice assistants and artificial intelligence, I thought of building a bot that can manage tasks from the Visual Studio Team Services board for me. This will be a multi-part series where I will explain what I did and show you how you can build your own assistant too.

Visual Studio Team Services has a REST API which you can use to do lots of tasks, such as managing work items, kicking off builds, doing test runs, adding widgets to dashboards, managing repositories, creating release definitions and many more: REST API Overview for Visual Studio Team Services and Team Foundation Server

I logged in to a VSTS test project and added a sample iteration and some tasks:

Next up, I decided to build an Azure Function, that would query the VSTS REST API and expose a list of my tasks.

First, I had to create an Azure AD Application that would have permissions for Visual Studio Team Services, and use ADAL to authenticate against it. A quick search would lead you to a very handy repository on GitHub: https://github.com/Microsoft/vsts-auth-samples

Because I would write C# code, I decided to look over the Managed Client sample, and quickly figured out how to configure the app, delegate permissoins, install and configure ADAL and get an authentication token. At the top, you would have to define a series of constant strings:

 internal const string VstsCollectionUrl = "https://account.visualstudio.com"; //change to the URL of your VSTS account; NOTE: This must use HTTPS
internal const string ClientId = "<clientId>"; //update this with your Application ID
internal const string Username = "<username>"; //This is your AAD username (user@tenant.onmicrosoft.com or user@domain.com if you use custom domains.)
internal const string Password = "<password>"; // This is your AAD password.
internal const string Project = "<project>"; // This is the name of your project

and then set up an authenticationContext and login to the Azure AD app in order to get the accessToken:

 private static AuthenticationContext GetAuthenticationContext(string tenant)
{
     AuthenticationContext ctx;
     if (tenant != null)
        ctx = new AuthenticationContext("https://login.microsoftonline.com/" + tenant);
     else
     {
        ctx = new AuthenticationContext("https://login.windows.net/common");
        if (ctx.TokenCache.Count > 0)
        {
            var homeTenant = ctx.TokenCache.ReadItems().First().TenantId;
            ctx = new AuthenticationContext("https://login.microsoftonline.com/" + homeTenant);
        }
     }
     return ctx;
}

The main function looks like this:

 
        [FunctionName("GetWorkItemList")]
        public static async Task Run([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)]HttpRequestMessage req, TraceWriter log)
        {
            log.Info("C# HTTP trigger function processed a request.");

            var ctx = GetAuthenticationContext(null);
            try
            {
                var adalCredential = new UserPasswordCredential(Username, Password);

                var result = ctx.AcquireTokenAsync(VstsResourceId, ClientId, adalCredential).Result;
                var bearerAuthHeader = new AuthenticationHeaderValue("Bearer", result.AccessToken);
                var speechData = GetWorkItemsByQuery(bearerAuthHeader);
                return new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new StringContent(speechData, Encoding.UTF8, "application/json")
                };
            }
            catch (Exception ex)
            {
                log.Error("Something went wrong.");
                log.Error("Message: " + ex.Message + "\n");
                return req.CreateErrorResponse(HttpStatusCode.InternalServerError, "Error!");
            }
        }

A pretty basic function - I'm acquiring a token, constructing an authorization header and calling a function called GetWorkItemsByQuery, where I pass that authorization header, so I can get the list in a JSON format.

Here are some examples on how you can manipulate work items: https://www.visualstudio.com/en-us/docs/integrate/api/wit/work-items

Looking over the samples here: https://www.visualstudio.com/en-us/docs/integrate/api/wit/samples

I decided to make a function that would retrieve a list of the items assigned to me:

 
public static string GetWorkItemsByQuery(AuthenticationHeaderValue authHeader)
        {
            const string path = "Shared Queries/My Tasks"; //path to the query   
            var speechJson = "{ \"speech\": \"Sorry, an error occurred.\" }";
            using (var client = new HttpClient())
            {
                client.BaseAddress = new Uri(VstsCollectionUrl);
                client.DefaultRequestHeaders.Accept.Clear();
                client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
                client.DefaultRequestHeaders.Add("User-Agent", "VstsRestApi");
                client.DefaultRequestHeaders.Add("X-TFS-FedAuthRedirect", "Suppress");
                client.DefaultRequestHeaders.Authorization = authHeader;

                //if you already know the query id, then you can skip this step
                var queryHttpResponseMessage = client.GetAsync(Project + "/_apis/wit/queries/" + path + "?api-version=2.2").Result;

                if (queryHttpResponseMessage.IsSuccessStatusCode)
                {
                    //bind the response content to the queryResult object
                    var queryResult = queryHttpResponseMessage.Content.ReadAsAsync().Result;
                    var queryId = queryResult.id;

                    //using the queryId in the url, we can execute the query
                    var httpResponseMessage = client.GetAsync(Project + "/_apis/wit/wiql/" + queryId + "?api-version=2.2").Result;

                    if (httpResponseMessage.IsSuccessStatusCode)
                    {
                        var workItemQueryResult = httpResponseMessage.Content.ReadAsAsync().Result;

                        //now that we have a bunch of work items, build a list of id's so we can get details
                        var builder = new System.Text.StringBuilder();
                        foreach (var item in workItemQueryResult.workItems)
                        {
                            builder.Append(item.id.ToString()).Append(",");
                        }

                        //clean up string of id's
                        var ids = builder.ToString().TrimEnd(',');

                        var getWorkItemsHttpResponse = client.GetAsync("_apis/wit/workitems?ids=" + ids + "&fields=System.Id,System.Title,System.State&asOf=" + workItemQueryResult.asOf + "&api-version=2.2").Result;

                        if (getWorkItemsHttpResponse.IsSuccessStatusCode)
                        {
                            var result = getWorkItemsHttpResponse.Content.ReadAsStringAsync().Result;

                            // the work item list is exposed as a JSON object
                            var myWorkItemList = JsonConvert.DeserializeObject(result);
                            
                            // I iterate through the list of work items and get each title and state, which I concatenate so the result can be 'spoken'
                            var response = myWorkItemList.value.Aggregate("", (current, item) => current + (item.fields.SystemTitle + " - " + item.fields.SystemState + ' '));

                            // Google Assistant-specific syntax
                            speechJson = "{ \"speech\": \"" + response + "\" }";
                        }
                    }
                }
            }
            return speechJson;
        }

As you can read through the code, I'm using a path to a specific query, as VSTS provides lots of queries. In my case, it would be "My Tasks".

Then, by using the authorization header I got earlier, I'm querying the API, and as the list is returned as a JSON object, I'm deserializing it - hint: you can use https://json2csharp.com/ to generate classes.

Because VSTS tasks have properties such as System.Id, System.State and System.Title, you must define the properties in the Fields class as below:

 
public class Fields
    {
        [JsonProperty("System.Id")]
        public int SystemId { get; set; }

        [JsonProperty("System.State")]
        public string SystemState { get; set; }

        [JsonProperty("System.Title")]
        public string SystemTitle { get; set; }
    }

After this operation, a "speech" JSON must be constructed, because you would want these to be spoken out loud by an assistant.

You can use Cortana, Alexa, Google Assistant, Siri or anything else you want, as long as it has a speech API implemented. Make sure you look the documentation up for what you want to use, and build the JSON object as described in those docs.

In my case, I used a Google Home Mini, so I had to look up the syntax for Dialogflow: https://dialogflow.com/docs/fulfillment

Done - ready to be consumed. Here's how the output looked like:

 { "speech": "Create the DB diagram - Active Create classes from DB tables - New " }

I went ahead and published it to Azure Functions, and copied the Function URL: https://yourfunctionappname.azurewebsites.net/api/GetWorkItemList?code=<token>

You can get this URL from the Functions portal, by clicking on Get Function URL:

And then I went to the Dialogflow console and created the bot. In the Fulfillment section, I had to paste the URL of the function in the Webhook field:

And at the end, I created an Intent, so that when it hears "show me my work items", it triggers the Webhook we configured, plays the response it receives, and ends the conversation:

That's it.

Let's see it in action.

[video width="1920" height="1080" mp4="https://msdnshared.blob.core.windows.net/media/2017/12/20171228_224211.mp4"][/video]

The code for the Function App can be found here: https://github.com/julianatanasoae/VSTSFunctionApp

That's it for Part 1 of this series. In the next article, we'll see how we can add work items, assign them to a user, change the state and more.

Cheers!