Hands-On: Github PR comments with Jenkins CI/CD logs integration

Developer Experience is an important topic. Improved productivity and happy developers are key to success. We made Jenkins logs accessible and available to everyone at a glance through Pull Request (PR) comments, to raise our developer's productivity.

TLDR; What you can expect from reading on

  • a script to create, update and delete comments of a GitHub Pull Request; written in JavaScript

  • a script to write Jenkins logs as a GitHub comment; written in Javascript

  • a Jenkinsfile to use the scripts above on failure and success

  • a complete example with the folder structure of how an integrated setup could look like

You'll find all the scripts from below in our related blog repository: github.com/satellytes/blog-jenkins-github-pr-comments

Our initial situation

In our customer project, we are working in an enterprise environment which uses a central GitHub enterprise as a version control system and a managed, but customized Jenkins instance per department.

This article focuses on a problem we faced within our mono-repository, which is publicly available internally. Hundreds of developers around the globe are working with and on our project, but not all have the same GitHub permissions. This leads to the fact that not all were able to access our Jenkins and therefore are not able to directly see the outcome and logs of their Pull Request checks. Another fact is, that our Jenkins can only be reached through the internal corporate network, which requires contracted developers (like us) to start a separate Citrix Desktop connection.

To make GitHub logs accessible and available to everyone at a glance, we decided to make the Jenkins error logs accessible as Pull Request (PR) comments to make our developers happy and raise productivity.

Hands-on knowledge of one or more tools of the following is required to fully understand this blog post: Github API, Jenkins (GroovyScript), Javascript

Getting started

The CRUD (create, update, delete) operations for GitHub PR comments can be done quite simply through the GitHub REST API. Authentication requires a PAT (Personal Access Token) with repo scope, which you can create on your GitHub Developer Settings.

We use environment variables to define arguments because parts of our used variables are predefined in the Jenkins pipeline. The environment variables can be set in the command line like this: VARIABLE_NAME=value node scriptname.js and we are listing example usage in the JsDoc description at the top of the scripts.

Additionally, we rely on axios as an HTTP request client, which you can install with npm install axios to your project.

Now that we are prepared, let’s get our hands dirty and continue with the scripts.

Script: package.json

In case you have a greenfield project. Otherwise, just install axios in your existing project.

{
  "name": "project",
  "devDependencies": {
    "axios": "^1.4.0"
  }
}
package.json

Script: helpers.js

The following helpers are used for easier access to environment variables and are used throughout our other scripts.

module.exports = {
  isTrue: (value) => value === 'true' || value === '1' || value === true,
  trimIfString: (value) => (typeof value === 'string' ? value.trim() : value),
  now: () => new Date().toISOString().replace(/T/, ' ').replace(/\\..+/, '')
};
helpers.js

Creating and updating GitHub comments

Creating a comment is as simple as doing an authenticated POST request to the API endpoint /v3/repos/${repo}/issues/${issueNumber}/comments where issueNumber is either a PR or issue number.

We will use the accordion format for posting our message. The headline will show a given string (existingContent variable of the createOrUpdateComment function below), which is also used to identify a commit upon an update:

And the content (up to 65k characters) will be inside the accordion:

We want to keep the messages lean. So for subsequent updates, we will use the PATCH request to update the initial comment, rather than posting a new one. What we are doing to achieve it:

  • GET a list of existing comments (API)

  • search their body for the title we have chosen above

  • PATCH the updated comment (API)

Let's read on and get to know the scripts.

Script: cli-github-methods.js

This is a file that contains functions around the GitHub API that we use not just in the pipeline functions we describe here, but also in other pipelines. Currently, it exports a method named createOrUpdateComment that is used to create and update GitHub comments.

/** Settings **/
const githubBaseUrl = '<https://api.github.com>';

/** Script **/
const { isTrue } = require('./helpers');
const axios = require('axios');

const token =
  process.env.GITHUB_TOKEN ||
  console.error('Error: GITHUB_TOKEN must be provided as environment variable (PAT with repo scope).') ||
  process.exit(1);
const repo =
  process.env.GITHUB_REPO ||
  console.error('Info: GITHUB_REPO not provided as environment variable (eg. organization/repo_name)') ||
  process.exit(1);

let defaultOptions = {
  headers: {
    Authorization: `token ${token}`,
    Accept: 'application/vnd.github+json',
    'Content-Type': 'application/json',
    'X-GitHub-Api-Version': '2022-11-28'
  },
  json: true,
  maxRedirects: 0
};

