Generate a change log from VSTS work items

As a software developer, it is important to inform users of your software when you release a new version along with the new features it contains. Moreover, it is common to communicate on the roadmap. Microsoft Edge platform status and Office 365 Roadmap are great examples of what users can expect:

If you are using VSTS (Visual Studio Team Services) and work items to plan and manage your project, you already have all the information to create a change log. Indeed, a work item has lots of information including a type (bug, feature, user story, …), a title, a description, a status and an updated date. Of course, you may want to filter the work items to include in the change log.

The solution breaks down into 3 steps:

  1. Creating a query to select the work items to use to generate the change log
  2. Using the VSTS API to execute the query and get the work items
  3. Create a page and a RSS feed

Creating the query

First, I've decided to filter the work items to include in the change log by adding them the "public" tag:

Then, I've created a custom query to get the work items that have the public tag:

Now, you can execute this query to get all the expected work items. Of course, you may want to adapt it to your needs.

Getting the work items using the VSTS API

We'll use the VSTS REST API to get the result of the query. First, you need to get a personal access token:

The access token is a random string:

The code executes 3 http requests. The first one gets the details of the custom query from its path. The second request executes the query and get a list of ids. The last request gets the details of the work items from their id.

public class WorkItem
{
    [JsonProperty("id")]
    public int Id { get; set; }
    [JsonProperty("rev")]
    public int Revision { get; set; }
    [JsonProperty("url")]
    public string Url { get; set; }
    [JsonProperty("fields")]
    public WorkItemFields Fields { get; set; }

    public static async Task<IList<WorkItem>> LoadAllAsync(CancellationToken ct)
    {
        // TODO Chage the constant values
        const string personalAccessToken = "TODO: personal access key";
        const string collectionUrl = "https://xxx.visualstudio.com/DefaultCollection/";
        const string projectUrl = "https://xxx.visualstudio.com/DefaultCollection/yyy/";
        const string queryPath = "Shared%20Queries/public%20Items"; // URL encoded

        string credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "", personalAccessToken)));
        using (var client = new HttpClient())
        {
            client.DefaultRequestHeaders.Accept.Clear();
            client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);

            // ----------------
            // 1 - Get the url of the custom query, so we can execute it.
            // ----------------
            string queryUrl;
            using (var response = await client.GetAsync(projectUrl + "_apis/wit/queries/" + queryPath + "?api-version=2.2", ct))
            {
                var result = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
                var jobject = JObject.Parse(result);

                queryUrl = jobject.SelectToken("$._links.wiql").Value<string>("href");
                if (queryUrl == null)
                    throw new Exception("Query not found");
            }

            // ----------------
            // 2 - Execute the query and get the ids of the work items
            // ----------------
            List<int> workItemIds;
            using (var response = await client.GetAsync(queryUrl, ct))
            {
                var result = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
                var jobject = JObject.Parse(result);
                workItemIds = jobject.SelectTokens("$.workItems..id").Cast<JValue>().Select(v => Convert.ToInt32(v.Value)).ToList();
            }

            // ----------------
            // 3 - Get work item details
            // ----------------
            var workItems = new List<WorkItem>();
            const int maxItemPerRequest = 200; // cannot get more than 200 items per query
            for (var i = 0; i < workItemIds.Count; i += maxItemPerRequest)
            {
                var ids = string.Join(",", workItemIds.Skip(i).Take(maxItemPerRequest));
                using (var response = await client.GetAsync(collectionUrl + "_apis/wit/workitems?api-version=2.2&ids=" + ids, ct))
                {
                    var result = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
                    var arrayResult = JsonConvert.DeserializeObject<ArrayResult<WorkItem>>(result);
                    workItems.AddRange(arrayResult.Value);
                }
            }

            return workItems;
        }
    }
}

internal class ArrayResult<T>
{
    public int Count { get; set; }
    public T[] Value { get; set; }
}

public class WorkItemFields
{
    [JsonProperty("System.AreaPath")]
    public string AreaPath { get; set; }
    [JsonProperty("System.TeamProject")]
    public string TeamProject { get; set; }
    [JsonProperty("System.IterationPath")]
    public string IterationPath { get; set; }
    [JsonProperty("System.WorkItemType")]
    public string WorkItemType { get; set; }
    [JsonProperty("System.State")]
    public string State { get; set; }
    [JsonProperty("System.Reason")]
    public string Reason { get; set; }
    [JsonProperty("System.Title")]
    public string Title { get; set; }
    [JsonProperty("System.Description")]
    public string Description { get; set; }
    [JsonProperty("System.Tags")]
    public string Tags { get; set; }
    [JsonProperty("System.CreatedDate")]
    public DateTime CreatedDate { get; set; }
    [JsonProperty("System.ChangedDate")]
    public DateTime ChangedDate { get; set; }

    [JsonExtensionData]
    public IDictionary<string, object> AdditionalFields { get; set; }
}

