Skip to content

Commit 32b9c7a

Browse files
committed
initial
0 parents  commit 32b9c7a

17 files changed

+1552
-0
lines changed

.eslintignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/

.eslintrc.json

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"env": {
3+
"node": true,
4+
"es6": true
5+
},
6+
"plugins": ["promise"],
7+
"parserOptions": {
8+
"ecmaVersion": 2018
9+
},
10+
"rules": {
11+
"no-unused-vars": ["error", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }],
12+
"promise/always-return": "off"
13+
}
14+
}

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
logs/*.log

README.md

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# @umbrellio/gbot
2+
3+
Gitlab bot platform.
4+
5+
## Installation
6+
7+
```sh
8+
$ yarn add @umbrellio/gbot
9+
```
10+
11+
or
12+
13+
```sh
14+
$ npm i @umbrellio/gbot
15+
```
16+
17+
## Usage
18+
19+
### `unapproved`
20+
21+
Sends unapproved MR to mattermost / slack. MR will be ignored if it has WIP mark.
22+
23+
```sh
24+
$ gbot unapproved -c /path/to/config/gbot.yaml
25+
```
26+
27+
## Configuration
28+
29+
Each setting can be set via environment variables.
30+
Each variable must starts with `GBOT_` prefix. Each double underline will be interpreted as nesting, for example:
31+
32+
```sh
33+
GBOT_GITLAB_TOKEN=token # { "gitlabToken": "token" }
34+
GBOT_GITLAB__TOKEN=token # {"gitlab": { "token": "token" } }
35+
```
36+
37+
Example of the config file:
38+
39+
```yml
40+
messenger:
41+
webhook: "<WEBHOOK URL>" # Mattermost / Slack webhook
42+
channel: "<CHANNEL>" # Mattermost / Slack channel where will be messages sent
43+
sender:
44+
username: "@ubmrellio/gbot" # Sender's display name
45+
icon: "<icon url>" # Sender's icon url
46+
gitlab:
47+
token: "<TOKEN>" # GitLab Private Access Token
48+
url: "<gitlab api url>" # Gitlab API base url
49+
projects: # List of your project ids
50+
- 42
51+
52+
# tasks config
53+
unapproved: # Config for `unapproved` command
54+
emoji: # Emoji which will be set for each MR (optional)
55+
24h: ":emoji1:" # If MR's last update time more than 24 hours
56+
# Time interval can be set in seconds, minutes,
57+
# hours and days (30s, 10m, 5h, 2d)
58+
12h: ":emoji2:" # If MR's last update time more than 12 hours
59+
default: ":emoji3:" # Default emoji (if other ones wasn't matched)
60+
```
61+
62+
## Contributing
63+
64+
Bug reports and pull requests are welcome on GitHub at https://github.com/umbrellio/gbot.
65+
66+
## License
67+
68+
Released under MIT License.
69+
70+
## Authors
71+
72+
Created by [Aleksei Bespalov](https://github.com/nulldef).
73+
74+
<a href="https://github.com/umbrellio/">
75+
<img style="float: left;" src="https://umbrellio.github.io/Umbrellio/supported_by_umbrellio.svg" alt="Supported by Umbrellio" width="439" height="72">
76+
</a>

api/Messenger.js

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const _ = require("lodash")
2+
const network = require("../utils/network")
3+
const logger = require("../utils/logger")
4+
5+
class Messenger {
6+
constructor({ messenger }) {
7+
this.channel = _.get(messenger, "channel")
8+
this.webhook = _.get(messenger, "webhook")
9+
this.username = _.get(messenger, "sender.username", "Gbot")
10+
this.icon = _.get(messenger, "sender.icon", null)
11+
}
12+
13+
send = text => {
14+
const message = {
15+
text,
16+
channel: this.channel,
17+
username: this.username,
18+
icon_url: this.icon
19+
}
20+
21+
return network.post(this.webhook, message)
22+
}
23+
}
24+
25+
module.exports = Messenger

api/gitlab.js

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const url = require("../utils/url")
2+
const network = require("../utils/network")
3+
4+
class GitLab {
5+
constructor({ gitlab }) {
6+
this.baseUrl = gitlab.url
7+
this.token = gitlab.token
8+
this.projects = gitlab.projects
9+
}
10+
11+
approvals = (project, request) => {
12+
const uri = this.__getUrl("projects", project, "merge_requests", request, "approvals")
13+
return this.__get(uri)
14+
}
15+
16+
project = id => this.__get(this.__getUrl("projects", id))
17+
18+
requests = project => {
19+
const query = {
20+
sort: "asc",
21+
per_page: 100,
22+
state: "opened",
23+
scope: "all",
24+
wip: "no",
25+
}
26+
27+
const uri = this.__getUrl("projects", project, "merge_requests")
28+
return this.__get(uri, query)
29+
}
30+
31+
discussions = (project, request) => {
32+
const uri = this.__getUrl("projects", project, "merge_requests", request, "discussions")
33+
return this.__get(uri)
34+
}
35+
36+
__getUrl = (...parts) => url.build(this.baseUrl, ...parts)
37+
38+
__get = (url, query = {}) => network.get(url, query, { "Private-Token": this.token })
39+
}
40+
41+
module.exports = GitLab

gbot

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env node
2+
3+
const yargs = require("yargs")
4+
5+
const pkg = require("./package")
6+
const configUtils = require("./utils/config")
7+
8+
const Unapproved = require("./tasks/Unapproved")
9+
10+
const runCommand = klass => argv => {
11+
const config = configUtils.load(argv.config)
12+
return new klass(config).perform()
13+
}
14+
15+
yargs
16+
.command("unapproved", "sends unapproved requests to Mattermost / Slack", {}, runCommand(Unapproved))
17+
.demandCommand(1, "must provide a valid command")
18+
.options({
19+
config: {
20+
alias: "c",
21+
describe: "path to config file",
22+
default: "./gbot.yml",
23+
type: "string",
24+
},
25+
})
26+
.version(pkg.version)
27+
.help()
28+
.strict()
29+
.argv

gbot.example.yml

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
messenger:
2+
webhook: "<WEBHOOK URL>"
3+
channel: "<CHANNEL>"
4+
sender:
5+
username: "@ubmrellio/gbot"
6+
icon: "<icon url>"
7+
gitlab:
8+
token: "<TOKEN>"
9+
url: "<gitlab api url>"
10+
projects:
11+
- 42
12+
13+
# tasks config
14+
unapproved:
15+
emoji:
16+
24h: ":emoji1:"
17+
12h: ":emoji2:"
18+
default: ":emoji3:"

package.json

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "@umbrellio/gbot",
3+
"version": "1.0.0",
4+
"bin": "gbot",
5+
"repository": "git@github.com:umbrellio/gbot.git",
6+
"author": "Aleksei Bespalov <nulldefiner@gmail.com>",
7+
"homepage": "https://github.com/umbrellio/gbot",
8+
"license": "MIT",
9+
"scripts": {
10+
"lint": "eslint ."
11+
},
12+
"dependencies": {
13+
"js-yaml": "^3.14.0",
14+
"lodash": "^4.17.20",
15+
"winston": "^3.3.3",
16+
"yargs": "^15.4.1"
17+
},
18+
"devDependencies": {
19+
"eslint": "^7.7.0",
20+
"eslint-plugin-promise": "^4.2.1"
21+
}
22+
}

tasks/BaseCommand.js

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const _ = require("lodash")
2+
3+
const logger = require("../utils/logger")
4+
5+
const GitLab = require("../api/GitLab")
6+
const Messenger = require("../api/Messenger")
7+
8+
class BaseCommand {
9+
constructor(config) {
10+
this.config = config
11+
this.logger = logger
12+
this.gitlab = new GitLab(config)
13+
this.messenger = new Messenger(config)
14+
15+
this.projects = _.get(config, "gitlab.projects")
16+
}
17+
18+
perform = () => {
19+
throw new Error("Not implemented")
20+
}
21+
}
22+
23+
module.exports = BaseCommand

tasks/unapproved.js

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const _ = require("lodash")
2+
3+
const timeUtils = require("../utils/time")
4+
5+
const BaseCommand = require("./BaseCommand")
6+
7+
class Unapproved extends BaseCommand {
8+
perform = () => {
9+
const promises = this.projects.map(this.__getUnapprovedRequests)
10+
11+
return Promise.all(promises)
12+
.then(_.flatten)
13+
.then(requests => requests.sort((a, b) => new Date(a.updated_at) - new Date(b.updated_at)))
14+
.then(this.__buildMessage)
15+
.then(message => {
16+
this.logger.info("Sending message")
17+
this.logger.info(message)
18+
return message
19+
})
20+
.then(this.messenger.send)
21+
}
22+
23+
__buildMessage = requests => {
24+
const list = requests.map(this.__buildRequestDescription).join("\n")
25+
const head = `#### Hey, there is a couple of requests waiting for your review`
26+
27+
return `${head}\n\n${list}`
28+
}
29+
30+
__buildRequestDescription = request => {
31+
const updated = new Date(request.updated_at)
32+
const reaction = this.__getEmoji(updated)
33+
34+
const link = `[${request.title}](${request.web_url})`
35+
const author = `@${request.author.username}`
36+
const project = `[${request.project.name}](${request.project.web_url})`
37+
38+
return `${reaction} **${link}** (${project}) by **${author}**`
39+
}
40+
41+
__getEmoji = lastUpdate => {
42+
const emoji = _.get(this.config, "unapproved.emoji", {})
43+
const interval = new Date().getTime() - lastUpdate.getTime()
44+
45+
const findEmoji = _.flow(
46+
_.partialRight(_.toPairs),
47+
_.partialRight(_.map, ([key, value]) => [timeUtils.parseInterval(key), value]),
48+
_.partialRight(_.sortBy, ([time]) => -time),
49+
_.partialRight(_.find, ([time]) => time < interval),
50+
_.partialRight(_.last),
51+
)
52+
53+
return findEmoji(emoji) || emoji.default || ""
54+
}
55+
56+
__getUnapprovedRequests = projectId => this.__getExtendedRequests(projectId)
57+
.then(requests => requests.filter(req => {
58+
const isCompleted = !req.work_in_progress
59+
const isUnapproved = req.approvals_left > 0
60+
const hasUnresolvedDiscussions = req.discussions.some(dis => {
61+
return dis.notes.some(note => note.resolvable && !note.resolved)
62+
})
63+
return isCompleted && (isUnapproved || hasUnresolvedDiscussions)
64+
}))
65+
66+
__getExtendedRequests = projectId => this.gitlab
67+
.project(projectId)
68+
.then(project => this.gitlab.requests(project.id).then(requests => {
69+
const promises = requests.map(request => this.__getExtendedRequest(project, request))
70+
return Promise.all(promises)
71+
}))
72+
73+
__getExtendedRequest = (project, request) => Promise.resolve(request)
74+
.then(req => this.__appendApprovals(project, req))
75+
.then(req => this.__appendDiscussions(project, req))
76+
.then(req => ({ ...req, project }))
77+
78+
__appendApprovals = (project, request) => this.gitlab
79+
.approvals(project.id, request.iid)
80+
.then(approvals => ({ ...approvals, ...request }))
81+
82+
__appendDiscussions = (project, request) => this.gitlab
83+
.discussions(project.id, request.iid)
84+
.then(discussions => ({ ...request, discussions }))
85+
}
86+
87+
module.exports = Unapproved

utils/config.js

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const _ = require("lodash")
2+
const path = require("path")
3+
const fs = require("fs")
4+
const yaml = require("js-yaml")
5+
6+
const PREFIX_REGEX = /^GBOT_/
7+
8+
const parseEnvValue = value => {
9+
try {
10+
return JSON.parse(value)
11+
} catch (e) {
12+
return value
13+
}
14+
}
15+
16+
const parseEnvKey = key => {
17+
return key.replace(PREFIX_REGEX, "").split("__").map(_.trim).map(_.camelCase)
18+
}
19+
20+
const getFileConfig = filePath => {
21+
const configPath = path.resolve(filePath)
22+
const content = fs.readFileSync(configPath)
23+
return yaml.safeLoad(content)
24+
}
25+
26+
const getEnvConfig = () => Object
27+
.entries(process.env)
28+
.reduce((mem, [key, value]) => {
29+
if (!PREFIX_REGEX.test(key)) return mem
30+
const keyPath = parseEnvKey(key)
31+
const parsedValue = parseEnvValue(value)
32+
return _.set(mem, keyPath, parsedValue)
33+
}, {})
34+
35+
const load = filePath => {
36+
const fileConfig = getFileConfig(filePath)
37+
const envConfig = getEnvConfig()
38+
39+
return _.merge(fileConfig, envConfig)
40+
}
41+
42+
module.exports = { load }

0 commit comments

Comments
 (0)