Jira has this great “Development Information” (DevInfo) that can be associated with your work items. Which has an API described here. The information provided in the development tabs are for Branches, Commits, Pull Requests, Builds, Deployments and Feature Flags. Which is a way to have visibility into all the development/code activity that is related to a particular work item. It’s a great way to connect everything together.
It really is great, but there are some pieces of the puzzle that can be improved. For example:
- Currently, github also associates code commits, pull requests, and build/action information into an issues history. But, github’s layout intermingles those changes within the history of an issue to create a timeline. This allows for a reviewer of an issue, to visually see when a change was made, what the context was surrounding that change. Maybe there was a compelling argument made by a team member half way through an issue being worked on. And, that argument resulted in 5 files changing; you can see that in the github history.
But, Jira’s history won’t show you that because the conversation history (Jira Comments) do not intermingle the code commits, pull requests, builds or deployments to create a timeline history.
That would be a really nice improvement. And, on a personal note, if would make reviewing my coworkers work items a lot easier. - The Commits display (screenshot above) has a weird little bug in it. The Files Count Column (right side) should be able to display a count of all the files within a commit. The File Details Display, the list of files associated with a commit (“MODIFIED PullRequest/Send-AzDPRStatusUpdates.ps1/” in the screenshot), will only show the first 10 files from the commit. But the File Count Column isn’t showing the total count of files, it only showing the count of files in the File Details Display that number (“1 file” in the screenshot). This seems to be a bug, but I haven’t reported it yet.
(PS. Why is there a ‘/’ on the end of “PullRequest/Send-AzDPRStatusUpdates.ps1/”? The information submitted to the API did not have a slash on the end.) - The Documentation is REALLY CONFUSING when it comes urls. All of the examples in the documentation present url structures that look like this:
https://your-domain.atlassian.net/rest/devinfo/0.10/bulk
Except, that’s not the right url!!
All the APIs have an “Authorization” section in their documentation, which has a link to Integrate JSW Cloud with On-Premise Tools. And BURIED in that documentation is this quick note:
The root URL for OAuth 2.0 operations is:https://api.atlassian.com/jira/<entity type>/0.1/cloud/<cloud ID>/
Note that this URL is different to the URLs used in the documentation. However, you easily translate from one to the other. For example,POST /rest/builds/0.1/bulk
translates toPOST https://api.atlassian.com/jira/builds/0.1/cloud/<cloud ID>/bulk
.
And I agree that it’s easy to translate. But, you have to first know that you need to translate it. Maybe, an alternative direction to take is to update the OAuth 2.0 APIs documentation to use the correct urls? Or, explicitly state it on all the API documentation, so that you don’t have to find it in a separate page?
Atlassian/Jira does provide this really great C# SDK for working/reading Jira issues. But, the SDK doesn’t contain any objects/code to work with the “DevInfo”. So, I want to post a couple baseline objects which can be used in an aspnetcore/netstandard application to push information to the DevInfo endpoint in Atlassian/Jira Cloud …
But, before doing that, this post will be of a few baseline objects to authenticate with the Atlassian/Jira OAuth 2.0 endpoint.
The next post will contain objects to use with the “DevInfo” API.
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Net.Http; | |
using System.Threading.Tasks; | |
using Atlassian.Jira; | |
using Microsoft.ApplicationInsights.Extensibility; | |
using Microsoft.AspNetCore.Http; | |
using Microsoft.AspNetCore.Http.Features; | |
using Microsoft.Extensions.DependencyInjection; | |
using Microsoft.Extensions.DependencyInjection.Extensions; | |
namespace YourProject | |
{ | |
public static class IServiceCollectionExtensions | |
{ | |
public static IServiceCollection AddSaEnterpriseAzDPullRequestsWeb( | |
this IServiceCollection services, | |
Action<AzDPullRequestOptions> configure = null | |
) | |
{ | |
services.TryAddTransient<JiraOAuthDelegatingHandler>(); | |
services.TryAddScoped<IAtlassianOAuthenticator, AtlassianOAuthenticator>(); | |
services.AddHttpClient(); | |
services.TryAddTransient<HttpClient>(s => | |
{ | |
var factory = s.GetService<IHttpClientFactory>(); | |
return factory.CreateClient(); | |
}); | |
services.AddHttpClient<IJiraDevInfoHttpClient, JiraDevInfoHttpClient>() | |
.AddHttpMessageHandler<JiraOAuthDelegatingHandler>(); | |
return services; | |
} | |
} | |
} |
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Net; | |
using System.Net.Http; | |
using System.Net.Http.Headers; | |
using System.Threading; | |
using System.Threading.Tasks; | |
namespace YourProject | |
{ | |
public class JiraOAuthDelegatingHandler : DelegatingHandler | |
{ | |
private readonly IAtlassianOAuthCredentialsManager _manager; | |
public JiraOAuthDelegatingHandler( | |
IAtlassianOAuthCredentialsManager manager | |
) | |
{ | |
_manager = manager; | |
} | |
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | |
{ | |
var headers = request.Headers; | |
if (string.IsNullOrWhiteSpace(headers.Authorization?.Parameter)) | |
{ | |
var jwt = await _manager.GetJwtAsync(); | |
headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); | |
} | |
var retries = 0; | |
HttpResponseMessage response; | |
do | |
{ | |
response = await base.SendAsync(request, cancellationToken); | |
// unauthorized responses should have the JWT token refreshed and then try again. | |
if (response.StatusCode == HttpStatusCode.Unauthorized) | |
{ | |
await _manager.RefreshJwtAsync(); | |
var jwt = await _manager.GetJwtAsync(); | |
headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); | |
} | |
retries++; // this could go in the if statement, but it works here too | |
} while (response.StatusCode == HttpStatusCode.Unauthorized && retries < 2); | |
if (!response.IsSuccessStatusCode) | |
{ | |
var body = await response.Content.ReadAsStringAsync(); | |
var message = $"{(int)response.StatusCode} ({response.StatusCode}) - {body}"; | |
throw new HttpRequestException(message); | |
} | |
return response; | |
} | |
} | |
} |
using System; | |
using System.Threading; | |
using System.Threading.Tasks; | |
using Microsoft.IdentityModel.JsonWebTokens; | |
using Nito.AsyncEx; | |
namespace YourProject | |
{ | |
public interface IAtlassianOAuthCredentialsManager | |
{ | |
Task<string> GetJwtAsync(); | |
Task RefreshJwtAsync(); | |
} | |
private readonly IAtlassianOAuthenticator _authenticator; | |
private readonly AsyncReaderWriterLock _jwtLock = new AsyncReaderWriterLock(); | |
private AtlassianOAuthTokenResponse _tokenResponse = null; | |
private DateTime? _validTo = null; | |
public AtlassianOAuthCredentialsManager( | |
IAtlassianOAuthenticator authenticator | |
) | |
{ | |
_authenticator = authenticator; | |
} | |
public async Task<string> GetJwtAsync() | |
{ | |
if (await ShouldRefreshJwtAsync()) | |
{ | |
await RefreshJwtAsync(); | |
} | |
string result; | |
using (await _jwtLock.ReaderLockAsync()) | |
{ | |
result = _tokenResponse.access_token; // should make an immutable copy | |
} | |
return result; | |
} | |
public async Task<AtlassianOAuthTokenResponse> GetCurrentTokenResponse() | |
{ | |
using (await _jwtLock.ReaderLockAsync()) | |
{ | |
var result = new AtlassianOAuthTokenResponse() | |
{ | |
access_token = _tokenResponse.access_token, | |
scope = _tokenResponse.scope, | |
expires_in = _tokenResponse.expires_in, | |
token_type = _tokenResponse.token_type | |
}; | |
return result; | |
} | |
} | |
private async Task<bool> ShouldRefreshJwtAsync() | |
{ | |
using(await _jwtLock.ReaderLockAsync()) { | |
if (_tokenResponse == null) return true; | |
if (_validTo == null) return true; | |
if (DateTime.Now >= _validTo.Value) return true; | |
return false; | |
} | |
} | |
public async Task RefreshJwtAsync() | |
{ | |
using(await _jwtLock.WriterLockAsync()) { | |
var response = await _authenticator.AuthenticateAsync(); | |
SetJwt(response); | |
} | |
} | |
private void SetJwt(AtlassianOAuthTokenResponse tokenResponse) | |
{ | |
_tokenResponse = tokenResponse; | |
var jwtHandler = new JsonWebTokenHandler(); | |
var token = jwtHandler.ReadJsonWebToken(_tokenResponse.access_token); | |
// https://developer.atlassian.com/cloud/jira/software/integrate-jsw-cloud-with-onpremises-tools/ | |
// Write your code to anticipate that a granted token might no longer work. For example, | |
// track the expiration time and request a new token before the existing one expires | |
// (at least 30-60 seconds prior). | |
_validTo = token.ValidTo.AddMinutes(-1); | |
} | |
} | |
} | |
using System; | |
using System.Net.Http; | |
using System.Threading.Tasks; | |
using Microsoft.Extensions.Options; | |
namespace YourProject | |
{ | |
public interface IAtlassianOAuthenticator | |
{ | |
HttpClient Client { get; } | |
Task<AtlassianOAuthTokenResponse> AuthenticateAsync(); | |
} | |
public interface IPasswordRetriever // you have to implement this class | |
{ | |
string GetPassword(string key); | |
} | |
public class AtlassianOAuthenticator : IAtlassianOAuthenticator | |
{ | |
private readonly YourProjectOptions _options; | |
private readonly IPasswordRetriever _passwordRetriever; | |
public HttpClient Client { get; } | |
public AtlassianOAuthenticator( | |
IOptions<YourProjectOptions> options, | |
IPasswordRetriever passwordRetriever, | |
HttpClient client | |
) | |
{ | |
_options = options.Value; | |
_passwordRetriever = passwordRetriever; | |
Client = client; | |
client.BaseAddress = AtlassianConstants.AtlassianApiRootUri; | |
} | |
internal string GetPassword(string nameExt) | |
{ | |
var name = _options.ApplicationName + nameExt; | |
var password = _passwordRetriever.GetPassword(name); // you write this one | |
return password; | |
} | |
public async Task<AtlassianOAuthTokenResponse> AuthenticateAsync() | |
{ | |
var credentials = new AtlassianOAuthCredentials() | |
{ | |
audience = AtlassianConstants.YourInstanceRootUrl, | |
grant_type = "client_credentials", | |
client_id = GetPassword(".JiraOAuth2ClientId"), | |
client_secret = GetPassword(".JiraOAuth2ClientSecret") | |
}; | |
var response = await Client.PostAsJsonAsync("oauth/token", credentials); | |
response.EnsureSuccessStatusCode(); | |
var result = await response.DeserializeAsync<AtlassianOAuthTokenResponse>(); | |
return result; | |
} | |
} | |
} |
using System; | |
using Ucsb.Sa.Enterprise.AzDPullRequests.Web.Models.Atlassian; | |
namespace Ucsb.Sa.Enterprise.AzDPullRequests.Web.Models | |
{ | |
public static class AtlassianConstants | |
{ | |
public static string YourInstanceRootUrl = "https://your-domain.atlassian.net/"; | |
public static string AtlassianApiRootUrl = "https://api.atlassian.com/"; | |
public const string YourInstanceCloudId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"; | |
public static Uri YourInstanceRootUri = new Uri(YourInstanceRootUrl); | |
public static Uri AtlassianApiRootUri = new Uri(AtlassianApiRootUrl); | |
public static JiraProviderMetadata YourInstanceProviderMetadata = new JiraProviderMetadata() | |
{ | |
Product = "<Where you get your data from. Bitbucket? AzureDevOps?>" | |
}; | |
} | |
} |