Generate the page

Before generating a web page, we need to extract the data from the work items:

public class ChangeLog
{
    public string ApplicationName { get; set; }
    public IList<ChangeLogItem> Items { get; set; }

    public static async Task<ChangeLog> LoadAsync(CancellationToken ct)
    {
        var workItems = await WorkItem.LoadAllAsync(ct);

        var changeLog = new ChangeLog();
        changeLog.ApplicationName = "Sample";
        changeLog.Items = workItems.Select(wi => new ChangeLogItem()
        {
            Id = wi.Id,
            Title = wi.Fields.Title,
            Description = wi.Fields.Description,
            Status = GetItemStatus(wi.Fields.State),
            Type = GetItemType(wi.Fields.WorkItemType),
            CreatedDate = wi.Fields.CreatedDate,
            LastUpdatedDate = wi.Fields.ChangedDate
        }).ToList();

        return changeLog;
    }

    private static ChangeLogItemType GetItemType(string value)
    {
        if (value == null)
            return ChangeLogItemType.Unknown;

        switch (value.ToLowerInvariant())
        {
            case "feature":
            case "task":
                return ChangeLogItemType.Feature;

            case "bug":
                return ChangeLogItemType.Bug;

            default:
                return ChangeLogItemType.Unknown;
        }
    }

    private static ChangeLogItemStatus GetItemStatus(string value)
    {
        if (value == null)
            return ChangeLogItemStatus.Unknown;

        switch (value.ToLowerInvariant())
        {
            case "new":
            case "active":
            case "resolved":
                return ChangeLogItemStatus.InDevelopment;

            case "closed":
                return ChangeLogItemStatus.Released;

            case "removed":
                return ChangeLogItemStatus.Cancelled;

            default:
                return ChangeLogItemStatus.Unknown;
        }
    }
}

public class ChangeLogItem
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
    public DateTime CreatedDate { get; set; }
    public DateTime LastUpdatedDate { get; set; }
    public ChangeLogItemType Type { get; set; }
    public ChangeLogItemStatus Status { get; set; }
}

public enum ChangeLogItemType
{
    Unknown,
    Feature,
    Bug
}

public enum ChangeLogItemStatus
{
    Unknown,
    Released,
    InDevelopment,
    Cancelled
}

Now we can generate a page. Here's the controller:

public class HomeController : Controller
{
    public async Task<IActionResult> ChangeLog()
    {
        var changeLog = await ChangeLog.LoadAsync(HttpContext.RequestAborted);
        return View(changeLog);
    }
}

And the view:

@model ChangeLog

<h2>What's new</h2>

@foreach (var item in Model.Items)
{
    <div id="wi-@item.Id" class="workitem workitem-@item.Type workitem-@item.Status">
        <h2>@item.Title</h2>
        <p>@item.Description</p>
    </div>
}

Of course, you'll need to add some CSS to get a beautiful page 😃

Notify the users of your application using a RSS feed

Creating a page is great, but some customers want to be notified when a new item is added to the list. Some of them want to subscribe to a RSS feed, some want to get an email, some may want something else. If you remember a previous post about Zapier, then you've figured out you only need to create an RSS feed to let the customer the choice.

Creating an RSS feed is very easy. While this is not the best way to do it, you can use Razor 😃 The code in the controller is the same as for the web page:

public class HomeController : Controller
{
    public async Task<IActionResult> ChangeLog()
    {
        var changeLog = await ChangeLog.LoadAsync(HttpContext.RequestAborted);
        return View(changeLog);
    }

    public async Task<IActionResult> Rss()
    {
        var changeLog = await ChangeLog.LoadAsync(HttpContext.RequestAborted);
        return View(changeLog);
    }
}

And the view generates a valid XML file:

@model WebAppChangeLog.Model.ChangeLog
@{
    Layout = null;
    Context.Response.ContentType = "application/rss+xml";
}
<?xml version="1.0" encoding="utf-8" ?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:wfw="http://wellformedweb.org/CommentAPI/">
    <channel>
        <title>@Model.ApplicationName</title>
        <link>http://www.sample.com/rss.aspx</link>
        <description>Change Log</description>
        <copyright>Copyright © @DateTime.UtcNow.Year</copyright>
        <pubDate>@DateTime.UtcNow.ToString("R")</pubDate>

        @foreach (var item in Model.Items)
        {
            <item>
                <title>@item.Title</title>
                <description>@item.Description</description>
                <pubDate>@item.CreatedDate.ToString("R")</pubDate>
                <guid isPermalink="false">@item.Id</guid>
            </item>
        }
    </channel>
</rss>

Conclusion

You now have a change log available as a web page or a RSS feed. The change log is always up to date as the data come from work items in VSTS which track precisely features and bugs of your project.

Enjoy this blog? Buy Me A Coffee Donate with PayPal

Leave a reply