/**
 * Retrieves a pull request's comments from GitHub.
 * @param {number} issueNumber
 * @returns {object} gh pull request entity <https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request>
 */
const getPullRequestComments = async (issueNumber) => {
  const options = Object.assign({}, defaultOptions, {
    uri: `${githubBaseUrl}/v3/repos/${repo}/issues/${issueNumber}/comments`
  });

  return axios.get(options.uri, options).then((response) => response.data);
};

/**
 * Creates or updates a comment on a GitHub PR or issue.
 * @param {object} options
 * - @param {number} issueNumber pull request number
 * - @param {string} body the content to write to the comment
 * - @param {string} bodyOnAppend if the comment exists, we just add that content instead of replacing it
 * @param {string} existingContent optional text to search for an existing comment, which gets replaced then
 */
const createOrUpdateComment = async (
  { issueNumber, body, bodyOnAppend },
  existingContent = '',
  appendIfExists = false
) => {
  const commentsUrl = `${githubBaseUrl}/v3/repos/${repo}/issues/${issueNumber}/comments`;

  const options = Object.assign({}, defaultOptions, {
    uri: commentsUrl
  });

  const addNewComment = () => {
    console.log('Adding new comment.');
    return axios.post(commentsUrl, { body }, options);
  };

  if (!existingContent) {
    return addNewComment();
  }

  return getPullRequestComments(issueNumber).then((comments) => {
    const existingComment = comments.find((comment) => comment.body.indexOf(existingContent) > -1);
    if (existingComment) {
      let newBody;
      if (appendIfExists && bodyOnAppend && existingComment.body.indexOf(bodyOnAppend) !== -1) {
        // separate body for append is provided, but it exists already, we do nothing.
        return Promise.resolve();
      } else if (appendIfExists && bodyOnAppend) {
        // separate body for append is provided, we append it to the existing body
        newBody = existingComment.body + bodyOnAppend;
      } else if (appendIfExists) {
        // no separate body for append is provided, we append the provided body to the existing body
        newBody = existingComment.body + body;
      } else {
        newBody = body;
      }

      return axios.patch(
        existingComment.url,
        {
          body: newBody
        },
        options
      );
    } else {
      return addNewComment();
    }
  });
};

module.exports = {
  createOrUpdateComment
};
cli-github-methods.js

Script: gh-add-or-update-comment.js

This script reads a given file and writes its content to a Pull Request comment. Since we run it usually within a Jenkins PR multibranch pipeline, the [BUILD_URL environment variable](https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables) is defined. It can also be defined manually for local testing purposes. Additionally, the LOGFILE variable represents a path to a file with the content that gets stored as content.

#!/usr/bin/env node

/**
 * Writes logfile content as a comment to a GitHub PR or updates an existing comment.
 *
 * Is being used in a Jenkins pipeline. Awaits the following environment variables:
 * - LOGFILE: The file to read the content from. E.g. "jenkins.log" (Mandatory)
 * - GITHUB_TOKEN: The GitHub token to authenticate with. (Mandatory)
 * - GITHUB_REPO: The GitHub repo to write the comment to. (Mandatory)
 * - BUILD_URL: The Jenkins build number to fetch the logs from. E.g. "<https://jenkins.domain/job/pr-multibranch-job/PR-12345/18/>" (Mandatory, optionally provided by Jenkins)
 *
 * Usage:
 * BUILD_URL="<https://jenkins.domain/job/pr-multibranch-job/PR-12345/18/>" GITHUB_REPO="organization/repo-name" GITHUB_TOKEN=00000 LOGFILE=jenkins.log node ./gh-add-or-update-comment.js
 *
 * Pipeline usage:
 *  withCredentials([
 *    string(credentialsId: 'git-token-secret-text', variable: 'GIT_AUTH_TOKEN')
 *  ]) {
 *    sh "BUILD_URL=${params.BUILD_URL} GITHUB_REPO=${params.GITHUB_REPO} LOGFILE=${logFileName} GITHUB_TOKEN=${GIT_AUTH_TOKEN} node ./tools/scripts/gh-add-or-update-comment.js"
 *  }
 *
 */
/** Settings **/
const commentPrefix = 'Last Jenkins Error Log';

// timeStamperStrLength is used to cuts of first N chars or each line. 
// eg. a length "[2023-02-23T12:15:30.709Z] "
// set to 0 if you do not want to truncate the lines or do not use timestamper plugin
const timeStamperStrLength = 27;

