diff --git a/.env.example b/.env.example index 04b8965..195e1c3 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,6 @@ DBUSER= DBPASSWORD= DB= LOG_LEVEL= +REDIS_PORT= +REDIS_HOST= +TRIGGER_QUERY= diff --git a/config.js b/config.js index cd5212a..651a1c0 100644 --- a/config.js +++ b/config.js @@ -8,6 +8,10 @@ const config = { dbUser: process.env.NODE_ENV === 'test' ? 'postgres' : process.env.DBUSER, dbPassword: process.env.NODE_ENV === 'test' ? 'mysecretpassword' : process.env.DBPASSWORD, db: process.env.NODE_ENV === 'test' ? 'test' : process.env.DB, + redis: { + port: process.env.REDIS_PORT, + host: process.env.REDIS_HOST, + }, logger: { level: process.env.LOG_LEVEL, }, diff --git a/db/migrations/1-db-init.js b/db/migrations/1-db-init.js index 6c835a4..f2d9cb8 100644 --- a/db/migrations/1-db-init.js +++ b/db/migrations/1-db-init.js @@ -9,7 +9,7 @@ async function up(knex) { try { await knex.schema.createTable(userTable, (table) => { table.increments('id').primary(); - table.string('login', 255).notNullable(); + table.string('login', 255).notNullable().unique(); table.string('avatar_url', 255); table.string('html_url', 255); }); diff --git a/db/models/repository.js b/db/models/repository.js index 2b879dd..72d1470 100644 --- a/db/models/repository.js +++ b/db/models/repository.js @@ -5,12 +5,12 @@ const schema = Joi.object({ owner: Joi.number().integer().required(), full_name: Joi.string().required(), stargazers_count: Joi.number().integer().required(), - html_url: Joi.string().uri().required(), - description: Joi.string(), + html_url: Joi.string().allow(''), + description: Joi.string().allow(''), language: Joi.string(), }); -const insert = data => db('repository').insert(data); +const insert = data => db('repository').insert(data).returning('*'); const read = params => db('repository').where(params).select(); diff --git a/db/models/user.js b/db/models/user.js index 12dfeba..2177b8f 100644 --- a/db/models/user.js +++ b/db/models/user.js @@ -7,7 +7,7 @@ const schema = Joi.object({ html_url: Joi.string().uri(), }); -const insert = data => db('user').insert(data); +const insert = data => db('user').insert(data).returning('*'); const read = param => db('user').where(param).select(); diff --git a/package-lock.json b/package-lock.json index 2b9d91e..c50ce8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -115,6 +115,46 @@ "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", "dev": true }, + "@sinonjs/commons": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.1.tgz", + "integrity": "sha512-892K+kWUUi3cl+LlqEWIDrhvLgdL79tECi8JZUyq6IviKy/DNhuzCRlbHUjxK89f4ypPMMaFnFuR9Ie6DoIMsw==", + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/formatio": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-5.0.1.tgz", + "integrity": "sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==", + "requires": { + "@sinonjs/commons": "^1", + "@sinonjs/samsam": "^5.0.2" + } + }, + "@sinonjs/samsam": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.2.0.tgz", + "integrity": "sha512-CaIcyX5cDsjcW/ab7HposFWzV1kC++4HNsfnEdFJa7cP1QIuILAKV+BgfeqRXhcnSAc76r/Rh/O5C+300BwUIw==", + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==" + }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", @@ -961,7 +1001,8 @@ "confusing-browser-globals": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.9.tgz", - "integrity": "sha512-KbS1Y0jMtyPgIxjO7ZzMAuUpAKMt1SzCL9fsrKsX6b0zJPTaT0SiSPmewwVZg9UAO83HVIlEhZF84LIjZ0lmAw==" + "integrity": "sha512-KbS1Y0jMtyPgIxjO7ZzMAuUpAKMt1SzCL9fsrKsX6b0zJPTaT0SiSPmewwVZg9UAO83HVIlEhZF84LIjZ0lmAw==", + "dev": true }, "console-control-strings": { "version": "1.1.0", @@ -1141,6 +1182,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, "requires": { "object-keys": "^1.0.12" } @@ -1192,6 +1234,11 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, + "denque": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz", + "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==" + }, "depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", @@ -1215,8 +1262,7 @@ "diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" }, "doctrine": { "version": "3.0.0", @@ -1300,6 +1346,7 @@ "version": "1.17.6", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz", "integrity": "sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==", + "dev": true, "requires": { "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", @@ -1339,6 +1386,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, "requires": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -1473,6 +1521,7 @@ "version": "14.2.0", "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.0.tgz", "integrity": "sha512-Snswd5oC6nJaevs3nZoLSTvGJBvzTfnBqOIArkf3cbyTyq9UD79wOk8s+RiL6bhca0p/eRO6veczhf6A/7Jy8Q==", + "dev": true, "requires": { "confusing-browser-globals": "^1.0.9", "object.assign": "^4.1.0", @@ -2168,7 +2217,8 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true }, "functional-red-black-tree": { "version": "1.0.1", @@ -2389,6 +2439,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -2401,7 +2452,8 @@ "has-symbols": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", - "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true }, "has-unicode": { "version": "2.0.1", @@ -2793,7 +2845,8 @@ "is-callable": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.0.tgz", - "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==" + "integrity": "sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==", + "dev": true }, "is-ci": { "version": "2.0.0", @@ -2830,7 +2883,8 @@ "is-date-object": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", - "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==" + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true }, "is-descriptor": { "version": "0.1.6", @@ -2939,6 +2993,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", + "dev": true, "requires": { "has-symbols": "^1.0.1" } @@ -2967,6 +3022,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, "requires": { "has-symbols": "^1.0.1" } @@ -3122,6 +3178,11 @@ "semver": "^5.6.0" } }, + "just-extend": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.1.1.tgz", + "integrity": "sha512-aWgeGFW67BP3e5181Ep1Fv2v8z//iBJfrvyTnq8wG86vEESwmonn1zPBJ0VfmT9CJq2FIT0VsETtrNFm2a+SHA==" + }, "jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -3263,6 +3324,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, "lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3708,6 +3774,18 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "nise": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/nise/-/nise-4.0.4.tgz", + "integrity": "sha512-bTTRUNlemx6deJa+ZyoCUTRvH3liK5+N6VQZ4NIw90AgDXY6iPnsqplNFf6STcj+ePk0H/xqxnP75Lr0J0Fq3A==", + "requires": { + "@sinonjs/commons": "^1.7.0", + "@sinonjs/fake-timers": "^6.0.0", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, "nock": { "version": "13.0.4", "resolved": "https://registry.npmjs.org/nock/-/nock-13.0.4.tgz", @@ -3900,12 +3978,14 @@ "object-inspect": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", - "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==" + "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", + "dev": true }, "object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true }, "object-visit": { "version": "1.0.1", @@ -3919,6 +3999,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, "requires": { "define-properties": "^1.1.2", "function-bind": "^1.1.1", @@ -3941,6 +4022,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.2.tgz", "integrity": "sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA==", + "dev": true, "requires": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5", @@ -4173,6 +4255,21 @@ "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0=" }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + } + } + }, "path-type": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", @@ -4676,6 +4773,35 @@ "resolve": "^1.1.6" } }, + "redis": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.0.2.tgz", + "integrity": "sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ==", + "requires": { + "denque": "^1.4.1", + "redis-commands": "^1.5.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + } + }, + "redis-commands": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.6.0.tgz", + "integrity": "sha512-2jnZ0IkjZxvguITjFTrGiLyzQZcTvaw8DAaCXxZq/dsHXz7KfMQ3OUJy7Tz9vnRtZRVz6VRCPDvruvU8Ts44wQ==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, "regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -4988,6 +5114,35 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" }, + "sinon": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.1.tgz", + "integrity": "sha512-naPfsamB5KEE1aiioaoqJ6MEhdUs/2vtI5w1hPAXX/UwvoPjXcwh1m5HiKx0HGgKR8lQSoFIgY5jM6KK8VrS9w==", + "requires": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/formatio": "^5.0.1", + "@sinonjs/samsam": "^5.2.0", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5319,6 +5474,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", + "dev": true, "requires": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -5328,6 +5484,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "dev": true, "requires": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -5562,8 +5719,7 @@ "type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" }, "type-fest": { "version": "0.8.1", diff --git a/package.json b/package.json index 40eb6b9..1a4e9a0 100644 --- a/package.json +++ b/package.json @@ -6,18 +6,19 @@ "scripts": { "start": "node index.js", "dev": "nodemon index.js", - "lint-check": "eslint \"**/*.js\" --ignore-pattern node_modules/", + "linter": "eslint \"**/*.js\" --ignore-pattern node_modules/", "test-web": "npm run migrate-test-db && LOG_LEVEL=silent NODE_ENV=test mocha './{!(node_modules)/**/,}*.spec.js' --exit", "migrate-db": "knex migrate:latest", "migrate-test-db": "NODE_ENV=test npm run migrate-down && NODE_ENV=test npm run migrate-up ", "migrate-up": "knex migrate:up", "migrate-down": "knex migrate:down", "seed-db": "knex seed:run", - "seed-test-db": "NODE_ENV=test knex seed:run" + "seed-test-db": "NODE_ENV=test knex seed:run", + "trigger": "node ./scripts/trigger.js" }, "husky": { "hooks": { - "pre-commit": "npm run lint-check", + "pre-commit": "npm run linter", "pre-push": "npm run test-web" } }, @@ -45,18 +46,20 @@ "lodash": "4.17.20", "pg": "8.3.3", "pino": "6.7.0", - "pino-pretty": "4.3.0" + "pino-pretty": "4.3.0", + "redis": "3.0.2" }, "devDependencies": { "chai": "4.2.0", "chai-as-promised": "7.1.1", "chai-http": "4.3.0", "eslint": "7.2.0", - "eslint-config-airbnb-base": "^14.2.0", + "eslint-config-airbnb-base": "14.2.0", "eslint-plugin-import": "2.22.1", "husky": "4.3.0", "mocha": "8.1.3", "nock": "13.0.4", - "nodemon": "2.0.4" + "nodemon": "2.0.4", + "sinon": "9.2.1" } } diff --git a/router.js b/router.js index 2d72e22..84a93d1 100644 --- a/router.js +++ b/router.js @@ -24,6 +24,11 @@ const repositorySchema = Joi.object({ router.get('/hello', (req, res) => res.send('Hello World !')); +// // Trigger +// router.post('/trigger', (req, res) => { +// // Redis +// }); + // Repository router.get('/repository/:id', async (req, res, next) => { const { id } = req.params; diff --git a/scripts/trigger.js b/scripts/trigger.js new file mode 100644 index 0000000..5b48072 --- /dev/null +++ b/scripts/trigger.js @@ -0,0 +1,18 @@ +const logger = require('../logger'); +const { + repositorySubscriber, + contributionSubscriber, + channels, +} = require('../worker/index'); + +logger.info('Initiating trigger script'); + +if (!process.env.TRIGGER_QUERY) { + logger.error('Missing TRIGGER_QUERY env variable!'); + process.exit(1); +} + +logger.info('Subscribing channels'); + +repositorySubscriber.subscribe(channels.trigger, () => logger.info(`Repository channel subscribed to ${channels.trigger}`)); +contributionSubscriber.subscribe(channels.repository, () => logger.info(`Contribution channel subscribed to ${channels.repository}`)); diff --git a/services/github.js b/services/github.js index c80adc6..5f4c28c 100644 --- a/services/github.js +++ b/services/github.js @@ -16,29 +16,43 @@ const graphQLClient = new GraphQLClient(endpoint, { }, }); -exports.searchRepositories = async (queryString) => { - if (!queryString) { +exports.searchRepositories = async (params) => { + const { queryString, first = 5 } = params; + + if (!params.queryString) { throw Error('queryString is a mandatory parameter'); } - const query = `query search($queryString:String!){ - search(query: $queryString, type: REPOSITORY, first: 10) { + const query = `query search($queryString:String!, $first:Int){ + search(query: $queryString, type: REPOSITORY, first: $first) { repositoryCount edges { node { - ... on RepositoryInfo { + ... on Repository { name + description + homepageUrl + stargazerCount + languages(first: 1) { + edges { + node { + name + } + } + } createdAt owner { id login + avatarUrl + url } } } }} }`; - const varibale = { queryString }; + const varibale = { queryString, first }; const response = await graphQLClient.request(query, varibale); return response; @@ -47,7 +61,7 @@ exports.searchRepositories = async (queryString) => { exports.getContributors = async (owner, repoName) => { const query = `query collaboratorsQuery($owner:String!,$repoName:String!){ repository(owner: $owner, name: $repoName) { - collaborators(first:100, affiliation: DIRECT) { + collaborators(first:10) { edges { node { id diff --git a/services/github.spec.js b/services/github.spec.js index 67976eb..f5deaf5 100644 --- a/services/github.spec.js +++ b/services/github.spec.js @@ -11,17 +11,29 @@ chai.should(); chai.use(chaiHttp); chai.use(chaiAsPromised); -const searchRepositoryQueryString = `query search($queryString:String!){ - search(query: $queryString, type: REPOSITORY, first: 10) { +const searchRepositoryQueryString = `query search($queryString:String!, $first:Int){ + search(query: $queryString, type: REPOSITORY, first: $first) { repositoryCount edges { node { - ... on RepositoryInfo { + ... on Repository { name + description + homepageUrl + stargazerCount + languages(first: 1) { + edges { + node { + name + } + } + } createdAt owner { id login + avatarUrl + url } } } @@ -30,7 +42,7 @@ const searchRepositoryQueryString = `query search($queryString:String!){ const getContributorsQueryString = `query collaboratorsQuery($owner:String!,$repoName:String!){ repository(owner: $owner, name: $repoName) { - collaborators(first:100, affiliation: DIRECT) { + collaborators(first:10) { edges { node { id @@ -48,7 +60,7 @@ const githubAPIMock = nock('https://api.github.com'); describe('Github service', () => { it('should use Authorization header and a token provided from config.js', async () => { const scope = githubAPIMock.matchHeader('Authorization', `Bearer ${config.githubToken}`).post('/graphql').reply(200, { data: {} }); - await github.searchRepositories('WordsMemorizer'); + await github.searchRepositories({ queryString: 'WordsMemorizer' }); scope.done(); }); @@ -91,17 +103,17 @@ describe('Github service', () => { }, }; - it('should throw "queryString is a mandatory parameter"', () => (github.searchRepositories().should.be.rejectedWith('queryString is a mandatory parameter'))); + it('should throw "queryString is a mandatory parameter"', () => (github.searchRepositories('').should.be.rejectedWith('queryString is a mandatory parameter'))); it('should return dummy response', async () => { githubAPIMock.post('/graphql').reply(200, { data: mockResponse }); - const response = await github.searchRepositories('WordsMemorizer'); + const response = await github.searchRepositories({ queryString: 'WordsMemorizer' }); response.should.deep.equal(mockResponse); }); it('should include "$queryString" query variable', async () => { const scope = githubAPIMock.post('/graphql', body => (body.query && body.query === searchRepositoryQueryString)).reply(200, { data: mockResponse }); - await github.searchRepositories('WordsMemorizer'); + await github.searchRepositories({ queryString: 'WordsMemorizer' }); scope.done(); }); }); diff --git a/worker/config.js b/worker/config.js new file mode 100644 index 0000000..8555868 --- /dev/null +++ b/worker/config.js @@ -0,0 +1,8 @@ +const config = require('../config'); + +const clientConfig = { + port: config.redis.port, + host: config.redis.host, +}; + +module.exports = clientConfig; diff --git a/worker/handlers/contribution.js b/worker/handlers/contribution.js new file mode 100644 index 0000000..06e96aa --- /dev/null +++ b/worker/handlers/contribution.js @@ -0,0 +1,65 @@ +const Joi = require('joi'); + +const logger = require('../../logger'); +const userModel = require('../../db/models/user'); +const contributionModel = require('../../db/models/contribution'); +const { getContributors } = require('../../services/github'); + +const repositorySchema = Joi.object().keys({ + id: Joi.number().required(), + owner: Joi.number().required(), + full_name: Joi.string().required(), + description: Joi.string().allow('').required(), + html_url: Joi.string().allow('').required(), + language: Joi.string().required(), + stargazers_count: Joi.number().required(), +}); + +async function onContribution(message) { + const repository = JSON.parse(message); + + Joi.assert(repository, repositorySchema); + + logger.info(JSON.stringify(repository, null, '-')); + + const repositoryName = repository.full_name; + + logger.info(`repositoryName: ${repositoryName}`); + + const dbResponse = await userModel.read({ id: repository.owner }); + const owner = dbResponse[0]; + + logger.info(`userLogin: ${owner.login}`); + + const githubResponse = await getContributors(owner.login, repositoryName); + const collaborators = githubResponse.repository.collaborators.edges; + + logger.info(JSON.stringify(collaborators)); + + const promises = await collaborators.map(async (collaborator) => { + const user = { + login: collaborator.node.login, + avatar_url: collaborator.node.avatarUrl, + html_url: collaborator.node.url, + }; + + let response = await userModel.read(user); + if (!response.length) { + response = await userModel.insert(user); + } + + const contribution = { + user: response[0].id, + repository: repository.id, + lineCount: 0, + }; + + return contributionModel.insertOrReplace(contribution); + }); + + await Promise.all(promises); +} + +module.exports = { + onContribution, +}; diff --git a/worker/handlers/repository.js b/worker/handlers/repository.js new file mode 100644 index 0000000..1437722 --- /dev/null +++ b/worker/handlers/repository.js @@ -0,0 +1,78 @@ +const Joi = require('joi'); + +const githubService = require('../../services/github'); +const repositoryModel = require('../../db/models/repository'); +const userModel = require('../../db/models/user'); +const logger = require('../../logger'); + +const searchRepositoriesResponseSchema = Joi.object().keys({ + edges: Joi.array().has(Joi.object({ + node: Joi.object().keys({ + owner: Joi.object().required(), + name: Joi.string().required(), + description: Joi.string().allow('').required(), + homepageUrl: Joi.string().allow('').required(), + stargazerCount: Joi.number().required(), + languages: Joi.object().required(), + createdAt: Joi.string().required(), + }).required(), + })).required(), + repositoryCount: Joi.number().required(), +}).required(); + +const onRepository = async (message) => { + const response = await githubService.searchRepositories({ queryString: message, first: 1 }); + + logger.info(response, 'Github searchRepositories() response:'); + + if (!response) throw Error(`Invalid Github API response from searchRepositories: ${response}`); + + Joi.assert(response.search, searchRepositoriesResponseSchema, Error('Invalid search property on response object.')); + + const { search: { edges } } = response; + const repository = edges[0].node; + const { owner } = repository; + + logger.info(repository, 'Found repository:'); + + const user = { + login: owner.login, + avatar_url: owner.avatarUrl, + html_url: owner.url, + }; + + logger.info(user, 'Constructed user object:'); + + Joi.assert(user, userModel.schema); + + let insertedUser = await userModel.read(user); + + if (!insertedUser.length) { + insertedUser = await userModel.insert(user); + } + + const formatedRepository = { + owner: insertedUser[0].id, + full_name: repository.name, + stargazers_count: repository.stargazerCount, + html_url: repository.homepageUrl ? repository.homepageUrl : '', + description: repository.description ? repository.description : '', + language: repository.languages.edges[0].node.name, + }; + + logger.info(formatedRepository, 'Constructed repository object:'); + + Joi.assert(formatedRepository, repositoryModel.schema); + + const insertedRepo = await repositoryModel.insert(formatedRepository); + const serviceResp = await githubService.getContributors(user.login, insertedRepo[0].full_name); + + const { repository: { collaborators: { edges: collaborators } } } = serviceResp; + logger.info(collaborators, 'Collaborators:'); + + return { repository: insertedRepo[0], collaborators }; +}; + +module.exports = { + onRepository, +}; diff --git a/worker/handlers/repository.spec.js b/worker/handlers/repository.spec.js new file mode 100644 index 0000000..8fc7279 --- /dev/null +++ b/worker/handlers/repository.spec.js @@ -0,0 +1,73 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const chaiAsPromised = require('chai-as-promised'); + +const { onRepository } = require('./repository'); +const githubService = require('../../services/github'); +const repositoryModel = require('../../db/models/repository'); + +chai.use(chaiAsPromised); +const { expect } = chai; +chai.should(); + +const mockMessage = { + search: { + edges: [{ + node: { + owner: { login: 'testUser', avatarUrl: 'https://www.test.com', url: 'https://www.test.com' }, + name: 'testName', + description: 'Test descreption', + homepageUrl: 'test.com', + stargazerCount: 10, + languages: { + edges: [{ + node: { + name: 'Test language', + }, + }], + }, + createdAt: '2010', + }, + }], + repositoryCount: 1, + }, +}; + +describe('Repository worker handler', () => { + let searchRepositories; + + beforeEach(function () { + searchRepositories = sinon.stub(githubService, 'searchRepositories'); + }); + + afterEach(function () { + searchRepositories.restore(); + }); + + it('Should run without errors', async () => { + searchRepositories.resolves(mockMessage); + + const repository = await onRepository('Test run'); + + const response = await repositoryModel.read({ full_name: 'testName' }); + + const mockRepository = { + description: 'Test descreption', + full_name: 'testName', + html_url: 'test.com', + id: response[0].id, + language: 'Test language', + owner: response[0].owner, + stargazers_count: 10, + }; + + expect(repository).to.deep.equal(mockRepository); + }); + + it('Should be rejected with error', async () => { + const invalidResponse = null; + searchRepositories.resolves(invalidResponse); + + onRepository('').should.be.rejectedWith(`Invalid Github API response from searchRepositories: ${invalidResponse}`); + }); +}); diff --git a/worker/handlers/trigger.js b/worker/handlers/trigger.js new file mode 100644 index 0000000..947d394 --- /dev/null +++ b/worker/handlers/trigger.js @@ -0,0 +1,15 @@ +const logger = require('../../logger'); +const { channels, triggerPublisher } = require('../index'); + +function onTrigger(message) { + if (!message) { + logger.error('Misisng query parameter!'); + process.exit(1); + } + + triggerPublisher.publish(channels.repository, message, () => logger.info(`Trigger message sent to ${channels.trigger} channel`)); +} + +module.exports = { + onTrigger, +}; diff --git a/worker/index.js b/worker/index.js new file mode 100644 index 0000000..3db6ecf --- /dev/null +++ b/worker/index.js @@ -0,0 +1,55 @@ +const redis = require('redis'); + +const redisConfig = require('./config'); +const logger = require('../logger'); +const { onRepository } = require('./handlers/repository'); +const { onContribution } = require('./handlers/contribution'); + +const channels = { + trigger: 'trigger', + repository: 'repository', + contribution: 'contribution', +}; + +const triggerPublisher = redis.createClient(redisConfig); +const repositorySubscriber = redis.createClient(redisConfig); +const repositoryPublisher = redis.createClient(redisConfig); +const contributionSubscriber = redis.createClient(redisConfig); + +repositorySubscriber.on('subscribe', (channel) => { + logger.info(`Subscribed even on ${channel}`); + triggerPublisher.publish(channels.trigger, process.env.TRIGGER_QUERY, + () => logger.info(`Trigger message sent to ${channels.trigger} channel`)); +}); + +repositorySubscriber.on('message', (channel, message) => { + logger.info(`[REPOSITORY] Message received on ${channel} channel`); + logger.info(`[REPOSITORY] Message: ${message}`); + onRepository(message).then(({ repository, collaborators }) => { + collaborators.forEach(element => { + repositoryPublisher.publish(channels.repository, repository, + () => logger.info(`Message sent to ${channels.repository} channel`)); + }); + }).catch(error => { + logger.error(error); + process.exit(1); + }); +}); + +contributionSubscriber.on('message', (channel, message) => { + logger.info(`[CONTRIBUTION] Message received on ${channel} channel`); + onContribution(message).then(() => { + logger.info('Trigger script completed successfully!'); + process.exit(0); + }).catch(error => { + logger.error(error); + process.exit(1); + }); +}); + +module.exports = { + channels, + triggerPublisher, + contributionSubscriber, + repositorySubscriber, +};