How to integrate YouTrack and Azure DevOps
Tags: youtrack, azuredevops, javascript
Published: 2022-05-10 21:31:14

We are using Azure DevOps for version control for ages. Until a year back we used for project tracking as well and the integration between Boards and Repos were seamless. Our new project tracking tool is Jetbrains YouTrack which lacks any integration with Azure DevOps.

However if you have some experience with YouTrack, you must be familiar with the concept of Workflows which have a huge potential and can serve as an extension point. Script are written in Javascript and can run based on a trigger on schedule.

Our idea was to automatically create a feature branch with name of the issue id when an issue becomes In Progress and create a pull request once the issue is in Pull request state. Links for the branch and pull request are added to the issue as fields.

First we created a common file for the resources that are shared between several scripts:

```javascript
var http = require('@jetbrains/youtrack-scripting-api/http');

function issueUrl(id) {
  return "https://youryoutrackinstanceurl/issue/" + id;
}

class Azure {
  constructor() {        
    this.organization = "your-azure-devops-organization";
    this.project = "Name of the Azure DevOps project";
    this.repository = "Name of the repository";
    this.defaultReviewers = [{
      "id": "ccd7d2cf-7120-4655-836e-a3ae28256dbd",
    }];
    this.login = "login@mail.com";
    this.PAT = "fdewqk321n55l55x7qmsfnlgdpbqnyhvdakdfsnczpwbriqjbhxvq";
    
  }

  url(api, item, operation, api_version) {
    var link = "https://dev.azure.com/" + this.organization + "/" + this.project + "/_apis/" + api;
    if (item)
      link += "/" + item;
    if (operation)
      link += "/" + operation;
    if (api_version)
      link += (link.includes("?") ? "&" : "?") + "api-version=" + api_version;
    return link;
  }

  branchUrl(branchName) {
    return "https://dev.azure.com/" + this.organization + "/" + this.project + "/_git/" + this.repository + "?version=GB" + branchName;
  }

  prUrl(id) {
    return "https://dev.azure.com/" + this.organization + "/" + this.project + "/_git/" + this.repository + "/pullrequest/" + id;
  }

  connection() {
    var connection = new http.Connection("", null, 2000);
    connection.basicAuth(this.login, this.PAT);
    return connection;
  }

  getFrom(url) {
    var connection = this.connection();
    var resp = connection.getSync(url);
    if (!resp.isSuccess) {
      throw new Error("" + resp.code);
    }    
    return JSON.parse(resp.response);
  }

  sendTo(url, data, mimeType = "application/json", action = "post") {
    var connection = this.connection();
    connection.addHeader({
      name: "Content-Type",
      value: mimeType
    });
    var resp = action == "patch" ? connection.patchSync(url, null, data) : connection.postSync(url, null, data);
    if (!resp.isSuccess) {
      throw new Error("" + resp.code);
    }
    return JSON.parse(resp.response);
  }  
}

module.exports.Azure = Azure;
module.exports.issueUrl = issueUrl;
```

If you pass the projectName parameter in the constructor of Azure you will get a more flexible solution however for the sake of simplicity I did not add that to the sample. And for the very same reason I used PAT authentication in this example.

Next we added a script to create the branch when work has started on an issue. In our case it is indicated by pushing it into In Progress state.