/** Script **/
*const* { readFileSync } = require('fs');
const { createOrUpdateComment } = require('./cli-github-methods');
const buildUrl =
  process.env.BUILD_URL ||
  console.error('Error: BUILD_URL must be provided as environment variable.') ||
  process.exit(1);

const jenkinsLogContent = process.env.LOGFILE
  ? readFileSync(process.env.LOGFILE).toString()
  : console.error('Error: LOGFILE must be provided as environment variable.') || process.exit(1);

// regex extract pr number, eg <https://jenkins.domain/job/pr-multibranch-job/PR-12345/18/>, result is 12345
const issueNumber = buildUrl.match(/PR-(\\d+)/)[1];
// regex extract pr number, eg <https://jenkins.domain/job/pr-multibranch-job/PR-12345/18/>, result is 18
const buildNumber = buildUrl.match(/(\\d+)\\/$/)[1];

// replace all content between `Z]`and `[Pipeline]` globally in every line, also remove `[Pipeline]` parts
const cleanupContent = (content) => {
  // remove the time stamper from the beginning of each line

  // we see some weird characters coming in, so clean them up as well
  const startStr = '\\x1B';
  const endStr = '[Pipeline]';

  // github max comment length is 65536, but we need to leave some space for the comment prefix and html tags
  const maxCommentLength = 65250;
  const cleanedTruncatedContent = content
    // split by line
    .split('\\n')
    // remove timestamper prefix (remove, if you do not use timestamper plugin)
    .map((line) => {
      const start = line.indexOf(startStr);
      const end = line.indexOf(endStr);
      return line.slice(timeStamperStrLength, start) + line.slice(end, line.length);
    })
    // back to one string
    .join('\\n')
    // truncate from beginning if longer than maxCommentLength
    .slice(-maxCommentLength);

  const truncatedMessage =
    content.length > maxCommentLength
      ? 'First ' + (content.length - maxCommentLength) + ' log characters truncated ... \\n\\n'
      : '';
  const startTimeStamp = content.slice(0, timeStamperStrLength);

  return {
    startTimeStamp,
    fileContent: `Pipeline started: ${startTimeStamp}\\n\\n${truncatedMessage}${cleanedTruncatedContent}`
  };
};

const run = async () => {
  const { fileContent, startTimeStamp } = cleanupContent(jenkinsLogContent);
  try {
    const body = `<details><summary>${commentPrefix}, run #${buildNumber}, started ${startTimeStamp}</summary>\\n\\n <pre>${fileContent}</pre></details>`;

    const result = await createOrUpdateComment(
      {
        issueNumber,
        body
      },
      commentPrefix
    );
    console.info('GitHub PR commented:', result?.data?.html_url || result);
  } catch (error) {
    console.error('Error while creating or updating GitHub comment:', error.message);
  }
};
run();
add-or-update-comment.js

Script: gh-remove-comment.js

Similar to the script above, we fetch the comments and check if a comment exists. If it exists, we do a DELETE request on the comment to remove it.

#!/usr/bin/env node

/**
 * Removes a GitHub comment if it exists.
 *
 * Is being used in a jenkins pipeline. Awaits the following environment variables:
 * - GITHUB_TOKEN: The GitHub token to authenticate with. (Mandatory)
 * - GITHUB_REPO: The GitHub repo to write the comment to. (Mandatory)
 * - BUILD_URL: The Jenkins build number to fetch the logs from. E.g. "<https://jenkins.domain/job/pr-multibranch-pipeline/PR-12345/18/>" (Mandatory, optionally provided by Jenkins)
 *
 * Usage:
 * BUILD_URL="<https://jenkins.domain/job/pr-multibranch-job/PR-12345/18/>" GITHUB_REPO="org/repo-name" GITHUB_TOKEN=00000 node ./gh-remove-comment.js
 *
 * Pipeline usage:
   success {
      script {
        withCredentials([
          string(credentialsId: 'git-token-secret-text', variable: 'GIT_AUTH_TOKEN')
        ]) {
          sh """
             GITHUB_REPO="org/repo" GITHUB_TOKEN=${GIT_AUTH_TOKEN} node ./gh-remove-comment.js
          """
        }
      }
    }
 *
 */
const githubBaseUrl = '<https://api.github.com>';
const contentPrefix = 'Last Jenkins Error Log';

const axios = require('axios');
const { isTrue } = require('./helpers');

const buildUrl =
  process.env.BUILD_URL ||
  console.error('Error: BUILD_URL must be provided as environment variable.') ||
  process.exit(1);
