diff --git a/lib/github-comment.js b/lib/github-comment.js index 221bc195..a838eb08 100644 --- a/lib/github-comment.js +++ b/lib/github-comment.js @@ -1,16 +1,61 @@ 'use strict' const githubClient = require('./github-client') +const GQL = require('./github-graphql-client') + +const getPRComments = `query getPRComments($owner: String!, $repo: String!, $number: Int!, $cursor: String){ + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + comments(first: 20, after:$cursor) { + nodes { + id + body + viewerDidAuthor + } + pageInfo { + endCursor + hasNextPage + } + } + labels(first: 15) { + nodes { + name + } + } + } + } +}` + +function graphQlIdToRestId (nodeId) { + const decoded = Buffer.from(nodeId, 'base64').toString() + return decoded.match(/\d+$/)[0] +} + +exports.getFirstBotComment = function getFirstBotComment ({ owner, repo, number }, cursor = null) { + return GQL(getPRComments, { owner, repo, number, cursor }).then(data => { + const { nodes, pageInfo } = data.repository.pullRequest.comments + const firstBotComment = nodes.find(e => e.viewerDidAuthor) + if (firstBotComment) { + return firstBotComment + } + if (pageInfo.hasNextPage) { + return exports.getFirstBotComment({ owner, repo, number }, pageInfo.endCursor) + } + return null + }) +} exports.createPrComment = function createPrComment ({ owner, repo, number, logger }, body) { - githubClient.issues.createComment({ - owner, - repo, - number, - body - }, (err) => { - if (err) { - logger.error(err, 'Error while creating comment on GitHub') + exports.getFirstBotComment({ owner, repo, number, logger }).then((comment) => { + if (comment) { + const { id: nodeId, body: oldBody } = comment + const newBody = `${oldBody}\n${body}` + const id = graphQlIdToRestId(nodeId) + return githubClient.issues.editComment({ owner, repo, id, body: newBody }) } + return githubClient.issues.createComment({ owner, repo, number, body }) + }).catch((err) => { + logger.error(err, 'Error while creating comment on GitHub') + // swallow error }) } diff --git a/lib/github-graphql-client.js b/lib/github-graphql-client.js new file mode 100644 index 00000000..ff080966 --- /dev/null +++ b/lib/github-graphql-client.js @@ -0,0 +1,10 @@ +'use strict' + +const GitHubGQL = require('@octokit/graphql').defaults({ + headers: { + 'user-agent': 'Node.js GitHub Bot v1.0-beta', + authorization: 'token ' + (process.env.GITHUB_TOKEN || 'invalid-placeholder-token') + } +}) + +module.exports = GitHubGQL diff --git a/package-lock.json b/package-lock.json index af476f4f..15100b3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -161,6 +161,69 @@ } } }, + "@octokit/endpoint": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-5.1.5.tgz", + "integrity": "sha512-Es0Qj6ynp0mznTnayCX8veXev43/fGjwVvctynwgzcnW+KIK6nrHdqQXUnAA1Az0DsRgbGsh9fDHjP/3Ybfyyw==", + "requires": { + "deepmerge": "3.2.0", + "is-plain-object": "^3.0.0", + "universal-user-agent": "^2.1.0", + "url-template": "^2.0.8" + }, + "dependencies": { + "is-plain-object": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", + "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", + "requires": { + "isobject": "^4.0.0" + } + }, + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==" + } + } + }, + "@octokit/graphql": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-2.1.1.tgz", + "integrity": "sha512-BVRBRFulb2H42u/Slt+x59tFw7lRf94xX9/Dv++mYDmYRXaY6LIOzrCTY2GYOVQVcoBjPhfEiYAMuJUCPNoe2g==", + "requires": { + "@octokit/request": "^3.0.0", + "universal-user-agent": "^2.0.3" + } + }, + "@octokit/request": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-3.0.3.tgz", + "integrity": "sha512-M7pUfsiaiiUMEP4/SMysTeWxyGrkoQg6FBPEtCBIFgeDnzHaPboTpUZGTh6u1GQXdrlzMfPVn/vQs98js1QtwQ==", + "requires": { + "@octokit/endpoint": "^5.1.0", + "deprecation": "^1.0.1", + "is-plain-object": "^3.0.0", + "node-fetch": "^2.3.0", + "once": "^1.4.0", + "universal-user-agent": "^2.0.1" + }, + "dependencies": { + "is-plain-object": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.0.tgz", + "integrity": "sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==", + "requires": { + "isobject": "^4.0.0" + } + }, + "isobject": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", + "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==" + } + } + }, "@types/body-parser": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz", @@ -1528,6 +1591,11 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "deepmerge": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-3.2.0.tgz", + "integrity": "sha512-6+LuZGU7QCNUnAJyX8cIrlzoEgggTM6B7mm+znKOX4t5ltluT9KLjN6g61ECMS0LTsLW7yDpNoxhix5FZcrIow==" + }, "default-require-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-2.0.0.tgz", @@ -1619,6 +1687,11 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" }, + "deprecation": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-1.0.1.tgz", + "integrity": "sha512-ccVHpE72+tcIKaGMql33x5MAjKQIZrk+3x2GbJ7TeraUCZWHoT+KSZpoC+JQFsUBlSTXUrBaGiF0j6zVTepPLg==" + }, "destroy": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", @@ -1706,7 +1779,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", - "dev": true, "requires": { "once": "^1.4.0" } @@ -4029,8 +4101,7 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "dev": true + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" }, "is-symbol": { "version": "1.0.2", @@ -4061,8 +4132,7 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isobject": { "version": "3.0.1", @@ -4566,6 +4636,11 @@ "yallist": "^2.1.2" } }, + "macos-release": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz", + "integrity": "sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA==" + }, "make-dir": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", @@ -4863,8 +4938,7 @@ "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" }, "nock": { "version": "9.6.1", @@ -4900,6 +4974,11 @@ } } }, + "node-fetch": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", + "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" + }, "node-modules-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz", @@ -4989,7 +5068,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", - "dev": true, "requires": { "path-key": "^2.0.0" } @@ -5339,6 +5417,15 @@ } } }, + "os-name": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz", + "integrity": "sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==", + "requires": { + "macos-release": "^2.2.0", + "windows-release": "^3.1.0" + } + }, "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -5369,8 +5456,7 @@ "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", - "dev": true + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" }, "p-is-promise": { "version": "2.1.0", @@ -5488,8 +5574,7 @@ "path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", - "dev": true + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" }, "path-parse": { "version": "1.0.6", @@ -5704,7 +5789,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, "requires": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -6219,7 +6303,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", - "dev": true, "requires": { "shebang-regex": "^1.0.0" } @@ -6227,14 +6310,12 @@ "shebang-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", - "dev": true + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=" }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", - "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", - "dev": true + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" }, "sinon": { "version": "1.17.7", @@ -6639,8 +6720,7 @@ "strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", - "dev": true + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" }, "strip-json-comments": { "version": "2.0.1", @@ -7466,6 +7546,14 @@ "crypto-random-string": "^1.0.0" } }, + "universal-user-agent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-2.1.0.tgz", + "integrity": "sha512-8itiX7G05Tu3mGDTdNY2fB4KJ8MgZLS54RdG6PkkfwMAavrXu1mV/lls/GABx9O3Rw4PnTtasxrvbMQoBYY92Q==", + "requires": { + "os-name": "^3.0.0" + } + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7581,6 +7669,11 @@ "prepend-http": "^1.0.1" } }, + "url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=" + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -7647,7 +7740,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, "requires": { "isexe": "^2.0.0" } @@ -7667,6 +7759,55 @@ "string-width": "^2.1.1" } }, + "windows-release": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-3.2.0.tgz", + "integrity": "sha512-QTlz2hKLrdqukrsapKsINzqMgOUpQW268eJ0OaOpJN32h272waxR9fkB9VoWRtK7uKHG5EHJcTXQBD8XZVJkFA==", + "requires": { + "execa": "^1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "requires": { + "pump": "^3.0.0" + } + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==" + } + } + }, "wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index d93763c9..1a6b6f26 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,12 @@ "test:watch": "nodemon -q -x 'npm test'" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 10" }, "private": true, "license": "MIT", "dependencies": { + "@octokit/graphql": "2.1.1", "async": "2.1.5", "basic-auth": "^1.0.4", "body-parser": "^1.15.0", diff --git a/test/_fixtures/pull-request-three-comments-gql.json b/test/_fixtures/pull-request-three-comments-gql.json new file mode 100644 index 00000000..8a078b2d --- /dev/null +++ b/test/_fixtures/pull-request-three-comments-gql.json @@ -0,0 +1,41 @@ +{ + "data": { + "repository": { + "pullRequest": { + "comments": { + "nodes": [ + { + "id": "MDEyOklzc3VlQ29tbWVudDQ4ODM3NzY4Mg==", + "body": "Lite-CI: https://ci.nodejs.org/job/node-test-pull-request-lite-pipeline/3416", + "viewerDidAuthor": true + }, + { + "id": "MDEyOklzc3VlQ29tbWVudDQ4ODM3ODU5MA==", + "body": "/to @nodejs/crypto @nodejs/lts ", + "viewerDidAuthor": false + }, + { + "id": "MDEyOklzc3VlQ29tbWVudDQ4ODc4MDk5Nw==", + "body": "CI: https://ci.nodejs.org/job/node-test-pull-request/22894/", + "viewerDidAuthor": true + } + ], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpHOHSI0xQ==", + "hasNextPage": false + } + }, + "labels": { + "nodes": [ + { + "name": "C++" + }, + { + "name": "tls" + } + ] + } + } + } + } +} diff --git a/test/_fixtures/pull-request-zero-comments-gql.json b/test/_fixtures/pull-request-zero-comments-gql.json new file mode 100644 index 00000000..59161f49 --- /dev/null +++ b/test/_fixtures/pull-request-zero-comments-gql.json @@ -0,0 +1,18 @@ +{ + "data": { + "repository": { + "pullRequest": { + "comments": { + "nodes": [], + "pageInfo": { + "endCursor": "Y3Vyc29yOnYyOpHOHSI0xQ==", + "hasNextPage": false + } + }, + "labels": { + "nodes": [] + } + } + } + } +} diff --git a/test/integration/push-jenkins-update.test.js b/test/integration/push-jenkins-update.test.js index 8f79d6b2..acd1661b 100644 --- a/test/integration/push-jenkins-update.test.js +++ b/test/integration/push-jenkins-update.test.js @@ -109,6 +109,11 @@ tap.test('Posts a CI comment in the related PR when Jenkins build is named node- .post('/repos/nodejs/node/issues/12345/comments', { body: 'CI: https://ci.nodejs.org/job/node-test-pull-request/21633/' }) .reply(200) + nock('https://api.github.com') + .filteringPath(ignoreQueryParams) + .post('/graphql') + .reply(200, readFixture('pull-request-zero-comments-gql.json')) + // we don't care about asserting the scopes below, just want to stop the requests from actually being sent setupGetCommitsMock('node') nock('https://api.github.com') @@ -137,6 +142,44 @@ tap.test('Posts a CI comment in the related PR when Jenkins build is named node- .post('/repos/nodejs/node/issues/12345/comments', { body: 'Lite-CI: https://ci.nodejs.org/job/node-test-pull-request/21633/' }) .reply(200) + nock('https://api.github.com') + .filteringPath(ignoreQueryParams) + .post('/graphql') + .reply(200, readFixture('pull-request-zero-comments-gql.json')) + + // we don't care about asserting the scopes below, just want to stop the requests from actually being sent + setupGetCommitsMock('node') + nock('https://api.github.com') + .filteringPath(ignoreQueryParams) + .post('/repos/nodejs/node/statuses/8a5fec2a6bade91e544a30314d7cf21f8a200de1') + .reply(201) + + t.plan(1) + + supertest(app) + .post('/node/jenkins/start') + .send(fixture) + .expect(201) + .end((err, res) => { + commentScope.done() + t.equal(err, null) + }) +}) + +tap.test('Edits existing comment in the related PR when Jenkins build is named node-test-pull-request', (t) => { + const fixture = readFixture('jenkins-test-pull-request-success-payload.json') + const commentScope = nock('https://api.github.com') + .filteringPath(ignoreQueryParams) + .patch('/repos/nodejs/node/issues/comments/488377682', { + body: 'Lite-CI: https://ci.nodejs.org/job/node-test-pull-request-lite-pipeline/3416\nCI: https://ci.nodejs.org/job/node-test-pull-request/21633/' + }) + .reply(200) + + nock('https://api.github.com') + .filteringPath(ignoreQueryParams) + .post('/graphql') + .reply(200, readFixture('pull-request-three-comments-gql.json')) + // we don't care about asserting the scopes below, just want to stop the requests from actually being sent setupGetCommitsMock('node') nock('https://api.github.com') diff --git a/test/unit/github-comment.js b/test/unit/github-comment.js new file mode 100644 index 00000000..f465e8c0 --- /dev/null +++ b/test/unit/github-comment.js @@ -0,0 +1,12 @@ +'use strict' + +const tap = require('tap') + +const githubComment = require('../../lib/github-comment.js') + +tap.test('githubComment.getLastBotComment(): returns the last comment made by the bot', (t) => { + t.plan(1) + return githubComment.getFirstBotComment({ owner: 'nodejs', repo: 'node', number: 26322 }).then((res) => { + t.same(res.user.id, 18269663) + }) +})