```javascript
var common = require('./common');
var entities = require('@jetbrains/youtrack-scripting-api/entities');
var workflow = require('@jetbrains/youtrack-scripting-api/workflow');

exports.rule = entities.Issue.onChange({
  title: "Create branch for issue",
  guard: function(ctx) {
    return ctx.issue.becomes(ctx.State, ctx.State.InProgress);
  },
  action: function(ctx) {
    logger.log("Creating branch for issue " + ctx.issue.id);

    try {

      const azure = new common.Azure();

      var issueBranchName = ctx.issue.id;

      var issueBranch = azure.getFrom(azure.url("git/repositories", azure.repository, "refs?filter=heads/&filterContains=" + issueBranchName, "6.0"));
      if (issueBranch && issueBranch.count && (issueBranch.value[0].name == "refs/heads/" + issueBranchName)) {        
        ctx.issue.fields.Branch = azure.branchUrl(issueBranchName);
        return;
      }
      
      var parentBranchName = "main";

      var repository = azure.getFrom(azure.url("git/repositories", azure.repository, null, "6.0"));
      var createBranchUrl = azure.url("git/repositories", repository.id, "refs", "6.0");
      var parentBranch = azure.getFrom(azure.url("git/repositories", azure.repository, "refs?filter=heads/&filterContains=" + parentBranchName, "6.0"));
      if (!parentBranch.count) {        
        workflow.message(workflow.i18n('Branch ' + parentBranchName + " not found in repository"));
        return;
      }
      var parentObjectId = parentBranch.value[0].objectId;
     
      var data = [{
        "name": "refs/heads/" + issueBranchName,
        "oldObjectId": "0000000000000000000000000000000000000000",
        "newObjectId": parentObjectId
      }];

      var response = azure.sendTo(createBranchUrl, JSON.stringify(data));
      if (!response || (response.count != 1) || !response.value[0].success) {
        workflow.message(workflow.i18n('Failed to create Azure DevOps branch ') + issueBranchName);
        workflow.message(response);
        return;
      }
     
      workflow.message(workflow.i18n('Created Azure DevOps branch ') + issueBranchName);

      ctx.issue.fields.Branch = azure.branchUrl(issueBranchName);

    } catch (e) {
      workflow.message(workflow.i18n('Failed to access Azure DevOps') + "\n" + e.message);
    }
  },
  requirements: {
    State: {
      name: "State",
      type: entities.State.fieldType,
      InProgress: {
        name: "In Progress"
      }
    },
    BranchField: {
      name: "Branch",
      type: entities.Field.stringType
    }
  }
});
```

The last step was to create a pull request once the assignee sets the issue's status to Pull request:

```javascript
var common = require('./common');
var entities = require('@jetbrains/youtrack-scripting-api/entities');
var workflow = require('@jetbrains/youtrack-scripting-api/workflow');

exports.rule = entities.Issue.onChange({
  title: "Create PR for issue",
  guard: function(ctx) {
    return ctx.issue.becomes(ctx.State, ctx.StateState.PullRequest);
  },
  action: function(ctx) {

    try {

      const azure = new common.Azure();

      var issueBranchName = ctx.issue.id;

      var issueBranch = azure.getFrom(azure.url("git/repositories", azure.repository, "refs?filter=heads/&filterContains=" + issueBranchName, "6.0"));
      if (!issueBranch || !issueBranch.count || (issueBranch.value[0].name != "refs/heads/" + issueBranchName)) {        
        workflow.message(workflow.i18n('Issue branch ' + issueBranchName + ' not found'));
        return;
      }
      
      var parentBranchName = "main";

      var repository = azure.getFrom(azure.url("git/repositories", azure.repository, null, "6.0"));

      var existingPr = azure.getFrom(azure.url("git/repositories", repository.id, "pullrequests?searchCriteria.sourceRefName=refs/heads/" + issueBranchName + "&searchCriteria.status=active&searchCriteria.targetRefName=refs/heads/" + parentBranchName, "6.0"));
      if (existingPr && existingPr.count && existingPr.value[0]) {
        var prId = existingPr.value[0].pullRequestId;
        workflow.message(workflow.i18n('PR ' + prId + ' already active'));
        ctx.issue.fields.PR = azure.prUrl(prId);
        return;
      }

      var createPrUrl = azure.url("git/repositories", repository.id, "pullrequests", "6.0");

      var data = {
        "sourceRefName": "refs/heads/" + issueBranchName,
        "targetRefName": "refs/heads/" + parentBranchName,
        "title": ctx.issue.id + " " + ctx.issue.summary,
        "description": common.issueUrl(ctx.issue.id),
        "reviewers": azure.defaultReviewers
      };
      
      var response = azure.sendTo(createPrUrl, JSON.stringify(data));
      if (!response || !response.pullRequestId) {
        workflow.message(workflow.i18n('Failed to create PR for branch ') + issueBranchName);
        return;
      }


      var prId = r.pullRequestId;
      workflow.message(workflow.i18n('Created PR ') + prId);
      ctx.issue.fields.PR = azure.prUrl(prId);

    } catch (e) {
      workflow.message(workflow.i18n('Failed to access Azure DevOps') + "\n" + e.message);
    }
  },
  requirements: {
    State: {
      name: "State",
      type: entities.State.fieldType,
      PullRequest: {
        name: "Pull request"
      },
    }
  }
});
```

That's all, folks! I encourage you to add the logging and it is possible to add constraints on ticket types so not every ticket will trigger the integration workflows.

The possibilities are endless.