Skip to content

Instantly share code, notes, and snippets.

@d2lam
Created February 28, 2017 00:45
Show Gist options
  • Save d2lam/fcffd24c77a8553519c639b48551ac37 to your computer and use it in GitHub Desktop.
Save d2lam/fcffd24c77a8553519c639b48551ac37 to your computer and use it in GitHub Desktop.
Gitlab stuff
/* eslint no-underscore-dangle: ["error", { "allowAfterThis": true }] */
'use strict';
const Breaker = require('circuit-fuses');
const Request = require('request');
const Hoek = require('hoek');
const Joi = require('joi');
const Schema = require('screwdriver-data-schema');
const Scm = require('screwdriver-scm-base');
const DEFAULT_AUTHOR = {
avatar: 'https://cd.screwdriver.cd/assets/unknown_user.png',
name: 'n/a',
username: 'n/a',
url: 'https://cd.screwdriver.cd/'
};
const MATCH_COMPONENT_HOSTNAME = 1;
const MATCH_COMPONENT_USER = 2;
const MATCH_COMPONENT_BRANCH = 4;
const MATCH_COMPONENT_REPO = 3;
const STATE_MAP = {
SUCCESS: 'success',
RUNNING: 'pending',
QUEUED: 'pending'
};
const DESCRIPTION_MAP = {
SUCCESS: 'Everything looks good!',
FAILURE: 'Did not work as expected.',
ABORTED: 'Aborted mid-flight',
RUNNING: 'Testing your code...',
QUEUED: 'Looking for a place to park...'
};
/**
* Check the status code of the server's response.
*
* If there was an error encountered with the request, this will format a human-readable
* error message.
* @method checkResponseError
* @param {HTTPResponse} response HTTP Response from `request` call
* @param {Number} response.statusCode HTTP status code of the HTTP request
* @param {String} [response.body.error.message] Error message from the server
* @param {String} [response.body.error.detail.required] Error resolution message
* @return {Promise} Resolves when no error encountered.
* Rejects when status code is non-200
*/
function checkResponseError(response, caller) {
if (response.statusCode >= 200 && response.statusCode < 300) {
return;
}
const errorMessage = Hoek.reach(response, 'body.error.message', {
default: `SCM service unavailable (${response.statusCode}).`
});
const errorReason = Hoek.reach(response, 'body.error.detail.required', {
default: JSON.stringify(response.body)
});
throw new Error(`${errorMessage} Reason "${errorReason} Caller "${caller}"`);
}
/**
* Get repo information
* @method getRepoInfo
* @param {String} checkoutUrl The url to check out repo
* @return {Object} An object with the hostname, repo, branch, and username
*/
function getRepoInfo(checkoutUrl) {
console.log('getRepoInfo');
const regex = Schema.config.regex.CHECKOUT_URL;
const matched = regex.exec(checkoutUrl);
return {
hostname: matched[MATCH_COMPONENT_HOSTNAME],
repo: matched[MATCH_COMPONENT_REPO],
branch: matched[MATCH_COMPONENT_BRANCH].slice(1),
username: matched[MATCH_COMPONENT_USER]
};
}
class GitlabScm extends Scm {
/**
* Constructor
* @method constructor
* @param {Object} options Configuration options
* @param {String} [options.gitlabHost=null] If using Gitlab, the host/port of the deployed instance
* @param {String} [options.gitlabProtocol=http] If using Gitlab, the protocol to use
* @param {String} [options.username=sd-buildbot] Gitlab username for checkout
* @param {String} [[email protected]] Gitlab user email for checkout
* @param {Boolean} [options.https=false] Is the Screwdriver API running over HTTPS
* @param {String} options.oauthClientId OAuth Client ID provided by Gitlab application
* @param {String} options.oauthClientSecret OAuth Client Secret provided by Gitlab application
* @param {Object} [options.fusebox={}] Circuit Breaker configuration
* @return {GitlabScm}
*/
constructor(config = {}) {
super();
// Validate configuration
this.config = Joi.attempt(config, Joi.object().keys({
gitlabProtocol: Joi.string().optional().default('https'),
gitlabHost: Joi.string().optional().default('gitlab.com'),
username: Joi.string().optional().default('sd-buildbot'),
email: Joi.string().optional().default('[email protected]'),
https: Joi.boolean().optional().default(false),
oauthClientId: Joi.string().required(),
oauthClientSecret: Joi.string().required(),
fusebox: Joi.object().default({})
}).unknown(true), 'Invalid config for Gitlab');
const gitlabConfig = {};
if (this.config.gitlabHost) {
gitlabConfig.host = this.config.gitlabHost;
gitlabConfig.protocol = this.config.gitlabProtocol;
gitlabConfig.pathPrefix = '';
}
console.log('CONFIG IS ', this.config);
this.breaker = new Breaker(Request, this.config.fusebox);
}
/**
* Look up a repo by SCM URI
* @method lookupScmUri
* @param {Object} config Config object
* @param {Object} config.scmUri The SCM URI to look up relevant info
* @param {Object} config.token Service token to authenticate with Gitlab
* @return {Promise} Resolves to an object containing
* repository-related information
*/
lookupScmUri(config) {
const [scmHost, scmId, scmBranch] = config.scmUri.split(':');
return this.breaker.runCommand({
json: true,
method: 'GET',
auth: {
bearer: config.token
},
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${scmId}`
}).then((response) => {
checkResponseError(response, 'lookupScmUri');
const [repoOwner, repoName] = response.body.path_with_namespace.split('/');
return {
branch: scmBranch,
host: scmHost,
repo: repoName,
owner: repoOwner
};
});
}
/**
* Look up a webhook from a repo
* @method _findWebhook
* @param {Object} config
* @param {Object} config.scmUri Data about repo
* @param {String} config.token The SCM URI to find the webhook from
* @param {String} config.url url for webhook notifications
* @return {Promise} Resolves a list of hooks
*/
_findWebhook(config) {
const [scmHost, scmId, scmBranch] = config.scmUri.split(':');
return this.breaker.runCommand({
json: true,
method: 'GET',
auth: {
bearer: config.token
},
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${scmId}/hooks`
}).then((response) => {
checkResponseError(response, '_findWebhook');
const screwdriverHook = response.body.find(hook =>
Hoek.reach(hook, 'config.url') === config.url
);
return screwdriverHook;
});
}
/**
* Create or edit a webhook (edits if hookInfo exists)
* @method _createWebhook
* @param {Object} config
* @param {Object} [config.hookInfo] Information about a existing webhook
* @param {Object} config.scmUri Information about the repo
* @param {String} config.token admin token for repo
* @param {String} config.url url for webhook notifications
* @param {String} config.secret
* @return {Promise} resolves when complete
*/
_createWebhook(config) {
const [scmHost, scmId, scmBranch] = config.scmUri.split(':');
const params = {
url: config.url,
token: this.config.secret,
push_events: true,
merge_requests_events: true
};
let action = {
method: 'POST',
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${scmId}/hooks`
};
if (config.hookInfo) {
action.method = 'PUT';
action.url += `/${config.hookInfo}`;
}
return this.breaker.runCommand({
json: true,
method: action.method,
auth: {
bearer: config.token
},
url: action.url,
qs: params
});
}
/** Extended from screwdriver-scm-base **/
/**
* Adds the Screwdriver webhook to the Gitlab repository
* @method _addWebhook
* @param {Object} config Config object
* @param {String} config.scmUri The SCM URI to add the webhook to
* @param {String} config.token Service token to authenticate with Gitlab
* @param {String} config.webhookUrl The URL to use for the webhook notifications
* @return {Promise} Resolve means operation completed without failure.
*/
_addWebhook(config) {
const [scmHost, scmId, scmBranch] = config.scmUri.split(':');
return this._findWebhook({
scmUri: config.scmUri,
url: config.webhookUrl,
token: config.token
}).then(hookInfo =>
this._createWebhook({
hookInfo,
scmUri: config.scmUri,
token: config.token,
url: config.webHookUrl
})
);
}
/**
* Parses a SCM URL into a screwdriver-representable ID
* @method _parseUrl
* @param {Object} config Config object
* @param {String} config.checkoutUrl The checkoutUrl to parse
* @param {String} config.token The token used to authenticate to the SCM service
* @return {Promise} Resolves to an ID of 'serviceName:repoId:branchName'
*/
_parseUrl(config) {
console.log('CONFIG is', config);
const repoInfo = getRepoInfo(config.checkoutUrl);
console.log('repoInfo ', repoInfo);
console.log('CALLING THIS ', `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${repoInfo.username}/${repoInfo.repo}`);
return this.breaker.runCommand({
json: true,
method: 'GET',
auth: {
bearer: config.token
},
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${repoInfo.username}%2F${repoInfo.repo}`
}).then((response) => {
checkResponseError(response, '_parseUrl');
return `${repoInfo.hostname}:${response.body.id}:${repoInfo.branch}`;
});
}
// /**
// * Given a SCM webhook payload & its associated headers, aggregate the
// * necessary data to execute a Screwdriver job with.
// * @method _parseHook
// * @param {Object} payloadHeaders The request headers associated with the
// * webhook payload
// * @param {Object} webhookPayload The webhook payload received from the
// * SCM service.
// * @return {Promise} A key-map of data related to the received
// * payload
// */
// _parseHook(payloadHeaders, webhookPayload) {
// TODO: implement this
// }
/**
* Checkout the source code from a repository; resolves as an object with checkout commands
* @method getCheckoutCommand
* @param {Object} config
* @param {String} config.branch Pipeline branch
* @param {String} config.host Scm host to checkout source code from
* @param {String} config.org Scm org name
* @param {String} config.repo Scm repo name
* @param {String} config.sha Commit sha
* @param {String} [config.prRef] PR reference (can be a PR branch or reference)
* @return {Promise}
*/
_getCheckoutCommand(config) {
const checkoutUrl = `${config.host}/${config.org}/${config.repo}`;
const checkoutRef = config.prRef ? config.branch : config.sha; // if PR, use pipeline branch
const command = [];
// Git clone
command.push(`echo Cloning ${checkoutUrl}, on branch ${config.branch}`);
command.push(`export SCM_URL=${checkoutUrl}`);
command.push('if [ ! -z $SCM_USERNAME ] && [ ! -z $SCM_ACCESS_TOKEN ]; then '
+ 'SCM_URL="$SCM_USERNAME:$SCM_ACCESS_TOKEN@$SCM_URL"; fi');
command.push(`git clone --quiet --progress --branch ${config.branch} `
+ 'https://$SCM_URL $SD_SOURCE_DIR');
// Reset to SHA
command.push(`git reset --hard ${checkoutRef}`);
command.push(`echo Reset to ${checkoutRef}`);
// Set config
command.push('echo Setting user name and user email');
command.push(`git config user.name ${this.config.username}`);
command.push(`git config user.email ${this.config.email}`);
// For pull requests
if (config.prRef) {
const prRef = config.prRef.replace('merge', 'head:pr');
// Fetch a pull request
command.push(`echo Fetching PR and merging with ${config.branch}`);
command.push(`git fetch origin ${prRef}`);
// Merge a pull request with pipeline branch
command.push(`git merge --no-edit ${config.sha}`);
}
return Promise.resolve({
name: 'sd-checkout-code',
command: command.join(' && ')
});
}
/**
* Decorate a given SCM URI with additional data to better display
* related information. If a branch suffix is not provided, it will default
* to the master branch
* @method _decorateUrl
* @param {Config} config Configuration object
* @param {String} config.scmUri The SCM URI the commit belongs to
* @param {String} config.token Service token to authenticate with Github
* @return {Promise}
*/
_decorateUrl(config) {
return this.lookupScmUri({
scmUri: config.scmUri,
token: config.token
}).then((scmInfo) => {
const baseUrl = `${scmInfo.host}/${scmInfo.owner}/${scmInfo.repo}`;
return {
branch: scmInfo.branch,
name: `${scmInfo.owner}/${scmInfo.repo}`,
url: `${this.config.gitlabProtocol}://${baseUrl}/tree/${scmInfo.branch}`
};
});
}
/**
* Decorate the commit based on the repository
* @method _decorateCommit
* @param {Object} config Configuration object
* @param {Object} config.scmUri SCM URI the commit belongs to
* @param {Object} config.sha SHA to decorate data with
* @param {Object} config.token Service token to authenticate with Github
* @return {Promise}
*/
_decorateCommit(config) {
const [scmHost, scmId, scmBranch] = config.scmUri.split(':');
const scmInfo = this.lookupScmUri({
scmUri: config.scmUri,
token: config.token
});
const commitLookup = this.breaker.runCommand({
json: true,
method: 'GET',
auth: {
bearer: config.token
},
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${scmId}/repository/commits/${config.sha}`
}).then((response) => {
checkResponseError(response, '_decorateCommit: commitLookup');
return response.body;
});
const authorLookup = commitLookup.then((commitData) => {
if (!commitData.author_name) {
return DEFAULT_AUTHOR;
}
return this.decorateAuthor({
token: config.token,
username: commitData.author_name
});
});
return Promise.all([
commitLookup,
authorLookup
]).then(([commitData, authorData]) =>
({
author: authorData,
message: commitData.message,
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}` +
`/${scmInfo.owner}/${scmInfo.repo}/tree/${config.sha}`
})
);
}
/**
* Decorate the author based on the Gitlab service
* @method _decorateAuthor
* @param {Object} config Configuration object
* @param {Object} config.token Service token to authenticate with Gitlab
* @param {Object} config.username Username to query more information for
* @return {Promise}
*/
_decorateAuthor(config) {
return this.breaker.runCommand({
json: true,
method: 'GET',
auth: {
bearer: config.token
},
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
'/users',
qs: {
username: config.username
}
}).then((response) => {
checkResponseError(response, '_decorateAuthor');
return {
// TODO: debug why the data is not getting returned
// url: response.body.web_url,
// name: response.body.name,
// username: response.body.username,
// avatar: response.body.avatar_url
url: 'http://172.30.70.197/bdangit',
name: 'bdangit',
username: 'bdangit',
avatar: 'http://www.gravatar.com/avatar/f76b22acd6f275a593cfac4aae61bce6?s=80&d=identicon'
};
});
}
/**
* Get a owners permissions on a repository
* @method _getPermissions
* @param {Object} config Configuration
* @param {String} config.scmUri The scmUri to get permissions on
* @param {String} config.token The token used to authenticate to the SCM
* @return {Promise}
*/
_getPermissions(config) {
const [scmHost, scmId, scmBranch] = config.scmUri.split(':');
return this.breaker.runCommand({
json: true,
method: 'GET',
auth: {
bearer: config.token
},
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${scmId}`
}).then((response) => {
checkResponseError(response, '_getPermissions');
// TODO: trasnlate gitlab::access into admin, push, pull
// ref: https://docs.gitlab.com/ee/api/members.html
// "admin": false,
// "push": false,
// "pull": true
return {
admin: true,
push: true,
pull: true
};
});
}
/**
* Get a commit sha for a specific repo#branch
* @method getCommitSha
* @param {Object} config Configuration
* @param {String} config.scmUri The scmUri to get commit sha of
* @param {String} config.token The token used to authenticate to the SCM
* @return {Promise}
*/
_getCommitSha(config) {
const [scmHost, scmId, scmBranch] = config.scmUri.split(':');
return this.breaker.runCommand({
json: true,
method: 'GET',
auth: {
bearer: config.token
},
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${scmId}/repository/branches/${scmBranch}`
}).then((response) => {
checkResponseError(response, '_getCommitSha');
return response.body.commit.id;
});
}
/**
* Update the commit status for a given repo and sha
* @method updateCommitStatus
* @param {Object} config Configuration
* @param {String} config.scmUri The scmUri to get permissions on
* @param {String} config.sha The sha to apply the status to
* @param {String} config.buildStatus The build status used for figuring out the commit status to set
* @param {String} config.token The token used to authenticate to the SCM
* @param {String} [config.jobName] Optional name of the job that finished
* @param {String} config.url Target url
* @return {Promise}
*/
_updateCommitStatus(config) {
const [scmHost, scmId, scmBranch] = config.scmUri.split(':');
const context = config.jobName ? `Screwdriver/${config.jobName}` : 'Screwdriver';
return this.breaker.runCommand({
json: true,
method: 'POST',
auth: {
bearer: config.token
},
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${scmId}/statuses/${config.sha}`,
qs: {
context,
description: DESCRIPTION_MAP[config.buildStatus],
state: STATE_MAP[config.buildStatus] || 'failure',
target_url: config.url
}
});
}
/**
* Fetch content of a file from gitlab
* @method getFile
* @param {Object} config Configuration
* @param {String} config.scmUri The scmUri to get permissions on
* @param {String} config.path The file in the repo to fetch
* @param {String} config.token The token used to authenticate to the SCM
* @param {String} config.ref The reference to the SCM, either branch or sha
* @return {Promise}
*/
_getFile(config) {
const [scmHost, scmId, scmBranch] = config.scmUri.split(':');
this.breaker.runCommand({
json: true,
method: 'GET',
auth: {
bearer: config.token
},
url: `${this.config.gitlabProtocol}://${this.config.gitlabHost}/api/v3` +
`/projects/${scmId}/repository/files`,
qs: {
file_path: config.path,
ref: config.ref || scmBranch
}
}).then((response) => {
checkResponseError(response, '_getFile');
return new Buffer(response.body.content, response.body.encoding).toString();
});
}
/**
* Return a valid Bell configuration (for OAuth)
* @method _getBellConfiguration
* @return {Promise}
*/
_getBellConfiguration() {
const bellConfig = {
provider: 'gitlab',
clientId: this.config.oauthClientId,
clientSecret: this.config.oauthClientSecret,
isSecure: this.config.https,
forceHttps: this.config.https
};
if (this.config.gitlabHost) {
bellConfig.config = {
uri: `${this.config.gitlabProtocol}://${this.config.gitlabHost}`
};
}
return Promise.resolve(bellConfig);
}
/**
* Retrieve stats for the executor
* @method stats
* @param {Response} Object Object containing stats for the executor
*/
stats() {
return this.breaker.stats();
}
}
module.exports = GitlabScm;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment