├── .claspignore ├── .eslintrc.json ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── docs ├── idle-period.md └── reactive-per.md ├── misc └── example.png ├── package.json ├── src ├── github-issues-notice.ts ├── github.ts ├── main.ts └── slack.ts └── tsconfig.json /.claspignore: -------------------------------------------------------------------------------- 1 | **/** 2 | !src/*.ts 3 | !appsscript.json 4 | src/*.test.ts 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier" 7 | ], 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "env": { 12 | "node": true, 13 | "es6": true 14 | }, 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "sourceType": "module", 18 | "project": "./tsconfig.json" 19 | }, 20 | "rules": { 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node-version: [12.x] 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: Run builds and deploys with ${{ matrix.node-version }} 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: ${{ matrix.node-version }} 15 | - name: npm install and lint 16 | run: | 17 | npm i -D 18 | npm run lint 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /package-lock.json 2 | /.clasp.json 3 | /appsscript.json 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tomohisa Oda 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 Issues Notice 2 | == 3 | 4 | Notify slack the title of the specified repository and label issues on GAS. 5 | 6 | 7 | 8 | 9 | 10 | Slack notification example is: 11 | 12 | example 13 | 14 | Usage 15 | ----- 16 | 17 | 1. Deploy this 18 | ```sh 19 | $ npm i 20 | $ npx clasp login 21 | $ npx clasp create 'GitHub Issues Notice' --rootDir ./src 22 | $ npx clasp push 23 | ``` 24 | 1. Create google spreadsheet. For example: 25 | 26 | Enabled | Channel | Time | Mention | Repository | Label/Threshold/Message | Stats | Idle Period | Add Relations | Only P/R | Label Protection | Show Organization | No Holiday 27 | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- 28 | [x] | general | 09
13
17 | @dev | foo/bar
foo/baz | WIP/5/There are a lot of things in progress. | [x] | 60 | [x] | [ ] | [ ] | [x] | [ ] 29 | [x] | dev | 13
1750 | @sre | foo/abc | needs-review/3/@techlead Please need review.
WIP/5/Yo. | [x] | 45 | [ ] | [x] | [ ] | [ ] | [x] 30 | [ ] | ... | ... | ... | ... | ... | [ ] | | [ ] | [ ] | [x] | [ ] | [ ] 31 | - Sheet name is `config` 32 | - Config start 2nd row, 1st row is subject 33 | 1. Set script properties as ENV(File > Project properties > Script properties) 34 | - SLACK_ACCESS_TOKEN 35 | - GITHUB_ACCESS_TOKEN 36 | - SHEET_ID 37 | - GITHUB_API_ENDPOINT(optional) 38 | 1. Add project trigger(Edit > Current project's triggers > Add trigger) 39 | - Choose which function to run: `notify` 40 | - Which run at deployment: `head` 41 | - Select event source: `Time-driven` 42 | - Select type of time based trigger: `Minute timer` 43 | - Select hour interval: `Every minute` 44 | 45 | Stats 46 | -- 47 | 48 | Stats reports issues total count, pull-requests total count and reactive percent. 49 | Reactive percent derives from `proactive` labeled issues and other issues count. 50 | If no proactive labels, no reports. 51 | By attaching `proactive` label to issues, it will be visible whether the work of 52 | the team is healthy. 53 | 54 | Idle Period 55 | -- 56 | 57 | If there is a number of days in the Idle period, it will be automatically closed 58 | if there are no issues or pulls that have not been updated over that number of days. 59 | If you do not want to use this function, please set it to blank or 0. 60 | 61 | Add Relations 62 | -- 63 | 64 | Add relationship to notifications to Slack. Specifically, include assignees and reviewers. 65 | If you use this option, you should set [code review assignments](https://docs.github.com/en/github/setting-up-and-managing-organizations-and-teams/managing-code-review-assignment-for-your-team) on Github. 66 | 67 | Only P/R 68 | -- 69 | 70 | The target of monitoring is only pull requests other than draft. 71 | 72 | Label Protection 73 | -- 74 | 75 | Closed Issue is also monitored for Issue label. This will notify you of any issues that have been accidentally closed. 76 | 77 | Show Organization 78 | -- 79 | 80 | Add organization name to the display If you want to notify multiple organizations. 81 | 82 | No Holiday 83 | -- 84 | 85 | This notification usually does not notify you on holidays. Use this option to run it regardless of holidays. 86 | 87 | Contribution 88 | ------------ 89 | 90 | 1. Fork (https://github.com/linyows/github-issues-notice/fork) 91 | 1. Create a feature branch 92 | 1. Commit your changes 93 | 1. Rebase your local changes against the master branch 94 | 1. Run test suite with the `npm ci` command and confirm that it passes 95 | 1. Create a new Pull Request 96 | 97 | Author 98 | ------ 99 | 100 | [linyows](https://github.com/linyows) 101 | -------------------------------------------------------------------------------- /docs/idle-period.md: -------------------------------------------------------------------------------- 1 | What is Idle Period? 2 | == 3 | 4 | If there is a number of days in the Idle period, 5 | it will be automatically closed if there are no issues or pulls that have not been updated over that number of days. 6 | If you do not want to use this function, please set it to blank or 0. 7 | 8 | 指定した日数以上更新がないIssueを自動的にCloseします。もしこの機能を使いたくない場合は、数値を0に設定してください。 9 | 10 | Why use? 11 | -- 12 | 13 | It is meaningless that Issue is Open, even though Issue has not been updated for a long time. 14 | Also, the extra Issue being Opened may even burst the Issue to concentrate. 15 | Therefore, Issues without updates should be closed periodically. 16 | In some cases, Issu may be troubled if it is Clos. 17 | However, if you set appropriate notification settings you will notice that it was closed, and you only need to open it again if the required Issue is closed. 18 | 19 | 長期間にわたってIssueに更新がないのに、IssueがOpenになっているのは無意味です。 20 | また、余計なIssueがOpenになっていることで、集中すべきIssueが埋もれてしまうことさえあるでしょう。 21 | したがって、更新のないIssueは定期的にCloseされるべきです。 22 | なかには、Closeされては困るIssueがあるかもしれません。 23 | しかし、適切な通知設定をしておけば閉じられたことは気づけますし、必要なIssueが閉じられても再度Openにすればよいだけです。 24 | -------------------------------------------------------------------------------- /docs/reactive-per.md: -------------------------------------------------------------------------------- 1 | What is Reactive Per? 2 | == 3 | 4 | Reactive percent derives from proactive labeled issues and other issues count. 5 | If no proactive labels, no reports. 6 | By attaching proactive label to issues, it will be visible whether the work of the team is healthy. 7 | 8 | Reactive Perは、自発的に取り組むIssueやP/Rに `proactive` ラベルをつけることで、タスクの割り込み度を可視化するものです。 9 | 10 | Why use? 11 | -- 12 | 13 | In addition to proactive tasks, our job has reactive tasks to be asked. 14 | Although the existence of the latter is not bad, as soon as that happens, the task of the team will not proceed. 15 | This is a bad thing. So, if you visualize this distribution, it will be easier to judge if it is in good condition or bad condition. 16 | The lower this figure, the more teams can concentrate on the task, this figure shows the health of the team. 17 | 18 | 私たちの仕事は、自発的にやるタスク以外に、依頼されて行うタスクがあります。 19 | 後者の存在が悪いわけではありませんが、そればかりになるとチームのタスクは進まなくなってしまいます。 20 | これは悪いことです。なので、この配分を可視化すると、今が良い状態か悪い状態かを判断しやすくなります。 21 | この数値が低いほどチームがタスクに集中できる状態と言え、この数値はチームの健全性を示しています。 22 | -------------------------------------------------------------------------------- /misc/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linyows/github-issues-notice/9e10b8c5500ccc3d9b4a72525f2eb4030a65e756/misc/example.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-issues-notice", 3 | "version": "6.0.0", 4 | "description": "Notify slack the title of the specified repository and label issues on GAS.", 5 | "scripts": { 6 | "ci": "npm i && npm run lint", 7 | "lint": "eslint ./src/**/*.ts" 8 | }, 9 | "devDependencies": { 10 | "@google/clasp": "^2.3.0", 11 | "@types/google-apps-script": "^1.0.14", 12 | "@typescript-eslint/eslint-plugin": "^5.21.0", 13 | "@typescript-eslint/parser": "^5.21.0", 14 | "eslint": "^8.14.0", 15 | "eslint-config-prettier": "^8.5.0", 16 | "eslint-plugin-prettier": "^4.0.0", 17 | "prettier": "^2.0.5" 18 | }, 19 | "tslintIgnore": [ 20 | "**/node_modules/**" 21 | ], 22 | "author": "linyows", 23 | "license": "MIT", 24 | "homepage": "https://github.com/linyows/github-issues-notice" 25 | } 26 | -------------------------------------------------------------------------------- /src/github-issues-notice.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub issues notice 3 | * 4 | * Copyright (c) 2018 Tomohisa Oda 5 | */ 6 | 7 | import { Github, Issue, PullRequest } from './github' 8 | import { Slack, Attachment } from './slack' 9 | 10 | /** 11 | * GithubIssuesNotice 12 | */ 13 | export class GithubIssuesNotice { 14 | private get slack(): Slack { 15 | if (this.pSlack === undefined) { 16 | this.pSlack = new Slack(this.config.slack.token) 17 | } 18 | 19 | return this.pSlack 20 | } 21 | 22 | private get github(): Github { 23 | if (this.pGithub === undefined) { 24 | if (this.config.github.apiEndpoint) { 25 | this.pGithub = new Github( 26 | this.config.github.token, 27 | this.config.github.apiEndpoint 28 | ) 29 | } else { 30 | this.pGithub = new Github(this.config.github.token) 31 | } 32 | } 33 | 34 | return this.pGithub 35 | } 36 | 37 | private get sheet(): GoogleAppsScript.Spreadsheet.Sheet { 38 | if (this.pSheet === undefined) { 39 | const s = SpreadsheetApp.openById(this.config.spreadsheets.id) 40 | this.pSheet = s.getSheetByName('config') 41 | } 42 | 43 | return this.pSheet 44 | } 45 | 46 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 47 | private get data(): any[][] { 48 | if (this.pData === undefined) { 49 | const startRow = 2 50 | const startColumn = 1 51 | const numRow = this.sheet.getLastRow() 52 | const numColumn = this.sheet.getLastColumn() 53 | this.pData = this.sheet.getSheetValues( 54 | startRow, 55 | startColumn, 56 | numRow, 57 | numColumn 58 | ) 59 | } 60 | 61 | return this.pData 62 | } 63 | 64 | private get isHoliday(): boolean { 65 | if (this.pIsHoliday === undefined) { 66 | const date = this.config.now 67 | const startWeek = 0 68 | const endWeek = 6 69 | const weekInt = date.getDay() 70 | 71 | if (weekInt <= startWeek || endWeek <= weekInt) { 72 | this.pIsHoliday = true 73 | } else { 74 | const calendarId = 'ja.japanese#holiday@group.v.calendar.google.com' 75 | const calendar = CalendarApp.getCalendarById(calendarId) 76 | const events = calendar.getEventsForDay(date) 77 | this.pIsHoliday = events.length > 0 78 | } 79 | } 80 | 81 | return this.pIsHoliday 82 | } 83 | 84 | public config: Config 85 | private pSheet: GoogleAppsScript.Spreadsheet.Sheet 86 | private pSlack: Slack 87 | private pGithub: Github 88 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 89 | private pData: any[][] 90 | private pIsHoliday: boolean 91 | 92 | constructor(c: Config) { 93 | this.config = c 94 | } 95 | 96 | public static CAPITALIZE(word: string): string { 97 | if (!word) { 98 | return word 99 | } 100 | 101 | return word.replace(/\w\S*/g, t => { 102 | return `${t.charAt(0).toUpperCase()}${t.substr(1).toLowerCase()}` 103 | }) 104 | } 105 | 106 | public static NORMALIZE(str: string): string[] { 107 | const arr = str.split('\n') 108 | for (let v of arr) { 109 | v = v.trim() 110 | } 111 | 112 | return arr.filter(v => v) 113 | } 114 | 115 | private static statsEmoji(r: number): string { 116 | const danger = 90 117 | const caution = 80 118 | const ng = 70 119 | const warn = 60 120 | const ok = 50 121 | const good = 40 122 | const great = 30 123 | 124 | switch (true) { 125 | case r > danger: 126 | return ':skull:' 127 | case r > caution: 128 | return ':fire:' 129 | case r > ng: 130 | return ':jack_o_lantern:' 131 | case r > warn: 132 | return ':space_invader:' 133 | case r > ok: 134 | return ':surfer:' 135 | case r > good: 136 | return ':palm_tree:' 137 | case r > great: 138 | return ':helicopter:' 139 | default: 140 | return ':rocket:' 141 | } 142 | } 143 | 144 | private static buildStatsAttachment(task: Task): Attachment { 145 | const p = task.stats.pulls 146 | const i = task.stats.issues 147 | const a = task.stats.proactive 148 | const hundred = 100 149 | const r = hundred - Math.floor((a / (a + (i - a))) * hundred) 150 | const url = 151 | 'https://github.com/linyows/github-issues-notice/blob/master/docs/reactive-per.md' 152 | const m = 153 | '--% :point_right: Please applying `proactive` label to voluntary issues' 154 | const info = r === hundred ? m : `${GithubIssuesNotice.statsEmoji(r)} ${r}%` 155 | 156 | return { 157 | title: `Stats for ${task.repos.length} repositories`, 158 | color: '#000000', 159 | text: '', 160 | footer: `Stats | <${url}|What is this?>`, 161 | footer_icon: 'https://octodex.github.com/images/surftocat.png', 162 | fields: [ 163 | { title: 'Reactive Per', value: `${info}`, short: false }, 164 | { title: 'Open Issues Total', value: `${i - p}`, short: true }, 165 | { title: 'Open Pulls Total', value: `${p}`, short: true }, 166 | ], 167 | } 168 | } 169 | 170 | public doJob(): void { 171 | const job = this.getJobByMatchedTime() 172 | for (const t of job) { 173 | this.doTask(t) 174 | } 175 | } 176 | 177 | private tidyUpIssues(repo: string, task: Task) { 178 | const oneD = 24 179 | const oneH = 3600 180 | const oneS = 1000 181 | const now = this.config.now 182 | const period = now.getTime() - task.idle.period * oneD * oneH * oneS 183 | const displayRepo = task.repos.length > 1 ? ` (${task.showOrg ? repo : repo.split('/').pop()})` : `` 184 | 185 | try { 186 | const issues = this.github.issues(repo, { 187 | sort: 'asc', 188 | direction: 'updated', 189 | }) 190 | for (const i of issues) { 191 | if (Date.parse(i.updated_at) > period) { 192 | continue 193 | } 194 | this.github.closeIssue(repo, i.number) 195 | task.idle.issueTitles.push( 196 | `<${i.html_url}|${i.title}>${displayRepo} by ${i.user.login}` 197 | ) 198 | } 199 | } catch (e) { 200 | console.error(e) 201 | } 202 | } 203 | 204 | private doTask(task: Task) { 205 | for (const repo of task.repos) { 206 | if (repo === '') { 207 | continue 208 | } 209 | 210 | if (task.idle.period > 0) { 211 | this.tidyUpIssues(repo, task) 212 | } 213 | 214 | if (task.stats.enabled) { 215 | const i = this.github.issues(repo) 216 | const p = this.github.pulls(repo) 217 | const splited = repo.split('/') 218 | const issueTotal = (i.length === 100) ? this.github.issueTotal(splited[0], splited[1]) : i.length 219 | const labels = 'proactive' 220 | const a = this.github.issues(repo, { labels }) 221 | task.stats.issues = task.stats.issues + issueTotal 222 | task.stats.pulls = task.stats.pulls + p.length 223 | task.stats.proactive = task.stats.proactive + a.length 224 | } 225 | const displayRepo = task.repos.length > 1 ? ` (${task.showOrg ? repo : repo.split('/').pop()})` : `` 226 | 227 | for (const l of task.labels) { 228 | try { 229 | const labels = l.name 230 | if (!task.onlyPulls) { 231 | // Issues without Pull Request 232 | const state = task.labelProtection ? 'all' : 'open' 233 | const issues = this.github.issues(repo, { labels, state }) 234 | for (const i of issues) { 235 | if (Github.IsPullRequestIssue(i)) { 236 | continue 237 | } 238 | for (const ll of i.labels) { 239 | if (l.name === ll.name) { 240 | l.color = ll.color 241 | } 242 | } 243 | const warn = 244 | task.labelProtection && i.state === 'closed' 245 | ? ':warning: Closed: ' 246 | : '' 247 | l.issueTitles.push( 248 | `${warn}<${i.html_url}|${i.title}>${displayRepo} by ${ 249 | i.user.login 250 | }${task.relations ? this.buildIssueRelations(i) : ''}` 251 | ) 252 | } 253 | } 254 | // Pull Requests without Draft 255 | const pulls = this.github.pullsWithoutDraftAndOnlySpecifiedLabels( 256 | repo, 257 | { labels } 258 | ) 259 | for (const i of pulls) { 260 | for (const ll of i.labels) { 261 | if (l.name === ll.name) { 262 | l.color = ll.color 263 | } 264 | } 265 | l.issueTitles.push( 266 | `<${i.html_url}|${i.title}>${displayRepo} by ${i.user.login}${ 267 | task.relations ? this.buildPullRelations(i) : '' 268 | }` 269 | ) 270 | } 271 | } catch (e) { 272 | console.error(e) 273 | } 274 | } 275 | } 276 | this.notify(task) 277 | } 278 | 279 | private buildIssueRelations(i: Issue): string { 280 | if (i.assignees.length == 0) { 281 | return '' 282 | } 283 | return ` (Assignees: ${i.assignees 284 | .map(u => { 285 | return u.login 286 | }) 287 | .join(', ')})` 288 | } 289 | 290 | private buildPullRelations(i: PullRequest): string { 291 | if (i.assignees.length == 0 && i.requested_reviewers.length == 0) { 292 | return '' 293 | } 294 | const r = [] 295 | if (i.assignees.length > 0) { 296 | r.push( 297 | 'Assignees: ' + 298 | i.assignees 299 | .map(u => { 300 | return u.login 301 | }) 302 | .join(', ') 303 | ) 304 | } 305 | if (i.requested_reviewers.length > 0) { 306 | r.push( 307 | 'Reviewers: ' + 308 | i.requested_reviewers 309 | .map(u => { 310 | return u.login 311 | }) 312 | .join(', ') 313 | ) 314 | } 315 | return ` (${r.join(', ')})` 316 | } 317 | 318 | private getTsIfDuplicated(channel: string): string { 319 | const msgs = this.slack.conversationsHistory({ channel }) 320 | const msg = msgs[0] 321 | 322 | return msg.username === this.config.slack.username && 323 | msg.text.indexOf(this.config.slack.textEmpty) !== -1 324 | ? msg.ts 325 | : '' 326 | } 327 | 328 | private postMessageOrUpdate(channel: string) { 329 | const ts = this.getTsIfDuplicated(channel) 330 | if (ts === '') { 331 | this.slack.postMessage({ 332 | channel, 333 | username: this.config.slack.username, 334 | icon_emoji: ':octocat:', 335 | link_names: 1, 336 | text: `${this.config.slack.textEmpty}${this.config.slack.textSuffix}`, 337 | }) 338 | } else { 339 | const updatedAt = ` -- :hourglass: last updated at: ${this.config.now}` 340 | this.slack.chatUpdate({ 341 | channel, 342 | text: `${this.config.slack.textEmpty}${this.config.slack.textSuffix}${updatedAt}`, 343 | ts: ts, 344 | }) 345 | } 346 | } 347 | 348 | private notify(task: Task) { 349 | const attachments = [] 350 | const mention = ` ${task.mentions.join(' ')} ` 351 | let empty = true 352 | 353 | if (task.stats.enabled) { 354 | attachments.push(GithubIssuesNotice.buildStatsAttachment(task)) 355 | } 356 | 357 | for (const l of task.labels) { 358 | if (l.issueTitles.length === 0) { 359 | continue 360 | } 361 | const h = l.name.replace(/-/g, ' ') 362 | const m = 363 | l.issueTitles.length > l.threshold 364 | ? `${l.name.length > 0 ? ' -- ' : ''}${l.message}` 365 | : '' 366 | empty = false 367 | attachments.push({ 368 | title: `${ 369 | h.toUpperCase() === h ? h : GithubIssuesNotice.CAPITALIZE(h) 370 | }${m}`, 371 | color: l.color, 372 | text: l.issueTitles.join('\n'), 373 | }) 374 | } 375 | 376 | if (task.idle.issueTitles.length > 0) { 377 | const url = 378 | 'https://github.com/linyows/github-issues-notice/blob/master/docs/idle-period.md' 379 | empty = false 380 | attachments.push({ 381 | title: `Closed with no change over ${task.idle.period}days`, 382 | color: '#CCCCCC', 383 | text: task.idle.issueTitles.join('\n'), 384 | footer: `Idle Period | <${url}|What is this?>`, 385 | footer_icon: 386 | 'https://octodex.github.com/images/Sentrytocat_octodex.jpg', 387 | fields: [ 388 | { 389 | title: 'Closed Total', 390 | value: `${task.idle.issueTitles.length}`, 391 | short: true, 392 | }, 393 | ], 394 | }) 395 | } 396 | 397 | const messages = [] 398 | if (!empty) { 399 | messages.push(this.config.slack.textDefault) 400 | } 401 | 402 | for (const channel of task.channels) { 403 | try { 404 | if (empty) { 405 | this.postMessageOrUpdate(channel) 406 | } else { 407 | this.slack.postMessage({ 408 | channel, 409 | username: this.config.slack.username, 410 | icon_emoji: ':octocat:', 411 | link_names: 1, 412 | text: `${mention}${messages.join(' ')}${ 413 | this.config.slack.textSuffix 414 | }`, 415 | attachments: JSON.stringify(attachments), 416 | }) 417 | } 418 | } catch (e) { 419 | console.error(e) 420 | } 421 | } 422 | } 423 | 424 | private getJobByMatchedTime(): Task[] { 425 | const enabledColumn = 0 426 | const channelColumn = 1 427 | const timeColumn = 2 428 | const mentionColumn = 3 429 | const repoColumn = 4 430 | const labelColumn = 5 431 | const statsColumn = 6 432 | const idleColumn = 7 433 | const relationsColumn = 8 434 | const onlyPullsColumn = 9 435 | const labelProtectionColumn = 10 436 | const showOrgColumn = 11 437 | const noHolidayColumn = 12 438 | 439 | const nameField = 0 440 | const thresholdField = 1 441 | const messageField = 2 442 | 443 | const job: Task[] = [] 444 | const timeLength = 2 445 | const timeFullLength = 4 446 | const minStart = 2 447 | const nowH = `0${this.config.now.getHours()}`.slice(-timeLength) 448 | const nowM = `00${this.config.now.getMinutes()}`.slice(-timeLength) 449 | 450 | for (const task of this.data) { 451 | const repos = GithubIssuesNotice.NORMALIZE(`${task[repoColumn]}`) 452 | if (repos.length === 0) { 453 | continue 454 | } 455 | 456 | const enabled = task[enabledColumn] 457 | if (typeof enabled === 'boolean') { 458 | if (!enabled) { 459 | continue 460 | } 461 | } else { 462 | console.error(`"enabled" columns must be of type boolean: ${enabled}`) 463 | } 464 | 465 | const channels = GithubIssuesNotice.NORMALIZE(`${task[channelColumn]}`) 466 | const times = GithubIssuesNotice.NORMALIZE(`${task[timeColumn]}`) 467 | const mentions = GithubIssuesNotice.NORMALIZE(`${task[mentionColumn]}`) 468 | const labelsWithInfo = GithubIssuesNotice.NORMALIZE( 469 | `${task[labelColumn]}` 470 | ) 471 | 472 | let relations = task[relationsColumn] 473 | if (typeof relations !== 'boolean') { 474 | relations = false 475 | } 476 | let onlyPulls = task[onlyPullsColumn] 477 | if (typeof onlyPulls !== 'boolean') { 478 | onlyPulls = false 479 | } 480 | let labelProtection = task[labelProtectionColumn] 481 | if (typeof labelProtection !== 'boolean') { 482 | labelProtection = false 483 | } 484 | let showOrg = task[showOrgColumn] 485 | if (typeof showOrg !== 'boolean') { 486 | showOrg = false 487 | } 488 | let noHoliday = task[noHolidayColumn] 489 | if (typeof noHoliday !== 'boolean') { 490 | noHoliday = false 491 | } 492 | 493 | let s = task[statsColumn] 494 | if (typeof s !== 'boolean') { 495 | s = false 496 | } 497 | const stats: Stats = { 498 | enabled: s, 499 | issues: 0, 500 | pulls: 0, 501 | proactive: 0, 502 | } 503 | 504 | let idlePeriod = task[idleColumn] 505 | if (typeof idlePeriod !== 'number') { 506 | idlePeriod = 0 507 | } 508 | const idle: Idle = { 509 | period: idlePeriod, 510 | issueTitles: [], 511 | } 512 | 513 | for (const time of times) { 514 | const hour = time.substr(0, timeLength) 515 | const min = 516 | time.length === timeFullLength 517 | ? time.substr(minStart, timeLength) 518 | : '00' 519 | 520 | if (hour === nowH && min === nowM) { 521 | const labels: Label[] = [] 522 | for (const l of labelsWithInfo) { 523 | const arr = `${l}`.split('/') 524 | labels.push({ 525 | name: arr[nameField], 526 | threshold: +arr[thresholdField], 527 | message: arr[messageField], 528 | color: '', 529 | issueTitles: [], 530 | }) 531 | } 532 | 533 | if (this.isHoliday && !noHoliday) { 534 | continue 535 | } 536 | 537 | job.push({ 538 | channels, 539 | times, 540 | mentions, 541 | repos, 542 | labels, 543 | stats, 544 | idle, 545 | relations, 546 | onlyPulls, 547 | labelProtection, 548 | showOrg, 549 | }) 550 | } 551 | } 552 | } 553 | 554 | return job 555 | } 556 | } 557 | 558 | type GithubConfig = { 559 | token: string 560 | apiEndpoint?: string 561 | } 562 | 563 | type SlackConfig = { 564 | token: string 565 | username: string 566 | textSuffix: string 567 | textEmpty: string 568 | textDefault: string 569 | } 570 | 571 | type SpreadsheetsConfig = { 572 | id: string 573 | url: string 574 | } 575 | 576 | type Config = { 577 | now: Date 578 | slack: SlackConfig 579 | github: GithubConfig 580 | spreadsheets: SpreadsheetsConfig 581 | } 582 | 583 | type Task = { 584 | channels: string[] 585 | times: string[] 586 | mentions: string[] 587 | repos: string[] 588 | labels: Label[] 589 | stats: Stats 590 | idle: Idle 591 | relations: boolean 592 | onlyPulls: boolean 593 | labelProtection: boolean 594 | showOrg: boolean 595 | } 596 | 597 | type Idle = { 598 | period: number 599 | issueTitles: string[] 600 | } 601 | 602 | type Stats = { 603 | enabled: boolean 604 | issues: number 605 | pulls: number 606 | proactive: number 607 | } 608 | 609 | type Label = { 610 | name: string 611 | threshold: number 612 | message: string 613 | color: string 614 | issueTitles: string[] 615 | } 616 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub issues notice 3 | * 4 | * Copyright (c) 2018 Tomohisa Oda 5 | */ 6 | 7 | /** 8 | * Github Client 9 | */ 10 | export class Github { 11 | private token: string 12 | private apiEndpoint: string 13 | 14 | constructor(token: string, apiEndpoint?: string) { 15 | this.token = token 16 | if (apiEndpoint) { 17 | this.apiEndpoint = apiEndpoint 18 | } else { 19 | this.apiEndpoint = 'https://api.github.com/' 20 | } 21 | } 22 | 23 | public static IsPullRequestIssue(i: Issue): boolean { 24 | return i.pull_request !== undefined 25 | } 26 | 27 | private static buildOptionUrl(opts: IssueOptions): string { 28 | let u = '' 29 | 30 | // Labels option is not available for pulls API 31 | // https://docs.github.com/en/rest/reference/pulls#list-pull-requests 32 | if (opts.labels) { 33 | u += `&labels=${opts.labels}` 34 | } 35 | if (opts.direction) { 36 | u += `&direction=${opts.direction}` 37 | } 38 | if (opts.sort) { 39 | u += `&sort=${opts.sort}` 40 | } 41 | if (opts.state) { 42 | u += `&state=${opts.state}` 43 | } 44 | 45 | return u 46 | } 47 | 48 | public get headers(): Headers { 49 | return { 50 | Authorization: `token ${this.token}`, 51 | } 52 | } 53 | 54 | public issues(repo: string, opts?: IssueOptions): Issue[] { 55 | const defaultUrl = `${this.apiEndpoint}repos/${repo}/issues?per_page=100` 56 | const optionUrl = opts ? Github.buildOptionUrl(opts) : '' 57 | const res = UrlFetchApp.fetch(`${defaultUrl}${optionUrl}`, { 58 | method: 'get', 59 | headers: this.headers, 60 | }) 61 | 62 | return JSON.parse(res.getContentText()) 63 | } 64 | 65 | public closeIssue(repo: string, num: number): Issue[] { 66 | const res = UrlFetchApp.fetch( 67 | `${this.apiEndpoint}repos/${repo}/issues/${num}`, 68 | { 69 | method: 'patch', 70 | headers: this.headers, 71 | payload: JSON.stringify({ state: 'closed' }), 72 | } 73 | ) 74 | 75 | return JSON.parse(res.getContentText()) 76 | } 77 | 78 | public pulls(repo: string, opts?: IssueOptions): PullRequest[] { 79 | const defaultUrl = `${this.apiEndpoint}repos/${repo}/pulls?per_page=100` 80 | const optionUrl = opts ? Github.buildOptionUrl(opts) : '' 81 | const res = UrlFetchApp.fetch(`${defaultUrl}${optionUrl}`, { 82 | method: 'get', 83 | headers: this.headers, 84 | }) 85 | 86 | return JSON.parse(res.getContentText()) 87 | } 88 | 89 | public pullsWithoutDraftAndOnlySpecifiedLabels( 90 | repo: string, 91 | opts?: IssueOptions 92 | ): PullRequest[] { 93 | return this.pulls(repo, opts) 94 | .map(p => { 95 | if (!p.draft) { 96 | if ( 97 | opts.labels.length === 0 || 98 | this.isLabelsMatching(opts.labels, p) 99 | ) { 100 | return p 101 | } 102 | } 103 | }) 104 | .filter(p => { 105 | return p !== undefined 106 | }) 107 | } 108 | 109 | private isLabelsMatching(l: string, p: PullRequest): boolean { 110 | const labels = l.split(',') 111 | for (const label of labels) { 112 | if (p.labels.find(pl => pl.name === label) === undefined) { 113 | return false 114 | } 115 | } 116 | return true 117 | } 118 | 119 | public issueTotal(owner: string, name: string): number { 120 | const url = `${this.apiEndpoint.replace('v3/', '')}graphql` 121 | 122 | const q = ` 123 | query { 124 | repository(owner:"${owner}", name:"${name}") { 125 | issues(states:OPEN) { 126 | totalCount 127 | } 128 | } 129 | } 130 | ` 131 | 132 | const res = UrlFetchApp.fetch(`${url}`, { 133 | method: 'post', 134 | headers: this.headers, 135 | payload: JSON.stringify({"query": q}), 136 | }) 137 | 138 | const resJson = JSON.parse(res.getContentText()) 139 | return resJson.data.repository.issues.totalCount 140 | } 141 | } 142 | 143 | export type User = { 144 | login: string 145 | id: number 146 | node_id: string 147 | avatar_url: string 148 | gravatar_id: string 149 | url: string 150 | html_url: string 151 | followers_url: string 152 | following_url: string 153 | gists_url: string 154 | starred_url: string 155 | subscriptions_url: string 156 | organizations_url: string 157 | repos_url: string 158 | events_url: string 159 | received_events_url: string 160 | type: string 161 | site_admin: boolean 162 | } 163 | 164 | export type Label = { 165 | id: number 166 | node_id: string 167 | url: string 168 | name: string 169 | color: string 170 | default: boolean 171 | description: string 172 | } 173 | 174 | export type Milestone = { 175 | url: string 176 | html_url: string 177 | labels_url: string 178 | id: number 179 | node_id: string 180 | number: number 181 | title: string 182 | description: string 183 | creator: User 184 | open_issues: number 185 | closed_issues: number 186 | state: string 187 | created_at: string 188 | updated_at: string 189 | due_on: string 190 | closed_at: null | string 191 | } 192 | 193 | export type Team = { 194 | id: number 195 | node_id: string 196 | url: string 197 | html_url: string 198 | name: string 199 | slug: string 200 | description: string 201 | privacy: string 202 | permission: string 203 | members_url: string 204 | repositories_url: string 205 | parent: null 206 | } 207 | 208 | export type Issue = { 209 | url: string 210 | repository_url: string 211 | labels_url: string 212 | comments_url: string 213 | events_url: string 214 | html_url: string 215 | id: number 216 | node_id: string 217 | number: number 218 | title: string 219 | user: User 220 | labels: Label[] 221 | state: string 222 | locked: boolean 223 | assignee: null | User 224 | assignees: User[] 225 | milestone: null | Milestone 226 | comments: number 227 | created_at: string 228 | updated_at: string 229 | author_association: string 230 | pull_request?: { 231 | url: string 232 | html_url: string 233 | diff_url: string 234 | patch_url: string 235 | } 236 | body: string 237 | } 238 | 239 | export type PullRequest = { 240 | url: string 241 | id: number 242 | node_id: string 243 | html_url: string 244 | diff_url: string 245 | patch_url: string 246 | issue_url: string 247 | number: number 248 | state: string 249 | locked: boolean 250 | title: string 251 | user: User 252 | body: string 253 | created_at: string 254 | updated_at: string 255 | closed_at: null | string 256 | merged_at: null | string 257 | merge_commit_sha: string 258 | assignee: null | User 259 | assignees: User[] 260 | requested_reviewers: User[] 261 | requested_teams: Team[] 262 | labels: Label[] 263 | milestone: null | Milestone 264 | draft: boolean 265 | commits_url: string 266 | review_comments_url: string 267 | review_comment_url: string 268 | comments_url: string 269 | statuses_url: string 270 | //head: any 271 | //base: any 272 | //_links: any 273 | author_association: string 274 | } 275 | 276 | type IssueOptions = { 277 | labels?: string 278 | since?: string 279 | sort?: string 280 | direction?: string 281 | state?: string 282 | } 283 | 284 | type Headers = { 285 | Authorization: string 286 | } 287 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub issues notice 3 | * 4 | * Copyright (c) 2018 Tomohisa Oda 5 | */ 6 | 7 | import { GithubIssuesNotice } from './github-issues-notice' 8 | 9 | /** 10 | * Main 11 | */ 12 | const sheetId = PropertiesService.getScriptProperties().getProperty('SHEET_ID') 13 | const sheetUrl = `https://docs.google.com/spreadsheets/d/${sheetId}/edit` 14 | const projectUrl = 'https://github.com/linyows/github-issues-notice' 15 | 16 | const notice = new GithubIssuesNotice({ 17 | now: new Date(), 18 | github: { 19 | token: PropertiesService.getScriptProperties().getProperty( 20 | 'GITHUB_ACCESS_TOKEN' 21 | ), 22 | apiEndpoint: PropertiesService.getScriptProperties().getProperty( 23 | 'GITHUB_API_ENDPOINT' 24 | ), 25 | }, 26 | slack: { 27 | token: PropertiesService.getScriptProperties().getProperty( 28 | 'SLACK_ACCESS_TOKEN' 29 | ), 30 | username: 'GitHub Issues Notice', 31 | textSuffix: ` -- <${sheetUrl}|Settings> | <${projectUrl}|About>`, 32 | textEmpty: 'Wow, No issues to notify! We did it! :tada:', 33 | textDefault: 'Check it out :point_down:', 34 | }, 35 | spreadsheets: { 36 | id: sheetId, 37 | url: sheetUrl, 38 | }, 39 | }) 40 | 41 | /** 42 | * notify notify labeled issues to slack 43 | */ 44 | /* eslint @typescript-eslint/no-unused-vars: 0 */ 45 | function notify() { 46 | notice.doJob() 47 | } 48 | -------------------------------------------------------------------------------- /src/slack.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub issues notice 3 | * 4 | * Copyright (c) 2018 Tomohisa Oda 5 | */ 6 | 7 | /** 8 | * Slack Client 9 | */ 10 | export class Slack { 11 | private token: string 12 | private channels: Channel[] 13 | 14 | constructor(token: string) { 15 | this.token = token 16 | this.channels = [] 17 | } 18 | 19 | public request({ endpoint, body }: Request): T { 20 | const res = UrlFetchApp.fetch(`https://slack.com/api/${endpoint}`, { 21 | method: 'post', 22 | payload: { 23 | token: this.token, 24 | ...body, 25 | }, 26 | }) 27 | console.log(endpoint, body, res.getContentText()) 28 | return JSON.parse(res.getContentText()) 29 | } 30 | 31 | public channelList(c: string): ListConversationResponse { 32 | return this.request({ 33 | endpoint: 'conversations.list', 34 | body: { 35 | exclude_archived: true, 36 | limit: 1000, 37 | cursor: c, 38 | }, 39 | }) 40 | } 41 | 42 | public channelListAll(): Channel[] { 43 | if (this.channels.length > 0) { 44 | return this.channels 45 | } 46 | let channels: Channel[] = [] 47 | let cursor = '' 48 | 49 | /* eslint no-constant-condition: 0 */ 50 | while (true) { 51 | const c = this.channelList(cursor) 52 | channels = [...channels, ...c.channels] 53 | cursor = c.response_metadata.next_cursor 54 | if (cursor === '') { 55 | break 56 | } 57 | } 58 | this.channels = channels 59 | return channels 60 | } 61 | 62 | public joinConversation(channel: string): Channel { 63 | return this.request({ 64 | endpoint: 'conversations.join', 65 | body: { channel }, 66 | }).channel 67 | } 68 | 69 | public joinChannel(channel: string): Channel { 70 | const c = this.channelListAll().find(v => v.name === channel) 71 | return this.joinConversation(c.id) 72 | } 73 | 74 | public postMessage(body: PostMessage): boolean { 75 | this.joinChannel(body.channel) 76 | return this.request({ 77 | endpoint: 'chat.postMessage', 78 | body, 79 | }).ok 80 | } 81 | 82 | public conversationsHistory(body: ConversationsHistory): ConversationsHistoryMessage[] { 83 | const ch = this.joinChannel(body.channel) 84 | body.channel = ch.id 85 | return this.request({ 86 | endpoint: 'conversations.history', 87 | body, 88 | }).messages 89 | } 90 | 91 | public chatUpdate(body: ChatUpdate): boolean { 92 | const ch = this.joinChannel(body.channel) 93 | body.channel = ch.id 94 | return this.request({ 95 | endpoint: 'chat.update', 96 | body, 97 | }).ok 98 | } 99 | } 100 | 101 | type Request = { 102 | endpoint: string 103 | body: { name: string } | PostMessage | ConversationsHistory | ChatUpdate | ListConversation 104 | } 105 | 106 | type PostMessage = { 107 | channel: string 108 | username: string 109 | icon_emoji?: string 110 | link_names: number 111 | text: string 112 | attachments?: string 113 | } 114 | 115 | type PostMessageResponse = { 116 | ok: boolean 117 | channel: string 118 | ts: string 119 | message: { 120 | type: string 121 | subtype: string 122 | text: string 123 | ts: string 124 | username: string 125 | //icon_emoji?: any 126 | bot_id?: string 127 | //attachments?: any 128 | } 129 | } 130 | 131 | type Channel = { 132 | id: string 133 | name: string 134 | members: string[] 135 | } 136 | 137 | type JoinConversationResponse = { 138 | ok: boolean 139 | channel: Channel 140 | } 141 | 142 | type ChatUpdateResponse = { 143 | ok: boolean 144 | channel?: string 145 | ts: string 146 | text: string 147 | message: { 148 | text: string 149 | user: string 150 | } 151 | } 152 | 153 | export type Field = { 154 | title: string 155 | value: string 156 | short: boolean 157 | } 158 | 159 | export type Attachment = { 160 | title: string 161 | title_link?: string 162 | color: string 163 | text: string 164 | fields?: Field[] 165 | footer?: string 166 | footer_icon?: string 167 | } 168 | 169 | export type ConversationsHistory = { 170 | channel: string 171 | cursor?: string 172 | inclusive?: number 173 | latest?: string 174 | limit?: number 175 | oldest?: number 176 | } 177 | 178 | export type ChatUpdate = { 179 | channel: string 180 | text: string 181 | ts: string 182 | as_user?: string 183 | attachments?: string 184 | blocks?: string 185 | link_names?: string 186 | parse?: string 187 | } 188 | 189 | type Reaction = { 190 | name: string 191 | count: number 192 | users: string[] 193 | } 194 | 195 | type ConversationsHistoryAttachment = { 196 | text: string 197 | id: number 198 | fallback: string 199 | } 200 | 201 | export type ConversationsHistoryMessage = { 202 | type: string 203 | ts: string 204 | user?: string 205 | text?: string 206 | is_starred?: boolean 207 | reactions?: Reaction[] 208 | username?: string 209 | bot_id?: string 210 | subtype?: string 211 | attachments?: ConversationsHistoryAttachment[] 212 | } 213 | 214 | type ConversationsHistoryResponse = { 215 | ok: boolean 216 | messages: ConversationsHistoryMessage[] 217 | has_more: boolean 218 | } 219 | 220 | type ListConversation = { 221 | exclude_archived: boolean 222 | limit: number 223 | cursor: string 224 | } 225 | 226 | type ListConversationResponse = { 227 | ok: boolean 228 | channels: Channel[] 229 | response_metadata: { 230 | next_cursor: string 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["ES2018", "dom"], 6 | "types": ["google-apps-script"] 7 | } 8 | } 9 | --------------------------------------------------------------------------------