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"
}
}
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(/\\..+/, '')
};
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
};
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();
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();
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)
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
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
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
"""
}
}
}
}
}
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
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
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
"""
}
}
}
}
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.