├── .prettierignore ├── .prettierrc.yml ├── .release-it.json ├── tsconfig.json ├── dist ├── github-client.d.ts ├── slack-client.d.ts ├── index.d.ts ├── github-client.js ├── slack-client.js └── index.js ├── .github └── workflows │ ├── main.yml │ └── node.js.yml ├── package.json ├── src ├── github-client.ts ├── slack-client.ts └── index.ts ├── .gitignore ├── README.md └── test.js /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "release": true 4 | }, 5 | "npm": { 6 | "skipChecks": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "target": "es6", 8 | "rootDir": "src", 9 | "outDir": "dist", 10 | "noImplicitAny": false 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /dist/github-client.d.ts: -------------------------------------------------------------------------------- 1 | export default class GithubClient { 2 | token: string; 3 | constructor(token: any); 4 | apiHeaders(): { 5 | 'Content-Type': string; 6 | Authorization: string; 7 | Accept: string; 8 | }; 9 | createIssue(repo: any, params: any): Promise; 10 | getLatestIssues(repo: any): Promise; 11 | } 12 | -------------------------------------------------------------------------------- /dist/slack-client.d.ts: -------------------------------------------------------------------------------- 1 | export default class SlackClient { 2 | token: string; 3 | constructor(token: any); 4 | apiHeaders(token: any): { 5 | 'Content-Type': string; 6 | Authorization: string; 7 | }; 8 | getMessages(channel: any, ts: any, count?: number): Promise; 9 | postMessage(channel: any, text: any): Promise; 10 | getPermalink(channel: any, ts: any): Promise; 11 | getUserInfo(user: any): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export declare class ReactionHandler { 2 | issueRepo: string; 3 | reactionName: string[] | null; 4 | slackToken: string; 5 | githubToken: string; 6 | constructor(params: { 7 | issueRepo: string; 8 | reactionName?: string[]; 9 | githubToken?: string; 10 | slackToken?: string; 11 | }); 12 | match(event: any): boolean; 13 | extractAssignee(reactionName: any): any; 14 | reactionNames(): string[]; 15 | extractSlackUsersFromText(text: any): any[]; 16 | removeSlackFormatting(text: any, users: any): any; 17 | extractSlackUsersFromMessages(messages: any): string[]; 18 | buildIssueContent(event: any): Promise<{ 19 | title: any; 20 | body: string; 21 | }>; 22 | handle(event: any): Promise; 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: npm publish 2 | on: 3 | # Enable running this workflow manually from the Actions tab 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: '16.x' 14 | cache: 'npm' 15 | - run: npm ci 16 | - run: npm run build --if-present 17 | - run: npm test 18 | - run: | 19 | npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 20 | git config user.email "kazato.sugimoto@gmail.com" 21 | git config user.name "Kazato Sugimoto" 22 | npm run release --ci 23 | env: 24 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emoji-to-issue", 3 | "version": "1.0.1", 4 | "description": "the fastest way to create github issues with slack emoji reactions", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "compile": "tsc", 8 | "test": "npm run compile && ava", 9 | "release": "release-it" 10 | }, 11 | "author": "Kazato Sugimoto", 12 | "license": "ISC", 13 | "dependencies": { 14 | "axios": "^0.21.1", 15 | "decode-html": "^2.0.0" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^17.0.10", 19 | "@types/prettier": "^1.19.1", 20 | "@types/uuid": "^7.0.5", 21 | "ava": "^4.0.1", 22 | "husky": "^3.0.3", 23 | "nock": "^10.0.6", 24 | "prettier": "^1.18.2", 25 | "pretty-quick": "^1.11.1", 26 | "release-it": "^14.12.3", 27 | "typescript": "^4.5.5", 28 | "uuid": "^7.0.2" 29 | }, 30 | "husky": { 31 | "hooks": { 32 | "pre-commit": "npm test && pretty-quick --staged" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /src/github-client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export default class GithubClient { 4 | token: string 5 | 6 | constructor(token) { 7 | this.token = token || process.env.GITHUB_TOKEN 8 | } 9 | 10 | apiHeaders() { 11 | return { 12 | 'Content-Type': 'application/json', 13 | Authorization: `Bearer ${this.token}`, 14 | Accept: 'application/vnd.github.v3+json' 15 | } 16 | } 17 | 18 | async createIssue(repo, params) { 19 | const res = await axios.post( 20 | `https://api.github.com/repos/${repo}/issues`, 21 | params, 22 | { 23 | headers: this.apiHeaders() 24 | } 25 | ) 26 | 27 | if (res.status > 300) { 28 | console.error(res.data) 29 | throw new Error(res.data.message) 30 | } 31 | 32 | return res.data 33 | } 34 | 35 | async getLatestIssues(repo) { 36 | const res = await axios.get(`https://api.github.com/repos/${repo}/issues`, { 37 | params: { 38 | state: 'all' 39 | }, 40 | headers: this.apiHeaders() 41 | }) 42 | 43 | if (res.status > 300) { 44 | console.error(res.data) 45 | throw new Error(res.data.message) 46 | } 47 | 48 | return res.data 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | # Edit at https://www.gitignore.io/?templates=node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # TypeScript v1 declaration files 50 | typings/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional REPL history 62 | .node_repl_history 63 | 64 | # Output of 'npm pack' 65 | *.tgz 66 | 67 | # Yarn Integrity file 68 | .yarn-integrity 69 | 70 | # dotenv environment variables file 71 | .env 72 | .env.test 73 | 74 | # parcel-bundler cache (https://parceljs.org/) 75 | .cache 76 | 77 | # next.js build output 78 | .next 79 | 80 | # nuxt.js build output 81 | .nuxt 82 | 83 | # vuepress build output 84 | .vuepress/dist 85 | 86 | # Serverless directories 87 | .serverless/ 88 | 89 | # FuseBox cache 90 | .fusebox/ 91 | 92 | # DynamoDB Local files 93 | .dynamodb/ 94 | 95 | # End of https://www.gitignore.io/api/node -------------------------------------------------------------------------------- /src/slack-client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export default class SlackClient { 4 | token: string 5 | constructor(token) { 6 | this.token = token || process.env.SLACK_TOKEN 7 | } 8 | 9 | apiHeaders(token) { 10 | return { 11 | 'Content-Type': 'application/json', 12 | Authorization: `Bearer ${token}` 13 | } 14 | } 15 | 16 | async getMessages(channel, ts, count = 1) { 17 | const res = await axios.get('https://slack.com/api/conversations.history', { 18 | params: { 19 | channel: channel, 20 | latest: ts, 21 | limit: count, 22 | inclusive: true 23 | }, 24 | headers: this.apiHeaders(this.token) 25 | }) 26 | 27 | if (res.status > 300) { 28 | console.error(res.data) 29 | throw new Error(res.data.message) 30 | } 31 | 32 | if (!res.data.ok) { 33 | console.error(res.data) 34 | throw new Error(JSON.stringify(res.data)) 35 | } 36 | 37 | return res.data 38 | } 39 | 40 | async postMessage(channel, text) { 41 | await axios.post( 42 | 'https://slack.com/api/chat.postMessage', 43 | { 44 | channel: channel, 45 | text: text 46 | }, 47 | { 48 | headers: this.apiHeaders(this.token) 49 | } 50 | ) 51 | } 52 | 53 | async getPermalink(channel, ts) { 54 | const res = await axios.get('https://slack.com/api/chat.getPermalink', { 55 | params: { 56 | channel: channel, 57 | message_ts: ts, 58 | token: this.token 59 | } 60 | }) 61 | 62 | return res.data.permalink 63 | } 64 | 65 | async getUserInfo(user) { 66 | if (!user) { 67 | throw new Error('user is null') 68 | } 69 | 70 | const res = await axios.get('https://slack.com/api/users.info', { 71 | params: { 72 | user: user, 73 | token: this.token 74 | } 75 | }) 76 | 77 | if (!res.data.ok) { 78 | return null 79 | } 80 | 81 | return res.data.user 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /dist/github-client.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | var __importDefault = (this && this.__importDefault) || function (mod) { 12 | return (mod && mod.__esModule) ? mod : { "default": mod }; 13 | }; 14 | Object.defineProperty(exports, "__esModule", { value: true }); 15 | const axios_1 = __importDefault(require("axios")); 16 | class GithubClient { 17 | constructor(token) { 18 | this.token = token || process.env.GITHUB_TOKEN; 19 | } 20 | apiHeaders() { 21 | return { 22 | 'Content-Type': 'application/json', 23 | Authorization: `Bearer ${this.token}`, 24 | Accept: 'application/vnd.github.v3+json' 25 | }; 26 | } 27 | createIssue(repo, params) { 28 | return __awaiter(this, void 0, void 0, function* () { 29 | const res = yield axios_1.default.post(`https://api.github.com/repos/${repo}/issues`, params, { 30 | headers: this.apiHeaders() 31 | }); 32 | if (res.status > 300) { 33 | console.error(res.data); 34 | throw new Error(res.data.message); 35 | } 36 | return res.data; 37 | }); 38 | } 39 | getLatestIssues(repo) { 40 | return __awaiter(this, void 0, void 0, function* () { 41 | const res = yield axios_1.default.get(`https://api.github.com/repos/${repo}/issues`, { 42 | params: { 43 | state: 'all' 44 | }, 45 | headers: this.apiHeaders() 46 | }); 47 | if (res.status > 300) { 48 | console.error(res.data); 49 | throw new Error(res.data.message); 50 | } 51 | return res.data; 52 | }); 53 | } 54 | } 55 | exports.default = GithubClient; 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # emoji-to-issue 2 | 3 | The fastest way to create GitHub issues on your phone. 4 | 5 | Add emoji reactions to messages on Slack and it creates an GitHub issue for you. 6 | 7 | If you don't make it an issue, you'll forget it. Don't miss problems of your product. 8 | This module helps you to accelerate product development process, especially when you're dog-fooding. 9 | 10 | ## usage 11 | 12 | Add an emoji reaction: 13 | 14 | ![](https://i.gyazo.com/d18f953b3857dd5a2f84fcc347f46170.png) 15 | 16 | Then, the issue is made: 17 | ![](https://i.gyazo.com/16499ff7e05e42a16895e1f46e6e76a3.png) 18 | 19 | ## setup 20 | 21 | In your `package.json`: 22 | 23 | ```js 24 | "dependencies": { 25 | "emoji-to-issue": "uiur/emoji-to-issue#master", 26 | ``` 27 | 28 | Then: 29 | 30 | ``` 31 | npm install 32 | ``` 33 | 34 | ### configure slack emoji 35 | 36 | https://slack.com/customize/emoji 37 | 38 | Add custom emoji with names such as `:issue:` or `:issue-assign-uiur:`. 39 | Use alias if you want short one like: `:uiu: -> :issue-assign-uiur:` 40 | 41 | This emoji generator is useful: https://emoji-gen.ninja/ 42 | 43 | ### write some code 44 | 45 | Following api tokens are required: 46 | 47 | - `SLACK_TOKEN` 48 | - slack bot token 49 | - Bot User OAuth Access Token `https://api.slack.com/apps/~~/install-on-team` 50 | - Enable Events and add subscription to `reaction added` events https://api.slack.com/apps/:app/event-subscriptions 51 | - Permissions: `channels:history` `users:read` https://api.slack.com/apps/:app/oauth 52 | - `GITHUB_TOKEN` 53 | - https://github.com/settings/tokens 54 | 55 | Set those tokens via environment variables or pass it to the arguments. 56 | 57 | ```js 58 | const { ReactionHandler } = require('emoji-to-issue') 59 | 60 | handler = new ReactionHandler({ 61 | issueRepo: 'hello-ai/sandbox', // required 62 | reactionName: ['bug'], // default: 'issue', 'issue-assign_:assignee' etc. 63 | slackToken: 'bot token', // default: process.env.SLACK_TOKEN 64 | githubToken: 'github token' // default: process.env.GITHUB_TOKEN 65 | }) 66 | 67 | // event = { 68 | // type: 'reaction_added', 69 | // user: 'UB9T3UXU0', 70 | // item: { type: 'message', channel: 'CGU971U2F', ts: '1565583510.003900' }, 71 | // reaction: 'issue', 72 | // item_user: 'UB9T3UXU0', 73 | // event_ts: '1565583513.004000' 74 | // } 75 | 76 | if (handler.match(event)) { 77 | handler 78 | .handle(event) 79 | .then(() => { 80 | console.log('ok') 81 | }) 82 | .catch(err => { 83 | console.error(err) 84 | }) 85 | } 86 | ``` 87 | -------------------------------------------------------------------------------- /dist/slack-client.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | var __importDefault = (this && this.__importDefault) || function (mod) { 12 | return (mod && mod.__esModule) ? mod : { "default": mod }; 13 | }; 14 | Object.defineProperty(exports, "__esModule", { value: true }); 15 | const axios_1 = __importDefault(require("axios")); 16 | class SlackClient { 17 | constructor(token) { 18 | this.token = token || process.env.SLACK_TOKEN; 19 | } 20 | apiHeaders(token) { 21 | return { 22 | 'Content-Type': 'application/json', 23 | Authorization: `Bearer ${token}` 24 | }; 25 | } 26 | getMessages(channel, ts, count = 1) { 27 | return __awaiter(this, void 0, void 0, function* () { 28 | const res = yield axios_1.default.get('https://slack.com/api/conversations.history', { 29 | params: { 30 | channel: channel, 31 | latest: ts, 32 | limit: count, 33 | inclusive: true 34 | }, 35 | headers: this.apiHeaders(this.token) 36 | }); 37 | if (res.status > 300) { 38 | console.error(res.data); 39 | throw new Error(res.data.message); 40 | } 41 | if (!res.data.ok) { 42 | console.error(res.data); 43 | throw new Error(JSON.stringify(res.data)); 44 | } 45 | return res.data; 46 | }); 47 | } 48 | postMessage(channel, text) { 49 | return __awaiter(this, void 0, void 0, function* () { 50 | yield axios_1.default.post('https://slack.com/api/chat.postMessage', { 51 | channel: channel, 52 | text: text 53 | }, { 54 | headers: this.apiHeaders(this.token) 55 | }); 56 | }); 57 | } 58 | getPermalink(channel, ts) { 59 | return __awaiter(this, void 0, void 0, function* () { 60 | const res = yield axios_1.default.get('https://slack.com/api/chat.getPermalink', { 61 | params: { 62 | channel: channel, 63 | message_ts: ts, 64 | token: this.token 65 | } 66 | }); 67 | return res.data.permalink; 68 | }); 69 | } 70 | getUserInfo(user) { 71 | return __awaiter(this, void 0, void 0, function* () { 72 | if (!user) { 73 | throw new Error('user is null'); 74 | } 75 | const res = yield axios_1.default.get('https://slack.com/api/users.info', { 76 | params: { 77 | user: user, 78 | token: this.token 79 | } 80 | }); 81 | if (!res.data.ok) { 82 | return null; 83 | } 84 | return res.data.user; 85 | }); 86 | } 87 | } 88 | exports.default = SlackClient; 89 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const nock = require('nock') 3 | const uuidv4 = require('uuid').v4 4 | 5 | const { ReactionHandler } = require('./dist') 6 | 7 | function mockSlackHistory(messages) { 8 | nock('https://slack.com') 9 | .get(/^\/api\/conversations.history/) 10 | .reply(200, { 11 | ok: true, 12 | messages 13 | }) 14 | } 15 | 16 | function slackMessage(text) { 17 | return { 18 | client_msg_id: uuidv4(), 19 | type: 'message', 20 | text: text, 21 | user: 'UB9T3UXU0', 22 | ts: Date.now() / 1000, 23 | team: 'T0ZFQ4P32' 24 | } 25 | } 26 | 27 | function slackBotMessage(text) { 28 | return { 29 | type: 'message', 30 | subtype: 'bot_message', 31 | text: text, 32 | ts: Date.now() / 1000, 33 | username: 'otochan-dev', 34 | bot_id: 'BMB12D06B' 35 | } 36 | } 37 | 38 | function slackReactionAddedEvent() { 39 | return { 40 | type: 'reaction_added', 41 | user: 'UB9T3UXU0', 42 | item: { type: 'message', channel: 'CGU971U2F', ts: '1565583510.003900' }, 43 | reaction: 'issue', 44 | item_user: 'UB9T3UXU0', 45 | event_ts: '1565583513.004000' 46 | } 47 | } 48 | 49 | test('handler.match()', t => { 50 | const event = slackReactionAddedEvent() 51 | 52 | const handler = new ReactionHandler({ issueRepo: 'hello-ai/sandbox' }) 53 | t.true(handler.match(event)) 54 | t.true(handler.match({ ...event, reaction: 'issue-assign-uiur' })) 55 | t.false(handler.match({ ...event, reaction: 'innocent' })) 56 | }) 57 | 58 | test.beforeEach(() => { 59 | nock('https://slack.com') 60 | .get(/^\/api\/chat.getPermalink/) 61 | .reply(200, { 62 | ok: true, 63 | channel: 'CGU971U2F', 64 | permalink: 'https://hello-ai.slack.com/foo/bar' 65 | }) 66 | }) 67 | 68 | test('handler.buildIssueContent()', async t => { 69 | mockSlackHistory([ 70 | slackMessage('issue title test'), 71 | slackMessage('<@UB9T3UXU0> user desu'), 72 | slackBotMessage('bot desu') 73 | ]) 74 | 75 | nock('https://slack.com') 76 | .get(/^\/api\/users\.info/) 77 | .reply(200, { 78 | ok: true, 79 | user: { 80 | id: 'UB9T3UXU0', 81 | team_id: 'T0ZFQ4P32', 82 | real_name: 'Kazato Sugimoto', 83 | profile: { 84 | title: '', 85 | display_name: 'kazato', 86 | display_name_normalized: 'kazato' 87 | } 88 | } 89 | }) 90 | 91 | const event = slackReactionAddedEvent() 92 | 93 | const handler = new ReactionHandler({ issueRepo: 'hello-ai/sandbox' }) 94 | const { title, body } = await handler.buildIssueContent(event) 95 | 96 | t.is(title, 'issue title test') 97 | t.true(body.length > 0) 98 | t.true(body.includes('@kazato user desu'), body) 99 | t.false(body.includes('bot desu')) 100 | }) 101 | 102 | test('buildIssueContent: user not found', async t => { 103 | mockSlackHistory([slackMessage('<@UB9T3UXU0> test')]) 104 | 105 | nock('https://slack.com') 106 | .get(/^\/api\/users\.info/) 107 | .reply(200, { 108 | ok: false, 109 | error: 'user_not_found' 110 | }) 111 | 112 | const event = slackReactionAddedEvent() 113 | 114 | const handler = new ReactionHandler({ issueRepo: 'hello-ai/sandbox' }) 115 | const { title, body } = await handler.buildIssueContent(event) 116 | 117 | t.true(title.includes('@UB9T3UXU0 test'), title) 118 | t.true(body.length > 0) 119 | t.true(body.includes('@UB9T3UXU0 test'), body) 120 | t.false(body.includes('bot desu')) 121 | }) 122 | 123 | test('buildIssueContent: subteam', async t => { 124 | mockSlackHistory([ 125 | slackMessage(' test ') 126 | ]) 127 | 128 | nock('https://slack.com') 129 | .get(/^\/api\/users\.info/) 130 | .reply(200, { 131 | ok: false, 132 | error: 'user_not_found' 133 | }) 134 | 135 | const event = slackReactionAddedEvent() 136 | 137 | const handler = new ReactionHandler({ issueRepo: 'hello-ai/sandbox' }) 138 | const { title, body } = await handler.buildIssueContent(event) 139 | 140 | t.true(title.includes('@hackers test http://example.com'), title) 141 | t.true(body.length > 0) 142 | t.true(body.includes('@hackers test'), body) 143 | }) 144 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import decode = require('decode-html') 2 | import GithubClient from './github-client' 3 | import SlackClient from './slack-client' 4 | 5 | function debug(message) { 6 | if (process.env.DEBUG) { 7 | console.log(message) 8 | } 9 | } 10 | 11 | export class ReactionHandler { 12 | // @param {Object} params 13 | // @example 14 | // handler = new ReactionHandler({ 15 | // reactionName: ['issue'], // default: ['issue', 'issue-assign-:assignee'] 16 | // issueRepo: 'hello-ai/sandbox', 17 | // slackToken: process.env.SLACK_TOKEN, 18 | // githubToken: process.env.GITHUB_TOKEN 19 | // }) 20 | 21 | issueRepo: string 22 | reactionName: string[] | null 23 | slackToken: string 24 | githubToken: string 25 | 26 | constructor(params: { 27 | issueRepo: string 28 | reactionName?: string[] 29 | githubToken?: string 30 | slackToken?: string 31 | }) { 32 | this.issueRepo = params.issueRepo 33 | this.reactionName = params.reactionName || null 34 | this.slackToken = params.slackToken || process.env.SLACK_TOKEN 35 | this.githubToken = params.githubToken || process.env.GITHUB_TOKEN 36 | } 37 | 38 | match(event): boolean { 39 | if (event.type !== 'reaction_added') return false 40 | if (this.reactionNames().includes(event.reaction)) return true 41 | 42 | const matched = this.reactionNames().some(name => { 43 | return new RegExp(`^${name}-assign-.+$`).test(event.reaction) 44 | }) 45 | 46 | return matched 47 | } 48 | 49 | extractAssignee(reactionName) { 50 | const matchData = reactionName.match(/-assign-(.+)$/) 51 | return matchData && matchData[1] 52 | } 53 | 54 | reactionNames() { 55 | return this.reactionName || ['issue', 'イシュー'] 56 | } 57 | 58 | extractSlackUsersFromText(text) { 59 | const result = [] 60 | const regex = /<@([^>]+)>/g 61 | let matched 62 | while ((matched = regex.exec(text)) !== null) { 63 | result.push(matched[1]) 64 | } 65 | 66 | return result 67 | } 68 | 69 | removeSlackFormatting(text, users) { 70 | return text.replace( 71 | /<([@#!])?([^>|]+)(?:\|([^>]+))?>/g, 72 | (match, type, link, label) => { 73 | if (type === '@') { 74 | const info = users[link] 75 | if (info) { 76 | return `@${info.profile.display_name}` 77 | } else { 78 | return `@${link}` 79 | } 80 | } 81 | 82 | if (type === '#') { 83 | return `#${label || link}` 84 | } 85 | 86 | return label || link 87 | } 88 | ) 89 | } 90 | 91 | extractSlackUsersFromMessages(messages) { 92 | const users = {} 93 | messages 94 | .filter(m => m.user) 95 | .forEach(m => { 96 | users[m.user] = null 97 | }) 98 | 99 | messages.forEach(m => { 100 | this.extractSlackUsersFromText(m.text).forEach(u => { 101 | users[u] = null 102 | }) 103 | }) 104 | 105 | return Object.keys(users) 106 | } 107 | 108 | async buildIssueContent(event) { 109 | const slackClient = new SlackClient(this.slackToken) 110 | 111 | const { channel, ts } = event.item 112 | const { messages } = await slackClient.getMessages(channel, ts, 10) 113 | 114 | const slackUsers = this.extractSlackUsersFromMessages(messages) 115 | 116 | const userInfos = await Promise.all( 117 | slackUsers.map(user => slackClient.getUserInfo(user)) 118 | ) 119 | 120 | const users = {} 121 | userInfos.forEach(info => { 122 | if (!info) return 123 | users[info.id] = info 124 | }) 125 | 126 | debug(messages) 127 | const message = messages[0] 128 | const permalink = await slackClient.getPermalink(channel, message.ts) 129 | 130 | const title = this.removeSlackFormatting(decode(message.text), users) 131 | const historyText = messages 132 | .reverse() 133 | .filter( 134 | m => 135 | m.type === 'message' && 136 | m.subtype !== 'bot_message' && 137 | m.bot_id === undefined && 138 | m.text 139 | ) 140 | .map(m => { 141 | const info = users[m.user] 142 | const username = info ? info.profile.display_name : m.user 143 | const decoded = decode(m.text) 144 | const expanded = this.removeSlackFormatting(decoded, users) 145 | return `${username}: ${expanded}` 146 | }) 147 | .join('\n') 148 | 149 | const body = `${permalink}\n` + '```\n' + historyText + '\n```' 150 | 151 | return { 152 | title: title, 153 | body: body 154 | } 155 | } 156 | 157 | // create an issue from a slack reaction event 158 | async handle(event) { 159 | debug(event) 160 | if (!this.match(event)) return 161 | 162 | const { title, body } = await this.buildIssueContent(event) 163 | const githubClient = new GithubClient(this.githubToken) 164 | 165 | const issueRepo = this.issueRepo 166 | const issues = await githubClient.getLatestIssues(issueRepo) 167 | const foundIssue = issues.find(issue => { 168 | return issue.title === title 169 | }) 170 | 171 | if (foundIssue) { 172 | return foundIssue 173 | } 174 | 175 | const issueParams: any = { 176 | title: title, 177 | body: body 178 | } 179 | 180 | const assignee = this.extractAssignee(event.reaction) 181 | if (assignee) { 182 | issueParams.assignees = [assignee] 183 | } 184 | 185 | debug(issueParams) 186 | const issue = await githubClient.createIssue(issueRepo, issueParams) 187 | 188 | const { channel } = event.item 189 | const slackClient = new SlackClient(this.slackToken) 190 | const slackMessage = `<@${event.user}> ${issue.html_url}` 191 | await slackClient.postMessage(channel, slackMessage) 192 | 193 | return issue 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | var __importDefault = (this && this.__importDefault) || function (mod) { 12 | return (mod && mod.__esModule) ? mod : { "default": mod }; 13 | }; 14 | Object.defineProperty(exports, "__esModule", { value: true }); 15 | exports.ReactionHandler = void 0; 16 | const decode = require("decode-html"); 17 | const github_client_1 = __importDefault(require("./github-client")); 18 | const slack_client_1 = __importDefault(require("./slack-client")); 19 | function debug(message) { 20 | if (process.env.DEBUG) { 21 | console.log(message); 22 | } 23 | } 24 | class ReactionHandler { 25 | constructor(params) { 26 | this.issueRepo = params.issueRepo; 27 | this.reactionName = params.reactionName || null; 28 | this.slackToken = params.slackToken || process.env.SLACK_TOKEN; 29 | this.githubToken = params.githubToken || process.env.GITHUB_TOKEN; 30 | } 31 | match(event) { 32 | if (event.type !== 'reaction_added') 33 | return false; 34 | if (this.reactionNames().includes(event.reaction)) 35 | return true; 36 | const matched = this.reactionNames().some(name => { 37 | return new RegExp(`^${name}-assign-.+$`).test(event.reaction); 38 | }); 39 | return matched; 40 | } 41 | extractAssignee(reactionName) { 42 | const matchData = reactionName.match(/-assign-(.+)$/); 43 | return matchData && matchData[1]; 44 | } 45 | reactionNames() { 46 | return this.reactionName || ['issue', 'イシュー']; 47 | } 48 | extractSlackUsersFromText(text) { 49 | const result = []; 50 | const regex = /<@([^>]+)>/g; 51 | let matched; 52 | while ((matched = regex.exec(text)) !== null) { 53 | result.push(matched[1]); 54 | } 55 | return result; 56 | } 57 | removeSlackFormatting(text, users) { 58 | return text.replace(/<([@#!])?([^>|]+)(?:\|([^>]+))?>/g, (match, type, link, label) => { 59 | if (type === '@') { 60 | const info = users[link]; 61 | if (info) { 62 | return `@${info.profile.display_name}`; 63 | } 64 | else { 65 | return `@${link}`; 66 | } 67 | } 68 | if (type === '#') { 69 | return `#${label || link}`; 70 | } 71 | return label || link; 72 | }); 73 | } 74 | extractSlackUsersFromMessages(messages) { 75 | const users = {}; 76 | messages 77 | .filter(m => m.user) 78 | .forEach(m => { 79 | users[m.user] = null; 80 | }); 81 | messages.forEach(m => { 82 | this.extractSlackUsersFromText(m.text).forEach(u => { 83 | users[u] = null; 84 | }); 85 | }); 86 | return Object.keys(users); 87 | } 88 | buildIssueContent(event) { 89 | return __awaiter(this, void 0, void 0, function* () { 90 | const slackClient = new slack_client_1.default(this.slackToken); 91 | const { channel, ts } = event.item; 92 | const { messages } = yield slackClient.getMessages(channel, ts, 10); 93 | const slackUsers = this.extractSlackUsersFromMessages(messages); 94 | const userInfos = yield Promise.all(slackUsers.map(user => slackClient.getUserInfo(user))); 95 | const users = {}; 96 | userInfos.forEach(info => { 97 | if (!info) 98 | return; 99 | users[info.id] = info; 100 | }); 101 | debug(messages); 102 | const message = messages[0]; 103 | const permalink = yield slackClient.getPermalink(channel, message.ts); 104 | const title = this.removeSlackFormatting(decode(message.text), users); 105 | const historyText = messages 106 | .reverse() 107 | .filter(m => m.type === 'message' && 108 | m.subtype !== 'bot_message' && 109 | m.bot_id === undefined && 110 | m.text) 111 | .map(m => { 112 | const info = users[m.user]; 113 | const username = info ? info.profile.display_name : m.user; 114 | const decoded = decode(m.text); 115 | const expanded = this.removeSlackFormatting(decoded, users); 116 | return `${username}: ${expanded}`; 117 | }) 118 | .join('\n'); 119 | const body = `${permalink}\n` + '```\n' + historyText + '\n```'; 120 | return { 121 | title: title, 122 | body: body 123 | }; 124 | }); 125 | } 126 | // create an issue from a slack reaction event 127 | handle(event) { 128 | return __awaiter(this, void 0, void 0, function* () { 129 | debug(event); 130 | if (!this.match(event)) 131 | return; 132 | const { title, body } = yield this.buildIssueContent(event); 133 | const githubClient = new github_client_1.default(this.githubToken); 134 | const issueRepo = this.issueRepo; 135 | const issues = yield githubClient.getLatestIssues(issueRepo); 136 | const foundIssue = issues.find(issue => { 137 | return issue.title === title; 138 | }); 139 | if (foundIssue) { 140 | return foundIssue; 141 | } 142 | const issueParams = { 143 | title: title, 144 | body: body 145 | }; 146 | const assignee = this.extractAssignee(event.reaction); 147 | if (assignee) { 148 | issueParams.assignees = [assignee]; 149 | } 150 | debug(issueParams); 151 | const issue = yield githubClient.createIssue(issueRepo, issueParams); 152 | const { channel } = event.item; 153 | const slackClient = new slack_client_1.default(this.slackToken); 154 | const slackMessage = `<@${event.user}> ${issue.html_url}`; 155 | yield slackClient.postMessage(channel, slackMessage); 156 | return issue; 157 | }); 158 | } 159 | } 160 | exports.ReactionHandler = ReactionHandler; 161 | --------------------------------------------------------------------------------