const token =
  process.env.GITHUB_TOKEN ||
  console.error('Error: GITHUB_TOKEN must be provided as environment variable.') ||
  process.exit(1);
const repo =
  process.env.GITHUB_REPO ||
  console.error('Info: GITHUB_REPO not provided as environment variable') ||
  process.exit(1);

// regex extract pr number, eg <https://jenkins.domain/job/pr-multibranch-job/PR-12345/18/>, result is 12345
const prNum = buildUrl.match(/PR-(\\d+)/)[1];

/**
 * Creates or updates a comment on a GitHub PR.
 * @param {string} token github token
 * @param {string} repo github repository
 * @param {number} prNumber pull request number
 */
const removePullRequestComment = ({ token, repo, prNum }) => {
  const commentsUrl = `${githubBaseUrl}/repos/${repo}/issues/${prNum}/comments`;

  let options = {
    uri: commentsUrl,
    headers: {
      Authorization: `token ${token}`,
      Accept: 'application/vnd.github.v3+json',
      'Content-Type': 'application/json',
      'X-GitHub-Api-Version': '2022-11-28'
    },
    json: true
  };

  return axios.get(commentsUrl, options).then((response) => {
    if (response.data.indexOf('Log in to toolchain') > -1) {
      throw new Error('Unauthorized');
    }

    const comments = response.data;
    const existingComment = comments.find(
      (comment) => comment.body.slice(0, 100).indexOf(contentPrefix) > -1
    );

    if (existingComment) {
      axios.delete(existingComment.url, options);
    }
  });
};

const run = async () => {
  try {
    await removePullRequestComment({
      token,
      repo,
      prNum
    });
  } catch (error) {
    console.error('Error while removing GitHub comment:', error.message);
  }
};
run();
gh-remove-comment.js

Alright! Now that we have all required scripts collected, we can continue to integrate them into our CI/CD pipeline.

Since we are working mainly with Jenkins, the following example is written in GroovyScript to be used in such a pipeline.

Script: Retrieving Jenkins Logs

There are numerous ways to retrieve the log content of the current run, but some of them require groovy sandbox whitelisting and are not recommended (whitelisting opens attack vectors for intruders). So we listed the most common here for your reference. The last one is mentioned in some sources and we mention it as a “do not use”, since it is blacklisted by the Jenkins Core team.

1. Accessing Jenkins Build Console Output with the Jenkins groovy API:

def build = Thread.currentThread().executable
def consoleLog = build.getLog(65000)
writeFile(file: 'jenkins.log', text: consoleLog)
Jenkin log (groovy, getLog)

2. Using Shell Command to Access Build Log:

#!/bin/bash
BUILD_NUMBER=$1
JENKINS_HOME=/var/lib/jenkins
BUILD_LOG="$JENKINS_HOME/jobs/YourJobName/builds/$BUILD_NUMBER/log"
echo $BUILD_LOG > jenkins.log
Jenkins log (shell)

3. Accessing Jenkins Build Log via REST API (curl):

BUILD_NUMBER=123  # Replace with your build number
JENKINS_URL="<http://your-jenkins-server>"
curl -s "$JENKINS_URL/job/YourJobName/$BUILD_NUMBER/consoleText" > jenkins.log
Jenkins log (shell, API)

Now let’s prepare the Jenkins pipeline so a job can report its own failure and success as a GitHub comment:

Jenkinsfile post failure and success GitHub commenting

This is the very last post block of a Jenkinsfile, so it gets always executed upon any error during runtime. Since we might also report errors on network hiccups, where we did not even reach the npm install or yarn install stage, we need to make sure that axios is always available. The simplest approach for us was to just replace an existing package.json with a simplified one and install it.

Also, we are using withCredentials to get the PAT from the Jenkins secrets store, where it is stored under the key value git-pat-token.

Script: Jenkinsfile

Jenkinsfiles are written in GroovyScript, the Standard language for Jenkins pipelines. The post block is placed at the very end of the Jenkinsfile within the last closing bracket ( } ) so it counts for the whole pipeline stages.

The failure block is triggered upon each error and creates the log file comment entry, the success is triggered upon a successful pipeline run and removes an existing failure comment.

