diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..9dd1a989 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# top-most EditorConfig file +root = true + +[*] +indent_style = space +# Unix-style newlines with a newline ending every file +end_of_line = lf +charset = utf-8 +insert_final_newline = true +indent_size = 4 diff --git a/README.md b/README.md index 781f731a..3fd0747e 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,12 @@ Jenkins Pipeline Shared library, that contains additional features for Git, Mave - [SonarCloud](#sonarcloud) - [Pull Requests in SonarQube](#pull-requests-in-sonarqube) - [Changelog](#changelog) - - [changelogFileName](#changelogFileName) + - [changelogFileName](#changelogfilename) - [GitHub](#github) - [GitFlow](#gitflow) +- [SCM-Manager](#scm-manager) + - [Pull Requests](#pull-requests) +- [HttpClient](#httpclient) - [Steps](#steps) - [mailIfStatusChanged](#mailifstatuschanged) - [isPullRequest](#ispullrequest) @@ -898,6 +901,105 @@ stage('Gitflow') { * `gitflow.finishRelease(releaseVersion)` - Finishes a git release by merging into develop and master. * Use the `releaseVersion` (String) as the name of the new git release. +# SCM-Manager + +Provides the functionality to handle pull requests on a SCMManager repository. + +You need to pass `usernamePassword` (i.e. a String containing the ID that refers to the +[Jenkins credentials](https://jenkins.io/doc/book/using/using-credentials/)) to `SCMManager` during construction. +These are then used for handling the pull requests. + +```groovy +SCMManager scmm = new SCMManager(this, 'ourCredentials') +``` + +Set the repository url through the `repositoryUrl` property like so: + +```groovy +SCMManager scmm = new SCMManager(this, 'https://hostname/scm', 'ourCredentials') +``` + +## Pull Requests + +Each method requires a `repository` parameter, a String containing namespace and name, e.g. `cloudogu/ces-build-lib`. + +* `scmm.searchPullRequestIdByTitle(repository, title)` - Returns a pull request ID by title, or empty, if not present. + * Use the `repository` (String) as the GitOps repository + * Use the `title` (String) as the title of the pull request in question. + * This methods requires the `readJSON()` step from the + [Pipeline Utility Steps plugin](https://plugins.jenkins.io/pipeline-utility-steps/). +* `scmm.createPullRequest(repository, source, target, title, description)` - Creates a pull request, or empty, if not present. + * Use the `repository` (String) as the GitOps repository + * Use the `source` (String) as the source branch of the pull request. + * Use the `target` (String) as the target branch of the pull request. + * Use the `title` (String) as the title of the pull request. + * Use the `description` (String) as the description of the pull request. +* `scmm.updateDescription(repository, pullRequestId, title, description)` - Updates the description of a pull request. + * Use the `repository` (String) as the GitOps repository + * Use the `pullRequestId` (String) as the ID of the pull request. + * Use the `title` (String) as the title of the pull request. + * Use the `description` (String) as the description of the pull request. +* `scmm.createOrUpdatePullRequest(repository, source, target, title, description)` - Creates a pull request if no PR is found or updates the existing one. + * Use the `repository` (String) as the GitOps repository + * Use the `source` (String) as the source branch of the pull request. + * Use the `target` (String) as the target branch of the pull request. + * Use the `title` (String) as the title of the pull request. + * Use the `description` (String) as the description of the pull request. +* `scmm.addComment(repository, pullRequestId, comment)` - Adds a comment to a pull request. + * Use the `repository` (String) as the GitOps repository + * Use the `pullRequestId` (String) as the ID of the pull request. + * Use the `comment` (String) as the comment to add to the pull request. + +Example: + +```groovy +def scmm = new SCMManager(this, 'https://your.ecosystem.com/scm', scmManagerCredentials) + +def pullRequestId = scmm.createPullRequest('cloudogu/ces-build-lib', 'feature/abc', 'develop', 'My title', 'My description') +pullRequestId = scmm.searchPullRequestIdByTitle('cloudogu/ces-build-lib', 'My title') +scmm.updatePullRequest('cloudogu/ces-build-lib', pullRequestId, 'My new title', 'My new description') +scmm.addComment('cloudogu/ces-build-lib', pullRequestId, 'A comment') +``` + +# HttpClient + +`HttpClient` provides a simple `curl` frontend for groovy. + +* Not surprisingly, it requires `curl` on the jenkins agents. +* If you need to authenticate, you can create a `HttpClient` with optional credentials ID (`usernamePassword` credentials) +* `HttpClient` provides `get()`, `put()` and `post()` methods +* All methods have the same signature, e.g. + `http.get(url, contentType = '', data = '')` + * `url` (String) + * optional `contentType` (String) - set as acceptHeader in the request + * optional `data` (Object) - sent in the body of the request +* If successful, all methods return the same data structure a map of + * `httpCode` - as string containing the http status code + * `headers` - a map containing the response headers, e.g. `[ location: 'http://url' ]` + * `body` - an optional string containing the body of the response +* In case of an error (Connection refused, Could not resolve host, etc.) an exception is thrown which fails the build + right away. If you don't want the build to fail, wrap the call in a `try`/`catch` block. + +Example: + +```groovy +HttpClient http = new HttpClient(scriptMock, 'myCredentialID') + +// Simplest example +echo http.get('http://url') + +// POSTing data +def dataJson = JsonOutput.toJson([ + comment: comment +]) +def response = http.post('http://url/comments"', 'application/json', dataJson) + +if (response.status == '201' && response.content-type == 'application/json') { + def json = readJSON text: response.body + echo json.count +} +``` + # Steps ## mailIfStatusChanged diff --git a/src/com/cloudogu/ces/cesbuildlib/HttpClient.groovy b/src/com/cloudogu/ces/cesbuildlib/HttpClient.groovy new file mode 100644 index 00000000..06ae5761 --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/HttpClient.groovy @@ -0,0 +1,90 @@ +package com.cloudogu.ces.cesbuildlib + +/** + * An HTTP client that calls curl on the shell. + * + * Returns a map of + * * httpCode (String) + * * headers (Map) + * * body (String) + */ +class HttpClient implements Serializable { + private script + private credentials + private Sh sh + + HttpClient(script, credentials = '') { + this.script = script + this.credentials = credentials + this.sh = new Sh(script) + } + + Map get(String url, String contentType = '', def data = '') { + return httpRequest('GET', url, contentType, data) + } + + Map put(String url, String contentType = '', def data = '') { + return httpRequest('PUT', url, contentType, data) + } + + Map post(String url, String contentType = '', def data = '') { + return httpRequest('POST', url, contentType, data) + } + + protected String executeWithCredentials(Closure closure) { + if (credentials) { + script.withCredentials([script.usernamePassword(credentialsId: credentials, + passwordVariable: 'CURL_PASSWORD', usernameVariable: 'CURL_USER')]) { + closure.call(true) + } + } else { + closure.call(false) + } + } + + protected String getCurlAuthParam() { + "-u ${script.env.CURL_USER}:${script.env.CURL_PASSWORD}" + } + + protected Map httpRequest(String httpMethod, String url, String contentType, def data) { + String httpResponse + def rawHeaders + def body + + executeWithCredentials { + + String curlCommand = + "curl -i -X ${httpMethod} " + + (credentials ? "${getCurlAuthParam()} " : '') + + (contentType ? "-H 'Content-Type: ${contentType}' " : '') + + (data ? "-d '${data.toString()}' " : '') + + "${url}" + + // Command must be run inside this closure, otherwise the credentials will not be masked (using '*') in the console + httpResponse = sh.returnStdOut curlCommand + } + + String[] responseLines = httpResponse.split("\n") + + // e.g. HTTP/2 301 + String httpCode = responseLines[0].split(" ")[1] + def separatingLine = responseLines.findIndexOf { it.trim().isEmpty() } + + if (separatingLine > 0) { + rawHeaders = responseLines[1..(separatingLine -1)] + body = responseLines[separatingLine+1..-1].join('\n') + } else { + // No body returned + rawHeaders = responseLines[1..-1] + body = '' + } + + def headers = [:] + for(String line: rawHeaders) { + // e.g. cache-control: no-cache + def splitLine = line.split(':', 2); + headers[splitLine[0].trim()] = splitLine[1].trim() + } + return [ httpCode: httpCode, headers: headers, body: body] + } +} diff --git a/src/com/cloudogu/ces/cesbuildlib/SCMManager.groovy b/src/com/cloudogu/ces/cesbuildlib/SCMManager.groovy new file mode 100644 index 00000000..008b0557 --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/SCMManager.groovy @@ -0,0 +1,140 @@ +package com.cloudogu.ces.cesbuildlib + +import groovy.json.JsonOutput + +class SCMManager implements Serializable { + + private script + protected HttpClient http + protected String baseUrl + + SCMManager(script, String baseUrl, String credentials) { + this.script = script + this.baseUrl = baseUrl + this.http = new HttpClient(script, credentials) + } + + String searchPullRequestIdByTitle(String repository, String title) { + def pullRequest + for (Map pr : getPullRequests(repository)) { + if (pr.title == title) { + pullRequest = pr + } + } + + if (pullRequest) { + return pullRequest.id.toString() + } else { + return '' + } + } + + String createPullRequest(String repository, String source, String target, String title, String description) { + def dataJson = JsonOutput.toJson([ + title : title, + description: description, + source : source, + target : target + ]) + def httpResponse = http.post(pullRequestEndpoint(repository), 'application/vnd.scmm-pullRequest+json;v=2', dataJson) + + script.echo "Creating pull request yields httpCode: ${httpResponse.httpCode}" + if (httpResponse.httpCode != "201") { + script.echo 'WARNING: Http status code indicates, that pull request was not created' + return '' + } + + // example: "location: https://some/pr/42" - extract id + return httpResponse.headers.location.split("/")[-1] + } + + boolean updatePullRequest(String repository, String pullRequestId, String title, String description) { + // In order to update the description put in also the title. Otherwise the title is overwritten with an empty string. + def dataJson = JsonOutput.toJson([ + title : title, + description: description + ]) + + def httpResponse = http.put("${pullRequestEndpoint(repository)}/${pullRequestId}", 'application/vnd.scmm-pullRequest+json;v=2', dataJson) + + script.echo "Pull request update yields http_code: ${httpResponse.httpCode}" + if (httpResponse.httpCode != "204") { + script.echo 'WARNING: Http status code indicates, that the pull request was not updated' + return false + } + return true + } + + String createOrUpdatePullRequest(String repository, String source, String target, String title, String description) { + + def pullRequestId = searchPullRequestIdByTitle(repository, title) + + if(pullRequestId.isEmpty()) { + return createPullRequest(repository, source, target, title, description) + } else { + if(updatePullRequest(repository, pullRequestId, title, description)) { + return pullRequestId + } else { + return '' + } + } + } + + boolean addComment(String repository, String pullRequestId, String comment) { + def dataJson = JsonOutput.toJson([ + comment: comment + ]) + def httpResponse = http.post("${pullRequestEndpoint(repository)}/${pullRequestId}/comments", 'application/json', dataJson) + + script.echo "Adding comment yields http_code: ${httpResponse.httpCode}" + if (httpResponse.httpCode != "201") { + script.echo 'WARNING: Http status code indicates, that the comment was not added' + return false + } + return true + } + + protected String pullRequestEndpoint(String repository) { + "${this.baseUrl}/api/v2/pull-requests/${repository}" + } + + /** + * @return SCM-Manager's representation of PRs. Basically a list of PR objects. + * properties (as of SCM-Manager 2.12.0) + * * id + * * author + * * id + * * displayName + * * mail + * * source - the source branch + * * target - the target branch + * * title + * * description (branch) + * * creationDate: (e.g. "2020-10-09T15:08:11.459Z") + * * lastModified" + * * status, e.g. "OPEN" + * * reviewer (list) + * * tasks + * * todo (number) + * * done (number + * * tasks sourceRevision + * * targetRevision + * * targetRevision + * * markedAsReviewed (list) + * * emergencyMerged + * * ignoredMergeObstacles + */ + protected List getPullRequests(String repository) { + def httpResponse = http.get(pullRequestEndpoint(repository), 'application/vnd.scmm-pullRequestCollection+json;v=2') + + script.echo "Getting all pull requests yields httpCode: ${httpResponse.httpCode}" + if (httpResponse.httpCode != "200") { + script.echo 'WARNING: Http status code indicates, that the pull requests could not be retrieved' + return [] + } + + def prsAsJson = script.readJSON text: httpResponse.body + return prsAsJson._embedded.pullRequests + } + +} diff --git a/test/com/cloudogu/ces/cesbuildlib/HttpClientTest.groovy b/test/com/cloudogu/ces/cesbuildlib/HttpClientTest.groovy new file mode 100644 index 00000000..b1c85696 --- /dev/null +++ b/test/com/cloudogu/ces/cesbuildlib/HttpClientTest.groovy @@ -0,0 +1,86 @@ +package com.cloudogu.ces.cesbuildlib + +import groovy.json.JsonOutput +import org.junit.Test + +import static org.assertj.core.api.Assertions.assertThat + +class HttpClientTest { + + ScriptMock scriptMock = new ScriptMock() + HttpClient http = new HttpClient(scriptMock) + + @Test + void "simple request"() { + def expectedResponse = 'HTTP/2 201\n' + + 'location: https://some:/url' + scriptMock.expectedDefaultShRetValue = expectedResponse + + def actualResponse = http.get('http://url') + + assertThat(actualResponse.httpCode).isEqualTo('201') + assertThat(actualResponse.headers['location']).isEqualTo('https://some:/url') + assertThat(actualResponse.body).isEqualTo('') + assertThat(scriptMock.actualShMapArgs[0]).isEqualTo('curl -i -X GET http://url') + } + + @Test + void "request with body"() { + def expectedResponse = 'HTTP/1.1 203\n' + + 'cache-control: no-cache\n' + + 'content-type: output' + scriptMock.expectedDefaultShRetValue = expectedResponse + + def dataJson = JsonOutput.toJson([ + title : 't', + description: 'd' + ]) + + def actualResponse = http.post('http://some-url', 'input', dataJson) + + assertThat(actualResponse.httpCode).isEqualTo('203') + assertThat(actualResponse.headers['content-type']).isEqualTo('output') + assertThat(actualResponse.body).isEqualTo('') + + assertThat(scriptMock.actualShMapArgs[0]) + .isEqualTo('curl -i -X POST -H \'Content-Type: input\' -d \'{"title":"t","description":"d"}\' http://some-url' ) + } + + @Test + void "response with body"() { + String expectedBody1 = '{"some":"body"}\n' + String expectedBody2 = 'second line' + def expectedResponse = 'HTTP/1.1 203\n' + + 'cache-control: no-cache\n' + + 'content-type: output\n' + + '\n' + + expectedBody1 + + expectedBody2 + scriptMock.expectedDefaultShRetValue = expectedResponse + + def actualResponse = http.post('http://some-url') + + assertThat(actualResponse.httpCode).isEqualTo('203') + assertThat(actualResponse.headers['content-type']).isEqualTo('output') + assertThat(actualResponse.body).isEqualTo(expectedBody1 + expectedBody2) + + assertThat(scriptMock.actualShMapArgs[0]) + .isEqualTo('curl -i -X POST http://some-url' ) + } + + @Test + void "request with credentials"() { + http = new HttpClient(scriptMock, "credentialsID") + scriptMock.env.put("CURL_USER", "user") + scriptMock.env.put("CURL_PASSWORD", "pw") + + def expectedResponse = 'HTTP/2 201\n' + + 'location: https://some:/url' + scriptMock.expectedDefaultShRetValue = expectedResponse + + http.get('http://url') + + assertThat(scriptMock.actualShMapArgs[0]).isEqualTo('curl -i -X GET -u user:pw http://url') + } +} + diff --git a/test/com/cloudogu/ces/cesbuildlib/SCMManagerTest.groovy b/test/com/cloudogu/ces/cesbuildlib/SCMManagerTest.groovy new file mode 100644 index 00000000..3edc2e32 --- /dev/null +++ b/test/com/cloudogu/ces/cesbuildlib/SCMManagerTest.groovy @@ -0,0 +1,252 @@ +package com.cloudogu.ces.cesbuildlib + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.junit.Before +import org.junit.Test + +import static org.assertj.core.api.Assertions.assertThat +import static org.mockito.ArgumentMatchers.any +import static org.mockito.Mockito.mock +import static org.mockito.Mockito.when + +class SCMManagerTest { + + ScriptMock scriptMock = new ScriptMock() + String repo = 'scm/repo' + String baseUrl = 'http://ho.st/scm' + SCMManager scmm = new SCMManager(scriptMock, baseUrl, "credentialsID") + HttpClient httpMock + + def slurper = new JsonSlurper() + + def jsonTwoPrs = JsonOutput.toJson([ + _embedded: [ + pullRequests: [ + [ + title: 'one', + id : '1' + ], + [ + title: 'two', + id : '2' + ] + ] + ] + ]) + + @Before + void init() { + httpMock = mock(HttpClient.class) + scmm.http = httpMock + } + + @Test + void "find pull request by title"() { + when(httpMock.get(any(), any())).then({ invocation -> + assert invocation.getArguments()[0] == 'http://ho.st/scm/api/v2/pull-requests/scm/repo' + assert invocation.getArguments()[1] == 'application/vnd.scmm-pullRequestCollection+json;v=2' + + return [ + httpCode: '200', + body : jsonTwoPrs.toString() + ] + }) + + def prs = scmm.searchPullRequestIdByTitle(repo, "one") + assertThat(prs).isEqualTo('1') + } + + @Test + void "did not find pull request by title"() { + when(httpMock.get(any(), any())).thenReturn([ + httpCode: '200', + body : jsonTwoPrs.toString() + ]) + def prs = scmm.searchPullRequestIdByTitle(repo, "3") + assertThat(prs).isEqualTo("") + } + + @Test + void "returns empty string when no pr is found"() { + when(httpMock.get(any(), any())).thenReturn([ + httpCode: '200', + body : JsonOutput.toJson([ + _embedded: [ + pullRequests: [] + ] + ]) + ]) + + def prs = scmm.searchPullRequestIdByTitle(repo, "just something") + assertThat(prs).isEqualTo("") + } + + @Test + void "successfully creating a pull request yields the created prs id"() { + def expected = [ + title : 'ti', + description: 'd', + source : 's', + target : 'ta' + ] + + when(httpMock.post(any(), any(), any())).then({ invocation -> + assert invocation.getArguments()[0] == 'http://ho.st/scm/api/v2/pull-requests/scm/repo' + assert invocation.getArguments()[1] == 'application/vnd.scmm-pullRequest+json;v=2' + assert invocation.getArguments()[2] == JsonOutput.toJson(expected) + + return [ + httpCode: '201', + headers: [ location: 'https://a/long/url/with/id/id/12' ] + ] + }) + + def id = scmm.createPullRequest(repo, expected.source, expected.target, expected.title, expected.description) + assertThat(id.toString()).isEqualTo('12') + } + + @Test + void "error on pull request creation makes build unstable"() { + when(httpMock.post(any(), any(), any())).thenReturn([ + httpCode: '500', + headers: [ location: 'https://a/long/url/with/id/id/12' ] + ]) + + def id = scmm.createPullRequest(repo, 'source', 'target', 'title', 'description') + assertThat(id.toString()).isEqualTo("") + } + + @Test + void "successful description update yields to a successful build"() { + def expectedTitle = 'title' + def expectedDescription = 'description' + + when(httpMock.put(any(), any(), any())).then({ invocation -> + assert invocation.getArguments()[0] == 'http://ho.st/scm/api/v2/pull-requests/scm/repo/123' + assert invocation.getArguments()[1] == 'application/vnd.scmm-pullRequest+json;v=2' + def body = slurper.parseText(invocation.getArguments()[2]) + assert body.title == expectedTitle + assert body.description == expectedDescription + + return [ httpCode: '204' ] + }) + boolean response = scmm.updatePullRequest(repo, '123', expectedTitle, expectedDescription) + assertThat(response).isTrue() + } + + @Test + void "error on description update yields to an unstable build"() { + when(httpMock.post(any(), any(), any())).then({ invocation -> + return [ httpCode: '500' ] + }) + + boolean response = scmm.updatePullRequest(repo, '123', 'title', 'description') + assertThat(response).isFalse() + } + + @Test + void "successful comment update yields to a successful build"() { + String expectedComment = 'com123' + when(httpMock.post(any(), any(), any())).then({ invocation -> + assert invocation.getArguments()[0] == 'http://ho.st/scm/api/v2/pull-requests/scm/repo/123/comments' + assert invocation.getArguments()[1] == 'application/json' + assert slurper.parseText(invocation.getArguments()[2]).comment == expectedComment + return [ + httpCode: '201' + ] + }) + + boolean response = scmm.addComment(repo,'123', expectedComment) + assertThat(response).isTrue() + } + + @Test + void "error on comment update yields to an unstable build"() { + when(httpMock.post(any(), any(), any())).thenReturn([ + httpCode: '500' + ]) + + boolean response = scmm.addComment(repo,'123', 'comment') + assertThat(response).isFalse() + } + + @Test + void "returns pr id when creating pr with createOrUpdate"() { + def expected = [ + title : 'ti', + description: 'd', + source : 's', + target : 'ta' + ] + + when(httpMock.get(any(), any())).thenReturn([ + httpCode: '200', + body : jsonTwoPrs.toString() + ]) + + when(httpMock.post(any(), any(), any())).then({ invocation -> + assert invocation.getArguments()[0] == 'http://ho.st/scm/api/v2/pull-requests/scm/repo' + assert invocation.getArguments()[1] == 'application/vnd.scmm-pullRequest+json;v=2' + assert invocation.getArguments()[2] == JsonOutput.toJson(expected) + + return [ + httpCode: '201', + headers: [ location: 'https://a/long/url/with/id/id/12' ] + ] + }) + + def id = scmm.createOrUpdatePullRequest(repo, expected.source, expected.target, expected.title, expected.description) + assertThat(id.toString()).isEqualTo('12') + } + + @Test + void "returns pr id when updating pr with createOrUpdate"() { + def expected = [ + title : 'one', + description: 'd', + source : 's', + target : 'ta' + ] + + when(httpMock.get(any(), any())).thenReturn([ + httpCode: '200', + body : jsonTwoPrs.toString() + ]) + + when(httpMock.put(any(), any(), any())).then({ invocation -> + assert invocation.getArguments()[0] == 'http://ho.st/scm/api/v2/pull-requests/scm/repo/1' + assert invocation.getArguments()[1] == 'application/vnd.scmm-pullRequest+json;v=2' + def body = slurper.parseText(invocation.getArguments()[2]) + assert body.title == expected.title + assert body.description == expected.description + + return [ httpCode: '204' ] + }) + + def id = scmm.createOrUpdatePullRequest(repo, expected.source, expected.target, expected.title, expected.description) + assertThat(id.toString()).isEqualTo('1') + } + + @Test + void "returns empty string when updating pr with createOrUpdate and fails"() { + def expected = [ + title : 'one', + description: 'd', + source : 's', + target : 'ta' + ] + + when(httpMock.get(any(), any())).thenReturn([ + httpCode: '200', + body : jsonTwoPrs.toString() + ]) + + when(httpMock.put(any(), any(), any())).then({ invocation -> + return [ httpCode: '500' ] + }) + + def id = scmm.createOrUpdatePullRequest(repo, expected.source, expected.target, expected.title, expected.description) + assertThat(id.toString()).isEqualTo('') + } +} diff --git a/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy b/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy index 2a64c573..241a738c 100644 --- a/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy @@ -1,5 +1,7 @@ package com.cloudogu.ces.cesbuildlib +import groovy.json.JsonSlurper + class ScriptMock { def env = [WORKSPACE: "", HOME: ""] @@ -50,7 +52,6 @@ class ScriptMock { } private Object getReturnValueFor(Object arg) { - // toString() to make Map also match GStrings def value = expectedShRetValueForScript.get(arg.toString().trim()) if (value == null) { @@ -132,6 +133,12 @@ class ScriptMock { return files.get(file) } + Object readJSON(Map args) { + String text = args.get('text') + def slurper = new JsonSlurper() + return slurper.parseText(text) + } + void dir(String dir, Closure closure) { actualDir = dir closure.call()