Tuesday, 2 January 2018

VSTS with TeamCity – Building a pull request server

Part 2 in a series on integrating VSTS with TeamCity
 
This is a web service that handles web hook messages triggered by events in VSTS and TeamCity.

I started by using the sample Create a pull request status server with Node.js. As the title suggests, this is a Node application and it uses the Express library to run as a web server. I’d never written a Node app before, but it turns out it is surprisingly easy. If you’re starting from scratch, I’d recommend following the sample tutorial first, so you get up to speed with creating a really simple Node app that VSTS can talk to. Once you’ve got that working, then you should be well placed to use the code samples below.

There’s really two main processes we need to support:
  1. VSTS – Pull Request Created or Updated → Trigger TeamCity build of branch
  2. TeamCity build complete → Tell VSTS if build was success or failure
It’s also worth highlighting a couple of design goals for the pull request server:
  • Avoid hard-coding any specific details in the script, so that it could be easily reused in other repositories. As such, specific details (user credentials, TeamCity build configuration names etc) are configured as extra parameters in the web hook / service hook calls.
  • Be stateless

NPM packages

  "dependencies": {
    "basic-auth": "^2.0.0",
    "body-parser": "^1.18.2",
    "express": "^4.16.2",
    "teamcity-rest-api": "0.0.8",
    "vso-node-api": "^6.2.8-preview"
  },
  "devDependencies": {
    "@types/basic-auth": "^1.1.2",
    "@types/body-parser": "^1.16.8",
    "@types/express": "^4.0.39",
    "@types/node": "^8.5.1",
    "nodemon": "^1.13.3",
    "ts-node": "^4.0.2",
    "tslint": "^5.8.0",
    "typescript": "^2.6.2"
  }

Note that the following code snippets have been ‘tidied’ for the blog post – the original has a fair bit of file and console logging which is invaluable when diagnosing what’s going on as you’re modifying the logic. I also switched to using TypeScript as I really appreciated the type checking it provides.

Get things ready

import * as auth from 'basic-auth';
import * as bodyParser from 'body-parser';
import * as express from 'express';
import * as fs from 'fs';
import * as vsts from 'vso-node-api';
import { GitPullRequestStatus, GitStatusState } from 'vso-node-api/interfaces/GitInterfaces';

const teamcity = require('teamcity-rest-api');

const app = express();
app.use(bodyParser.json({ limit: '5mb'}));

const collectionURL = process.env.COLLECTIONURL;
const token = process.env.VSTSTOKEN;

const authHandler = vsts.getPersonalAccessTokenHandler(token!);
const connection = new vsts.WebApi(collectionURL!, authHandler);

const vstsGit = connection.getGitApi();

Handling a pull request event

app.post('/prserver', (req, res) => {
    const user = auth(req);

    const buildTypeId = req.query.buildTypeId;

    if (!buildTypeId || !user) {
        res.sendStatus(400);
        return;
    }

    const mergeStatus = req.body.resource.mergeStatus;

    if (mergeStatus !== 'succeeded') {
        return;
    }

    // Get the details about the PR from the service hook payload
    const repoId = req.body.resource.repository.id;
    const pullRequestId = req.body.resource.pullRequestId;
    const commitId = req.body.resource.lastMergeSourceCommit.commitId;

    // This needs to match the branch pattern in TeamCity
    const branchName = pullRequestId;

    // We need to find out the pull request IterationId
    vstsGit.getPullRequestIterations(repoId, pullRequestId, undefined, false)
    .then((iterations: any[]) => {
        return iterations.filter(item => item.sourceRefCommit.commitId === commitId)[0].id;
    })
    .then((iterationId: string) => {

        // trigger TeamCity build
        const client = teamcity.create({
            url: 'http://localhost:8111',
            username: user.name,
            password: user.pass
        });

        // This is the data we are posting to TeamCity
        // tslint:disable-next-line:max-line-length
        const buildNodeObject = `<build branchName='${branchName}'><buildType id='${buildTypeId}' /><properties><property name='vsts.pullRequestId' value="${pullRequestId}" inherited="false"/><property name='vsts.repositoryId' value="${repoId}" inherited="false"/><property name='vsts.iterationId' value="${iterationId}" inherited="false"/></properties></build>`;

        client.builds.startBuild(buildNodeObject)
            .then((buildStatus: any) => {
                // Build the status object that we want to post.
                // Assume that the PR is ready for review...
                const prStatus = {
                    'iterationId': iterationId,
                    'state': 'pending',
                    'description': `Queued build: ${buildStatus.buildTypeId}`,
                    'targetUrl': buildStatus.webUrl,
                    'context': {
                        'name': 'teamcity',
                        'genre': 'continuous-integration'
                    },
                    _links: null
                };

                // Post the status to the PR
                vstsGit.createPullRequestStatus(prStatus as any, repoId, pullRequestId);
            }, (reason: any) => {
                // error logging
            });
    }).catch((reason: any) => {
        // error logging
    });

    res.send('Received the POST');
});

Handling build completion from TeamCity

app.post('/prserver/buildComplete', (req, res) => {
    const pullRequestIdNode = req.body.build.teamcityProperties.find((item: any) => item.name === 'vsts.pullRequestId');

    if (!pullRequestIdNode) {
        // ignore if we don't have expected property
        return;
    }

    const pullRequestId = pullRequestIdNode.value;

    const repositoryId = req.body.build.teamcityProperties.filter((item: any) => item.name === 'vsts.repositoryId')[0].value;
    const iterationId = req.body.build.teamcityProperties.filter((item: any) => item.name === 'vsts.iterationId')[0].value as number;

    const prStatus = {
        iterationId: iterationId,
        state: GitStatusState.Succeeded,
        'description': 'Build succeeded',
        'targetUrl': req.body.build.buildStatusUrl,
        'context': {
            'name': 'teamcity',
            'genre': 'continuous-integration'
        },
        _links: null
    };

    if (req.body.build.buildResult !== 'success') {
        prStatus.state = GitStatusState.Failed; // 'failed';
        prStatus.description = `Build result: ${req.body.build.buildResult}`;
    }

    // Post the status to the PR
    vstsGit.createPullRequestStatus(prStatus as any, repositoryId, pullRequestId);

});

During development I ran the Node app from the command-line and made extensive use of console and file logging.

Once I was satisfied it was working as expected, I switched to using iisnode to run it under IIS.

Where this app runs depends on how accessible your TeamCity server is. If TeamCity is Internet-facing then the server could run anywhere (maybe even as something like an Azure Function). If TeamCity is running behind your firewall, then you might choose to host the pull server internally too. Note that VSTS does need to be able to contact the pull server – either directly (so the pull server itself is Internet-facing), or using a reverse proxy service like ngrok.

In the next post, I’ll describe how to configure TeamCity and VSTS to call the pull request server.

No comments: