├── .env.example ├── .eslintrc.json ├── .github └── workflows │ └── lint.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commands ├── debug.js ├── list.js ├── sub.js ├── unsub.js └── utils.js ├── config.js ├── datastore └── index.js ├── index.js ├── package-lock.json ├── package.json └── webhook ├── api.js ├── helpers.js └── index.js /.env.example: -------------------------------------------------------------------------------- 1 | DISCORD_BOT_TOKEN= 2 | GITHUB_TOKEN= 3 | DEBUG_CHANNEL= 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2019 9 | }, 10 | "rules": { 11 | "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], 12 | "comma-dangle": ["error", "always-multiline"], 13 | "comma-spacing": "error", 14 | "comma-style": "error", 15 | "keyword-spacing": "error", 16 | "curly": ["error", "multi-line", "consistent"], 17 | "dot-location": ["error", "property"], 18 | "handle-callback-err": "off", 19 | "indent": ["error", 2], 20 | "max-nested-callbacks": ["error", { "max": 4 }], 21 | "max-statements-per-line": ["error", { "max": 2 }], 22 | "no-console": "off", 23 | "no-empty-function": "error", 24 | "no-floating-decimal": "error", 25 | "no-inline-comments": "error", 26 | "no-lonely-if": "error", 27 | "no-multi-spaces": "error", 28 | "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 1, "maxBOF": 0 }], 29 | "no-shadow": ["error", { "allow": ["err", "resolve", "reject"] }], 30 | "no-trailing-spaces": ["error"], 31 | "no-var": "error", 32 | "object-curly-spacing": ["error", "always"], 33 | "prefer-const": "error", 34 | "quotes": ["error", "single"], 35 | "semi": ["error", "always"], 36 | "space-before-blocks": "error", 37 | "space-before-function-paren": ["error", { 38 | "anonymous": "never", 39 | "named": "never", 40 | "asyncArrow": "always" 41 | }], 42 | "space-in-parens": "error", 43 | "space-infix-ops": "error", 44 | "space-unary-ops": "error", 45 | "spaced-comment": "error", 46 | "eol-last": ["error", "always"], 47 | "yoda": "error" 48 | } 49 | } -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repo 10 | uses: actions/checkout@v2 11 | - name: Setup node 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | - name: Cache dependencies 16 | uses: actions/cache@v2 17 | with: 18 | path: ~/.npm 19 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 20 | restore-keys: | 21 | ${{ runner.os }}-node- 22 | - run: npm install 23 | - run: npm run lint 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | lerna-debug.log* 7 | 8 | node_modules/ 9 | .env 10 | .idea/ 11 | db.json 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at github@weispot.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's: 3 | 4 | - Reporting a bug 5 | - Discussing the current state of the code 6 | - Submitting a fix 7 | - Proposing new features 8 | - Becoming a maintainer 9 | 10 | ## Set Up Environment 11 | 12 | 1. Create a Discord server and [Discord Application](https://discord.com/developers/applications) then add a bot. 13 | 2. Install Discord bot onto Discord server by going to `https://discordapp.com/oauth2/authorize?client_id=&scope=bot`. 14 | 3. Copy `.env.example` to `.env` and fill in the variables. 15 | 16 | - `DISCORD_BOT_TOKEN` - from step 1 17 | - `GITHUB_TOKEN` - create [here](https://github.com/settings/tokens) with `public_repo` scope 18 | - `DEBUG_CHANNEL` - always cc messages to this channel 19 | 20 | 4. Run `npm install`. 21 | 22 | ### Development 23 | 24 | 5. Open two terminals, and run: 25 | 26 | 1. `npm run dev` - _Starts the development server_ 27 | 1. `npm run smee` - _Starts the smee server which proxies GitHub webhook events to development server_ 28 | 29 | 6. Go to a test GitHub repository and create a webhook with the smee url, select `Content type: application/json` and check only the `project_card` event. 30 | 7. Retrieve channel id by sending `!github-project debug` to `GitHub Project Notifier` bot you installed in step 2, update `DEBUG_CHANNEL` in `.env`, and restart `npm run dev`. 31 | 32 | ### Production 33 | 34 | 5. `npm start` - _Starts the production server. Alternatively, you can use `pm2`_ 35 | 6. Set up a proxy to 5. with domain and SSL. 36 | 7. Create a [GitHub App](https://github.com/settings/apps) with `Project card` webhook, and configure 6. as the webhook url. 37 | 38 | ## We Use [GitHub Flow](https://guides.GitHub.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests 39 | Pull requests are the best way to propose changes to the codebase (we use [GitHub Flow](https://guides.GitHub.com/introduction/flow/index.html)). We actively welcome your pull requests: 40 | 41 | 1. Fork the repo and create your branch from `master`. 42 | 1. If you've added code that should be tested, add tests. 43 | 1. If you've changed APIs, update the documentation. 44 | 1. Ensure the test suite passes. 45 | 1. Make sure your code lints. 46 | 1. Create that pull request! 47 | 48 | ## Commit Message and Pull Request Title 49 | 50 | Commit message and pull request title should follow [Conventional Commits](https://www.conventionalcommits.org). 51 | 52 | An easy way to achieve that is to install [`commitizen`](https://github.com/commitizen/cz-cli) and run `git cz` when committing. 53 | 54 | ## Any contributions you make will be under the MIT Software License 55 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 56 | 57 | ## License 58 | By contributing, you agree that your contributions will be licensed under its MIT License. 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Wei He 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![github-project-notifier](https://socialify.git.ci/wei/github-project-notifier/image?description=1&font=Bitter&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB2aWV3Qm94PSIwIDAgMTI3LjEgOTYuNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48c3R5bGU%2BQG1lZGlhIChwcmVmZXJzLWNvbG9yLXNjaGVtZTogZGFyaykgeyNkaXNjb3Jke2ZpbGw6I2ZmZiAhaW1wb3J0YW50fX08L3N0eWxlPjxwYXRoIGlkPSJkaXNjb3JkIiBkPSJNMTA3LjcgOGExMDUuMiAxMDUuMiAwIDAgMC0yNi4yLThBNzIgNzIgMCAwIDAgNzggNi44YTk3LjcgOTcuNyAwIDAgMC0yOS4xIDBBNzIuNCA3Mi40IDAgMCAwIDQ1LjYgMGExMDUuOSAxMDUuOSAwIDAgMC0yNi4yIDhBMTA2IDEwNiAwIDAgMCAuNSA4MC4zYTEwNS43IDEwNS43IDAgMCAwIDMyLjIgMTYuMiA3Ny43IDc3LjcgMCAwIDAgNi45LTExLjIgNjguNCA2OC40IDAgMCAxLTEwLjgtNS4xbDIuNi0yYTc1LjYgNzUuNiAwIDAgMCA2NC4zIDBsMi43IDJhNjguNyA2OC43IDAgMCAxLTEwLjkgNS4yIDc3IDc3IDAgMCAwIDcgMTEgMTA1LjMgMTA1LjMgMCAwIDAgMzIuMS0xNkExMDYgMTA2IDAgMCAwIDEwNy43IDhaTTQyLjQgNjUuOEMzNi4zIDY1LjcgMzEgNjAgMzEgNTNzNS0xMi43IDExLjQtMTIuN1M1NCA0NiA1NCA1M3MtNSAxMi43LTExLjQgMTIuN1ptNDIuMyAwYy02LjMgMC0xMS41LTUuNy0xMS41LTEyLjdzNS0xMi43IDExLjUtMTIuN1M5Ni4yIDQ2IDk2IDUzcy01IDEyLjctMTEuNCAxMi43WiIgZmlsbD0iIzU4NjVmMiIvPjwvc3ZnPg%3D%3D&pattern=Formal%20Invitation&theme=Auto) 2 | 3 | Never miss updates on your GitHub Projects again~ 4 | 5 | It is easy to overlook activities in GitHub Projects. **GitHub Project Notifier** helps you and your collaborators keep track of any project card updates on your project channels in Discord! 6 | 7 | ## Usage 8 | 9 | 1. ~~Install **[GitHub Project Notifier](https://github.com/apps/discord-github-project-notifier)** GitHub App onto your project repositories.~~ 10 | 1. ~~Add **[GitHub Project Notifier](https://discord.com/oauth2/authorize?client_id=777194551701536798&scope=bot)** Discord Bot to your server.~~ 11 | 12 | > **Note** 13 | > * This app only works with GitHub Projects (classic). 14 | > * Please self-host the application. 15 | 16 | ## Commands 17 | 18 | * **list**: Lists all subscribed GitHub Project boards 19 | * ```!github-project list``` 20 | * **sub**: Subscribe to a GitHub Project board 21 | * ```!github-project sub https://github.com/[owner]/[repo]/projects/[0-9]+``` 22 | * **unsub**: Unsubscribe from a GitHub Project board 23 | * ```!github-project unsub https://github.com/[owner]/[repo]/projects/[0-9]+``` 24 | 25 | ## Examples 26 | 27 |

28 | card-created 29 | card-moved 30 |

31 |

32 | card-deleted 33 | card-edited 34 |

35 | 36 | ## Self-host 37 | 38 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for detailed instructions. 39 | 40 | ## License 41 | 42 | This project is developed as part of [MLH Fellowship](https://fellowship.mlh.io/) Halfway Hackathon and licensed under [MIT](https://wei.mit-license.org/) 43 | -------------------------------------------------------------------------------- /commands/debug.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'debug', 3 | description: 'Debug', 4 | async execute(message) { 5 | const channelId = message.channel.id; 6 | 7 | console.log({ 'command': 'debug', channelId }); 8 | 9 | message.channel.send(`Your channel id is \`${channelId}\``); 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /commands/list.js: -------------------------------------------------------------------------------- 1 | const { query } = require('../datastore'); 2 | 3 | module.exports = { 4 | name: 'list', 5 | aliases: ['ls'], 6 | description: 'List subscribed GitHub Project boards', 7 | async execute(message) { 8 | const channelId = message.channel.id; 9 | 10 | const subscriptions = query({ channelId: channelId }); 11 | 12 | if (subscriptions.length === 0) { 13 | message.channel.send('There are no subscribed GitHub Project boards for this channel. Please use `sub` command to subscribe first!'); 14 | } 15 | else { 16 | message.channel.send(`GitHub Project boards subscribed: ${ 17 | subscriptions.map(subscription => `\n * \`${subscription.githubProjectUrl}\``) 18 | }`); 19 | } 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /commands/sub.js: -------------------------------------------------------------------------------- 1 | const { add } = require('../datastore'); 2 | const { validateGithubProjectUrl } = require('./utils'); 3 | 4 | module.exports = { 5 | name: 'sub', 6 | aliases: ['subscribe'], 7 | description: 'Subscribe to a GitHub Project board', 8 | usage: 'https://github.com/[owner]/[repo]/projects/1', 9 | args: true, 10 | async execute(message, args) { 11 | const channelId = message.channel.id; 12 | const githubProjectUrl = args[0].toLowerCase(); 13 | 14 | if (validateGithubProjectUrl({ githubProjectUrl: githubProjectUrl })) { 15 | try { 16 | add({ channelId: channelId, githubProjectUrl: githubProjectUrl }); 17 | message.channel.send(`Subscribed to \`${githubProjectUrl}\``); 18 | } 19 | catch (error) { 20 | message.channel.send(`Already subscribed to \`${githubProjectUrl}\``); 21 | } 22 | } 23 | else { 24 | message.channel.send('Please use correct format:\n`https://github.com/[owner]/[repo]/projects/[0-9]+`'); 25 | } 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /commands/unsub.js: -------------------------------------------------------------------------------- 1 | const { remove } = require('../datastore'); 2 | const { validateGithubProjectUrl } = require('./utils'); 3 | 4 | module.exports = { 5 | name: 'unsub', 6 | aliases: ['unsubscribe'], 7 | description: 'Unsubscribe from a GitHub Project board', 8 | usage: 'https://github.com/[owner]/[repo]/projects/1', 9 | args: true, 10 | async execute(message, args) { 11 | const channelId = message.channel.id; 12 | const githubProjectUrl = args[0].toLowerCase(); 13 | 14 | if (validateGithubProjectUrl({ githubProjectUrl: githubProjectUrl })) { 15 | try { 16 | remove({ channelId: channelId, githubProjectUrl: githubProjectUrl }); 17 | message.channel.send(`Unsubscribed from \`${githubProjectUrl}\``); 18 | } 19 | catch (error) { 20 | message.channel.send(`You are not subscribed to \`${githubProjectUrl}\`!`); 21 | } 22 | } 23 | else { 24 | message.channel.send('Please use correct format:\n`https://github.com/[owner/org]/[repo]/projects/[0-9]+`'); 25 | } 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /commands/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | validateGithubProjectUrl({ githubProjectUrl }) { 3 | const pattern = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/projects\/[0-9]+$/; 4 | return pattern.test(githubProjectUrl); 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prefix: process.env.DISCORD_COMMAND_PREFIX || '!github-project', 3 | token: process.env.DISCORD_BOT_TOKEN, 4 | githubToken: process.env.GITHUB_TOKEN, 5 | }; 6 | -------------------------------------------------------------------------------- /datastore/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | let _data = { 'subscriptions': [] }; 4 | const _datastore_path = 'db.json'; 5 | 6 | module.exports = { 7 | async connectToDatastore() { 8 | fs.readFile(_datastore_path, (err, data) => { 9 | if (err) { 10 | if (err.code === 'ENOENT') { 11 | // Create local datastore 12 | module.exports.commit(); 13 | } 14 | else { 15 | throw err; 16 | } 17 | } 18 | else { 19 | _data = JSON.parse(data); 20 | } 21 | }); 22 | }, 23 | add({ channelId, githubProjectUrl, commit = true }) { 24 | const snapshot = module.exports.query({ channelId: channelId, githubProjectUrl: githubProjectUrl }); 25 | if (snapshot.length === 0) { 26 | _data.subscriptions.push({ channelId: channelId, githubProjectUrl: githubProjectUrl.toLowerCase() }); 27 | if (commit) { 28 | module.exports.commit(); 29 | } 30 | } 31 | else { 32 | throw Error('Subscription entry already exists!'); 33 | } 34 | }, 35 | remove({ channelId, githubProjectUrl, commit = true }) { 36 | const index = _data.subscriptions.findIndex(subscription => subscription.channelId === channelId && 37 | subscription.githubProjectUrl === githubProjectUrl.toLowerCase()); 38 | if (index > -1) { 39 | _data.subscriptions.splice(index, 1); 40 | if (commit) { 41 | module.exports.commit(); 42 | } 43 | } 44 | else { 45 | throw Error('Subscription entry does not exist'); 46 | } 47 | }, 48 | query({ channelId, githubProjectUrl }) { 49 | return _data.subscriptions.filter(subscription => 50 | (channelId ? subscription.channelId === channelId : true) && 51 | (githubProjectUrl ? subscription.githubProjectUrl === githubProjectUrl.toLowerCase() : true), 52 | ); 53 | }, 54 | commit() { 55 | fs.writeFileSync(_datastore_path, JSON.stringify(_data)); 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const fs = require('fs'); 3 | const Discord = require('discord.js'); 4 | const { token, prefix } = require('./config'); 5 | const webhookHandler = require('./webhook'); 6 | const { connectToDatastore } = require('./datastore'); 7 | 8 | connectToDatastore(); 9 | 10 | const client = new Discord.Client({ 11 | intents: [ 12 | Discord.Intents.FLAGS.DIRECT_MESSAGES, 13 | Discord.Intents.FLAGS.GUILDS, 14 | Discord.Intents.FLAGS.GUILD_MESSAGES, 15 | ], 16 | partials: ['MESSAGE', 'CHANNEL'], 17 | }); 18 | client.commands = new Discord.Collection(); 19 | 20 | const commandFiles = fs.readdirSync('./commands').filter(file => file.endsWith('.js')); 21 | 22 | for (const file of commandFiles) { 23 | const command = require(`./commands/${file}`); 24 | client.commands.set(command.name, command); 25 | } 26 | 27 | client.once('ready', () => { 28 | console.log('Discord bot ready'); 29 | }); 30 | 31 | client.on('messageCreate', message => { 32 | const args = message.content.slice(prefix.length).trim().split(/ +/); 33 | const commandName = args.shift().toLowerCase(); 34 | 35 | const command = client.commands.get(commandName) 36 | || client.commands.find(cmd => cmd.aliases && cmd.aliases.includes(commandName)); 37 | 38 | if (!command) return; 39 | 40 | if (command.args && !args.length) { 41 | let reply = `You didn't provide any arguments, ${message.author}!`; 42 | 43 | if (command.usage) { 44 | reply += `\nThe proper usage would be: \`${prefix} ${command.name} ${command.usage}\``; 45 | } 46 | 47 | return message.channel.send(reply); 48 | } 49 | 50 | try { 51 | command.execute(message, args); 52 | } 53 | catch (error) { 54 | console.error(error); 55 | message.reply('There was an error trying to execute that command!'); 56 | } 57 | }); 58 | 59 | client.login(token); 60 | 61 | const fastify = require('fastify')({ 62 | logger: process.env.NODE_ENV !== 'production', 63 | }); 64 | 65 | fastify.post('/webhook', webhookHandler(client)); 66 | 67 | fastify.listen(process.env.PORT || 3000, process.env.HOST || '0.0.0.0', (err, address) => { 68 | if (err) throw err; 69 | console.log(`Webhook server ready at ${address}`); 70 | }); 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-project-notifier", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "node index.js", 7 | "dev": "nodemon --ignore db.json index.js", 8 | "smee": "smee --path /webhook", 9 | "lint": "eslint .", 10 | "lint:fix": "npm run lint -- --fix" 11 | }, 12 | "license": "MIT", 13 | "dependencies": { 14 | "@octokit/rest": "^18.12.0", 15 | "discord.js": "^13.2.0", 16 | "dotenv": "^10.0.0", 17 | "fastify": "^3.22.0" 18 | }, 19 | "devDependencies": { 20 | "eslint": "^8.0.1", 21 | "nodemon": "^2.0.13", 22 | "smee-client": "^1.2.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /webhook/api.js: -------------------------------------------------------------------------------- 1 | const { Octokit } = require('@octokit/rest'); 2 | const { githubToken } = require('../config'); 3 | 4 | const octokit = new Octokit({ 5 | userAgent: 'github-project-bot', 6 | auth: githubToken, 7 | }); 8 | 9 | async function getGitHubProject(payload) { 10 | const { project_card } = payload; 11 | const projectId = project_card.project_url.split('/').pop(); 12 | 13 | return (await octokit.projects.get({ project_id: projectId })).data; 14 | } 15 | 16 | async function getProjectCardColumn(colId) { 17 | if (!colId) { 18 | return null; 19 | } 20 | try { 21 | return (await octokit.projects.getColumn({ column_id: colId })).data; 22 | } 23 | catch { 24 | return null; 25 | } 26 | } 27 | 28 | async function getIssue(owner, repo, issue_number) { 29 | try { 30 | return (await octokit.issues.get({ 31 | owner, 32 | repo, 33 | issue_number, 34 | })).data; 35 | } 36 | catch { 37 | return null; 38 | } 39 | } 40 | 41 | async function getPull(owner, repo, pull_number) { 42 | try { 43 | return (await octokit.pulls.get({ 44 | owner, 45 | repo, 46 | pull_number, 47 | })).data; 48 | } 49 | catch { 50 | return null; 51 | } 52 | } 53 | 54 | async function getCardState(issue, pullRequest) { 55 | try { 56 | if (!pullRequest) { 57 | return `issue-${issue.state}`; 58 | } 59 | else if (issue.state == 'closed' && pullRequest.merged) { 60 | return 'pr-merged'; 61 | } 62 | else { 63 | return `pr-${issue.state}`; 64 | } 65 | } 66 | catch { 67 | return 'card'; 68 | } 69 | } 70 | 71 | module.exports = { 72 | getGitHubProject, 73 | getProjectCardColumn, 74 | getIssue, 75 | getPull, 76 | getCardState, 77 | }; 78 | -------------------------------------------------------------------------------- /webhook/helpers.js: -------------------------------------------------------------------------------- 1 | const { MessageEmbed } = require('discord.js'); 2 | const { 3 | getProjectCardColumn, 4 | getIssue, 5 | getPull, 6 | getCardState, 7 | } = require('./api'); 8 | 9 | async function prepareMessage({ payload, githubProject }) { 10 | const { action, changes, project_card, sender, repository } = payload; 11 | 12 | if (action === 'moved' && !changes) { 13 | // Ignore card moves within the same column 14 | return null; 15 | } 16 | 17 | const { login: userName, avatar_url: userAvatar, html_url: userUrl } = sender; 18 | const { html_url: githubProjectUrl, name: projectName } = githubProject; 19 | const { full_name: repoFullName } = repository; 20 | 21 | let description = (project_card.note || '').trim(); 22 | let prevColumn = null; 23 | let cardState = 'card'; 24 | const firstLine = description.split(/\n+/)[0]; 25 | let title = `[${toPascalCase(action)}] ${firstLine}`; 26 | description = description.replace(firstLine, '').trim(); 27 | const column = await getProjectCardColumn(project_card.column_id); 28 | let issue = null; 29 | let pullRequest = null; 30 | 31 | if (changes && changes.column_id) { 32 | prevColumn = await getProjectCardColumn(changes.column_id.from); 33 | } 34 | 35 | const detectedIssue = detectProjectCardIssue(project_card); 36 | if (detectedIssue) { 37 | issue = await getIssue(detectedIssue.owner, detectedIssue.repo, detectedIssue.number); 38 | if (issue) { 39 | pullRequest = issue.pull_request ? await getPull(detectedIssue.owner, detectedIssue.repo, detectedIssue.number) : null; 40 | cardState = await getCardState(issue, pullRequest); 41 | const issueRepo = `${detectedIssue.owner}/${detectedIssue.repo}`; 42 | const repoPrefix = issueRepo !== repoFullName ? issueRepo : ''; 43 | title = `[${toPascalCase(action)}] ${repoPrefix}#${issue.number}\n${issue.title}`; 44 | description = project_card.note || ''; 45 | if (!description) { 46 | description = (issue.body || '').trim(); 47 | 48 | if (description.length > 350) { 49 | description = `${description.substring(0, 350)}... [(Read more)](${issue.html_url})`; 50 | } 51 | } 52 | } 53 | } 54 | 55 | let embed = new MessageEmbed() 56 | .setTitle(title) 57 | .setDescription(description) 58 | .setURL(`${githubProjectUrl}#card-${project_card.id}`) 59 | .setColor(getColor(action)) 60 | .setThumbnail(getThumbnail(cardState)) 61 | .setAuthor(userName, userAvatar, userUrl) 62 | .setFooter(`${repoFullName} • ${projectName}`, 'https://user-images.githubusercontent.com/5880908/99613426-888b1d00-29e5-11eb-981e-029a23b84763.png') 63 | .setTimestamp(); 64 | 65 | const embedFields = { 66 | prevColName: prevColumn ? `[${prevColumn.name}](${githubProjectUrl}#column-${prevColumn.id})` : undefined, 67 | colName: column ? `[${column.name}](${githubProjectUrl}#column-${column.id})` : '-', 68 | created: project_card.creator 69 | ? `[@${project_card.creator.login}](https://github.com/${project_card.creator.login}) on ${project_card.created_at.substr(0, 10)}` : '-', 70 | assignees: (issue && issue.assignees || []).map(a => `[@${a.login}](https://github.com/${a.login})`).join('\n'), 71 | reviewers: (pullRequest && pullRequest.requested_reviewers || []).map(r => `[@${r.login}](https://github.com/${r.login})`).join('\n'), 72 | labels: (issue && issue.labels || []).map(l => `\`${l.name}\``).join(' '), 73 | }; 74 | embed = setEmbedFields(embed, embedFields); 75 | 76 | return embed; 77 | } 78 | 79 | function setEmbedFields(embed, { prevColName, colName, created, assignees, reviewers, labels }) { 80 | if (prevColName) { 81 | embed = embed 82 | .addFields( 83 | { name: 'Moved To', value: colName, inline: true }, 84 | { name: '\u200B', value: '\u200B', inline: true }, 85 | { name: 'Moved From', value: prevColName, inline: true }, 86 | ); 87 | } 88 | else { 89 | embed = embed 90 | .addFields( 91 | { name: 'Column', value: colName }, 92 | ); 93 | } 94 | 95 | if (assignees || reviewers) { 96 | embed = embed 97 | .addFields( 98 | { name: 'Reviewers', value: reviewers || '-', inline: true }, 99 | { name: '\u200B', value: '\u200B', inline: true }, 100 | { name: 'Assignees', value: assignees || '-', inline: true }, 101 | ); 102 | } 103 | 104 | if (labels) { 105 | embed = embed 106 | .addFields( 107 | { name: 'Labels', value: labels }, 108 | ); 109 | } 110 | 111 | embed = embed 112 | .addFields( 113 | { name: 'Created', value: created }, 114 | ); 115 | 116 | return embed; 117 | } 118 | 119 | 120 | function getThumbnail(name = 'card') { 121 | return `https://gist.github.com/wei/49bcf5309a964fdd39f2d23d04c3a992/raw/${name}-128x128.png`; 122 | } 123 | 124 | function getColor(action) { 125 | switch (action) { 126 | case 'created': return 'GREEN'; 127 | case 'edited': return 'GOLD'; 128 | case 'moved': return 'BLUE'; 129 | case 'converted': return 'ORANGE'; 130 | case 'deleted': return 'RED'; 131 | default: return 'DARK_BUT_NOT_BLACK'; 132 | } 133 | } 134 | 135 | function toPascalCase(s) { 136 | return s.replace(/\w+/g, 137 | function(w) {return w[0].toUpperCase() + w.slice(1).toLowerCase();}); 138 | } 139 | 140 | function detectProjectCardIssue(project_card) { 141 | const string = `${project_card.content_url}\n${project_card.note || ''}`; 142 | 143 | const match = /([^/\s]+)\/([^/\s]+)(?:\/issues\/|\/pull\/|#)(\d+)/.exec(string); 144 | if (match) { 145 | const [, owner, repo, number] = match; 146 | return { 147 | owner, 148 | repo, 149 | number, 150 | }; 151 | } 152 | 153 | return null; 154 | } 155 | 156 | module.exports = { 157 | prepareMessage, 158 | }; 159 | -------------------------------------------------------------------------------- /webhook/index.js: -------------------------------------------------------------------------------- 1 | const datastore = require('../datastore'); 2 | const { getGitHubProject } = require('./api'); 3 | const { prepareMessage } = require('./helpers'); 4 | 5 | // TODO Performance improvements using `Promise.all` 6 | module.exports = client => async (request, reply) => { 7 | if (request.headers['x-github-event'] !== 'project_card') { 8 | reply.type('application/json').code(400); 9 | return { status: 'bad request' }; 10 | } 11 | 12 | const payload = request.body; 13 | 14 | const githubProject = await getGitHubProject(payload); 15 | const { html_url: githubProjectUrl } = githubProject || {}; 16 | if (!githubProject || !githubProjectUrl) { 17 | return { status: 'not found', message: 'GitHub project not found' }; 18 | } 19 | 20 | const messageEmbed = await prepareMessage({ payload, githubProject }); 21 | if (!messageEmbed) { 22 | return { status: 'ok' }; 23 | } 24 | 25 | const subscribedChannels = await datastore.query({ githubProjectUrl }); 26 | (process.env.DEBUG_CHANNEL || '').split(',').forEach( 27 | channelId => subscribedChannels.push({ channelId, githubProjectUrl }), 28 | ); 29 | 30 | for (const { channelId } of subscribedChannels) { 31 | const channel = await client.channels.fetch(channelId); 32 | await channel.send({ embeds: [messageEmbed] }); 33 | } 34 | 35 | reply.type('application/json').code(200); 36 | return { status: 'ok' }; 37 | }; 38 | --------------------------------------------------------------------------------