diff --git a/build.gradle b/build.gradle index fbd6a6a..6babed9 100644 --- a/build.gradle +++ b/build.gradle @@ -15,8 +15,8 @@ dependencies { implementation 'org.kohsuke:github-api:1.95' implementation 'com.electriccloud.plugins:flowpdf-groovy-lib:1.1.1.0' - //That's ours - //implementation 'com.electriccloud:commander-api-bindings:9.0.0-SNAPSHOT' + //That's ours (you can uncomment this for local testing) +// implementation 'com.electriccloud:commander-api-bindings:9.0.0-SNAPSHOT' } diff --git a/config/pluginspec.yaml b/config/pluginspec.yaml index 68ac6f9..e6ecd4f 100644 --- a/config/pluginspec.yaml +++ b/config/pluginspec.yaml @@ -1,6 +1,6 @@ pluginInfo: pluginName: EC-Github - version: 3.3.0 + version: 3.4.0 description: CloudBees CD integration for Github API author: Polina authorUrl: pshubina@cloudbees.com @@ -27,18 +27,16 @@ properties: - propertyName: ec_webhook credentialProtected: true properties: - - propertyName: displayName - value: GitHub - - propertyName: procedureName - value: webhook - - propertyName: script - value: - path: dsl/properties/ec_webhook/script.groovy - - propertyName: setupProcedure - value: SetupWebhook - - propertyName: ec_polling - properties: - displayName: GitHub + - propertyName: default + properties: + - propertyName: displayName + value: GitHub + - propertyName: procedureName + value: webhook + - propertyName: script + value: '$[/myProject/ec_webhook/script.groovy]' + - propertyName: setupProcedure + value: SetupWebhook procedures: - name: SetupWebhook @@ -63,28 +61,77 @@ procedures: # this is a webhook backing procedure (gives form for the webhook) parameters: - name: repositories + label: Repositories + documentation: List of repositories, separated by a newline. Leave empty to process events for all the repositories where the webhook is set up. type: textarea - documentation: List of repositories, separated by a newline. required: false - name: pushEvent + label: Process Push Events? type: checkbox - label: Process Push? - documentation: Processes push events - - name: prEvent - type: checkbox - label: Process Pull Request? - - name: prAction - type: textarea - dependsOn: prEvent - condition: ${prEvent == "true"} - documentation: The action that was performed. Can be one of opened, edited, closed, assigned, unassigned, review_requested, review_request_removed, ready_for_review, labeled, unlabeled, synchronize, locked, unlocked, or reopened. If the action is closed and the merged key is false, the pull request was closed with unmerged commits. If the action is closed and the merged key is true, the pull request was merged. + documentation: Check this if you want trigger to be run when the new commit appears in one of the monitored branches. - name: includeBranches + label: Include Branches + documentation: | + List of branch names, separated by a comma. + Incoming events will be discarded if not relate to one of the specified branches. + Leave empty to process events for all branches except ones specified in the “excludeBranches” parameter. type: textarea dependsOn: pushEvent - condition: ${pushEvent == "true"} + condition: ${pushEvent} == "true" - name: excludeBranches + label: Exclude Branches type: textarea + documentation: | + List of branch names, separated by a comma. + Incoming events will be discarded if relate to one of the specified branches. + Leave empty to process events for all branches or only for the specified in the "Include Branches". required: false + dependsOn: pushEvent + condition: ${pushEvent} == "true" + - name: prEvent + label: Process Pull Requests? + documentation: | + Check this if you want the trigger to be run when Pull Request event occurs + type: checkbox + required: false + - name: includePrActions + type: textarea + dependsOn: prEvent + condition: ${prEvent} == "true" + documentation: | + The action that was performed. Can be one of: + + - name: commitStatusEvent + label: Process Commit Status Events? + type: checkbox + documentation: Check this if you want trigger to be run when a commit status has been changed. + required: false + - name: includeCommitStatuses + label: Include Commit Status Events + type: textarea + documentation: | + Limit to following commit statuses. Comma-separated list with following statuses: + - name: Create Repository description: Creates a GitHub Repository diff --git a/dsl/properties/ec_webhook/script.groovy b/dsl/properties/ec_webhook/script.groovy index cfe91a2..abadedf 100644 --- a/dsl/properties/ec_webhook/script.groovy +++ b/dsl/properties/ec_webhook/script.groovy @@ -1,50 +1,450 @@ +import groovy.json.JsonSlurper +import org.apache.commons.codec.binary.Hex + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import java.util.regex.Pattern + +import static java.nio.charset.StandardCharsets.UTF_8 + println args def trigger = args.trigger -def headers = args.headers -def method = args.method -def body = args.body -def url = args.url +Map headers = args.headers +String method = args.method +String body = args.body +String url = args.url def query = args.query -// do something +// This map corresponds to the procedure form +final Map SUPPORTED_EVENTS = [ + 'push' : [ + enabledParamName: 'pushEvent', + actionsParamName: null + ], + 'pull_request': [ + enabledParamName: 'prEvent', + actionsParamName: 'includePrActions' + ], + 'status' : [ + enabledParamName: 'commitStatusEvent', + actionsParamName: 'includeCommitStatuses' + ], + 'ping' : [ + enabledParamName: null, + actionsParamName: null + ], +] -def event = '' -def signature = '' +// Parsing headers +String event = headers.get('X-GitHub-Event') +String signature = headers.get('X-Hub-Signature') +if (!signature) { + throw new RuntimeException("Request does not contain the signature header") +} +if (!event) { + throw new RuntimeException("Request does not contain the event header") +} -headers.each { k, v -> - if (k.toLowerCase() == 'X-GitHub-Event') { - event = v - } - if (k.toLowerCase() == 'X-Hub-Signature') { - signature = v - } +// As we do not use other restrictions, every trigger should have a signature secret +if (!trigger.webhookSecret) { + throw new RuntimeException("Trigger '${trigger.getName()}' does not have webhookSecret set up") +} +//validating signature +if (!verifySignedPayload(signature, (String) trigger.webhookSecret, body)) { + // Todo: change to agreed exception + throw new RuntimeException("Signatures do not match. Please check that the trigger's 'webhookSecret' field value matches one specified in the Github repository webhook settings.") } -//validate signature +// Receiving trigger parameters +def paramsPropertySheet = trigger.pluginParameters +Map pluginParameters = [:] +paramsPropertySheet['properties'].each { String k, Map v -> + pluginParameters[k] = v['value'] +} + + +WebhookEvent webhookEvent = WebhookEvent.getForType(event, body) +if (webhookEvent == null) { + return [ + responseMessage: "Ignoring unsupported '${event}' event", + launchWebhook : false + ] +} -if (event == 'ping') { +// Check repository +String repositoryName = webhookEvent.getRepositoryName() +if (!repositoryName) { + throw new RuntimeException("Webhook event '${event}' doesn't contain 'repository' object") +} +if (!doCheckRepositoryIncluded(pluginParameters.get('repositories'), repositoryName)) { return [ - eventType : 'ping', - webhookData : ['some data': 'some data'], - commitId : null, - commitAuthorName : null, - commitAuthorEmail: null, - branch : null, - launchWebhook : false + responseMessage: "Ignoring ${repositoryName} repository event", + launchWebhook : false ] -} else if (event == 'push') { - def payload = new JsonSlurper().parseText(body) - def commits = payload.commits - def repo = payload.repository -} - -return [ - eventType : 'push', - webhookData : ['some data': 'some data'], - commitId : null, - commitAuthorName : null, - commitAuthorEmail: null, - branch : null, - launchWebhook : true -] \ No newline at end of file +} + +// Check event enabled +String eventEnabledParamName = SUPPORTED_EVENTS[event]['enabledParamName'] +if (eventEnabledParamName != null) { + + boolean eventEnabled = pluginParameters.get(eventEnabledParamName) != 'false' + if (!eventEnabled) { + return [ + responseMessage: "Processing for the '${webhookEvent.getName()}' event is disabled", + launchWebhook : false + ] + } +} + + +// Check action enabled +String includedActionParamName = SUPPORTED_EVENTS[event]['actionsParamName'] +if (includedActionParamName != null) { + + String includedActions = pluginParameters.get(includedActionParamName) + String eventAction = webhookEvent.getAction() + + boolean actionEnabled = doCheckActionIncluded(includedActions, eventAction) + + if (!actionEnabled) { + return [ + responseMessage: "Processing for the '${eventAction}' action of the '${event}' event is disabled", + launchWebhook : false + ] + } +} + +// Check that branch is included and not excluded +String includeBranches = pluginParameters.get('includeBranches') +String excludeBranches = pluginParameters.get('excludeBranches') + +ArrayList eventBranches = webhookEvent.getBranchNames() +String branchName = eventBranches.join(', ') + +if (includeBranches) { + if (!doCheckBranchIncluded(includeBranches, eventBranches)) { + return [ + responseMessage: "Ignoring '${event}' event for branch '${branchName}'", + launchWebhook : false + ] + } +} +if (excludeBranches) { + if (doCheckBranchIncluded(excludeBranches, eventBranches)) { + return [ + responseMessage: "Ignoring '${event}' event for exluded branch '${branchName}'", + launchWebhook : false + ] + } +} + +// Collect data for response +Map webhookData = webhookEvent.collectWebhookData() +Map recentCommit = webhookEvent.getRecentCommit() + +Map response = [ + eventType : event, + launchWebhook: true, + branch : webhookEvent.getBranchNames().join(', ') +] as Map + +if (webhookData) { + response['webhookData'] = webhookData +} + +if (recentCommit) { + response['commitId'] = recentCommit['commitId'] + response['commitAuthorName'] = recentCommit['commitAuthorName'] + response['commitAuthorEmail'] = recentCommit['commitAuthorEmail'] +} + +return response + +/** + * These methods depend on the form declaration + */ + +private static boolean doCheckRepositoryIncluded(String parameterValue, String checked) { + ArrayList list = parameterValue.tokenize("\n").collect({ it.trim() }) + return list.contains(checked) +} + +private static boolean doCheckActionIncluded(String parameterValue, String checked) { + if (!parameterValue) return true + ArrayList list = parameterValue.tokenize(/,\s+?/).collect({ it.trim() }) + return list.contains(checked) +} + +private static boolean doCheckBranchIncluded(String parameterValue, ArrayList checked) { + ArrayList list = parameterValue.tokenize(/,\s+?/).collect({ it.trim() }) + for (String b : checked) { + if (listContainsGlobMatch(list, b)) { + return true + } + } + return false +} + +////////////////////////////////////////////////////////////////////////////////////////////// +// End of business logic +////////////////////////////////////////////////////////////////////////////////////////////// + +static boolean verifySignedPayload(String remoteSignature, String secretToken, String payload) { + def signature = 'sha1=' + hmacSignature(payload, secretToken) + return signature.equals(remoteSignature) +} + +static String hmacSignature(String data, String key) { + try { + final SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(UTF_8), "HmacSHA1") + final Mac mac = Mac.getInstance("HmacSHA1") + mac.init(keySpec) + final byte[] rawHMACBytes = mac.doFinal(data.getBytes(UTF_8) as byte[]) + + return Hex.encodeHexString(rawHMACBytes) + } catch (Exception e) { + throw new RuntimeException("Computed invalid signature: " + e.getMessage()) + } +} + +static boolean listContainsGlobMatch(ArrayList list, String checked) { + for (String l : list) { + def pattern = Pattern.compile(l) + if (pattern.matcher(checked).lookingAt()) { + return true + } + } + return false +} + +abstract class WebhookEvent { + abstract String name + abstract String body + + @Lazy + String action = { + payload.get('action') + }() + + Map payload + + WebhookEvent(String payload) { + this.body = payload + this.payload = (new JsonSlurper()).parseText(this.body) as Map + } + + static WebhookEvent getForType(String event, String payload) { + if (event == 'ping') { + return new PingEvent(payload) + } else if (event == 'pull_request') { + return new PullRequestEvent(payload) + } else if (event == 'push') { + return new PushEvent(payload) + } else if (event == 'status') { + return new CommitStatusEvent(payload) + } else { + return null + } + } + + String getRepositoryName() { return payload.get('repository')?.get('full_name') } + + abstract ArrayList getBranchNames() + + abstract Map getRecentCommit() + + abstract Map collectWebhookData() + +} + +class PingEvent extends WebhookEvent { + static String name = 'ping' + + PingEvent(String payload) { + super(payload) + } + + @Override + ArrayList getBranchNames() { + return null + } + + @Override + Map getRecentCommit() { + return null + } + + @Override + Map collectWebhookData() { + return null + } +} + +class PullRequestEvent extends WebhookEvent { + + static String name = 'pull_request' + + @Lazy + ArrayList branchNames = { + String refName = payload.get('pull_request')?.get('head')?.get('ref') + if (!refName) { + return null + } + return [refName.replace('/refs/heads/', '')] + }() + + @Lazy + ArrayList> commits = { + def prHead = payload['pull_request']['head'] + return [ + [ + commitId : prHead['sha'], + branch : prHead['ref'], + commitAuthorName : prHead['user']['login'], + + //TODO: check if we should request additionally + commitAuthorEmail: null, + ] as Map + ] + }() + + @Override + Map getRecentCommit() { + if (!commits || !commits.size()) return null + return commits.first() + } + + PullRequestEvent(String payload) { + super(payload) + } + + @Override + Map collectWebhookData() { + def pullRequest = payload['pull_request'] + return [ + number : pullRequest['number'], + title : pullRequest['title'], + body : pullRequest['body'], + state : pullRequest['state'], + url : pullRequest['html_url'], + payload: this.body + ] as Map + } + + /** + * We are adding two additional virtual actions: closed_merged, closed_discarded + */ + @Override + String getAction() { + String action = payload.get('action') + if (action == 'closed') { + def pullRequest = payload.get('pull_request') + if (pullRequest['merged'] == 'true') { + return 'closed_merged' + } else { + return 'closed_discarded' + } + + } + return action + } +} + +class PushEvent extends WebhookEvent { + + static String name = 'push' + + PushEvent(String payload) { + super(payload) + } + + @Lazy + ArrayList branchNames = { + String refName = payload.get('ref') + if (!refName) { + return null + } + if (!refName.contains('refs/heads/')) { + // This is not a branch push + throw new RuntimeException("Only the branch 'push' events are supported, got '${refName}'") + } + + // Stripping the ref path + String branchName = refName.replaceAll(/^refs\/heads\//, '') + + return [branchName] + }() + + @Lazy + ArrayList> commits = { + ArrayList> res = new ArrayList<>() + payload.get('commits').each { Map commit -> + return [ + commitId : commit['sha'], + commitMessage : commit['message'], + commitAuthorName : commit['author']['name'], + commitAuthorEmail: commit['author']['email'], + ] + } + return res + }() + + @Override + Map getRecentCommit() { + if (!commits || !commits.size()) return null + return commits.first() + } + + @Override + Map collectWebhookData() { + return [ + ref : payload['ref'], + branch : getBranchNames().join(', '), + payload: this.body + ] as Map + } +} + +class CommitStatusEvent extends WebhookEvent { + + static String name = 'status' + + CommitStatusEvent(String payload) { + super(payload) + } + + @Lazy + ArrayList branchNames = { + ArrayList> commitBranches = payload.get('branches') as ArrayList> + if (!commitBranches || !commitBranches.size()) { + return null + } + return commitBranches.collect({ it.get('name') }) + }() + + ArrayList> getCommits() { + // Single commit in an array + return [getRecentCommit()] + } + + @Override + Map getRecentCommit() { + def commit = payload['commit'] + return [ + commitId : commit['sha'], + commitMessage : commit['message'], + commitAuthorName : commit['commit']['author']['name'], + commitAuthorEmail: commit['commit']['author']['email'], + ] as Map + } + + @Override + Map collectWebhookData() { + return [ + sha : payload['sha'], + state : payload['state'], + payload: this.body + ] as Map + } +} diff --git a/help/changelog.yaml b/help/changelog.yaml index 11448ed..cda7698 100644 --- a/help/changelog.yaml +++ b/help/changelog.yaml @@ -19,4 +19,6 @@ 3.0.1: - Fixed setup procedure (with proper classpath calculation) 3.3.0: - - Added Creat Pull Request procedure. + - Added Create Pull Request procedure. +3.4.0: + - Added webhooks support. diff --git a/help/metadata.yaml b/help/metadata.yaml index 73c2394..2a5603e 100644 --- a/help/metadata.yaml +++ b/help/metadata.yaml @@ -1,5 +1,5 @@ overview: | - This plugin integates with Github API. + This plugin integrates with Github API. excludeProcedures: - flowpdk-setup