pipeline {
  stages {
    // ....
  }  
  post {
    failure {
      script {
        def build = Thread.currentThread().executable
        writeFile(file: 'jenkins.log', text: build.getLog(65000))

        withCredentials([
          string(credentialsId: 'git-pat-token', variable: 'GIT_AUTH_TOKEN')
        ]) {
          sh """
            echo '{ "name": "project", "dependencies": { "axios": "^1.4.0" }}' > package.json
            npm i
            GITHUB_REPO="org/repo-name" LOGFILE=jenkins.log GITHUB_TOKEN=${GIT_AUTH_TOKEN} node ./scripts/gh-add-or-update-comment.js
          """
        }
      }
    }
    success {
      script {
        withCredentials([
          string(credentialsId: 'git-pat-token', variable: 'GIT_AUTH_TOKEN')
        ]) {
          sh """
             GITHUB_REPO="org/repo-name" GITHUB_TOKEN=${GIT_AUTH_TOKEN} node ./scripts/gh-remove-comment.js
          """
        }
      }
    }
  }
}
Jenkinsfile (partial)

Putting everything together

We assume you have already a Jenkins instance running, that contains an agent with Node.js installed. Or you have a single-node setup of Jenkins which includes the nodejs plugin, so the agent would have Node.js available as well.

Now let’s wrap together what we created so far and take a look how a possible folder structure of the whole setup could look like.

scripts/helpers.js
scripts/cli-github-methods.js
scripts/gh-add-or-update-comment.js
scripts/gh-remove-comment.js
ci/shared.groovy
package.json
Jenkinsfile
folder structure

Including the Jenkins logs

In our use case, we used option 3 to fetch the logs via Jenkins API. And since we like clean code, we have excluded some parts into separate files.

ci/shared.groovy

/**
  * Get the Jenkins log for a given build and write it to file jenkins.log
  * @param buildUrl The URL of the Jenkins build (env.BUILD_URL)
  * @param logFilename The name of the file to write the log to (default: jenkins.log)
  */
def writeJenkinsLog(buildUrl, logFilename = 'jenkins.log') {
  withCredentials([usernamePassword(credentialsId: 'jenkins-technical-user', usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) {
    try {
      def curlCmd = "curl -u ${USERNAME}:${PASSWORD} ${buildUrl}consoleText > ${logFilename}"
      def response = sh(returnStdout: true, script: curlCmd)
    } catch (Exception e) {
      print "Error: Failed to retrieve Jenkins Log:"
      sh "cat jenkins.log"
      print e.message
      // throw error to abort the build
      throw new Error("Failed to retrieve Jenkins Log")
    }
  }
}

return this
ci/shared.groovy

Script: Jenkinsfile

For simplicity, we provide the GitHub personal access token as an inline variable GIT_AUTH_TOKEN. In a production environment, you would store it in the Jenkins vault and retrieve it like listed in the Jenkinsfile partial above (withCredentials).

def GIT_AUTH_TOKEN='ghp_1234567890' 
def shared

pipeline {
  // If you have a separate agent set up for Node.js you can define it here
  agent {
    node {
      label 'JENKINS_NODEJS_AGENT'
    }
  }

  options {
    timeout(time: 15, unit: 'MINUTES')
  }

  stages {
    stage("Init") {
      steps {
        script {
          println "Let's force an error to trigger the failure .."
					throw new Exception("Throwing an exception on purpose")
        }
      }
    }
  }

  post {
    failure {
      script {
        shared = load 'ci/shared.groovy'
        shared.writeJenkinsLog(env.BUILD_URL, 'jenkins.log')

        sh """
          echo '{ "name": "workspace", "dependencies": { "axios": "^1.4.0" }}' > package.json
          npm i
          GITHUB_REPO="myuser/myrepo" LOGFILE=jenkins.log GITHUB_TOKEN=${GIT_AUTH_TOKEN} node ./scripts/gh-add-or-update-comment.js
        """
      }
    }
    success {
      script {
        sh """
          GITHUB_REPO="myuser/myrepo" GITHUB_TOKEN=${GIT_AUTH_TOKEN} node ./scripts/gh-remove-comment.js
        """
      }
    }
  }
}
Jenkinsfile

Now you can create a Jenkins job that uses that Jenkinsfile and enjoy the log errors as PR comments.

Conclusion

By integrating Jenkins logs as GitHub PR comments, we've successfully tackled the challenge of restricted access within our project. This solution not only enhances visibility but also fosters collaboration and productivity among developers globally. The scripts showcased here, coupled with CI/CD pipeline integration, provide a streamlined way to offer accessible feedback, enabling quick and efficient responses to PR outcomes.

We hope you found the earlier sections useful and are experiencing these improvements in your PR comments.

Interested in working on pipelines aside of front- or backend with us?

Career