diff --git a/Jenkinsfile b/Jenkinsfile index 2c4987a..82777fa 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -4,6 +4,7 @@ pipeline { environment { NEXUS = credentials('jenkins_nexus') NCLAZZ = credentials('nclazz_api_token') + GITEA_TOKEN = credentials('jenkins_gitea_token') DOCKER_REGISTRY = "docker.nclazz.de" DOCKER_GROUP = 'nclazz-bots' @@ -46,7 +47,9 @@ pipeline { env: [ PORT: 7007, VERSION: env.DOCKER_VERSION, - AUTH_TOKEN: env.NCLAZZ + AUTH_TOKEN: env.NCLAZZ, + GITEA_TOKEN: env.GITEA_TOKEN, + GITEA_BASE_URL: 'https://git.l--n.de/api/v1' ] ) exposeService( diff --git a/README.md b/README.md index 94b9710..07bb3c8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,22 @@ Gitea Bot ============================ -This bot/server manages issues in gitea repositories. \ No newline at end of file +This bot/server manages issues in gitea repositories. + +## Webhooks + +The bot provides some useful webhooks. Webhooks can either be enabled opt-in by +listing the required webhooks in the query-parameter `hooks` as a comma separated list +or enable all hooks by leaving the hooks parameter empty. + +Send gitea events to `https://gitea-bot.nclazz.de/webhook` + +### issueBranchName + +Updates the branch name in the issues body. The name is created from the title, +issue number and optionally a `bug` label, resulting in the following format: + +> `feature/{issue}-{title}` + +the prefix is either`feature` or `bugfix`. Whitespaces in the title are replaced by +`-`. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3419943..b14ef93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "axios": "^0.27.2", "body-parser": "^1.20.0", "dotenv": "^16.0.1", "express": "^4.18.1" @@ -33,6 +34,22 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://nexus.nclazz.de/repository/npm_public/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "0.27.2", + "resolved": "https://nexus.nclazz.de/repository/npm_public/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "node_modules/body-parser": { "version": "1.20.0", "resolved": "https://nexus.nclazz.de/repository/npm_public/body-parser/-/body-parser-1.20.0.tgz", @@ -79,6 +96,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://nexus.nclazz.de/repository/npm_public/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://nexus.nclazz.de/repository/npm_public/content-disposition/-/content-disposition-0.5.4.tgz", @@ -124,6 +153,15 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://nexus.nclazz.de/repository/npm_public/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://nexus.nclazz.de/repository/npm_public/depd/-/depd-2.0.0.tgz", @@ -242,6 +280,40 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.15.1", + "resolved": "https://nexus.nclazz.de/repository/npm_public/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://nexus.nclazz.de/repository/npm_public/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://nexus.nclazz.de/repository/npm_public/forwarded/-/forwarded-0.2.0.tgz", @@ -672,6 +744,20 @@ "resolved": "https://nexus.nclazz.de/repository/npm_public/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://nexus.nclazz.de/repository/npm_public/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "0.27.2", + "resolved": "https://nexus.nclazz.de/repository/npm_public/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "requires": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "body-parser": { "version": "1.20.0", "resolved": "https://nexus.nclazz.de/repository/npm_public/body-parser/-/body-parser-1.20.0.tgz", @@ -705,6 +791,14 @@ "get-intrinsic": "^1.0.2" } }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://nexus.nclazz.de/repository/npm_public/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "content-disposition": { "version": "0.5.4", "resolved": "https://nexus.nclazz.de/repository/npm_public/content-disposition/-/content-disposition-0.5.4.tgz", @@ -736,6 +830,11 @@ "ms": "2.0.0" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://nexus.nclazz.de/repository/npm_public/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "depd": { "version": "2.0.0", "resolved": "https://nexus.nclazz.de/repository/npm_public/depd/-/depd-2.0.0.tgz", @@ -823,6 +922,21 @@ "unpipe": "~1.0.0" } }, + "follow-redirects": { + "version": "1.15.1", + "resolved": "https://nexus.nclazz.de/repository/npm_public/follow-redirects/-/follow-redirects-1.15.1.tgz", + "integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://nexus.nclazz.de/repository/npm_public/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://nexus.nclazz.de/repository/npm_public/forwarded/-/forwarded-0.2.0.tgz", diff --git a/package.json b/package.json index 0c205bb..96299e4 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "author": "Niclas Thobaben", "license": "MIT", "dependencies": { + "axios": "^0.27.2", "body-parser": "^1.20.0", "dotenv": "^16.0.1", "express": "^4.18.1" diff --git a/src/gitea-api.js b/src/gitea-api.js new file mode 100644 index 0000000..5c2232a --- /dev/null +++ b/src/gitea-api.js @@ -0,0 +1,36 @@ +const axios = require('axios') +const api = {} + +const BASE_URL = process.env.GITEA_BASE_URL || 'https://git.l--n.de/api/v1' +const TOKEN = process.env.GITEA_TOKEN + +console.log(`Use gitea api @ ${BASE_URL}`) + +api.post = (path, payload) => { + const url = `${BASE_URL}${path}` + + return axios.post( + url, + payload, + { + headers: { + Authorization: `token ${TOKEN}` + } + } + ) +} +api.patch = (path, payload) => { + const url = `${BASE_URL}${path}` + + return axios.patch( + url, + payload, + { + headers: { + Authorization: `token ${TOKEN}` + } + } + ) +} + +module.exports = api \ No newline at end of file diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 0000000..01d4975 --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1,17 @@ +const fs = require('fs') +const path = require('path') + +const hooks = {} +fs.readdirSync(__dirname).forEach(file => { + if(file === 'index.js') { + return + } + const name = file.replace(/\.[^/.]+$/, '') + const hook = require(path.join(__dirname, file)) + hook.name = name + hooks[name] = hook +}) + +console.log(`Loaded available Hooks [${Object.keys(hooks)}]`) + +module.exports = hooks \ No newline at end of file diff --git a/src/hooks/issueBranchName.js b/src/hooks/issueBranchName.js new file mode 100644 index 0000000..f2f071c --- /dev/null +++ b/src/hooks/issueBranchName.js @@ -0,0 +1,37 @@ +const gitea = require('../gitea-api') + +const log = (msg) => { + console.log('[issueBranchName]', msg) +} + +module.exports = { + exec: (req) => { + if(req.headers['x-gitea-event'] !== 'issues') { + return + } + const { issue, repository } = req.body + const isBug = issue.labels.length && !!issue.labels.find(label => label.name === 'bug') + const prefix = isBug ? 'bugfix' : 'feature' + const branch = issue.title.replace(/[^a-z0-9\s]/gi, '').replaceAll(' ', '-') + const branchName = `${prefix}/${issue.number}-${branch}` + + log(`Created branch name ${branchName} in ${repository.full_name}`) + + issue.body = issue.body || '' + + let body + if(issue.body.includes('Branchname')) { + body = issue.body.replaceAll(/\*\*Branchname\*\*: .+<\/code>/g, `**Branchname**: ${branchName}`) + }else { + body = `${issue.body}
**Branchname**: ${branchName}` + } + + body = body.replaceAll('\n', '\\n') + + const path = `/repos/${repository.full_name}/issues/${issue.number}` + return gitea.patch(path, { body }) + .then(() => {}) + .catch(reason => reason) + } + +} \ No newline at end of file diff --git a/src/hooks/issueBranchRef.js b/src/hooks/issueBranchRef.js new file mode 100644 index 0000000..3c47788 --- /dev/null +++ b/src/hooks/issueBranchRef.js @@ -0,0 +1,14 @@ +const log = (msg) => { + console.log('[issueBranchRef]', msg) +} + +module.exports = { + + exec: (req) => { + if(req.headers['x-gitea-event'] !== 'issues') { + return + } + log('issue branch ref') + } + +} \ No newline at end of file diff --git a/src/server.js b/src/server.js index 404d4b1..de6355d 100644 --- a/src/server.js +++ b/src/server.js @@ -1,5 +1,6 @@ const express = require('express') const bodyParser = require('body-parser') +const hooks = require('./hooks') console.log('Initialize express server') @@ -9,5 +10,36 @@ app.use(bodyParser.json()) const port = process.env.SERVER_PORT || 8080 +app.post('/webhook', async (req, res) => { + const selectedHooks = [] + if(req.query['hooks']) { + req.query.hooks.split(',').forEach(hookName => { + const hook = hooks[hookName.trim()] + if(!hook) { + console.warn(`Hook ${hookName} does not exist! from=${req.ip}`) + return + } + selectedHooks.push(hook) + }) + }else { + selectedHooks.push(...Object.values(hooks)) + } + + const errors = {} + const promises = selectedHooks.map(async hook => { + console.log(`Execute hook ${hook.name} from=${req.ip}`) + const error = await hook.exec(req) + if(error) { + errors[hook.name] = error + } + }) + await Promise.all(promises) + if(Object.keys(errors).length) { + res.status(400).json(errors) + return + } + res.status(200).send() +}) + app.listen(port) console.log(`Started express server on port ${port}`)