├── .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 |
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 |
--------------------------------------------------------------------------------