├── .github └── workflows │ └── test.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── docs └── issues-flow.jpg ├── firebase.json ├── functions ├── package-lock.json ├── package.json ├── src │ ├── badge.ts │ ├── bigquery.ts │ ├── config.ts │ ├── cron.ts │ ├── database.ts │ ├── email.ts │ ├── github.ts │ ├── index.ts │ ├── issues.ts │ ├── log.ts │ ├── pubsub.ts │ ├── pullrequests.ts │ ├── report.ts │ ├── scripts │ │ ├── README.md │ │ └── deploy-config.ts │ ├── shared │ │ ├── README.md │ │ └── encoding.ts │ ├── snapshot.ts │ ├── stats.ts │ ├── template.ts │ ├── test │ │ ├── config-test.ts │ │ ├── mock_data │ │ │ ├── comment_created_bot_test.json │ │ │ ├── config.json │ │ │ ├── issue_opened_bot_test_empty.json │ │ │ ├── issue_opened_bot_test_full.json │ │ │ ├── issue_opened_bot_test_partial.json │ │ │ ├── issue_opened_js_sdk_22.json │ │ │ ├── issue_opened_js_sdk_35.json │ │ │ ├── issue_opened_js_sdk_59.json │ │ │ ├── issue_template_empty.md │ │ │ ├── issue_template_empty_with_opts.md │ │ │ ├── issue_template_filled.md │ │ │ ├── issue_template_filled_no_required.md │ │ │ ├── issue_template_partial.md │ │ │ ├── issue_with_opts_bad.md │ │ │ └── old_pull_requests.json │ │ ├── mocks.ts │ │ ├── smoketest.ts │ │ ├── stale-issues-test.ts │ │ ├── test-util.ts │ │ └── util-test.ts │ ├── types.ts │ └── util.ts ├── templates │ ├── repo-weekly.mjml │ └── weekly.mjml ├── tsconfig.json └── tslint.json ├── public ├── assets │ ├── css │ │ ├── bootstrap-responsive.css │ │ ├── bootstrap.css │ │ ├── fonts │ │ │ ├── Fixedsys500c.eot │ │ │ ├── Fixedsys500c.svg │ │ │ ├── Fixedsys500c.ttf │ │ │ └── Fixedsys500c.woff │ │ └── mvp.css │ └── img │ │ ├── loading.gif │ │ └── octocat-firebase.png ├── audit.css ├── audit.html ├── audit.js ├── charts.css ├── charts.html ├── charts.js ├── favicon.ico ├── index.html └── samscore.html └── scripts └── moveconfig.sh /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI Tests 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | env: 8 | CI: true 9 | 10 | jobs: 11 | unit: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: 16 | - 16.x 17 | steps: 18 | - uses: actions/checkout@v1 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | - name: Cache npm 24 | uses: actions/cache@v1 25 | with: 26 | path: ~/.npm 27 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 28 | 29 | - name: Run tests 30 | run: make test-functions 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /**/npm-debug.log 2 | /**/node_modules 3 | functions/dist 4 | functions/config/* 5 | .firebaserc 6 | .firebase 7 | *.log 8 | functions/config/config.json 9 | /**/.runtimeconfig.json 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug test-ts", 11 | "program": "${workspaceFolder}/functions/node_modules/mocha/bin/_mocha", 12 | "runtimeArgs": [ 13 | "--inspect-brk" 14 | ], 15 | "args": [ 16 | "-r", 17 | "ts-node/register", 18 | "functions/src/test/*test.ts" 19 | ], 20 | }, 21 | { 22 | "type": "node", 23 | "request": "launch", 24 | "name": "Launch Program", 25 | "program": "${file}" 26 | }, 27 | { 28 | "type": "node", 29 | "request": "attach", 30 | "name": "Attach to Port", 31 | "address": "localhost", 32 | "port": 5858 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "typescript.tsdk": "./node_modules/typescript/lib" 4 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to become a contributor and submit your own code 2 | 3 | ## Contributor License Agreements 4 | 5 | We'd love to accept your sample apps and patches! Before we can 6 | take them, we have to jump a couple of legal hurdles. 7 | 8 | Please fill out either the individual or corporate Contributor 9 | License Agreement (CLA). 10 | 11 | * If you are an individual writing original source code and you're 12 | sure you own the intellectual property, then you'll need to sign 13 | an [individual CLA](https://cla.developers.google.com). 14 | * If you work for a company that wants to allow you to contribute 15 | your work, then you'll need to sign a 16 | [corporate CLA](https://cla.developers.google.com). 17 | 18 | Follow either of the two links above to access the appropriate CLA and instructions for how to sign and return it. Once we receive it, we'll be able to accept your pull requests. 19 | 20 | ## Contributing A Patch 21 | 22 | 1. Submit an issue describing your proposed change to the repo 23 | in question. 24 | 1. The repo owner will respond to your issue promptly. 25 | 1. If your proposed change is accepted, and you haven't already done 26 | so, sign a Contributor License Agreement (see details above). 27 | 1. Fork the desired repo, develop and test your code changes. 28 | 1. Ensure that your code adheres to the existing style in the sample 29 | to which you are contributing. 30 | 1. Ensure that your code has an appropriate set of unit tests which 31 | all pass. 32 | 1. Submit a pull request. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2015 Google Inc 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | 204 | All code in any directories or sub-directories that end with *.html or 205 | *.css is licensed under the Creative Commons Attribution International 206 | 4.0 License, which full text can be found here: 207 | https://creativecommons.org/licenses/by/4.0/legalcode. 208 | 209 | As an exception to this license, all html or css that is generated by 210 | the software at the direction of the user is copyright the user. The 211 | user has full ownership and control over such content, including 212 | whether and how they wish to license it. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT ?= ossbot-test 2 | 3 | check-config: 4 | echo "Project is $(PROJECT)" 5 | ./scripts/moveconfig.sh 6 | 7 | build-functions: functions/src/*.ts functions/src/test/*.ts 8 | cd functions \ 9 | && npm install \ 10 | && npm run build \ 11 | && cd - 12 | 13 | test-functions: build-functions 14 | cd functions \ 15 | && npm run test-ts \ 16 | && cd - 17 | 18 | deploy-hosting: 19 | cd functions \ 20 | && npx firebase --project=$(PROJECT) deploy --only hosting \ 21 | && cd - 22 | 23 | deploy-functions-config: 24 | cd functions \ 25 | && npx ts-node src/scripts/deploy-config.ts config/config.json $(PROJECT) \ 26 | && cd - 27 | 28 | deploy-functions: test-functions 29 | cd functions \ 30 | && npx firebase --project=$(PROJECT) deploy --only functions \ 31 | && cd - 32 | 33 | deploy: check-config deploy-functions-config deploy-functions deploy-hosting 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firebase OSS Robot 2 | 3 | ## Introduction 4 | 5 | A robot to make open source easy for Firebasers. The robot has multiple 6 | distinct features which can be enabled individually. 7 | 8 | ### Custom Emails 9 | 10 | In shared repos you may only want to receive email notifications for a subset 11 | of the events that are fired. The bot allows you to specify a specific email 12 | address for each label and then you will only receive emails for issues/PRs 13 | with that label: 14 | 15 | ```javascript 16 | // In this example, the engineers on the 'foo' team will get emails 17 | // about issues labeled 'foo' and the engineers on the 'bar' team 18 | // will get emails about issues labeled 'bar' 19 | "my-repo": { 20 | "labels": { 21 | "foo": { 22 | "email":"foo-engineers@googlegroups.com" 23 | }, 24 | "bar":{ 25 | "email":"bar-engineers@googlegroups.com" 26 | } 27 | } 28 | } 29 | ``` 30 | 31 | ### Issue Labeling 32 | 33 | The bot can automatically label incoming issues based on regex matching: 34 | 35 | ```javascript 36 | "my-repo": { 37 | "labels": { 38 | "foo": { 39 | "regex":"Component:[\\s]+?[Ff]oo", 40 | "email": // ... 41 | }, 42 | } 43 | } 44 | ``` 45 | 46 | In the example above, an issue that contains "Component: foo" or "Component: Foo" or 47 | any variation would automatically get the "foo" label. When combined with the custom 48 | emails configuration above, this means the bot can auto-label and then auto-notify the Foo 49 | engineering team about new issues or issue events. 50 | 51 | If bot can't find a label for an issue, it will add the label `needs-triage` and then 52 | add a comment explaining to the developer that a human will come to help. 53 | 54 | ### Template Matching 55 | 56 | If you use issue templates on your GitHub repo, the bot can enforce that new issues 57 | match the template. 58 | 59 | Issue templates are considered in "sections" where each section is a third-level header, 60 | denoted in markdown by `###`. For example: 61 | 62 | ```md 63 | ### Are you in the right place? 64 | 65 | Are you using FooBar Engineering products? If not, go away! 66 | 67 | ### [REQUIRED] Describe your environment 68 | 69 | * FooBar SDK Version: ____ 70 | * Operating System: ____ 71 | 72 | ### [REQUIRED] Describe your problem 73 | 74 | * What are the steps to reproduce? 75 | * What do the logs say? 76 | ``` 77 | 78 | The bot does checks at two levels: 79 | 80 | * **Template Integrity** - did the developer filling out the template leave all the 81 | headers in place? If not, we can't easily parse the content. 82 | * **Required Sections** - for any section marked with `[REQUIRED]` did the developer 83 | make at least _some_ change to the content of the section? 84 | 85 | If the user violates either of the above checks, the bot will leave a comment 86 | telling them what they may have done wrong. 87 | 88 | If your issue template is located at `ISSUE_TEMPLATE.md` then the bot will 89 | know where to find it without any configuration. If you want to specify a different 90 | location for your template, add it to the config: 91 | 92 | ```javascript 93 | "my-repo": { 94 | // ... 95 | "templates":{ 96 | "issue":".github/ISSUE_TEMPLATE.md" 97 | } 98 | } 99 | ``` 100 | 101 | If your repo has multiple templates (like one for bugs and one for features) you must 102 | add a markdown comment to the template to let the robot know how to locate it: 103 | 104 | ```md 105 | 109 | 110 | ### My first section 111 | ... 112 | ``` 113 | 114 | #### Template validation 115 | 116 | You can configure how the required sections of an issue template are validated and whether/which label is added to the issue in case of a violation: 117 | 118 | ``` 119 | "validation": { 120 | "templates": { 121 | ".github/ISSUE_TEMPLATE/bug.md": { 122 | "validation_failed_label": "need more info", 123 | "required_section_validation": "relaxed" 124 | }, 125 | ... 126 | } 127 | } 128 | ``` 129 | There are three different levels of required section validation: 130 | 131 | - `strict`: Any empty required section is a violation 132 | - `relaxed`: As long as one required section is filled, it's ok 133 | - `none`: No validation of required sections 134 | 135 | ### Stale Issue Cleanup 136 | 137 | The bot can help you clean up issues that have gone "stale", meaning that 138 | more information is needed but has not been provided. 139 | 140 | The flow is described in this chart: 141 | 142 | 143 | 144 | The names of the labels and the length of certain time periods can 145 | be configured in your repo config: 146 | 147 | ```javascript 148 | "my-repo": { 149 | // ... 150 | "cleanup": { 151 | "issue": { 152 | // [REQUIRED] Label manually applied for issues that need more information. 153 | "label_needs_info": "Needs Info", 154 | 155 | // [OPTIONAL] Label to be applied for issues that need Googler attention. 156 | // If unspecified, this state will not have a visible label. 157 | "label_needs_attention": "Needs Attention", 158 | 159 | // [REQUIRED] Label to be applied for issues that don't have recent activity 160 | "label_stale": "Stale", 161 | 162 | // [OPTIONAL] Label(s) that can be applied to issues to exempt them from the stale 163 | // checker. 164 | "ignore_labels": ["Feature Request", "Internal"], 165 | 166 | // [REQUIRED] Time, in days, to stay in the needs_info state before becoming stale 167 | // stale. These issues transition from label_needs_info to label_stale. 168 | "needs_info_days": 7, 169 | 170 | // [REQUIRED] Time, in days, to close an issue after the warning message is posted 171 | // if there is no recent activity. These issues will transition from 172 | // label_stale to closed. 173 | "stale_days": 3, 174 | 175 | // [OPTIONAL] Time, in days, to lock an issue after it has been closed. 176 | "lock_days": 60 177 | } 178 | } 179 | } 180 | ``` 181 | 182 | ### Old Issue Locking 183 | 184 | The bot can lock issues that have been closed for a while to prevent new discussion. 185 | To enable this, add the `lock_days` key to your "stale issue config" (see above). 186 | 187 | ### Repo Reports 188 | 189 | The bot can send you a weekly report of how healthy your repo is. To receive this 190 | report, just add a reporting config: 191 | 192 | ```javascript 193 | "my-repo": { 194 | // ... 195 | "reports": { 196 | "email": "foo-engineering@googlegroups.com" 197 | } 198 | } 199 | ``` 200 | 201 | You will then receive a weekly email with: 202 | 203 | * Change in open issues, stars, and forks. 204 | * List of which issues were opened in the past week. 205 | * List of which issues were closed in the past week. 206 | 207 | ## Monitoring 208 | 209 | The bot keeps a log of visible actions it takes on GitHub, which you can view on 210 | a per-repo basis: 211 | 212 | https://ossbot.computer/audit.html?org=ORG_NAME&repo=REPO_NAME 213 | 214 | ## Deployment 215 | 216 | ### Deploy Functions, Configuration, and Cron Jobs 217 | 218 | After completing all configuration below, run `make deploy`. 219 | 220 | ### Customize Configuration 221 | 222 | Edit the `functions/config/config.json` file to have configuration in the following form: 223 | 224 | ```javascript 225 | { 226 | "": { 227 | "": { 228 | // .. REPO CONFIGURATION ... 229 | } 230 | } 231 | } 232 | ``` 233 | 234 | See the feature sections above for the different properties that can be added to the 235 | repo configuration. 236 | 237 | ### Configure Secrets 238 | 239 | #### GitHub: 240 | 241 | Go to the [github token page](https://github.com/settings/tokens/new) and 242 | create a new personal access token for the bot account with the following 243 | permissions: 244 | 245 | * `public_repo` - access public repositories. 246 | * `admin:repo_hook` - read and write repository hooks. 247 | 248 | ``` 249 | firebase functions:config:set github.token="" 250 | ``` 251 | 252 | #### Mailgun: 253 | 254 | ``` 255 | firebase functions:config:set mailgun.key="" 256 | firebase functions:config:set mailgun.domain="" 257 | ``` 258 | 259 | ### Email 260 | 261 | In order to use the `SendWeeklyEmail` endpoint you need to configure the 262 | recipient to some public group. 263 | 264 | ``` 265 | firebase functions:config:set email.recipient="" 266 | ``` 267 | 268 | #### Configure GitHub Webhook 269 | 270 | In GitHub add a webhook with the following configuration: 271 | 272 | * Payload URL - your cloud functions HTTP URL. Which should be 273 | `https://.cloudfunctions.net/githubWebhook`. 274 | * Content type - application/json 275 | * Secret - N/A 276 | * Select individual events: 277 | * Issues 278 | * Pull request 279 | * Issue comment 280 | 281 | ## Development 282 | 283 | ### Test 284 | 285 | To run basic tests, use `make test-functions` which runs the mocha tests the `functions` 286 | directory. These tests are mostly a sanity check, used to verify basic behavior without 287 | needing an end-to-end deploy. 288 | 289 | ### Formatting 290 | 291 | Code is formatted using `prettier` so no bikeshedding allowed. Run 292 | `npm run build` in the `functions` directory before committing. 293 | 294 | ## Build Status 295 | 296 | [![Actions Status][gh-actions-badge]][gh-actions] 297 | 298 | [gh-actions]: https://github.com/firebase/oss-bot/actions 299 | [gh-actions-badge]: https://github.com/firebase/oss-bot/workflows/CI%20Tests/badge.svg 300 | -------------------------------------------------------------------------------- /docs/issues-flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/oss-bot/f51982e401afff17080bd2eced645c76dbdc30dd/docs/issues-flow.jpg -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "public", 4 | "rewrites": [ 5 | { 6 | "source": "/issuestats", 7 | "function": "RepoIssueStatistics" 8 | }, 9 | { 10 | "source": "/samscorebadge", 11 | "function": "SamScoreBadge" 12 | } 13 | ] 14 | }, 15 | "emulators": { 16 | "functions": { 17 | "port": 5001 18 | }, 19 | "database": { 20 | "port": 9000 21 | }, 22 | "hosting": { 23 | "port": 5000 24 | }, 25 | "pubsub": { 26 | "port": 8085 27 | }, 28 | "ui": { 29 | "enabled": true 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oss-bot", 3 | "description": "GitHub monitoring robot.", 4 | "engines": { 5 | "node": "16" 6 | }, 7 | "main": "dist/index.js", 8 | "dependencies": { 9 | "@firebase/app": "^0.5.4", 10 | "@google-cloud/bigquery": "^4.7.0", 11 | "@google-cloud/logging": "^7.1.0", 12 | "@google-cloud/pubsub": "^1.5.0", 13 | "@octokit/plugin-retry": "^2.2.0", 14 | "@octokit/rest": "^18.0.0", 15 | "chalk": "^2.1.0", 16 | "date-fns": "^1.28.5", 17 | "diff": "^3.2.0", 18 | "firebase-admin": "^9.2.0", 19 | "firebase-functions": "^3.20.1", 20 | "mailgun-js": "^0.22.0", 21 | "marked": "^0.7.0", 22 | "moment": "^2.29.4", 23 | "mustache": "^2.3.0", 24 | "node-fetch": "^3.1.1" 25 | }, 26 | "devDependencies": { 27 | "@types/chai": "^4.0.1", 28 | "@types/chalk": "^0.4.31", 29 | "@types/diff": "^3.2.0", 30 | "@types/duplexify": "^3.6.0", 31 | "@types/marked": "0.0.28", 32 | "@types/mocha": "^2.2.47", 33 | "@types/mustache": "^0.8.29", 34 | "@types/node": "^12.7.5", 35 | "@types/node-fetch": "^2.1.2", 36 | "@types/request": "^2.48.4", 37 | "@types/simple-mock": "^0.8.1", 38 | "chai": "^4.1.2", 39 | "firebase-tools": "^11.22.0", 40 | "mjml": "^4.6.3", 41 | "mocha": "^10.2.0", 42 | "prettier": "^1.15.3", 43 | "request": "^2.88.0", 44 | "simple-mock": "^0.8.0", 45 | "ts-lint": "^4.5.1", 46 | "ts-node": "^8.4.1", 47 | "ts-server": "0.0.15", 48 | "tslint": "^5.20.0", 49 | "typescript": "^3.6.3", 50 | "typings": "^2.1.1" 51 | }, 52 | "scripts": { 53 | "test": "mocha", 54 | "debug": "mocha --debug-brk", 55 | "fmt": "prettier --write src/**/*.ts src/**.ts", 56 | "format": "npm run fmt", 57 | "build": "npm run fmt && npm run build-ts && npm run tslint && npm run build-mjml", 58 | "build:watch": "tsc --watch", 59 | "dev": "tsc --watch", 60 | "build-ts": "tsc", 61 | "build-mjml": "mjml templates/weekly.mjml --output dist/weekly.mustache && mjml templates/repo-weekly.mjml --output dist/repo-weekly.mustache", 62 | "test-ts": "mocha -r ts-node/register src/test/*test.ts", 63 | "watch-ts": "tsc -w", 64 | "tslint": "tslint -c tslint.json --project tsconfig.json", 65 | "task:get-organization-snapshot": "npm run-script task:run-promise './dist/snapshot' 'GetOrganizationSnapshot' 'firebase'", 66 | "task:get-weekly-report": "npm run-script task:run-promise './dist/report' 'GetWeeklyReport' 'firebase'", 67 | "task:get-weekly-email": "npm run-script task:run-promise './dist/report' 'GetWeeklyEmail' 'firebase'", 68 | "task:run-promise": "node -e \"require(process.argv[1])[process.argv[2]](process.argv[3]).then(console.log).catch(console.warn)\"" 69 | }, 70 | "private": true 71 | } 72 | -------------------------------------------------------------------------------- /functions/src/badge.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "firebase-functions"; 2 | import { Octokit } from "@octokit/rest"; 3 | import * as util from "./util"; 4 | 5 | export const SamScoreBadge = functions.https.onRequest(async (req, res) => { 6 | const org = req.query["org"] as string; 7 | const repo = req.query["repo"] as string; 8 | 9 | if (!org || !repo) { 10 | res.status(400).send("Must include both 'org' and 'repo' query params"); 11 | return; 12 | } 13 | 14 | const api = new Octokit(); 15 | 16 | const repoResp = await api.repos.get({ owner: org, repo: repo }); 17 | const openIssues = repoResp.data.open_issues_count; 18 | 19 | const searchResp = await api.search.issuesAndPullRequests({ 20 | q: `repo:${org}/${repo} type:issue state:closed` 21 | }); 22 | const closedIssues = searchResp.data.total_count; 23 | 24 | const samScore = util.samScore(openIssues, closedIssues); 25 | const color = 26 | samScore < 0.5 27 | ? "brightgreen" 28 | : samScore < 1.0 29 | ? "green" 30 | : samScore < 2.0 31 | ? "yellow" 32 | : "red"; 33 | 34 | // Construct the Shield URL 35 | const shieldURL = `https://img.shields.io/static/v1?label=SAM%20Score&message=${samScore}&color=${color}`; 36 | 37 | // Fetch the shield 38 | const fetch = await import("node-fetch"); // tslint:disable-line 39 | const fetchRes = await fetch.default(shieldURL); 40 | 41 | // Set key headers 42 | res.set("Content-Type", "image/svg+xml;charset=utf-8"); 43 | res.set("Cache-Control", "public, max-age=43200, s-maxage=42300"); // 12h 44 | 45 | // Forward the body 46 | const text = await fetchRes.text(); 47 | res.status(fetchRes.status).send(text); 48 | }); 49 | -------------------------------------------------------------------------------- /functions/src/bigquery.ts: -------------------------------------------------------------------------------- 1 | import { BigQuery, TableSchema } from "@google-cloud/bigquery"; 2 | import { snapshot, bigquery } from "./types"; 3 | import * as log from "./log"; 4 | 5 | const ISSUES_DATASET = "github_issues"; 6 | 7 | const ISSUES_SCHEMA: TableSchema = { 8 | fields: [ 9 | { name: "repo", type: "STRING", mode: "NULLABLE" }, 10 | { name: "number", type: "INTEGER", mode: "NULLABLE" }, 11 | { name: "title", type: "STRING", mode: "NULLABLE" }, 12 | { name: "state", type: "STRING", mode: "NULLABLE" }, 13 | { name: "pull_request", type: "BOOLEAN", mode: "NULLABLE" }, 14 | { name: "locked", type: "BOOLEAN", mode: "NULLABLE" }, 15 | { name: "comments", type: "INTEGER", mode: "NULLABLE" }, 16 | { 17 | name: "user", 18 | type: "RECORD", 19 | mode: "NULLABLE", 20 | fields: [{ name: "login", type: "STRING", mode: "NULLABLE" }] 21 | }, 22 | { 23 | name: "assignee", 24 | type: "RECORD", 25 | mode: "NULLABLE", 26 | fields: [{ name: "login", type: "STRING", mode: "NULLABLE" }] 27 | }, 28 | { name: "labels", type: "STRING", mode: "REPEATED" }, 29 | { name: "created_at", type: "STRING", mode: "NULLABLE" }, 30 | { name: "updated_at", type: "STRING", mode: "NULLABLE" }, 31 | { name: "ingested", type: "TIMESTAMP", mode: "NULLABLE" } 32 | ] 33 | }; 34 | 35 | const EVENTS_DATASET = "github_events"; 36 | 37 | // For common payloads see: 38 | // https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#webhook-payload-object-common-properties 39 | const EVENTS_SCHEMA: TableSchema = { 40 | fields: [ 41 | // The event type: issue, issue_comment, etc 42 | { name: "type", type: "STRING", mode: "NULLABLE" }, 43 | 44 | // The event action: created, opened, labeled, etc 45 | { name: "action", type: "STRING", mode: "NULLABLE" }, 46 | 47 | // The user that triggered the event 48 | { 49 | name: "sender", 50 | type: "RECORD", 51 | mode: "NULLABLE", 52 | fields: [ 53 | { name: "id", type: "INTEGER", mode: "NULLABLE" }, 54 | { name: "login", type: "STRING", mode: "NULLABLE" } 55 | ] 56 | }, 57 | 58 | // The repository where the event ocurred 59 | { 60 | name: "repository", 61 | type: "RECORD", 62 | mode: "NULLABLE", 63 | fields: [ 64 | { name: "id", type: "INTEGER", mode: "NULLABLE" }, 65 | { name: "full_name", type: "STRING", mode: "NULLABLE" } 66 | ] 67 | }, 68 | 69 | // The full JSON payload of the event 70 | { name: "payload", type: "STRING", mode: "NULLABLE" }, 71 | 72 | // The time the event was captured 73 | { name: "ingested", type: "TIMESTAMP", mode: "NULLABLE" } 74 | ] 75 | }; 76 | 77 | const bqClient = new BigQuery({ 78 | projectId: process.env.GCLOUD_PROJECT 79 | }); 80 | 81 | export async function listEventsTables(): Promise { 82 | const [tables] = await bqClient.dataset(EVENTS_DATASET).getTables(); 83 | return tables.map(x => x.id || ""); 84 | } 85 | 86 | export async function createEventsTable(org: string): Promise { 87 | await bqClient.dataset(EVENTS_DATASET).createTable(org, { 88 | schema: EVENTS_SCHEMA 89 | }); 90 | } 91 | 92 | export async function listIssuesTables(): Promise { 93 | const [tables] = await bqClient.dataset(ISSUES_DATASET).getTables(); 94 | return tables.map(x => x.id || ""); 95 | } 96 | 97 | export async function createIssuesTable(org: string): Promise { 98 | await bqClient.dataset(ISSUES_DATASET).createTable(org, { 99 | schema: ISSUES_SCHEMA 100 | }); 101 | 102 | await bqClient.dataset(ISSUES_DATASET).createTable(`${org}_view`, { 103 | view: { 104 | query: getIssuesViewSql(org), 105 | useLegacySql: false 106 | } 107 | }); 108 | } 109 | 110 | export async function insertIssues( 111 | org: string, 112 | repo: string, 113 | issueData: snapshot.Issue[], 114 | ingested: Date 115 | ) { 116 | const issues = Object.values(issueData).map( 117 | i => new bigquery.Issue(i, repo, ingested) 118 | ); 119 | 120 | if (issues.length === 0) { 121 | log.debug(`No issues to insert into BigQuery`); 122 | return; 123 | } 124 | 125 | log.debug(`Inserting ${issues.length} issues into BigQuery`); 126 | const insertRes = await bqClient 127 | .dataset(ISSUES_DATASET) 128 | .table(org) 129 | .insert(issues); 130 | log.debug(`Inserted: ${JSON.stringify(insertRes[0])}`); 131 | } 132 | 133 | export async function insertEvent(org: string, event: bigquery.Event) { 134 | log.debug( 135 | `Inserting event ${event.type}.${event.action} in org ${org} into BigQuery` 136 | ); 137 | log.debug("event", event); 138 | const insertRes = await bqClient 139 | .dataset(EVENTS_DATASET) 140 | .table(org) 141 | .insert([event]); 142 | log.debug(`Inserted: ${JSON.stringify(insertRes[0])}`); 143 | } 144 | 145 | function getIssuesViewSql(org: string) { 146 | return `SELECT 147 | issues.* 148 | FROM ( 149 | SELECT 150 | * 151 | FROM 152 | github_issues.${org}) AS issues 153 | JOIN ( 154 | SELECT 155 | repo, 156 | MAX(ingested) AS timestamp 157 | FROM 158 | github_issues.${org} 159 | GROUP BY 160 | repo) AS max_ingestion 161 | ON 162 | issues.repo = max_ingestion.repo 163 | AND issues.ingested = max_ingestion.timestamp`; 164 | } 165 | -------------------------------------------------------------------------------- /functions/src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as functions from "firebase-functions"; 17 | 18 | import * as log from "./log"; 19 | import * as encoding from "./shared/encoding"; 20 | import * as types from "./types"; 21 | 22 | interface Repo { 23 | org: string; 24 | name: string; 25 | } 26 | 27 | interface RelevantLabelResponse { 28 | label?: string; 29 | new?: boolean; 30 | matchedRegex?: string; 31 | error?: string; 32 | } 33 | 34 | export function getFunctionsConfig(key: string): any { 35 | // Allow the environment to overrride anything else 36 | const envKey = encoding.toEnvKey(key); 37 | const envOverride = process.env[envKey]; 38 | if (envOverride) { 39 | log.debug(`Config override: ${key}=${envKey}=${envOverride}`); 40 | return envOverride; 41 | } 42 | 43 | const encodedKey = encoding.encodeKey(key); 44 | const parts = encodedKey.split("."); 45 | 46 | let val = functions.config(); 47 | for (const part of parts) { 48 | if (val === undefined) { 49 | return undefined; 50 | } 51 | 52 | val = val[part]; 53 | } 54 | 55 | return encoding.deepDecodeObject(val); 56 | } 57 | 58 | /** 59 | * Create a new config handler. 60 | * @param {object} config JSON config data. 61 | */ 62 | export class BotConfig { 63 | config: types.Config; 64 | 65 | static getDefault() { 66 | return new BotConfig(getFunctionsConfig("runtime.config")); 67 | } 68 | 69 | constructor(config: types.Config) { 70 | this.config = config; 71 | } 72 | 73 | /** 74 | * Get a list of all configured repos. 75 | */ 76 | getAllRepos() { 77 | const repos: Repo[] = []; 78 | 79 | for (const org in this.config) { 80 | for (const name in this.config[org]) { 81 | repos.push({ 82 | org: org, 83 | name: name 84 | }); 85 | } 86 | } 87 | 88 | return repos; 89 | } 90 | 91 | /** 92 | * Determine the features that are enabled for a repo. 93 | */ 94 | getRepoFeatures(org: string, name: string): types.FeatureConfig { 95 | const features: types.FeatureConfig = { 96 | custom_emails: false, 97 | issue_labels: false, 98 | issue_cleanup: false, 99 | repo_reports: false 100 | }; 101 | 102 | const config = this.getRepoConfig(org, name); 103 | if (!config) { 104 | return features; 105 | } 106 | 107 | // Emails are enabled if any label has an 'email' entry. 108 | // Labels are enabled if any label has a 'regex' entry. 109 | if (config.labels) { 110 | const labels = config.labels; 111 | for (const label in labels) { 112 | const labelConfig = config.labels[label]; 113 | if (labelConfig.email) { 114 | features.custom_emails = true; 115 | } 116 | 117 | if (labelConfig.regex) { 118 | features.issue_labels = true; 119 | } 120 | } 121 | } 122 | 123 | // Issue cleanup is enabled if there is a configuration for it. 124 | if (config.cleanup && config.cleanup.issue) { 125 | features.issue_cleanup = true; 126 | } 127 | 128 | // Repo reports are enabled if an email is specified. 129 | if (config.reports && config.reports.email) { 130 | features.repo_reports = true; 131 | } 132 | 133 | return features; 134 | } 135 | 136 | /** 137 | * Get the config object for a specific repo. 138 | */ 139 | getRepoConfig(org: string, name: string): types.RepoConfig | undefined { 140 | const cleanOrg = encoding.sanitizeKey(org); 141 | const cleanName = encoding.sanitizeKey(name); 142 | if (this.config[cleanOrg] && this.config[cleanOrg][cleanName]) { 143 | return this.config[cleanOrg][cleanName]; 144 | } 145 | } 146 | 147 | /** 148 | * Get the config object for a single label of a specific repo. 149 | */ 150 | getRepoLabelConfig( 151 | org: string, 152 | name: string, 153 | label: string 154 | ): types.LabelConfig | undefined { 155 | const repoConfig = this.getRepoConfig(org, name); 156 | 157 | const cleanLabel = encoding.sanitizeKey(label); 158 | if (repoConfig && repoConfig.labels && repoConfig.labels[cleanLabel]) { 159 | return repoConfig.labels[cleanLabel]; 160 | } 161 | } 162 | 163 | /** 164 | * Get the templates configuration for a specific repo. 165 | */ 166 | getRepoTemplateConfig( 167 | org: string, 168 | name: string, 169 | template: string 170 | ): string | undefined { 171 | const repoConfig = this.getRepoConfig(org, name); 172 | 173 | const cleanTemplate = encoding.sanitizeKey(template); 174 | if ( 175 | repoConfig && 176 | repoConfig.templates && 177 | repoConfig.templates[cleanTemplate] 178 | ) { 179 | return repoConfig.templates[cleanTemplate]; 180 | } 181 | } 182 | 183 | /** 184 | * Get the config for weekly repo report emails. 185 | */ 186 | getRepoReportingConfig( 187 | org: string, 188 | name: string 189 | ): types.ReportConfig | undefined { 190 | const repoConfig = this.getRepoConfig(org, name); 191 | 192 | if (repoConfig && repoConfig.reports) { 193 | return repoConfig.reports; 194 | } 195 | } 196 | 197 | /** 198 | * Get the config for cleaning up stale issues on a repo. 199 | */ 200 | getRepoCleanupConfig( 201 | org: string, 202 | name: string 203 | ): types.CleanupConfig | undefined { 204 | const repoConfig = this.getRepoConfig(org, name); 205 | if (repoConfig && repoConfig.cleanup) { 206 | return repoConfig.cleanup; 207 | } 208 | } 209 | 210 | /** 211 | * Get the config for validating issues on a repo. 212 | */ 213 | getRepoTemplateValidationConfig( 214 | org: string, 215 | name: string, 216 | templatePath: string 217 | ): types.TemplateValidationConfig | undefined { 218 | const repoConfig = this.getRepoConfig(org, name); 219 | if (repoConfig && repoConfig.validation) { 220 | return repoConfig.validation.templates[templatePath]; 221 | } 222 | } 223 | 224 | /** 225 | * Pick the first label from an issue that has a related configuration. 226 | */ 227 | getRelevantLabel( 228 | org: string, 229 | name: string, 230 | issue: types.internal.IssueOrPullRequest 231 | ): RelevantLabelResponse { 232 | // Make sure we at least have configuration for this repository 233 | const repo_mapping = this.getRepoConfig(org, name); 234 | if (!repo_mapping) { 235 | log.debug(`No config for ${org}/${name} in: `, this.config); 236 | 237 | return { 238 | error: "No config found" 239 | }; 240 | } 241 | 242 | // Get the labeling rules for this repo 243 | log.debug("Found config: ", repo_mapping); 244 | 245 | // Iterate through issue labels, see if one of the existing ones works 246 | // TODO(samstern): Deal with needs_triage separately 247 | const issueLabelNames: string[] = issue.labels.map(label => { 248 | return label.name; 249 | }); 250 | 251 | for (const key of issueLabelNames) { 252 | const label_mapping = this.getRepoLabelConfig(org, name, key); 253 | if (label_mapping) { 254 | return { 255 | label: key, 256 | new: false 257 | }; 258 | } 259 | } 260 | 261 | // Try to match the issue body to a new label 262 | log.debug("No existing relevant label, trying regex"); 263 | log.debug("Issue body: " + issue.body); 264 | 265 | for (const label in repo_mapping.labels) { 266 | const labelInfo = repo_mapping.labels[label]; 267 | 268 | // Some labels do not have a regex 269 | if (!labelInfo.regex) { 270 | log.debug(`Label ${label} does not have a regex.`); 271 | continue; 272 | } 273 | 274 | const regex = new RegExp(labelInfo.regex); 275 | 276 | // If the regex matches, choose the label and email then break out 277 | if (regex.test(issue.body)) { 278 | log.debug("Matched label: " + label, JSON.stringify(labelInfo)); 279 | return { 280 | label, 281 | new: true, 282 | matchedRegex: regex.source 283 | }; 284 | } else { 285 | log.debug(`Did not match regex for ${label}: ${labelInfo.regex}`); 286 | } 287 | } 288 | 289 | // Return undefined if none found 290 | log.debug("No relevant label found"); 291 | return { 292 | label: undefined 293 | }; 294 | } 295 | 296 | /** 297 | * Get the default template path for a type. 298 | */ 299 | static getDefaultTemplateConfig(template: string) { 300 | if (template === "issue") { 301 | return "ISSUE_TEMPLATE.md"; 302 | } else { 303 | return "PULL_REQUEST_TEMPLATE.md"; 304 | } 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /functions/src/cron.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { BotConfig } from "./config"; 18 | import * as github from "./github"; 19 | import * as log from "./log"; 20 | import * as util from "./util"; 21 | import * as types from "./types"; 22 | 23 | // Metadata the bot can leave in comments to mark its actions 24 | const EVT_MARK_STALE = "event: mark-stale"; 25 | const EVT_CLOSE_STALE = "event: close-stale"; 26 | 27 | const DAY_MS = 24 * 60 * 60 * 1000; 28 | 29 | /** 30 | * Create a new handler for cron-style tasks. 31 | * @param {GitHubClient} gh_client client for accessing GitHub. 32 | */ 33 | export class CronHandler { 34 | gh_client: github.GitHubClient; 35 | config: BotConfig; 36 | 37 | constructor(gh_client: github.GitHubClient, config: BotConfig) { 38 | this.gh_client = gh_client; 39 | this.config = config; 40 | } 41 | 42 | async processIssues( 43 | org: string, 44 | name: string, 45 | issueConfig: types.IssueCleanupConfig 46 | ): Promise { 47 | log.debug(`processIssues(${org}/${name})`); 48 | 49 | const actions: types.Action[] = []; 50 | 51 | const lockActions = await this.handleClosedIssues(org, name, issueConfig); 52 | actions.push(...lockActions); 53 | 54 | const now = new Date(); 55 | if (!util.isWorkday(now)) { 56 | console.log( 57 | `Not processing stale issues on a weekend: ${now.toDateString()} @ ${now.toLocaleTimeString()} (${ 58 | Intl.DateTimeFormat().resolvedOptions().timeZone 59 | })` 60 | ); 61 | return actions; 62 | } 63 | 64 | const staleActions = await this.handleStaleIssues(org, name, issueConfig); 65 | actions.push(...staleActions); 66 | 67 | return actions; 68 | } 69 | 70 | async handleClosedIssues( 71 | org: string, 72 | name: string, 73 | issueConfig: types.IssueCleanupConfig 74 | ): Promise { 75 | if (!issueConfig.lock_days) { 76 | log.debug(`No issue locking config for ${org}/${name}`); 77 | return []; 78 | } 79 | 80 | const actions: types.Action[] = []; 81 | const issues = await this.gh_client.getIssuesForRepo(org, name, "closed"); 82 | 83 | for (const issue of issues) { 84 | const issueActions = await this.handleClosedIssue( 85 | org, 86 | name, 87 | issue, 88 | issueConfig 89 | ); 90 | actions.push(...issueActions); 91 | 92 | if (actions.length >= 100) { 93 | console.warn( 94 | `Found >100 (${actions.length} issues to perform when checking closed issues for ${org}/${name}, will do the rest tomorrow.` 95 | ); 96 | return actions; 97 | } 98 | } 99 | 100 | return actions; 101 | } 102 | 103 | async handleClosedIssue( 104 | org: string, 105 | name: string, 106 | issue: types.internal.Issue, 107 | issueConfig: types.IssueCleanupConfig 108 | ): Promise { 109 | const actions: types.Action[] = []; 110 | 111 | // Skip already-locked issues 112 | if (issue.locked) { 113 | return actions; 114 | } 115 | 116 | // We have already verified before calling this function that lock_days is defined, but 117 | // we default to MAX_NUMBER (aka never lock) just in case. 118 | const nowMs = new Date().getTime(); 119 | const lockDays = issueConfig.lock_days || Number.MAX_VALUE; 120 | const lockMillis = lockDays * 24 * 60 * 60 * 1000; 121 | 122 | // This is a "this should never happen" case but the GitHub API 123 | // is not type-safe enough to ignore the possibility. 124 | if (!issue.closed_at) { 125 | log.warn(`Closed issue ${org}/${name}/${issue.number} has no closed_at.`); 126 | return actions; 127 | } 128 | 129 | const closedAtStr = "" + issue.closed_at; 130 | const closedAtMs = new Date(closedAtStr).getTime(); 131 | 132 | if (nowMs - closedAtMs > lockMillis) { 133 | actions.push( 134 | new types.GitHubLockAction( 135 | org, 136 | name, 137 | issue.number, 138 | `Issue was closed at ${closedAtStr} which is more than ${lockDays} ago` 139 | ) 140 | ); 141 | } 142 | 143 | return actions; 144 | } 145 | 146 | async handleStaleIssues( 147 | org: string, 148 | name: string, 149 | issueConfig: types.IssueCleanupConfig 150 | ): Promise { 151 | const actions: types.Action[] = []; 152 | 153 | const issues = await this.gh_client.getIssuesForRepo(org, name, "open"); 154 | for (const issue of issues) { 155 | const issueActions = await this.handleStaleIssue( 156 | org, 157 | name, 158 | issue, 159 | issueConfig 160 | ); 161 | actions.push(...issueActions); 162 | } 163 | 164 | return actions; 165 | } 166 | 167 | async handleStaleIssue( 168 | org: string, 169 | name: string, 170 | issue: types.internal.Issue, 171 | issueConfig: types.IssueCleanupConfig 172 | ): Promise { 173 | const actions: types.Action[] = []; 174 | 175 | const number = issue.number; 176 | const labelNames = issue.labels.map(label => label.name); 177 | 178 | const stateNeedsInfo = labelNames.includes(issueConfig.label_needs_info); 179 | const stateStale = labelNames.includes(issueConfig.label_stale); 180 | 181 | // If an issue is not labeled with either the stale or needs-info labels 182 | // then we don't need to do any cron processing on it. 183 | if (!(stateNeedsInfo || stateStale)) { 184 | return actions; 185 | } 186 | 187 | // If the issue has one of the specified labels to ignore, then we 188 | // never mark it as stale or close it automatically. 189 | let hasIgnoredLabel = false; 190 | const ignoredLabels = issueConfig.ignore_labels || []; 191 | ignoredLabels.forEach(label => { 192 | hasIgnoredLabel = hasIgnoredLabel || labelNames.includes(label); 193 | }); 194 | 195 | if (hasIgnoredLabel) { 196 | log.debug( 197 | `Issue ${name}#${number} is ignored due to labels: ${JSON.stringify( 198 | labelNames 199 | )}` 200 | ); 201 | return actions; 202 | } 203 | 204 | // We fetch the comments for the issue so we can determine when the last actions were taken. 205 | // We manually sort the API response by timestamp (newest to oldest) because the API 206 | // does not guarantee an order. 207 | let comments = await this.gh_client.getCommentsForIssue(org, name, number); 208 | comments = comments.sort(util.compareTimestamps).reverse(); 209 | 210 | if (!comments || comments.length === 0) { 211 | console.log(`Issue ${name}#${number} has no comments.`); 212 | return actions; 213 | } 214 | 215 | // When the issue was marked stale, the bot will have left a comment with certain metadata 216 | const markStaleComment = comments.find(comment => { 217 | return comment.body.includes(EVT_MARK_STALE); 218 | }); 219 | 220 | if (stateStale && !markStaleComment) { 221 | log.warn( 222 | `Issue ${name}/${number} is stale but no relevant comment was found.` 223 | ); 224 | } 225 | 226 | if (stateNeedsInfo || stateStale) { 227 | log.debug( 228 | `Processing ${name}#${number} as needs-info or stale, labels=${JSON.stringify( 229 | labelNames 230 | )}` 231 | ); 232 | } 233 | 234 | // The github webhook handler will automatically remove the needs-info label 235 | // if the author comments, so we can assume inside the cronjob that this has 236 | // not happened and just look at the date of the last comment. 237 | // 238 | // A comment by anyone in the last 7 days makes the issue non-stale. 239 | const lastCommentTime = util.createdDate(comments[0]); 240 | const shouldMarkStale = 241 | stateNeedsInfo && 242 | util.workingDaysAgo(lastCommentTime) >= issueConfig.needs_info_days; 243 | 244 | const shouldClose = 245 | stateStale && 246 | markStaleComment != undefined && 247 | util.workingDaysAgo(util.createdDate(markStaleComment)) >= 248 | issueConfig.stale_days; 249 | 250 | if (shouldClose) { 251 | // 1) Add a comment about closing 252 | const addClosingComment = new types.GitHubCommentAction( 253 | org, 254 | name, 255 | number, 256 | this.getCloseComment(issue.user.login), 257 | false, 258 | `Comment after closing issue for being stale (comment at ${util.createdDate( 259 | markStaleComment! 260 | )}).` 261 | ); 262 | actions.push(addClosingComment); 263 | 264 | // 2) Close the issue 265 | const closeIssue = new types.GitHubCloseAction( 266 | org, 267 | name, 268 | number, 269 | `Closing issue for being stale.` 270 | ); 271 | actions.push(closeIssue); 272 | 273 | // 3) Add and remove labels (according to config) 274 | if (issueConfig.auto_close_labels) { 275 | for (const l of issueConfig.auto_close_labels.add) { 276 | actions.push(new types.GitHubAddLabelAction(org, name, number, l)); 277 | } 278 | for (const l of issueConfig.auto_close_labels.remove) { 279 | actions.push(new types.GitHubRemoveLabelAction(org, name, number, l)); 280 | } 281 | } else { 282 | // Default is to add 'closed-by-bot' 283 | actions.push( 284 | new types.GitHubAddLabelAction(org, name, number, "closed-by-bot") 285 | ); 286 | } 287 | } else if (shouldMarkStale) { 288 | // We add the 'stale' label and also add a comment. Note that 289 | // if the issue was labeled 'needs-info' this label is not removed 290 | // here. 291 | const addStaleLabel = new types.GitHubAddLabelAction( 292 | org, 293 | name, 294 | number, 295 | issueConfig.label_stale, 296 | `Last comment was ${util.workingDaysAgo( 297 | lastCommentTime 298 | )} working days ago (${lastCommentTime}).` 299 | ); 300 | const addStaleComment = new types.GitHubCommentAction( 301 | org, 302 | name, 303 | number, 304 | this.getMarkStaleComment( 305 | issue.user.login, 306 | issueConfig.needs_info_days, 307 | issueConfig.stale_days 308 | ), 309 | false, 310 | `Comment that goes alongside the stale label.` 311 | ); 312 | actions.push(addStaleLabel, addStaleComment); 313 | } 314 | 315 | return actions; 316 | } 317 | 318 | getMarkStaleComment( 319 | author: string, 320 | needsInfoDays: number, 321 | staleDays: number 322 | ): string { 323 | return ` 324 | Hey @${author}. We need more information to resolve this issue but there hasn't been an update in ${needsInfoDays} weekdays. I'm marking the issue as stale and if there are no new updates in the next ${staleDays} days I will close it automatically. 325 | 326 | If you have more information that will help us get to the bottom of this, just add a comment!`; 327 | } 328 | 329 | getCloseComment(author: string) { 330 | return ` 331 | Since there haven't been any recent updates here, I am going to close this issue. 332 | 333 | @${author} if you're still experiencing this problem and want to continue the discussion just leave a comment here and we are happy to re-open this.`; 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /functions/src/database.ts: -------------------------------------------------------------------------------- 1 | import * as admin from "firebase-admin"; 2 | 3 | let DATABASE: admin.database.Database | undefined = undefined; 4 | 5 | export function database(): admin.database.Database { 6 | if (!DATABASE) { 7 | DATABASE = admin.initializeApp().database(); 8 | } 9 | 10 | return DATABASE; 11 | } 12 | -------------------------------------------------------------------------------- /functions/src/email.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as log from "./log"; 17 | import * as types from "./types"; 18 | import * as config from "./config"; 19 | 20 | const mailgun = require("mailgun-js"); 21 | 22 | /** 23 | * Get a new email client that uses Mailgun. 24 | * @param {string} key Mailgun API key. 25 | * @param {string} domain Mailgun sender domain. 26 | */ 27 | export class EmailClient { 28 | apiKey: string; 29 | domain: string; 30 | 31 | sender: any; 32 | 33 | constructor(apiKey: string, domain: string) { 34 | this.apiKey = apiKey; 35 | this.domain = domain; 36 | } 37 | 38 | /** 39 | * Send a new email. 40 | */ 41 | sendEmail(recipient: string, subject: string, body: string): Promise { 42 | const data = { 43 | from: "Firebase OSS Bot ", 44 | to: recipient, 45 | subject: subject, 46 | html: body 47 | }; 48 | 49 | log.debug("Sending email: ", JSON.stringify(data)); 50 | 51 | // Return a promise for the email 52 | return new Promise((resolve, reject) => { 53 | this.getSender() 54 | .messages() 55 | .send(data, (error: string, body: string) => { 56 | if (error) { 57 | log.debug("Email Error: " + error); 58 | reject(error); 59 | } else { 60 | log.debug("Send Email Body: " + JSON.stringify(body)); 61 | resolve(body); 62 | } 63 | }); 64 | }); 65 | } 66 | 67 | /** 68 | * Send an emails styled like a GitHub update. 69 | */ 70 | sendStyledEmail( 71 | recipient: string, 72 | subject: string, 73 | header: string, 74 | body_html: string, 75 | link: string, 76 | action: string 77 | ): Promise { 78 | const smartmail_markup = this.getSmartmailMarkup(link, action); 79 | 80 | const body = ` 81 | 82 | 83 |

84 | ${link} 85 |

86 | 87 |

88 | ${header} 89 |

90 | 91 |
92 | ${body_html} 93 |
94 | 95 | ___ 96 | 97 |

(This email is automatically generated, do not reply)

98 | 99 | ${smartmail_markup} 100 | 101 | `; 102 | 103 | return this.sendEmail(recipient, subject, body); 104 | } 105 | 106 | /** 107 | * Invisible email markup to add action in Gmail. 108 | * 109 | * Note: Get registered with google. 110 | * https://developers.google.com/gmail/markup/registering-with-google 111 | */ 112 | getSmartmailMarkup(url: string, title: string): string { 113 | const email_markup = ` 114 |
115 |
116 | 117 | 118 |
119 | 120 |
`; 121 | 122 | return email_markup; 123 | } 124 | 125 | // Lazy initialize sender 126 | getSender(): any { 127 | if (!this.sender) { 128 | this.sender = mailgun({ 129 | apiKey: this.apiKey, 130 | domain: this.domain 131 | }); 132 | } 133 | 134 | return this.sender; 135 | } 136 | } 137 | 138 | export interface SendIssueUpdateEmailOpts { 139 | header: string; 140 | body: string; 141 | label?: string; 142 | } 143 | 144 | export class EmailUtils { 145 | constructor(private config: config.BotConfig) {} 146 | 147 | /** 148 | * Send an email when an issue has been updated. 149 | */ 150 | getIssueUpdateEmailAction( 151 | repo: types.internal.Repository, 152 | issue: types.internal.IssueOrPullRequest, 153 | opts: SendIssueUpdateEmailOpts 154 | ): types.SendEmailAction | undefined { 155 | // Get basic issue information 156 | const org = repo.owner.login; 157 | const name = repo.name; 158 | const number = issue.number; 159 | 160 | // Check if emails are enabled at all 161 | const repoFeatures = this.config.getRepoFeatures(org, name); 162 | if (!repoFeatures.custom_emails) { 163 | log.debug("Repo does not have the email feature enabled."); 164 | return undefined; 165 | } 166 | 167 | // See if this issue belongs to any team. 168 | let label: string | undefined = opts.label; 169 | if (!label) { 170 | const labelRes = this.config.getRelevantLabel(org, name, issue); 171 | label = labelRes.label; 172 | } 173 | if (!label) { 174 | log.debug("Not a relevant label, no email needed."); 175 | return undefined; 176 | } 177 | 178 | // Get label email from mapping 179 | let recipient; 180 | const label_config = this.config.getRepoLabelConfig(org, name, label); 181 | if (label_config) { 182 | recipient = label_config.email; 183 | } 184 | 185 | if (!recipient) { 186 | log.debug("Nobody to notify, no email needed."); 187 | return undefined; 188 | } 189 | 190 | // Get email subject 191 | const subject = this.getIssueEmailSubject(issue.title, org, name, label); 192 | 193 | const issue_url = 194 | issue.html_url || `https://github.com/${org}/${name}/issues/${number}`; 195 | 196 | // Send email update 197 | return new types.SendEmailAction( 198 | recipient, 199 | subject, 200 | opts.header, 201 | opts.body, 202 | issue_url, 203 | "Open Issue" 204 | ); 205 | } 206 | 207 | /** 208 | * Make an email subject that"s suitable for filtering. 209 | * ex: "[firebase/ios-sdk][auth] I have an auth issue!" 210 | */ 211 | getIssueEmailSubject( 212 | title: string, 213 | org: string, 214 | name: string, 215 | label: string 216 | ): string { 217 | return `[${org}/${name}][${label}] ${title}`; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /functions/src/github.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as log from "./log"; 17 | import * as util from "./util"; 18 | import { Octokit } from "@octokit/rest"; 19 | import { OctokitResponse } from "@octokit/types"; 20 | 21 | const OctokitRetry = require("@octokit/plugin-retry"); 22 | const GitHubApi = Octokit.plugin(OctokitRetry); 23 | 24 | /** 25 | * Get a new client for interacting with GitHub. 26 | * @param {string} token GitHub API token. 27 | */ 28 | export class GitHubClient { 29 | private token: string; 30 | private api: Octokit; 31 | 32 | constructor(token: string) { 33 | // GitHub API token 34 | this.token = token; 35 | 36 | // Underlying GitHub API client 37 | this.api = new GitHubApi({ 38 | auth: this.token, 39 | timeout: 10000 40 | }); 41 | } 42 | 43 | /** 44 | * Add a label to a github issue, returns a promise. 45 | */ 46 | addLabel( 47 | org: string, 48 | name: string, 49 | number: number, 50 | label: string 51 | ): Promise { 52 | return this.api.issues.addLabels({ 53 | owner: org, 54 | repo: name, 55 | issue_number: number, 56 | labels: [label] 57 | }); 58 | } 59 | 60 | /** 61 | * Remove a label from github issue, returns a promise. 62 | */ 63 | removeLabel( 64 | org: string, 65 | name: string, 66 | number: number, 67 | label: string 68 | ): Promise { 69 | return this.api.issues.removeLabel({ 70 | owner: org, 71 | repo: name, 72 | issue_number: number, 73 | name: label 74 | }); 75 | } 76 | 77 | /** 78 | * Add a comment to a github issue, returns a promise. 79 | */ 80 | addComment( 81 | org: string, 82 | name: string, 83 | number: number, 84 | body: string 85 | ): Promise { 86 | return this.api.issues.createComment({ 87 | owner: org, 88 | repo: name, 89 | issue_number: number, 90 | body: body 91 | }); 92 | } 93 | 94 | /** 95 | * Gets issue template from a github repo. 96 | */ 97 | getIssueTemplate(org: string, name: string, file: string) { 98 | log.debug(`GitHubClient.getIssueTemplate: ${org}/${name}, file=${file}`); 99 | return this.getFileContent(org, name, file); 100 | } 101 | 102 | /** 103 | * Gets file content from a github repo. 104 | */ 105 | getFileContent(org: string, name: string, file: string) { 106 | return this.api.repos 107 | .getContent({ 108 | owner: org, 109 | repo: name, 110 | path: file 111 | }) 112 | .then(function(res) { 113 | // Content is encoded as base64, we need to decode it 114 | return new Buffer(res.data.content, "base64").toString(); 115 | }); 116 | } 117 | 118 | /** 119 | * Closes an issue on a github repo. 120 | */ 121 | closeIssue(org: string, name: string, issue_number: number): Promise { 122 | return this.api.issues.update({ 123 | owner: org, 124 | repo: name, 125 | issue_number, 126 | state: "closed" 127 | }); 128 | } 129 | 130 | /** 131 | * Closes an issue on a github repo and erases its content. 132 | */ 133 | wipeIssue(org: string, name: string, issue_number: number): Promise { 134 | return this.api.issues.update({ 135 | owner: org, 136 | repo: name, 137 | issue_number, 138 | state: "closed", 139 | state_reason: "not_planned", 140 | title: "Spam", 141 | body: "This issue was filtered as spam." 142 | }); 143 | } 144 | 145 | /** 146 | * Get all comments on a GitHUb issue. 147 | */ 148 | getCommentsForIssue(owner: string, repo: string, issue_number: number) { 149 | return paginate(this.api.issues.listComments, { 150 | owner, 151 | repo, 152 | issue_number 153 | }); 154 | } 155 | 156 | /** 157 | * Get information about a GitHub organization. 158 | */ 159 | getOrg(org: string) { 160 | return this.api.orgs.get({ 161 | org 162 | }); 163 | } 164 | 165 | /** 166 | * Blocks the given user on behalf of the specified organization. 167 | */ 168 | blockFromOrg(org: string, username: string) { 169 | return this.api.request('PUT /orgs/' + org + '/blocks/' + username, { 170 | org: org, 171 | username: username, 172 | }); 173 | } 174 | 175 | /** 176 | * Gets information about a GitHub repo. 177 | */ 178 | async getRepo(org: string, repo: string) { 179 | const res = await this.api.repos.get({ 180 | owner: org, 181 | repo 182 | }); 183 | 184 | return res.data; 185 | } 186 | 187 | /** 188 | * List all the repos in a GitHub organization. 189 | */ 190 | getReposInOrg(org: string) { 191 | return paginate(this.api.repos.listForOrg, { 192 | org 193 | }); 194 | } 195 | 196 | /** 197 | * List all the issues (open or closed) on a GitHub repo. 198 | */ 199 | getIssuesForRepo( 200 | owner: string, 201 | repo: string, 202 | state?: IssueState, 203 | labels?: string[] 204 | ) { 205 | const opts: any = { 206 | owner, 207 | repo, 208 | state: state || "all" 209 | }; 210 | 211 | if (labels && labels.length > 0) { 212 | opts.labels = labels.join(","); 213 | } 214 | 215 | return paginate(this.api.issues.listForRepo, opts); 216 | } 217 | 218 | /** 219 | * List GitHub logins of all collaborators on a repo, direct or otherwise. 220 | */ 221 | getCollaboratorsForRepo(owner: string, repo: string) { 222 | return paginate(this.api.repos.listCollaborators, { 223 | owner, 224 | repo, 225 | affiliation: "all" 226 | }).then(collabs => { 227 | return collabs.map(c => c.login); 228 | }); 229 | } 230 | 231 | /** 232 | * Lock a GitHub issue. 233 | */ 234 | lockIssue(owner: string, repo: string, issue_number: number) { 235 | return this.api.issues.lock({ 236 | owner, 237 | repo, 238 | issue_number 239 | }); 240 | } 241 | } 242 | 243 | type IssueState = "open" | "closed" | "all"; 244 | 245 | /** 246 | * Interface for a GitHub API call. 247 | */ 248 | interface GitHubFn { 249 | (params?: S): Promise>; 250 | } 251 | 252 | /** 253 | * Interface for the parameters to a call to the GitHub API 254 | * that can be paginated. 255 | */ 256 | interface PageParams { 257 | // Results per page (max 100) 258 | per_page?: number; 259 | 260 | // Page number of the results to fetch. 261 | page?: number; 262 | 263 | // Ignore extra properties 264 | [others: string]: unknown; 265 | } 266 | 267 | /** 268 | * Read all pages of a GitHub API call and return them all as an 269 | * array. 270 | */ 271 | async function paginate( 272 | fn: GitHubFn>, 273 | options: S 274 | ): Promise { 275 | const per_page = 100; 276 | let pagesRemaining = true; 277 | let page = 0; 278 | 279 | let allData = [] as T[]; 280 | while (pagesRemaining) { 281 | page++; 282 | 283 | // Merge pagination options with the options passed in 284 | const pageOptions = Object.assign( 285 | { 286 | per_page, 287 | page 288 | }, 289 | options 290 | ); 291 | 292 | const res = await fn(pageOptions); 293 | allData = allData.concat(res.data); 294 | 295 | // We assume another page remaining if we got exactly as many 296 | // issues as we asked for. 297 | pagesRemaining = res.data.length == per_page; 298 | 299 | // Wait 0.5s between pages 300 | await util.delay(0.5); 301 | } 302 | 303 | return allData; 304 | } 305 | -------------------------------------------------------------------------------- /functions/src/log.ts: -------------------------------------------------------------------------------- 1 | import { Logging } from "@google-cloud/logging"; 2 | 3 | const LOG_NAME = "custom-log"; 4 | 5 | // This makes the logs appear in the same place as console.log() 6 | // invocations from Cloud Functions 7 | const METADATA = { 8 | resource: { 9 | type: "cloud_function", 10 | labels: { 11 | function_name: "CustomMetrics", 12 | region: "us-central1" 13 | } 14 | }, 15 | severity: "DEBUG" 16 | }; 17 | 18 | // The Logging instance detects the project ID from the environment 19 | // automatically. 20 | const logging = new Logging(); 21 | const log = logging.log(LOG_NAME); 22 | 23 | export enum Level { 24 | ALL = 0, 25 | DEBUG = 1, 26 | WARN = 2, 27 | ERROR = 3, 28 | NONE = 4 29 | } 30 | 31 | let LOG_LEVEL = Level.ALL; 32 | export function setLogLevel(level: Level) { 33 | LOG_LEVEL = level; 34 | } 35 | 36 | export function debug(message: any, ...args: any[]) { 37 | if (LOG_LEVEL > Level.DEBUG) { 38 | return; 39 | } 40 | 41 | if (args) { 42 | console.log(message, ...args); 43 | } else { 44 | console.log(message); 45 | } 46 | } 47 | 48 | export function warn(message: any, ...args: any) { 49 | if (LOG_LEVEL > Level.WARN) { 50 | return; 51 | } 52 | 53 | if (args) { 54 | console.warn(message, ...args); 55 | } else { 56 | console.warn(message); 57 | } 58 | } 59 | 60 | export function error(message: any, ...args: any) { 61 | if (LOG_LEVEL > Level.ERROR) { 62 | return; 63 | } 64 | 65 | if (args) { 66 | console.error(message, ...args); 67 | } else { 68 | console.error(message); 69 | } 70 | } 71 | 72 | /** 73 | * Log JSON data. 74 | */ 75 | export function logData(data: any) { 76 | // Add a message (if there isn't one) 77 | if (!data.message) { 78 | data.message = JSON.stringify(data); 79 | } 80 | 81 | const entry = log.entry(METADATA, data); 82 | // Log (fire-and-forget) 83 | log.write(entry); 84 | } 85 | -------------------------------------------------------------------------------- /functions/src/pubsub.ts: -------------------------------------------------------------------------------- 1 | import { PubSub } from "@google-cloud/pubsub"; 2 | import * as log from "./log"; 3 | 4 | // Just #pubsubthings 5 | const pubsubClient = new PubSub({ 6 | projectId: process.env.GCLOUD_PROJECT 7 | }); 8 | 9 | export function sendPubSub(topic: string, data: any): Promise { 10 | const publisher = pubsubClient.topic(topic).publisher; 11 | 12 | log.debug(`PubSub(${topic}, ${JSON.stringify(data)}`); 13 | return publisher.publish(Buffer.from(JSON.stringify(data))); 14 | } 15 | -------------------------------------------------------------------------------- /functions/src/pullrequests.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as marked from "marked"; 17 | 18 | import * as config from "./config"; 19 | import * as email from "./email"; 20 | import * as log from "./log"; 21 | import * as types from "./types"; 22 | 23 | // Event: pull_request 24 | // https://developer.github.com/v3/activity/events/types/#pullrequestevent 25 | // Keys 26 | // * number - the pull request number. 27 | // * changes - the changes to the comment if the action was "edited" 28 | // * pull_request - the pull request itself. 29 | enum PullRequestAction { 30 | ASSIGNED = "assigned", 31 | UNASSIGNED = "unassigned", 32 | REVIEW_REQUESTED = "review_requested", 33 | REVIEW_REQUEST_REMOVED = "review_request_removed", 34 | LABELED = "labeled", 35 | UNLABLED = "unlabeled", 36 | OPENED = "opened", 37 | EDITED = "edited", 38 | CLOSED = "closed", 39 | REOPENED = "reopened" 40 | } 41 | 42 | // Label for issues that confuse the bot 43 | const LABEL_NEEDS_TRIAGE = "needs-triage"; 44 | 45 | /** 46 | * Create a new handler for github pull requests. 47 | */ 48 | export class PullRequestHandler { 49 | config: config.BotConfig; 50 | emailer: email.EmailUtils; 51 | 52 | constructor(config: config.BotConfig) { 53 | // Configuration 54 | this.config = config; 55 | 56 | // Email utiltity 57 | this.emailer = new email.EmailUtils(this.config); 58 | } 59 | 60 | /** 61 | * Handle an issue associated with a GitHub pull request. 62 | */ 63 | async handlePullRequestEvent( 64 | event: types.github.WebhookEvent, 65 | action: PullRequestAction, 66 | pr: types.github.PullRequest, 67 | repo: types.github.Repository, 68 | sender: types.github.Sender 69 | ): Promise { 70 | switch (action) { 71 | case PullRequestAction.OPENED: 72 | return this.onNewPullRequest(repo, pr); 73 | case PullRequestAction.LABELED: 74 | return this.onPullRequestLabeled(repo, pr, event.label.name); 75 | case PullRequestAction.ASSIGNED: 76 | /* falls through */ 77 | case PullRequestAction.UNASSIGNED: 78 | /* falls through */ 79 | case PullRequestAction.REVIEW_REQUESTED: 80 | /* falls through */ 81 | case PullRequestAction.REVIEW_REQUEST_REMOVED: 82 | /* falls through */ 83 | case PullRequestAction.UNLABLED: 84 | /* falls through */ 85 | case PullRequestAction.EDITED: 86 | /* falls through */ 87 | case PullRequestAction.CLOSED: 88 | /* falls through */ 89 | case PullRequestAction.REOPENED: 90 | /* falls through */ 91 | default: 92 | log.debug("Unsupported pull request action: " + action); 93 | log.debug("Pull Request: " + pr.title); 94 | break; 95 | } 96 | 97 | // Return empty action array if no action to be taken. 98 | return Promise.resolve([]); 99 | } 100 | 101 | /** 102 | * Handle a newly opened pull request. 103 | */ 104 | async onNewPullRequest( 105 | repo: types.github.Repository, 106 | pr: types.github.PullRequest 107 | ): Promise { 108 | const actions: types.Action[] = []; 109 | 110 | // Get basic issue information 111 | const org = repo.owner.login; 112 | const name = repo.name; 113 | const number = pr.number; 114 | 115 | // Check for skip 116 | if (this.hasSkipTag(repo, pr)) { 117 | return actions; 118 | } 119 | 120 | // Right now we are not doing anything on pull requests... 121 | 122 | return actions; 123 | } 124 | 125 | async onPullRequestLabeled( 126 | repo: types.github.Repository, 127 | pr: types.github.PullRequest, 128 | label: string 129 | ): Promise { 130 | // Render the PR body 131 | const body_html = marked(pr.body || ""); 132 | 133 | // Send a new PR email 134 | const action = this.emailer.getIssueUpdateEmailAction(repo, pr, { 135 | header: `New Pull Request from ${pr.user.login} in label ${label}`, 136 | body: body_html, 137 | label: label 138 | }); 139 | 140 | if (!action) { 141 | return []; 142 | } 143 | 144 | return [action]; 145 | } 146 | 147 | /** 148 | * Determine if a PR has the [triage-skip] tag. 149 | */ 150 | hasSkipTag(repo: types.github.Repository, pr: types.github.PullRequest) { 151 | return pr.title.indexOf("[triage-skip]") >= 0; 152 | } 153 | 154 | /** 155 | * Determine if the pull request links to a github issue (fuzzy). 156 | */ 157 | hasIssueLink(repo: types.github.Repository, pr: types.github.PullRequest) { 158 | // Match either /issues/NUM or #NUM 159 | const issueRegex = new RegExp("(/issues/|#)[0-9]+"); 160 | 161 | return issueRegex.test(pr.body); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /functions/src/scripts/README.md: -------------------------------------------------------------------------------- 1 | Files in this folder are scripts, which means they are meant 2 | to be run and are not safe to import. -------------------------------------------------------------------------------- /functions/src/scripts/deploy-config.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as encoding from "../shared/encoding"; 3 | 4 | const firebase = require("firebase-tools"); 5 | 6 | async function deployConfig(configFile: string, project: string) { 7 | console.log(`Deploying ${configFile} to ${project}.`); 8 | 9 | // Read the local JSON file and then wrap it in { runtime: config: { ... } } 10 | const configFileString = fs.readFileSync(configFile).toString(); 11 | const config = { 12 | runtime: { 13 | config: JSON.parse(configFileString) 14 | } 15 | }; 16 | 17 | // Encode the proposed config into a flat map of dot-separated values 18 | const newConfig = encoding.flattenConfig(config, encoding.Direction.ENCODE); 19 | 20 | // Get the current runtime config from Firebase as a giant object 21 | const current = await firebase.functions.config.get("runtime", { 22 | project: project 23 | }); 24 | 25 | // Decode the config into a flat map of dot-separated values. 26 | const currentConfig = encoding.flattenConfig( 27 | { 28 | runtime: current 29 | }, 30 | encoding.Direction.NONE 31 | ); 32 | 33 | const keysRemoved: string[] = []; 34 | const keysAddedOrChanged: string[] = []; 35 | 36 | const newKeys = Object.keys(newConfig); 37 | const currentKeys = Object.keys(currentConfig); 38 | const allKeys = new Set([...newKeys, ...currentKeys]); 39 | 40 | allKeys.forEach((key: string) => { 41 | const newVal = "" + newConfig[key]; 42 | const currentVal = "" + currentConfig[key]; 43 | 44 | if (newKeys.indexOf(key) < 0 && currentKeys.indexOf(key) >= 0) { 45 | console.log(`REMOVED: ${key}`); 46 | console.log(`\tcurrent=${currentVal}`); 47 | keysRemoved.push(key); 48 | } else if (newVal !== currentVal) { 49 | console.log(`CHANGED: ${key}`); 50 | console.log(`\tcurrent=${currentVal}`); 51 | console.log(`\tnew=${newVal}`); 52 | keysAddedOrChanged.push(key); 53 | } 54 | }); 55 | 56 | const args = []; 57 | if (keysRemoved.length > 0) { 58 | // If anything is removed we need to nuke and start over 59 | for (const key in newConfig) { 60 | const val = newConfig[key]; 61 | args.push(`${key}=${val}`); 62 | } 63 | 64 | // Unset the 'runtime' config variable and all children 65 | await firebase.functions.config.unset(["runtime"], { 66 | project: project 67 | }); 68 | } else { 69 | // Otherwise we can just update what changed 70 | for (const key of keysAddedOrChanged) { 71 | const val = newConfig[key]; 72 | args.push(`${key}=${val}`); 73 | } 74 | } 75 | 76 | // If no changes, we're done 77 | if (args.length == 0) { 78 | console.log("No config changes."); 79 | return; 80 | } 81 | 82 | // Log out everything that is changing 83 | console.log(args); 84 | 85 | // Set the new config 86 | await firebase.functions.config.set(args, { 87 | project: project 88 | }); 89 | } 90 | 91 | // ============================================= 92 | // MAIN 93 | // ============================================= 94 | 95 | // Validate command-line arguments 96 | if (process.argv.length < 4) { 97 | console.log( 98 | "Please specify a config file and project: ts-node deploy-config.ts $FILE $PROJECT" 99 | ); 100 | process.exit(1); 101 | } 102 | 103 | const configFile = process.argv[2]; 104 | const project = process.argv[3]; 105 | deployConfig(configFile, project) 106 | .then(function() { 107 | console.log("Deployed."); 108 | }) 109 | .catch(function(e) { 110 | console.warn(e); 111 | if (e.context && e.context.body) { 112 | console.warn(JSON.stringify(e.context.body)); 113 | } 114 | process.exit(1); 115 | }); 116 | -------------------------------------------------------------------------------- /functions/src/shared/README.md: -------------------------------------------------------------------------------- 1 | Files in this folder should be usable from either local scripts 2 | or from functions, so be careful about dependencies. -------------------------------------------------------------------------------- /functions/src/shared/encoding.ts: -------------------------------------------------------------------------------- 1 | type StringMap = { [s: string]: string }; 2 | 3 | const ESCAPES: StringMap = { 4 | ":": "0col0", 5 | " ": "0spc0", 6 | "/": "0sls0", 7 | ".github": "0dgh0", 8 | ".md": "0dmd0" 9 | }; 10 | 11 | export enum Direction { 12 | ENCODE = "ENCODE", 13 | DECODE = "DECODE", 14 | NONE = "NONE" 15 | } 16 | 17 | /** 18 | * Ex: github.token --> GITHUB_TOKEN 19 | */ 20 | export function toEnvKey(key: string): string { 21 | return replaceAll(key.toUpperCase(), ".", "_"); 22 | } 23 | 24 | export function encodeKey(key: string): string { 25 | // From the docs: 26 | // A variable key can contain digts, letters, dashes, and slashes, 27 | // and the max length for a name is 256 characters. 28 | let encoded = key; 29 | 30 | // Replace some bad characters with made up 'escapes' 31 | Object.keys(ESCAPES).forEach(char => { 32 | encoded = replaceAll(encoded, char, ESCAPES[char]); 33 | }); 34 | 35 | // Make sure we will be able to read the key back 36 | const decodeTest = decodeKey(encoded); 37 | if (decodeTest !== key) { 38 | throw `Cannot encode key: ${key} !== ${decodeTest}`; 39 | } 40 | 41 | return encoded; 42 | } 43 | 44 | export function decodeKey(key: string): string { 45 | let decoded = key; 46 | 47 | Object.keys(ESCAPES).forEach(char => { 48 | decoded = replaceAll(decoded, ESCAPES[char], char); 49 | }); 50 | 51 | return decoded; 52 | } 53 | 54 | export function sanitizeKey(key: string) { 55 | return key.toLowerCase().trim(); 56 | } 57 | 58 | export function flattenConfig(ob: any, dir: Direction): StringMap { 59 | const flattened = flattenObject(ob); 60 | const result: StringMap = {}; 61 | for (const key in flattened) { 62 | let newKey = sanitizeKey(key); 63 | 64 | switch (dir) { 65 | case Direction.ENCODE: 66 | newKey = encodeKey(newKey); 67 | break; 68 | case Direction.DECODE: 69 | newKey = decodeKey(newKey); 70 | break; 71 | } 72 | 73 | const val = flattened[key]; 74 | result[newKey] = val; 75 | } 76 | 77 | return result; 78 | } 79 | 80 | /** 81 | * Decode all the KEYS of an object of arbitrary depth. 82 | */ 83 | export function deepDecodeObject(ob: any): any { 84 | if (typeof ob !== "object") { 85 | return ob; 86 | } 87 | 88 | const toReturn: any = {}; 89 | for (const i in ob) { 90 | if (!ob.hasOwnProperty(i)) { 91 | continue; 92 | } 93 | 94 | const decodedKey = decodeKey(i); 95 | 96 | const val = ob[i]; 97 | if (typeof val == "object" && !Array.isArray(val)) { 98 | const decodedObject = deepDecodeObject(val); 99 | toReturn[decodedKey] = decodedObject; 100 | } else { 101 | toReturn[decodedKey] = val; 102 | } 103 | } 104 | 105 | return toReturn; 106 | } 107 | 108 | /** 109 | * Source: https://gist.github.com/penguinboy/762197 110 | */ 111 | function flattenObject(ob: any): any { 112 | const toReturn: any = {}; 113 | 114 | for (const i in ob) { 115 | if (!ob.hasOwnProperty(i)) { 116 | continue; 117 | } 118 | 119 | if (typeof ob[i] == "object") { 120 | const flatObject = flattenObject(ob[i]); 121 | for (const x in flatObject) { 122 | if (!flatObject.hasOwnProperty(x)) { 123 | continue; 124 | } 125 | 126 | toReturn[i + "." + x] = flatObject[x]; 127 | } 128 | } else { 129 | toReturn[i] = ob[i]; 130 | } 131 | } 132 | return toReturn; 133 | } 134 | 135 | function replaceAll(str: string, src: string, dst: string) { 136 | let replaced = str; 137 | while (replaced.indexOf(src) >= 0) { 138 | replaced = replaced.replace(src, dst); 139 | } 140 | 141 | return replaced; 142 | } 143 | -------------------------------------------------------------------------------- /functions/src/snapshot.ts: -------------------------------------------------------------------------------- 1 | import * as functions from "firebase-functions"; 2 | import { database } from "./database"; 3 | import * as github from "./github"; 4 | import * as log from "./log"; 5 | import * as util from "./util"; 6 | import { snapshot } from "./types"; 7 | import * as config from "./config"; 8 | import { insertIssues } from "./bigquery"; 9 | import { sendPubSub } from "./pubsub"; 10 | 11 | // Config 12 | const bot_config = config.BotConfig.getDefault(); 13 | 14 | const gh_client = new github.GitHubClient( 15 | config.getFunctionsConfig("github.token") 16 | ); 17 | 18 | function cleanRepoName(name: string): string { 19 | let cleanName = name.toLowerCase(); 20 | cleanName = cleanName.replace(".", "_"); 21 | 22 | return cleanName; 23 | } 24 | 25 | function scrubArray(obj: any[], fieldsToScrub: string[]) { 26 | return obj.map((item: any) => { 27 | return scrubObject(item, fieldsToScrub); 28 | }); 29 | } 30 | 31 | function scrubObject(obj: any, fieldsToScrub: string[]) { 32 | Object.keys(obj) 33 | .filter(key => { 34 | const isValid = fieldsToScrub.filter(fieldMatch => { 35 | return key.match(new RegExp(fieldMatch)); 36 | }); 37 | 38 | return isValid.length; 39 | }) 40 | .forEach(key => { 41 | delete obj[key]; 42 | }); 43 | 44 | return obj; 45 | } 46 | 47 | function OrgSnapshotPath(org: string) { 48 | if (org === "firebase") { 49 | return "/snapshots/github"; 50 | } 51 | 52 | return `/snapshots/${org}`; 53 | } 54 | 55 | function DateSnapshotPath(org: string, date: Date) { 56 | return `${OrgSnapshotPath(org)}/${util.DateSlug(date)}`; 57 | } 58 | 59 | function RepoSnapshotPath(org: string, repo: string, date: Date) { 60 | return `${DateSnapshotPath(org, date)}/repos/${repo}`; 61 | } 62 | 63 | export async function userIsCollaborator( 64 | org: string, 65 | repo: string, 66 | user: string 67 | ): Promise { 68 | const repoKey = cleanRepoName(repo); 69 | const repoMetaRef = database() 70 | .ref("repo-metadata") 71 | .child(org) 72 | .child(repoKey); 73 | 74 | const userSnap = await repoMetaRef 75 | .child("collaborators") 76 | .child(user) 77 | .once("value"); 78 | 79 | return userSnap.exists() && userSnap.val() === true; 80 | } 81 | 82 | /** 83 | * Get a point-in-time snapshot of a GitHub org. 84 | * 85 | * Must be followed up by a job to snap each repo. 86 | */ 87 | export async function GetOrganizationSnapshot(org: string, deep: boolean) { 88 | // Get basic data about the org 89 | const orgRes = await gh_client.getOrg(org); 90 | const orgData = scrubObject(orgRes.data, ["owner", "organization", "url"]); 91 | 92 | // For shallow snapshots, don't retrieve all of the repos 93 | if (!deep) { 94 | return orgData; 95 | } 96 | 97 | // Fill in repos data 98 | const repos: { [s: string]: any } = {}; 99 | let reposData: any[] = await gh_client.getReposInOrg(org); 100 | reposData = scrubArray(reposData, ["owner", "organization", "url"]); 101 | 102 | for (const key in reposData) { 103 | const repoData = reposData[key]; 104 | const cleanName = cleanRepoName(repoData.name); 105 | repos[cleanName] = repoData; 106 | } 107 | 108 | orgData.repos = repos; 109 | return orgData; 110 | } 111 | 112 | /** 113 | * Get a point-in-time snapshot for a GitHub repo. 114 | * 115 | * repoData is the base data retrieved by GetOrganizationSnapshot. 116 | * Yes, I know this is ugly. 117 | */ 118 | export async function GetRepoSnapshot( 119 | owner: string, 120 | repo: string, 121 | repoData: any 122 | ): Promise<{ repoData: any; issueData: snapshot.Map }> { 123 | if (!repoData) { 124 | log.warn(`GetRepoSnapshot called with null data for ${owner}/${repo}`); 125 | } 126 | 127 | // Note: GitHub gives open_issues_count but it includes PRs. 128 | // We want to separate the two and keep our own counts. 129 | repoData.open_issues_count = 0; 130 | repoData.open_pull_requests_count = 0; 131 | repoData.closed_issues_count = 0; 132 | repoData.closed_pull_requests_count = 0; 133 | 134 | const keyed_issues: { [s: string]: any } = {}; 135 | let issues = await gh_client.getIssuesForRepo(owner, repo); 136 | issues = scrubArray(issues, ["organization", "url"]); 137 | 138 | // We're going to keep a copy of all issues for a given repo 139 | const issueData: snapshot.Map = {}; 140 | for (const issue of issues) { 141 | issueData[`id_${issue.number}`] = { 142 | number: issue.number, 143 | title: issue.title, 144 | state: issue.state, 145 | locked: issue.locked, 146 | pull_request: !!issue.pull_request, 147 | comments: issue.comments, 148 | user: { 149 | login: issue.user.login 150 | }, 151 | assignee: { 152 | login: issue.assignee?.login || "" 153 | }, 154 | labels: issue.labels.map(l => l.name), 155 | updated_at: issue.updated_at, 156 | created_at: issue.created_at 157 | }; 158 | } 159 | 160 | // Normalize the user and pull_request fields 161 | issues.forEach((issue: any) => { 162 | issue.user = scrubObject(issue.user, ["url"]); 163 | issue.pull_request = !!issue.pull_request; 164 | }); 165 | 166 | // Gather issues by key and count states 167 | issues.forEach((issue: any) => { 168 | // Store all open issues in the snapshot 169 | if (issue.state === "open") { 170 | keyed_issues["id_" + issue.number] = issue; 171 | } 172 | 173 | // Increment one of the four counters. 174 | if (issue.state === "open") { 175 | if (issue.pull_request) { 176 | repoData.open_pull_requests_count += 1; 177 | } else { 178 | repoData.open_issues_count += 1; 179 | } 180 | } else { 181 | if (issue.pull_request) { 182 | repoData.closed_pull_requests_count += 1; 183 | } else { 184 | repoData.closed_issues_count += 1; 185 | } 186 | } 187 | }); 188 | 189 | repoData.issues = keyed_issues; 190 | 191 | return { repoData, issueData }; 192 | } 193 | 194 | /** 195 | * Get the snapshot for a repo on a specific Date. 196 | */ 197 | export async function FetchRepoSnapshot( 198 | org: string, 199 | repo: string, 200 | date: Date 201 | ): Promise { 202 | const path = RepoSnapshotPath(org, repo, date); 203 | const snap = await database() 204 | .ref(path) 205 | .once("value"); 206 | const data = snap.val(); 207 | return data; 208 | } 209 | 210 | export const SaveRepoSnapshot = functions 211 | .runWith(util.FUNCTION_OPTS) 212 | .pubsub.topic("repo_snapshot") 213 | .onPublish(async event => { 214 | // Date for ingestion 215 | const now = new Date(); 216 | 217 | // TODO: Enable retry, using retry best practices 218 | const data = event.json; 219 | const org = data.org; 220 | 221 | const repoName = data.repo; 222 | const repoKey = cleanRepoName(repoName); 223 | 224 | if (!(org && repoName)) { 225 | log.debug(`PubSub message must include 'org' and 'repo': ${event.data}`); 226 | } 227 | 228 | log.debug(`SaveRepoSnapshot(${org}/${repoName})`); 229 | const orgRef = database().ref(DateSnapshotPath(org, new Date())); 230 | const repoSnapRef = orgRef.child("repos").child(repoKey); 231 | 232 | // Get the "base" data that was retriebed during the org snapshot 233 | let baseRepoData = (await repoSnapRef.once("value")).val(); 234 | if (!baseRepoData) { 235 | log.debug( 236 | `Couldn't get base repo data for ${org}/${repoName}, getting from GitHub` 237 | ); 238 | 239 | // Get the repo data from GitHub API directly 240 | const repoData = await gh_client.getRepo(org, repoName); 241 | const cleanRepoData = scrubObject(repoData, [ 242 | "owner", 243 | "organization", 244 | "url" 245 | ]); 246 | 247 | repoSnapRef.set(cleanRepoData); 248 | baseRepoData = cleanRepoData; 249 | } 250 | 251 | // Store the repo snapshot under the proper path 252 | util.startTimer("GetRepoSnapshot"); 253 | const { repoData, issueData } = await GetRepoSnapshot( 254 | org, 255 | repoName, 256 | baseRepoData 257 | ); 258 | util.endTimer("GetRepoSnapshot"); 259 | 260 | log.debug(`Saving repo snapshot to ${repoSnapRef.path}`); 261 | await repoSnapRef.set(repoData); 262 | 263 | const repoIssueRef = database() 264 | .ref("issues") 265 | .child(org) 266 | .child(repoKey); 267 | 268 | log.debug(`Saving issue snapshot to ${repoIssueRef.path}`); 269 | try { 270 | await repoIssueRef.set(issueData); 271 | } catch (e) { 272 | throw new Error( 273 | `Failed to save snapshot of issues for ${org}/${repoKey}: ${e}` 274 | ); 275 | } 276 | 277 | // Stream issues to BigQuery 278 | try { 279 | await insertIssues(org, repoName, Object.values(issueData), now); 280 | } catch (e) { 281 | log.warn("BigQuery failure", JSON.stringify(e)); 282 | } 283 | 284 | // Store non-date-specific repo metadata 285 | // TODO: This should probably be broken out into a function like GetRepoSnapshot 286 | // and then only saved/timed here. 287 | const repoMetaRef = database() 288 | .ref("repo-metadata") 289 | .child(org) 290 | .child(repoKey); 291 | 292 | // Store collaborators as a map of name --> true 293 | const collabMap: { [s: string]: boolean } = {}; 294 | try { 295 | const collabNames = await gh_client.getCollaboratorsForRepo( 296 | org, 297 | repoName 298 | ); 299 | collabNames.forEach((name: string) => { 300 | collabMap[name] = true; 301 | }); 302 | } catch (e) { 303 | log.warn(`Failed to get collaborators for repo ${org}/${repoName}`, e); 304 | } 305 | 306 | // Even if we fail to get the collaborators, set an empty map 307 | await repoMetaRef.child("collaborators").set(collabMap); 308 | }); 309 | 310 | export const SaveOrganizationSnapshot = functions 311 | .runWith(util.FUNCTION_OPTS) 312 | .pubsub.schedule("every day 12:00") 313 | .onRun(async () => { 314 | const configRepos = bot_config.getAllRepos(); 315 | 316 | // Gather all the unique orgs from the configured repos 317 | const configOrgs: string[] = []; 318 | for (const r of configRepos) { 319 | if (configOrgs.indexOf(r.org) < 0 && r.org !== "samtstern") { 320 | configOrgs.push(r.org); 321 | } 322 | } 323 | 324 | // First snapshot the Fireabse org (deep snapshot) 325 | const firebaseOrgSnap = await GetOrganizationSnapshot("firebase", true); 326 | await database() 327 | .ref(DateSnapshotPath("firebase", new Date())) 328 | .set(firebaseOrgSnap); 329 | 330 | // Next take a shallow snapshot of all other orgs 331 | for (const org of configOrgs) { 332 | if (org !== "firebase") { 333 | log.debug(`Taking snapshot of org: ${org}`); 334 | const orgSnap = await GetOrganizationSnapshot(org, false); 335 | await database() 336 | .ref(DateSnapshotPath(org, new Date())) 337 | .set(orgSnap); 338 | } 339 | } 340 | 341 | // Build a list of all repos to snapshot, across orgs 342 | const reposToSnapshot: OrgRepo[] = []; 343 | 344 | // All Firebase orgs are automatically included 345 | const firebaseRepoKeys = Object.keys(firebaseOrgSnap.repos); 346 | for (const repoKey of firebaseRepoKeys) { 347 | const repoName = firebaseOrgSnap.repos[repoKey].name; 348 | reposToSnapshot.push({ 349 | org: "firebase", 350 | repo: repoName 351 | }); 352 | } 353 | 354 | // Push in all non-Firebase repos that are present in the config 355 | for (const r of configRepos) { 356 | if (r.org !== "firebase") { 357 | reposToSnapshot.push({ 358 | org: r.org, 359 | repo: r.name 360 | }); 361 | } 362 | } 363 | 364 | // Fan out for each repo via PubSub, adding a 1s delay in 365 | // between to avoid spamming the function. 366 | for (const r of reposToSnapshot) { 367 | util.delay(1.0); 368 | await sendPubSub("repo_snapshot", r); 369 | } 370 | }); 371 | 372 | interface OrgRepo { 373 | org: string; 374 | repo: string; 375 | } 376 | -------------------------------------------------------------------------------- /functions/src/stats.ts: -------------------------------------------------------------------------------- 1 | import { snapshot } from "./types"; 2 | import { database } from "./database"; 3 | import * as util from "./util"; 4 | import * as log from "./log"; 5 | 6 | const IssueFilters = { 7 | isOpen: (x: snapshot.Issue) => { 8 | return x.state === "open"; 9 | }, 10 | 11 | isPullRequest: (x: snapshot.Issue) => { 12 | return x.pull_request; 13 | }, 14 | 15 | isFeatureRequest: (x: snapshot.Issue) => { 16 | if (!x.labels) { 17 | return false; 18 | } 19 | 20 | return x.labels.indexOf("type: feature request") >= 0; 21 | }, 22 | 23 | isInternal: (c: snapshot.Map) => (x: snapshot.Issue) => { 24 | return c[x.user.login]; 25 | } 26 | }; 27 | 28 | function calculateStats(issues: Array): IssueStats { 29 | const [openIss, closedIss] = util.split(issues, IssueFilters.isOpen); 30 | 31 | const open = openIss.length; 32 | const closed = closedIss.length; 33 | const percent_closed = 34 | closed === 0 ? 0 : Math.floor((closed / (closed + open)) * 100); 35 | const sam_score = util.samScore(open, closed); 36 | 37 | return { 38 | open, 39 | closed, 40 | percent_closed, 41 | sam_score 42 | }; 43 | } 44 | 45 | export interface IssueStats { 46 | open: number; 47 | closed: number; 48 | percent_closed: number; 49 | sam_score: number; 50 | } 51 | 52 | export async function getRepoIssueStats(org: string, repo: string) { 53 | const issuesSnap = await database() 54 | .ref("issues") 55 | .child(org) 56 | .child(repo) 57 | .once("value"); 58 | const issueObj = issuesSnap.val() as snapshot.Map; 59 | 60 | const collaboratorsSnap = await database() 61 | .ref("repo-metadata") 62 | .child(org) 63 | .child(repo) 64 | .child("collaborators") 65 | .once("value"); 66 | 67 | let collaborators = collaboratorsSnap.val() as snapshot.Map | null; 68 | if (!collaborators) { 69 | log.debug(`Unable to get collaborators for ${org}/${repo}`); 70 | collaborators = {}; 71 | } 72 | 73 | // All issues and prs sorted by age 74 | const issuesAndPrs = Object.values(issueObj).sort((x, y) => { 75 | return util.timeAgo(x) - util.timeAgo(y); 76 | }); 77 | 78 | // Split into filed-by-googlers and not. 79 | const [internal, external] = util.split( 80 | issuesAndPrs, 81 | IssueFilters.isInternal(collaborators) 82 | ); 83 | 84 | const [prs, issues] = util.split(issuesAndPrs, IssueFilters.isPullRequest); 85 | const [feature_requests, bugs] = util.split( 86 | issues, 87 | IssueFilters.isFeatureRequest 88 | ); 89 | 90 | // external_bugs are the issues we care about: 91 | // * Issue or PR 92 | // * Not filed by a Googler 93 | // * Not a feature request 94 | const [internal_bugs, external_bugs] = util.split( 95 | bugs, 96 | IssueFilters.isInternal(collaborators) 97 | ); 98 | 99 | const [internal_prs, external_prs] = util.split( 100 | prs, 101 | IssueFilters.isInternal(collaborators) 102 | ); 103 | 104 | // TODO: Maybe exclude based on the repo's acual label config. 105 | const labelBlacklist = ["type:", "priority", "needs"]; 106 | 107 | // Group issues by label 108 | const labelIssues: { [label: string]: snapshot.Issue[] } = {}; 109 | for (const issue of issues) { 110 | if (!issue.labels) { 111 | continue; 112 | } 113 | 114 | for (const label of issue.labels) { 115 | if (labelBlacklist.some(prefix => label.toLowerCase().includes(prefix))) { 116 | continue; 117 | } 118 | 119 | if (!labelIssues[label]) { 120 | labelIssues[label] = []; 121 | } 122 | 123 | labelIssues[label].push(issue); 124 | } 125 | } 126 | 127 | // Get stats per label 128 | const labelStats: { [label: string]: IssueStats } = {}; 129 | Object.keys(labelIssues).forEach(label => { 130 | labelStats[label] = calculateStats(labelIssues[label]); 131 | }); 132 | 133 | const counts = { 134 | combined: { 135 | all: calculateStats(issuesAndPrs), 136 | internal: calculateStats(internal), 137 | external: calculateStats(external) 138 | }, 139 | issues: { 140 | all: calculateStats(issues), 141 | feature_requests: calculateStats(feature_requests), 142 | bugs: calculateStats(bugs), 143 | external_bugs: calculateStats(external_bugs) 144 | }, 145 | prs: { 146 | all: calculateStats(prs), 147 | internal: calculateStats(internal_prs), 148 | external: calculateStats(external_prs) 149 | }, 150 | labelStats 151 | }; 152 | 153 | return counts; 154 | } 155 | -------------------------------------------------------------------------------- /functions/src/template.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as diff from "diff"; 17 | 18 | interface SectionValidationResult { 19 | all: string[]; 20 | invalid: string[]; 21 | } 22 | 23 | class TemplateContent { 24 | sections: TemplateSection[]; 25 | index: { [name: string]: TemplateSection } = {}; 26 | 27 | constructor(sections: TemplateSection[]) { 28 | this.sections = sections; 29 | for (const section of sections) { 30 | this.index[section.cleanName] = section; 31 | } 32 | } 33 | 34 | get(cleanName: string): TemplateSection | undefined { 35 | return this.index[cleanName]; 36 | } 37 | } 38 | 39 | class TemplateSection { 40 | name: string; 41 | cleanName: string; 42 | required: boolean; 43 | body: string[]; 44 | 45 | constructor(name: string, body: string[], checker: TemplateChecker) { 46 | this.name = name; 47 | this.body = body; 48 | this.cleanName = checker.cleanSectionName(name); 49 | this.required = this.name.indexOf(checker.requiredMarker) >= 0; 50 | } 51 | } 52 | 53 | /** 54 | * An object that checks whether a given piece of text matches a template. 55 | * @param {string} sectionPrefix a prefix that identifies a line as a new section (trailing space assumed). 56 | * @param {string} requiredMarker a string that identifies a section as requied. 57 | * @param {string} templateText text of the empty template. 58 | */ 59 | export class TemplateChecker { 60 | sectionPrefix: string; 61 | requiredMarker: string; 62 | templateText: string; 63 | 64 | constructor( 65 | sectionPrefix: string, 66 | requiredMarker: string, 67 | templateText: string 68 | ) { 69 | // String prefix for a section (normally ###) 70 | this.sectionPrefix = sectionPrefix; 71 | 72 | // String that marks a required section (normally [REQUIRED]) 73 | this.requiredMarker = requiredMarker; 74 | 75 | // String text of the template 76 | this.templateText = templateText; 77 | } 78 | 79 | /** 80 | * Take a string and turn it into a map from section header to section content. 81 | */ 82 | extractSections(data: string): TemplateContent { 83 | // Fix newlines 84 | data = data.replace(/\r\n/g, "\n"); 85 | 86 | // Then split 87 | const lines = data.split("\n"); 88 | 89 | const sections: TemplateSection[] = []; 90 | let currentSection: TemplateSection | undefined = undefined; 91 | 92 | for (const line of lines) { 93 | if (line.startsWith(this.sectionPrefix + " ")) { 94 | // New section 95 | currentSection = new TemplateSection(line, [], this); 96 | sections.push(currentSection); 97 | } else if (currentSection) { 98 | // Line in current section 99 | currentSection.body.push(line); 100 | } 101 | } 102 | 103 | return new TemplateContent(sections); 104 | } 105 | 106 | /** 107 | * Determine if a string has the same sections as the template. 108 | * 109 | * Returns an array of sections that were present in the template 110 | * but not in the issue. 111 | */ 112 | matchesTemplateSections(data: string): SectionValidationResult { 113 | const otherSections = this.extractSections(data); 114 | const templateSections = this.extractSections(this.templateText); 115 | 116 | const missingSections: string[] = []; 117 | for (const section of templateSections.sections) { 118 | if (!otherSections.get(section.cleanName)) { 119 | missingSections.push(section.name); 120 | } 121 | } 122 | 123 | const all = templateSections.sections.map(x => x.name); 124 | const invalid = missingSections; 125 | return { all, invalid }; 126 | } 127 | 128 | /** 129 | * Get the names of all required sections that were not filled out (unmodified). 130 | */ 131 | getRequiredSectionsEmpty(data: string): SectionValidationResult { 132 | const otherContent = this.extractSections(data); 133 | const templateContent = this.extractSections(this.templateText); 134 | const emptySections: string[] = []; 135 | 136 | const requiredSections = templateContent.sections.filter(x => x.required); 137 | 138 | for (const section of requiredSections) { 139 | // For a required section, we want to make sure that the user 140 | // made *some* modification to the section body. 141 | const otherSection = otherContent.get(section.cleanName); 142 | if (!otherSection) { 143 | emptySections.push(section.cleanName); 144 | continue; 145 | } 146 | 147 | const templateSectionBody = section.body.join("\n"); 148 | const otherSectionBody = otherSection.body.join("\n"); 149 | 150 | if (this.areStringsEqual(templateSectionBody, otherSectionBody)) { 151 | emptySections.push(section.cleanName); 152 | } 153 | } 154 | 155 | const all = requiredSections.map(x => x.name); 156 | const invalid = emptySections; 157 | return { all, invalid }; 158 | } 159 | 160 | cleanSectionName(name: string): string { 161 | let result = "" + name; 162 | 163 | result = result.replace(this.sectionPrefix, ""); 164 | 165 | const markerIndex = result.indexOf(this.requiredMarker); 166 | if (markerIndex >= 0) { 167 | result = result.substring(markerIndex + this.requiredMarker.length); 168 | } 169 | 170 | result = result.trim(); 171 | result = result.toLocaleLowerCase(); 172 | 173 | return result; 174 | } 175 | 176 | /** 177 | * Compare two multiline strings 178 | */ 179 | areStringsEqual(a: string, b: string) { 180 | const diffs = diff.diffWords(a, b); 181 | for (const d of diffs) { 182 | if (d.added || d.removed) { 183 | return false; 184 | } 185 | } 186 | 187 | return true; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /functions/src/test/config-test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import "mocha"; 17 | 18 | import * as assert from "assert"; 19 | import * as log from "../log"; 20 | import * as encoding from "../shared/encoding"; 21 | 22 | describe("Configuration", async () => { 23 | before(() => { 24 | log.setLogLevel(log.Level.WARN); 25 | }); 26 | 27 | after(() => { 28 | log.setLogLevel(log.Level.ALL); 29 | }); 30 | 31 | it("should properly encode and decode keys", async () => { 32 | const cases = [ 33 | { 34 | original: "a", 35 | encoded: "a" 36 | }, 37 | { 38 | original: "a:b", 39 | encoded: "a0col0b" 40 | }, 41 | { 42 | original: "a b", 43 | encoded: "a0spc0b" 44 | }, 45 | { 46 | original: "a: b", 47 | encoded: "a0col00spc0b" 48 | }, 49 | { 50 | original: "a b", 51 | encoded: "a0spc00spc0b" 52 | }, 53 | { 54 | original: ".github/foo.md", 55 | encoded: "0dgh00sls0foo0dmd0" 56 | } 57 | ]; 58 | 59 | for (const c of cases) { 60 | const encoded = encoding.encodeKey(c.original); 61 | const decoded = encoding.decodeKey(encoded); 62 | assert.deepEqual(c.encoded, encoded); 63 | assert.deepEqual(c.original, decoded); 64 | } 65 | }); 66 | 67 | it("should properly flatten a config", async () => { 68 | const deep = { 69 | a: 1, 70 | b: { 71 | x: 2, 72 | "y: z": 3 73 | } 74 | }; 75 | 76 | const flat = { 77 | a: 1, 78 | "b.x": 2, 79 | "b.y0col00spc0z": 3 80 | }; 81 | 82 | assert.deepEqual( 83 | encoding.flattenConfig(deep, encoding.Direction.ENCODE), 84 | flat 85 | ); 86 | }); 87 | 88 | it("should properly deep decode an object", async () => { 89 | const encoded = { 90 | a: 1, 91 | b: { 92 | c0col0d: 2, 93 | e0spc0f: { 94 | g0spc0h: 3 95 | } 96 | } 97 | }; 98 | 99 | const decoded = { 100 | a: 1, 101 | b: { 102 | "c:d": 2, 103 | "e f": { 104 | "g h": 3 105 | } 106 | } 107 | }; 108 | 109 | assert.deepEqual(encoding.deepDecodeObject(encoded), decoded); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /functions/src/test/mock_data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "samtstern": { 3 | "bottest": { 4 | "labels": { 5 | "auth": { 6 | "regex": "Product:[\\s]+?[Aa]uth", 7 | "email": "samstern+auth@google.com" 8 | }, 9 | "database": { 10 | "regex": "Product:[\\s]+?[Dd]atabase", 11 | "email": "samstern+database@google.com" 12 | }, 13 | "messaging": { 14 | "regex": "Product:[\\s]+?[Mm]essaging" 15 | }, 16 | "storage": { 17 | "regex": "Product:[\\s]+?[Ss]torage", 18 | "email": "samstern+storage@google.com" 19 | } 20 | }, 21 | "templates": { 22 | "issue": ".github/ISSUE_TEMPLATE.md" 23 | }, 24 | "cleanup": { 25 | "issue": { 26 | "label_needs_info": "needs-info", 27 | "label_needs_attention": "needs-attention", 28 | "label_stale": "stale", 29 | "ignore_labels": [ 30 | "feature-request" 31 | ], 32 | "needs_info_days": 7, 33 | "stale_days": 3 34 | } 35 | } 36 | } 37 | }, 38 | "google": { 39 | "exoplayer": { 40 | "cleanup": { 41 | "issue": { 42 | "label_needs_info": "needs-info", 43 | "label_stale": "stale", 44 | "ignore_labels": [ 45 | "feature-request" 46 | ], 47 | "needs_info_days": 7, 48 | "stale_days": 3 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_opened_bot_test_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "url": "https://api.github.com/repos/samtstern/BotTest/issues/50", 5 | "repository_url": "https://api.github.com/repos/samtstern/BotTest", 6 | "labels_url": "https://api.github.com/repos/samtstern/BotTest/issues/50/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/samtstern/BotTest/issues/50/comments", 8 | "events_url": "https://api.github.com/repos/samtstern/BotTest/issues/50/events", 9 | "html_url": "https://github.com/samtstern/BotTest/issues/50", 10 | "id": 229497821, 11 | "number": 50, 12 | "title": "Gonna do nothing here, I hope the bot is nice to me!", 13 | "user": { 14 | "login": "samtstern", 15 | "id": 8466666, 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/8466666?v=3", 17 | "gravatar_id": "", 18 | "url": "https://api.github.com/users/samtstern", 19 | "html_url": "https://github.com/samtstern", 20 | "followers_url": "https://api.github.com/users/samtstern/followers", 21 | "following_url": "https://api.github.com/users/samtstern/following{/other_user}", 22 | "gists_url": "https://api.github.com/users/samtstern/gists{/gist_id}", 23 | "starred_url": "https://api.github.com/users/samtstern/starred{/owner}{/repo}", 24 | "subscriptions_url": "https://api.github.com/users/samtstern/subscriptions", 25 | "organizations_url": "https://api.github.com/users/samtstern/orgs", 26 | "repos_url": "https://api.github.com/users/samtstern/repos", 27 | "events_url": "https://api.github.com/users/samtstern/events{/privacy}", 28 | "received_events_url": "https://api.github.com/users/samtstern/received_events", 29 | "type": "User", 30 | "site_admin": false 31 | }, 32 | "labels": [ 33 | 34 | ], 35 | "state": "open", 36 | "locked": false, 37 | "assignee": null, 38 | "assignees": [ 39 | 40 | ], 41 | "milestone": null, 42 | "comments": 0, 43 | "created_at": "2017-05-17T21:51:17Z", 44 | "updated_at": "2017-05-17T21:51:17Z", 45 | "closed_at": null, 46 | "body": "### [READ] Step 1: Are you in the right place?\r\n\r\n * For issues or feature requests related to __the code in this repository__\r\n file a GitHub issue.\r\n * If this is a __feature request__ make sure the issue title starts with \"FR:\".\r\n * For general technical questions, post a question on [StackOverflow](http://stackoverflow.com/)\r\n with the firebase tag.\r\n * For general Firebase discussion, use the [firebase-talk](https://groups.google.com/forum/#!forum/firebase-talk)\r\n google group.\r\n * For help troubleshooting your application that does not fall under one\r\n of the above categories, reach out to the personalized\r\n [Firebase support channel](https://firebase.google.com/support/).\r\n\r\n### [REQUIRED] Step 2: Describe your environment\r\n\r\n * Operating System version: _____\r\n * Firebase SDK version: _____\r\n * Library version: _____\r\n * Firebase Product: _____ (auth, database, storage, etc)\r\n\r\n### [REQUIRED] Step 3: Describe the problem\r\n\r\n#### Steps to reproduce:\r\n\r\nWhat happened? How can we make the problem occur?\r\nThis could be a description, log/console output, etc.\r\n\r\n#### Relevant Code:\r\n\r\n```\r\n// TODO(you): code here to reproduce the problem\r\n```\r\n" 47 | }, 48 | "repository": { 49 | "id": 84993274, 50 | "name": "BotTest", 51 | "full_name": "samtstern/BotTest", 52 | "owner": { 53 | "login": "samtstern", 54 | "id": 8466666, 55 | "avatar_url": "https://avatars1.githubusercontent.com/u/8466666?v=3", 56 | "gravatar_id": "", 57 | "url": "https://api.github.com/users/samtstern", 58 | "html_url": "https://github.com/samtstern", 59 | "followers_url": "https://api.github.com/users/samtstern/followers", 60 | "following_url": "https://api.github.com/users/samtstern/following{/other_user}", 61 | "gists_url": "https://api.github.com/users/samtstern/gists{/gist_id}", 62 | "starred_url": "https://api.github.com/users/samtstern/starred{/owner}{/repo}", 63 | "subscriptions_url": "https://api.github.com/users/samtstern/subscriptions", 64 | "organizations_url": "https://api.github.com/users/samtstern/orgs", 65 | "repos_url": "https://api.github.com/users/samtstern/repos", 66 | "events_url": "https://api.github.com/users/samtstern/events{/privacy}", 67 | "received_events_url": "https://api.github.com/users/samtstern/received_events", 68 | "type": "User", 69 | "site_admin": false 70 | }, 71 | "private": true, 72 | "html_url": "https://github.com/samtstern/BotTest", 73 | "description": "A repository for testing webhooks", 74 | "fork": false, 75 | "url": "https://api.github.com/repos/samtstern/BotTest", 76 | "forks_url": "https://api.github.com/repos/samtstern/BotTest/forks", 77 | "keys_url": "https://api.github.com/repos/samtstern/BotTest/keys{/key_id}", 78 | "collaborators_url": "https://api.github.com/repos/samtstern/BotTest/collaborators{/collaborator}", 79 | "teams_url": "https://api.github.com/repos/samtstern/BotTest/teams", 80 | "hooks_url": "https://api.github.com/repos/samtstern/BotTest/hooks", 81 | "issue_events_url": "https://api.github.com/repos/samtstern/BotTest/issues/events{/number}", 82 | "events_url": "https://api.github.com/repos/samtstern/BotTest/events", 83 | "assignees_url": "https://api.github.com/repos/samtstern/BotTest/assignees{/user}", 84 | "branches_url": "https://api.github.com/repos/samtstern/BotTest/branches{/branch}", 85 | "tags_url": "https://api.github.com/repos/samtstern/BotTest/tags", 86 | "blobs_url": "https://api.github.com/repos/samtstern/BotTest/git/blobs{/sha}", 87 | "git_tags_url": "https://api.github.com/repos/samtstern/BotTest/git/tags{/sha}", 88 | "git_refs_url": "https://api.github.com/repos/samtstern/BotTest/git/refs{/sha}", 89 | "trees_url": "https://api.github.com/repos/samtstern/BotTest/git/trees{/sha}", 90 | "statuses_url": "https://api.github.com/repos/samtstern/BotTest/statuses/{sha}", 91 | "languages_url": "https://api.github.com/repos/samtstern/BotTest/languages", 92 | "stargazers_url": "https://api.github.com/repos/samtstern/BotTest/stargazers", 93 | "contributors_url": "https://api.github.com/repos/samtstern/BotTest/contributors", 94 | "subscribers_url": "https://api.github.com/repos/samtstern/BotTest/subscribers", 95 | "subscription_url": "https://api.github.com/repos/samtstern/BotTest/subscription", 96 | "commits_url": "https://api.github.com/repos/samtstern/BotTest/commits{/sha}", 97 | "git_commits_url": "https://api.github.com/repos/samtstern/BotTest/git/commits{/sha}", 98 | "comments_url": "https://api.github.com/repos/samtstern/BotTest/comments{/number}", 99 | "issue_comment_url": "https://api.github.com/repos/samtstern/BotTest/issues/comments{/number}", 100 | "contents_url": "https://api.github.com/repos/samtstern/BotTest/contents/{+path}", 101 | "compare_url": "https://api.github.com/repos/samtstern/BotTest/compare/{base}...{head}", 102 | "merges_url": "https://api.github.com/repos/samtstern/BotTest/merges", 103 | "archive_url": "https://api.github.com/repos/samtstern/BotTest/{archive_format}{/ref}", 104 | "downloads_url": "https://api.github.com/repos/samtstern/BotTest/downloads", 105 | "issues_url": "https://api.github.com/repos/samtstern/BotTest/issues{/number}", 106 | "pulls_url": "https://api.github.com/repos/samtstern/BotTest/pulls{/number}", 107 | "milestones_url": "https://api.github.com/repos/samtstern/BotTest/milestones{/number}", 108 | "notifications_url": "https://api.github.com/repos/samtstern/BotTest/notifications{?since,all,participating}", 109 | "labels_url": "https://api.github.com/repos/samtstern/BotTest/labels{/name}", 110 | "releases_url": "https://api.github.com/repos/samtstern/BotTest/releases{/id}", 111 | "deployments_url": "https://api.github.com/repos/samtstern/BotTest/deployments", 112 | "created_at": "2017-03-14T20:18:17Z", 113 | "updated_at": "2017-05-16T17:21:34Z", 114 | "pushed_at": "2017-05-09T21:24:44Z", 115 | "git_url": "git://github.com/samtstern/BotTest.git", 116 | "ssh_url": "git@github.com:samtstern/BotTest.git", 117 | "clone_url": "https://github.com/samtstern/BotTest.git", 118 | "svn_url": "https://github.com/samtstern/BotTest", 119 | "homepage": null, 120 | "size": 5, 121 | "stargazers_count": 0, 122 | "watchers_count": 0, 123 | "language": null, 124 | "has_issues": true, 125 | "has_projects": true, 126 | "has_downloads": true, 127 | "has_wiki": true, 128 | "has_pages": false, 129 | "forks_count": 0, 130 | "mirror_url": null, 131 | "open_issues_count": 5, 132 | "forks": 0, 133 | "open_issues": 5, 134 | "watchers": 0, 135 | "default_branch": "master" 136 | }, 137 | "sender": { 138 | "login": "samtstern", 139 | "id": 8466666, 140 | "avatar_url": "https://avatars1.githubusercontent.com/u/8466666?v=3", 141 | "gravatar_id": "", 142 | "url": "https://api.github.com/users/samtstern", 143 | "html_url": "https://github.com/samtstern", 144 | "followers_url": "https://api.github.com/users/samtstern/followers", 145 | "following_url": "https://api.github.com/users/samtstern/following{/other_user}", 146 | "gists_url": "https://api.github.com/users/samtstern/gists{/gist_id}", 147 | "starred_url": "https://api.github.com/users/samtstern/starred{/owner}{/repo}", 148 | "subscriptions_url": "https://api.github.com/users/samtstern/subscriptions", 149 | "organizations_url": "https://api.github.com/users/samtstern/orgs", 150 | "repos_url": "https://api.github.com/users/samtstern/repos", 151 | "events_url": "https://api.github.com/users/samtstern/events{/privacy}", 152 | "received_events_url": "https://api.github.com/users/samtstern/received_events", 153 | "type": "User", 154 | "site_admin": false 155 | } 156 | } -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_opened_bot_test_partial.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "url": "https://api.github.com/repos/samtstern/BotTest/issues/29", 5 | "repository_url": "https://api.github.com/repos/samtstern/BotTest", 6 | "labels_url": "https://api.github.com/repos/samtstern/BotTest/issues/29/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/samtstern/BotTest/issues/29/comments", 8 | "events_url": "https://api.github.com/repos/samtstern/BotTest/issues/29/events", 9 | "html_url": "https://github.com/samtstern/BotTest/issues/29", 10 | "id": 224930420, 11 | "number": 29, 12 | "title": "This one has a topic but otherwise does not follow the template.", 13 | "user": { 14 | "login": "samtstern", 15 | "id": 8466666, 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/8466666?v=3", 17 | "gravatar_id": "", 18 | "url": "https://api.github.com/users/samtstern", 19 | "html_url": "https://github.com/samtstern", 20 | "followers_url": "https://api.github.com/users/samtstern/followers", 21 | "following_url": "https://api.github.com/users/samtstern/following{/other_user}", 22 | "gists_url": "https://api.github.com/users/samtstern/gists{/gist_id}", 23 | "starred_url": "https://api.github.com/users/samtstern/starred{/owner}{/repo}", 24 | "subscriptions_url": "https://api.github.com/users/samtstern/subscriptions", 25 | "organizations_url": "https://api.github.com/users/samtstern/orgs", 26 | "repos_url": "https://api.github.com/users/samtstern/repos", 27 | "events_url": "https://api.github.com/users/samtstern/events{/privacy}", 28 | "received_events_url": "https://api.github.com/users/samtstern/received_events", 29 | "type": "User", 30 | "site_admin": false 31 | }, 32 | "labels": [ 33 | 34 | ], 35 | "state": "open", 36 | "locked": false, 37 | "assignee": null, 38 | "assignees": [ 39 | 40 | ], 41 | "milestone": null, 42 | "comments": 0, 43 | "created_at": "2017-04-27T22:26:09Z", 44 | "updated_at": "2017-04-27T22:26:09Z", 45 | "closed_at": null, 46 | "body": "### [READ] Step 1: Are you in the right place?\r\n\r\n * For issues or feature requests related to __the code in this repository__\r\n file a GitHub issue.\r\n * For general technical questions, post a question on [StackOverflow](http://stackoverflow.com/)\r\n with the firebase tag.\r\n * For general Firebase discussion, use the [firebase-talk](https://groups.google.com/forum/#!forum/firebase-talk)\r\n google group.\r\n * For help troubleshooting your application that does not fall under one\r\n of the above categories, reach out to the personalized\r\n [Firebase support channel](https://firebase.google.com/support/).\r\n\r\n### [REQUIRED] Step 2: Describe your environment\r\n\r\n * Operating System version: MacOS\r\n * Firebase SDK version: 1.2.3\r\n * Library version: 3.4.5\r\n * Firebase Product: auth\r\n\r\n### [REQUIRED] Step 3: Describe the problem\r\n\r\n#### Steps to reproduce:\r\n\r\nWhat happened? How can we make the problem occur?\r\nThis could be a description, log/console output, etc.\r\n\r\n#### Relevant Code:\r\n\r\n```\r\n// TODO(you): code here to reproduce the problem\r\n```\r\n" 47 | }, 48 | "repository": { 49 | "id": 84993274, 50 | "name": "BotTest", 51 | "full_name": "samtstern/BotTest", 52 | "owner": { 53 | "login": "samtstern", 54 | "id": 8466666, 55 | "avatar_url": "https://avatars1.githubusercontent.com/u/8466666?v=3", 56 | "gravatar_id": "", 57 | "url": "https://api.github.com/users/samtstern", 58 | "html_url": "https://github.com/samtstern", 59 | "followers_url": "https://api.github.com/users/samtstern/followers", 60 | "following_url": "https://api.github.com/users/samtstern/following{/other_user}", 61 | "gists_url": "https://api.github.com/users/samtstern/gists{/gist_id}", 62 | "starred_url": "https://api.github.com/users/samtstern/starred{/owner}{/repo}", 63 | "subscriptions_url": "https://api.github.com/users/samtstern/subscriptions", 64 | "organizations_url": "https://api.github.com/users/samtstern/orgs", 65 | "repos_url": "https://api.github.com/users/samtstern/repos", 66 | "events_url": "https://api.github.com/users/samtstern/events{/privacy}", 67 | "received_events_url": "https://api.github.com/users/samtstern/received_events", 68 | "type": "User", 69 | "site_admin": false 70 | }, 71 | "private": false, 72 | "html_url": "https://github.com/samtstern/BotTest", 73 | "description": "A repository for testing webhooks", 74 | "fork": false, 75 | "url": "https://api.github.com/repos/samtstern/BotTest", 76 | "forks_url": "https://api.github.com/repos/samtstern/BotTest/forks", 77 | "keys_url": "https://api.github.com/repos/samtstern/BotTest/keys{/key_id}", 78 | "collaborators_url": "https://api.github.com/repos/samtstern/BotTest/collaborators{/collaborator}", 79 | "teams_url": "https://api.github.com/repos/samtstern/BotTest/teams", 80 | "hooks_url": "https://api.github.com/repos/samtstern/BotTest/hooks", 81 | "issue_events_url": "https://api.github.com/repos/samtstern/BotTest/issues/events{/number}", 82 | "events_url": "https://api.github.com/repos/samtstern/BotTest/events", 83 | "assignees_url": "https://api.github.com/repos/samtstern/BotTest/assignees{/user}", 84 | "branches_url": "https://api.github.com/repos/samtstern/BotTest/branches{/branch}", 85 | "tags_url": "https://api.github.com/repos/samtstern/BotTest/tags", 86 | "blobs_url": "https://api.github.com/repos/samtstern/BotTest/git/blobs{/sha}", 87 | "git_tags_url": "https://api.github.com/repos/samtstern/BotTest/git/tags{/sha}", 88 | "git_refs_url": "https://api.github.com/repos/samtstern/BotTest/git/refs{/sha}", 89 | "trees_url": "https://api.github.com/repos/samtstern/BotTest/git/trees{/sha}", 90 | "statuses_url": "https://api.github.com/repos/samtstern/BotTest/statuses/{sha}", 91 | "languages_url": "https://api.github.com/repos/samtstern/BotTest/languages", 92 | "stargazers_url": "https://api.github.com/repos/samtstern/BotTest/stargazers", 93 | "contributors_url": "https://api.github.com/repos/samtstern/BotTest/contributors", 94 | "subscribers_url": "https://api.github.com/repos/samtstern/BotTest/subscribers", 95 | "subscription_url": "https://api.github.com/repos/samtstern/BotTest/subscription", 96 | "commits_url": "https://api.github.com/repos/samtstern/BotTest/commits{/sha}", 97 | "git_commits_url": "https://api.github.com/repos/samtstern/BotTest/git/commits{/sha}", 98 | "comments_url": "https://api.github.com/repos/samtstern/BotTest/comments{/number}", 99 | "issue_comment_url": "https://api.github.com/repos/samtstern/BotTest/issues/comments{/number}", 100 | "contents_url": "https://api.github.com/repos/samtstern/BotTest/contents/{+path}", 101 | "compare_url": "https://api.github.com/repos/samtstern/BotTest/compare/{base}...{head}", 102 | "merges_url": "https://api.github.com/repos/samtstern/BotTest/merges", 103 | "archive_url": "https://api.github.com/repos/samtstern/BotTest/{archive_format}{/ref}", 104 | "downloads_url": "https://api.github.com/repos/samtstern/BotTest/downloads", 105 | "issues_url": "https://api.github.com/repos/samtstern/BotTest/issues{/number}", 106 | "pulls_url": "https://api.github.com/repos/samtstern/BotTest/pulls{/number}", 107 | "milestones_url": "https://api.github.com/repos/samtstern/BotTest/milestones{/number}", 108 | "notifications_url": "https://api.github.com/repos/samtstern/BotTest/notifications{?since,all,participating}", 109 | "labels_url": "https://api.github.com/repos/samtstern/BotTest/labels{/name}", 110 | "releases_url": "https://api.github.com/repos/samtstern/BotTest/releases{/id}", 111 | "deployments_url": "https://api.github.com/repos/samtstern/BotTest/deployments", 112 | "created_at": "2017-03-14T20:18:17Z", 113 | "updated_at": "2017-03-14T20:18:17Z", 114 | "pushed_at": "2017-04-27T18:32:18Z", 115 | "git_url": "git://github.com/samtstern/BotTest.git", 116 | "ssh_url": "git@github.com:samtstern/BotTest.git", 117 | "clone_url": "https://github.com/samtstern/BotTest.git", 118 | "svn_url": "https://github.com/samtstern/BotTest", 119 | "homepage": null, 120 | "size": 3, 121 | "stargazers_count": 0, 122 | "watchers_count": 0, 123 | "language": null, 124 | "has_issues": true, 125 | "has_projects": true, 126 | "has_downloads": true, 127 | "has_wiki": true, 128 | "has_pages": false, 129 | "forks_count": 0, 130 | "mirror_url": null, 131 | "open_issues_count": 8, 132 | "forks": 0, 133 | "open_issues": 8, 134 | "watchers": 0, 135 | "default_branch": "master" 136 | }, 137 | "sender": { 138 | "login": "samtstern", 139 | "id": 8466666, 140 | "avatar_url": "https://avatars1.githubusercontent.com/u/8466666?v=3", 141 | "gravatar_id": "", 142 | "url": "https://api.github.com/users/samtstern", 143 | "html_url": "https://github.com/samtstern", 144 | "followers_url": "https://api.github.com/users/samtstern/followers", 145 | "following_url": "https://api.github.com/users/samtstern/following{/other_user}", 146 | "gists_url": "https://api.github.com/users/samtstern/gists{/gist_id}", 147 | "starred_url": "https://api.github.com/users/samtstern/starred{/owner}{/repo}", 148 | "subscriptions_url": "https://api.github.com/users/samtstern/subscriptions", 149 | "organizations_url": "https://api.github.com/users/samtstern/orgs", 150 | "repos_url": "https://api.github.com/users/samtstern/repos", 151 | "events_url": "https://api.github.com/users/samtstern/events{/privacy}", 152 | "received_events_url": "https://api.github.com/users/samtstern/received_events", 153 | "type": "User", 154 | "site_admin": false 155 | } 156 | } -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_opened_js_sdk_35.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/35", 5 | "repository_url": "https://api.github.com/repos/firebase/firebase-js-sdk", 6 | "labels_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/35/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/35/comments", 8 | "events_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/35/events", 9 | "html_url": "https://github.com/firebase/firebase-js-sdk/issues/35", 10 | "id": 233943662, 11 | "number": 35, 12 | "title": "`startAt()` does not respect 2nd parameter when ordering by child", 13 | "user": { 14 | "login": "tjwoon", 15 | "id": 5500559, 16 | "avatar_url": "https://avatars3.githubusercontent.com/u/5500559?v=3", 17 | "gravatar_id": "", 18 | "url": "https://api.github.com/users/tjwoon", 19 | "html_url": "https://github.com/tjwoon", 20 | "followers_url": "https://api.github.com/users/tjwoon/followers", 21 | "following_url": "https://api.github.com/users/tjwoon/following{/other_user}", 22 | "gists_url": "https://api.github.com/users/tjwoon/gists{/gist_id}", 23 | "starred_url": "https://api.github.com/users/tjwoon/starred{/owner}{/repo}", 24 | "subscriptions_url": "https://api.github.com/users/tjwoon/subscriptions", 25 | "organizations_url": "https://api.github.com/users/tjwoon/orgs", 26 | "repos_url": "https://api.github.com/users/tjwoon/repos", 27 | "events_url": "https://api.github.com/users/tjwoon/events{/privacy}", 28 | "received_events_url": "https://api.github.com/users/tjwoon/received_events", 29 | "type": "User", 30 | "site_admin": false 31 | }, 32 | "labels": [ 33 | 34 | ], 35 | "state": "open", 36 | "locked": false, 37 | "assignee": null, 38 | "assignees": [ 39 | 40 | ], 41 | "milestone": null, 42 | "comments": 0, 43 | "created_at": "2017-06-06T16:05:27Z", 44 | "updated_at": "2017-06-06T16:05:27Z", 45 | "closed_at": null, 46 | "body": "### Describe your environment\r\n\r\n * Operating System version: macOS 10.12.4\r\n * Firebase SDK version: 4.1.1\r\n * Firebase Product: database\r\n\r\n### Describe the problem\r\n\r\nUsing a query with `orderByChild()` and `startAt(null, 'some-key')`, I expect the results to be returned starting with the record with key `'some-key'`, however returned results start at the very first item to match any given filter.\r\n\r\n#### Steps to reproduce:\r\n\r\nStart with this data:\r\n\r\n```json\r\n{\r\n \"products\" : {\r\n \"-KlsqFgVWwUrA-j0VsZS\" : {\r\n \"name\" : \"Product 4\",\r\n \"price\" : 666\r\n },\r\n \"-Klst-cLSckuwAuNAJF8\" : {\r\n \"name\" : \"Product 1\",\r\n \"price\" : 100\r\n },\r\n \"-Klst7IINdt8YeMmauRz\" : {\r\n \"name\" : \"Product 2\",\r\n \"price\" : 50\r\n },\r\n \"-Klst9KfM2QWp8kXrOlR\" : {\r\n \"name\" : \"Product 6\",\r\n \"price\" : 30\r\n },\r\n \"-KlstB51ap1L2tcK8cL6\" : {\r\n \"name\" : \"Product 5\",\r\n \"price\" : 99\r\n },\r\n \"-KlstDR5cCayGH0XKtZ0\" : {\r\n \"name\" : \"Product 3\",\r\n \"price\" : 500\r\n }\r\n }\r\n}\r\n```\r\n\r\nI retrieve the first three matching records from this list using this code:\r\n\r\n```javascript\r\nfirebase.database().ref('products')\r\n.orderByChild('price')\r\n.limitToFirst(3)\r\n.on('child_added', function (snapshot) {\r\n var key = snapshot.key;\r\n var data = snapshot.val();\r\n console.log(key + ': ' + JSON.stringify(data))\r\n})\r\n```\r\n\r\nUsing the key of the 3rd record, I try to retrieve records number 3-5:\r\n\r\n```javascript\r\nfirebase.database().ref('products')\r\n.orderByChild('price')\r\n.startAt(null, '-KlstB51ap1L2tcK8cL6') // this is the key of the 3rd matching record.\r\n.limitToFirst(3)\r\n.on('child_added', function (snapshot) {\r\n var key = snapshot.key;\r\n var data = snapshot.val();\r\n console.log(key + ': ' + JSON.stringify(data))\r\n})\r\n```\r\n\r\n\r\nHowever, if I remove the line `.orderByChild('price')`, then I am able to use `.startAt()` to retrieve items starting at the given record, which is very weird, because the default ordering is `.orderByKey()`, which is not supposed to support startAt's 2nd parameter according to [the documentation](https://firebase.google.com/docs/reference/js/firebase.database.Query#startAt), and also according to [the source code](https://github.com/firebase/firebase-js-sdk/blob/v4.1.1/src/database/js-client/api/Query.js#L68)" 47 | }, 48 | "repository": { 49 | "id": 89290483, 50 | "name": "firebase-js-sdk", 51 | "full_name": "firebase/firebase-js-sdk", 52 | "owner": { 53 | "login": "firebase", 54 | "id": 1335026, 55 | "avatar_url": "https://avatars1.githubusercontent.com/u/1335026?v=3", 56 | "gravatar_id": "", 57 | "url": "https://api.github.com/users/firebase", 58 | "html_url": "https://github.com/firebase", 59 | "followers_url": "https://api.github.com/users/firebase/followers", 60 | "following_url": "https://api.github.com/users/firebase/following{/other_user}", 61 | "gists_url": "https://api.github.com/users/firebase/gists{/gist_id}", 62 | "starred_url": "https://api.github.com/users/firebase/starred{/owner}{/repo}", 63 | "subscriptions_url": "https://api.github.com/users/firebase/subscriptions", 64 | "organizations_url": "https://api.github.com/users/firebase/orgs", 65 | "repos_url": "https://api.github.com/users/firebase/repos", 66 | "events_url": "https://api.github.com/users/firebase/events{/privacy}", 67 | "received_events_url": "https://api.github.com/users/firebase/received_events", 68 | "type": "Organization", 69 | "site_admin": false 70 | }, 71 | "private": false, 72 | "html_url": "https://github.com/firebase/firebase-js-sdk", 73 | "description": "Firebase Javascript SDK", 74 | "fork": false, 75 | "url": "https://api.github.com/repos/firebase/firebase-js-sdk", 76 | "forks_url": "https://api.github.com/repos/firebase/firebase-js-sdk/forks", 77 | "keys_url": "https://api.github.com/repos/firebase/firebase-js-sdk/keys{/key_id}", 78 | "collaborators_url": "https://api.github.com/repos/firebase/firebase-js-sdk/collaborators{/collaborator}", 79 | "teams_url": "https://api.github.com/repos/firebase/firebase-js-sdk/teams", 80 | "hooks_url": "https://api.github.com/repos/firebase/firebase-js-sdk/hooks", 81 | "issue_events_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/events{/number}", 82 | "events_url": "https://api.github.com/repos/firebase/firebase-js-sdk/events", 83 | "assignees_url": "https://api.github.com/repos/firebase/firebase-js-sdk/assignees{/user}", 84 | "branches_url": "https://api.github.com/repos/firebase/firebase-js-sdk/branches{/branch}", 85 | "tags_url": "https://api.github.com/repos/firebase/firebase-js-sdk/tags", 86 | "blobs_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/blobs{/sha}", 87 | "git_tags_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/tags{/sha}", 88 | "git_refs_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/refs{/sha}", 89 | "trees_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/trees{/sha}", 90 | "statuses_url": "https://api.github.com/repos/firebase/firebase-js-sdk/statuses/{sha}", 91 | "languages_url": "https://api.github.com/repos/firebase/firebase-js-sdk/languages", 92 | "stargazers_url": "https://api.github.com/repos/firebase/firebase-js-sdk/stargazers", 93 | "contributors_url": "https://api.github.com/repos/firebase/firebase-js-sdk/contributors", 94 | "subscribers_url": "https://api.github.com/repos/firebase/firebase-js-sdk/subscribers", 95 | "subscription_url": "https://api.github.com/repos/firebase/firebase-js-sdk/subscription", 96 | "commits_url": "https://api.github.com/repos/firebase/firebase-js-sdk/commits{/sha}", 97 | "git_commits_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/commits{/sha}", 98 | "comments_url": "https://api.github.com/repos/firebase/firebase-js-sdk/comments{/number}", 99 | "issue_comment_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/comments{/number}", 100 | "contents_url": "https://api.github.com/repos/firebase/firebase-js-sdk/contents/{+path}", 101 | "compare_url": "https://api.github.com/repos/firebase/firebase-js-sdk/compare/{base}...{head}", 102 | "merges_url": "https://api.github.com/repos/firebase/firebase-js-sdk/merges", 103 | "archive_url": "https://api.github.com/repos/firebase/firebase-js-sdk/{archive_format}{/ref}", 104 | "downloads_url": "https://api.github.com/repos/firebase/firebase-js-sdk/downloads", 105 | "issues_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues{/number}", 106 | "pulls_url": "https://api.github.com/repos/firebase/firebase-js-sdk/pulls{/number}", 107 | "milestones_url": "https://api.github.com/repos/firebase/firebase-js-sdk/milestones{/number}", 108 | "notifications_url": "https://api.github.com/repos/firebase/firebase-js-sdk/notifications{?since,all,participating}", 109 | "labels_url": "https://api.github.com/repos/firebase/firebase-js-sdk/labels{/name}", 110 | "releases_url": "https://api.github.com/repos/firebase/firebase-js-sdk/releases{/id}", 111 | "deployments_url": "https://api.github.com/repos/firebase/firebase-js-sdk/deployments", 112 | "created_at": "2017-04-24T21:52:11Z", 113 | "updated_at": "2017-06-06T14:42:37Z", 114 | "pushed_at": "2017-06-05T20:13:13Z", 115 | "git_url": "git://github.com/firebase/firebase-js-sdk.git", 116 | "ssh_url": "git@github.com:firebase/firebase-js-sdk.git", 117 | "clone_url": "https://github.com/firebase/firebase-js-sdk.git", 118 | "svn_url": "https://github.com/firebase/firebase-js-sdk", 119 | "homepage": "https://firebase.google.com/docs/web/setup", 120 | "size": 10781, 121 | "stargazers_count": 384, 122 | "watchers_count": 384, 123 | "language": "JavaScript", 124 | "has_issues": true, 125 | "has_projects": true, 126 | "has_downloads": true, 127 | "has_wiki": true, 128 | "has_pages": false, 129 | "forks_count": 19, 130 | "mirror_url": null, 131 | "open_issues_count": 10, 132 | "forks": 19, 133 | "open_issues": 10, 134 | "watchers": 384, 135 | "default_branch": "master" 136 | }, 137 | "organization": { 138 | "login": "firebase", 139 | "id": 1335026, 140 | "url": "https://api.github.com/orgs/firebase", 141 | "repos_url": "https://api.github.com/orgs/firebase/repos", 142 | "events_url": "https://api.github.com/orgs/firebase/events", 143 | "hooks_url": "https://api.github.com/orgs/firebase/hooks", 144 | "issues_url": "https://api.github.com/orgs/firebase/issues", 145 | "members_url": "https://api.github.com/orgs/firebase/members{/member}", 146 | "public_members_url": "https://api.github.com/orgs/firebase/public_members{/member}", 147 | "avatar_url": "https://avatars1.githubusercontent.com/u/1335026?v=3", 148 | "description": "" 149 | }, 150 | "sender": { 151 | "login": "tjwoon", 152 | "id": 5500559, 153 | "avatar_url": "https://avatars3.githubusercontent.com/u/5500559?v=3", 154 | "gravatar_id": "", 155 | "url": "https://api.github.com/users/tjwoon", 156 | "html_url": "https://github.com/tjwoon", 157 | "followers_url": "https://api.github.com/users/tjwoon/followers", 158 | "following_url": "https://api.github.com/users/tjwoon/following{/other_user}", 159 | "gists_url": "https://api.github.com/users/tjwoon/gists{/gist_id}", 160 | "starred_url": "https://api.github.com/users/tjwoon/starred{/owner}{/repo}", 161 | "subscriptions_url": "https://api.github.com/users/tjwoon/subscriptions", 162 | "organizations_url": "https://api.github.com/users/tjwoon/orgs", 163 | "repos_url": "https://api.github.com/users/tjwoon/repos", 164 | "events_url": "https://api.github.com/users/tjwoon/events{/privacy}", 165 | "received_events_url": "https://api.github.com/users/tjwoon/received_events", 166 | "type": "User", 167 | "site_admin": false 168 | } 169 | } -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_opened_js_sdk_59.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/59", 5 | "repository_url": "https://api.github.com/repos/firebase/firebase-js-sdk", 6 | "labels_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/59/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/59/comments", 8 | "events_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/59/events", 9 | "html_url": "https://github.com/firebase/firebase-js-sdk/issues/59", 10 | "id": 236509053, 11 | "number": 59, 12 | "title": "[Messaging] onTokenRefresh never triggers", 13 | "user": { 14 | "login": "jsayol", 15 | "id": 2029586, 16 | "avatar_url": "https://avatars2.githubusercontent.com/u/2029586?v=3", 17 | "gravatar_id": "", 18 | "url": "https://api.github.com/users/jsayol", 19 | "html_url": "https://github.com/jsayol", 20 | "followers_url": "https://api.github.com/users/jsayol/followers", 21 | "following_url": "https://api.github.com/users/jsayol/following{/other_user}", 22 | "gists_url": "https://api.github.com/users/jsayol/gists{/gist_id}", 23 | "starred_url": "https://api.github.com/users/jsayol/starred{/owner}{/repo}", 24 | "subscriptions_url": "https://api.github.com/users/jsayol/subscriptions", 25 | "organizations_url": "https://api.github.com/users/jsayol/orgs", 26 | "repos_url": "https://api.github.com/users/jsayol/repos", 27 | "events_url": "https://api.github.com/users/jsayol/events{/privacy}", 28 | "received_events_url": "https://api.github.com/users/jsayol/received_events", 29 | "type": "User", 30 | "site_admin": false 31 | }, 32 | "labels": [ 33 | 34 | ], 35 | "state": "open", 36 | "locked": false, 37 | "assignee": null, 38 | "assignees": [ 39 | 40 | ], 41 | "milestone": null, 42 | "comments": 0, 43 | "created_at": "2017-06-16T15:01:32Z", 44 | "updated_at": "2017-06-16T15:01:32Z", 45 | "closed_at": null, 46 | "body": "### [REQUIRED] Describe your environment\r\n\r\n * Operating System version: Linux\r\n * Firebase SDK version: 4.1.2\r\n * Firebase Product: messaging\r\n\r\n### [REQUIRED] Describe the problem\r\nI noticed that nothing ever gets _next_'ed into `tokenRefreshObserver_`, meaning that the function or observer passed to `firebase.messaging.onTokenRefresh()` never gets triggered.\r\n\r\nSee: [src/messaging/controllers/window-controller.ts#L72-L75](https://github.com/firebase/firebase-js-sdk/blob/fd0728138d88c454f8e38a78f35d831d6365070c/src/messaging/controllers/window-controller.ts#L72-L75)\r\n\r\n#### Steps to reproduce:\r\nn/a\r\n\r\n#### Relevant Code:\r\nSee linked file.\r\n" 47 | }, 48 | "repository": { 49 | "id": 89290483, 50 | "name": "firebase-js-sdk", 51 | "full_name": "firebase/firebase-js-sdk", 52 | "owner": { 53 | "login": "firebase", 54 | "id": 1335026, 55 | "avatar_url": "https://avatars1.githubusercontent.com/u/1335026?v=3", 56 | "gravatar_id": "", 57 | "url": "https://api.github.com/users/firebase", 58 | "html_url": "https://github.com/firebase", 59 | "followers_url": "https://api.github.com/users/firebase/followers", 60 | "following_url": "https://api.github.com/users/firebase/following{/other_user}", 61 | "gists_url": "https://api.github.com/users/firebase/gists{/gist_id}", 62 | "starred_url": "https://api.github.com/users/firebase/starred{/owner}{/repo}", 63 | "subscriptions_url": "https://api.github.com/users/firebase/subscriptions", 64 | "organizations_url": "https://api.github.com/users/firebase/orgs", 65 | "repos_url": "https://api.github.com/users/firebase/repos", 66 | "events_url": "https://api.github.com/users/firebase/events{/privacy}", 67 | "received_events_url": "https://api.github.com/users/firebase/received_events", 68 | "type": "Organization", 69 | "site_admin": false 70 | }, 71 | "private": false, 72 | "html_url": "https://github.com/firebase/firebase-js-sdk", 73 | "description": "Firebase Javascript SDK", 74 | "fork": false, 75 | "url": "https://api.github.com/repos/firebase/firebase-js-sdk", 76 | "forks_url": "https://api.github.com/repos/firebase/firebase-js-sdk/forks", 77 | "keys_url": "https://api.github.com/repos/firebase/firebase-js-sdk/keys{/key_id}", 78 | "collaborators_url": "https://api.github.com/repos/firebase/firebase-js-sdk/collaborators{/collaborator}", 79 | "teams_url": "https://api.github.com/repos/firebase/firebase-js-sdk/teams", 80 | "hooks_url": "https://api.github.com/repos/firebase/firebase-js-sdk/hooks", 81 | "issue_events_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/events{/number}", 82 | "events_url": "https://api.github.com/repos/firebase/firebase-js-sdk/events", 83 | "assignees_url": "https://api.github.com/repos/firebase/firebase-js-sdk/assignees{/user}", 84 | "branches_url": "https://api.github.com/repos/firebase/firebase-js-sdk/branches{/branch}", 85 | "tags_url": "https://api.github.com/repos/firebase/firebase-js-sdk/tags", 86 | "blobs_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/blobs{/sha}", 87 | "git_tags_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/tags{/sha}", 88 | "git_refs_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/refs{/sha}", 89 | "trees_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/trees{/sha}", 90 | "statuses_url": "https://api.github.com/repos/firebase/firebase-js-sdk/statuses/{sha}", 91 | "languages_url": "https://api.github.com/repos/firebase/firebase-js-sdk/languages", 92 | "stargazers_url": "https://api.github.com/repos/firebase/firebase-js-sdk/stargazers", 93 | "contributors_url": "https://api.github.com/repos/firebase/firebase-js-sdk/contributors", 94 | "subscribers_url": "https://api.github.com/repos/firebase/firebase-js-sdk/subscribers", 95 | "subscription_url": "https://api.github.com/repos/firebase/firebase-js-sdk/subscription", 96 | "commits_url": "https://api.github.com/repos/firebase/firebase-js-sdk/commits{/sha}", 97 | "git_commits_url": "https://api.github.com/repos/firebase/firebase-js-sdk/git/commits{/sha}", 98 | "comments_url": "https://api.github.com/repos/firebase/firebase-js-sdk/comments{/number}", 99 | "issue_comment_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues/comments{/number}", 100 | "contents_url": "https://api.github.com/repos/firebase/firebase-js-sdk/contents/{+path}", 101 | "compare_url": "https://api.github.com/repos/firebase/firebase-js-sdk/compare/{base}...{head}", 102 | "merges_url": "https://api.github.com/repos/firebase/firebase-js-sdk/merges", 103 | "archive_url": "https://api.github.com/repos/firebase/firebase-js-sdk/{archive_format}{/ref}", 104 | "downloads_url": "https://api.github.com/repos/firebase/firebase-js-sdk/downloads", 105 | "issues_url": "https://api.github.com/repos/firebase/firebase-js-sdk/issues{/number}", 106 | "pulls_url": "https://api.github.com/repos/firebase/firebase-js-sdk/pulls{/number}", 107 | "milestones_url": "https://api.github.com/repos/firebase/firebase-js-sdk/milestones{/number}", 108 | "notifications_url": "https://api.github.com/repos/firebase/firebase-js-sdk/notifications{?since,all,participating}", 109 | "labels_url": "https://api.github.com/repos/firebase/firebase-js-sdk/labels{/name}", 110 | "releases_url": "https://api.github.com/repos/firebase/firebase-js-sdk/releases{/id}", 111 | "deployments_url": "https://api.github.com/repos/firebase/firebase-js-sdk/deployments", 112 | "created_at": "2017-04-24T21:52:11Z", 113 | "updated_at": "2017-06-16T05:41:47Z", 114 | "pushed_at": "2017-06-16T14:48:20Z", 115 | "git_url": "git://github.com/firebase/firebase-js-sdk.git", 116 | "ssh_url": "git@github.com:firebase/firebase-js-sdk.git", 117 | "clone_url": "https://github.com/firebase/firebase-js-sdk.git", 118 | "svn_url": "https://github.com/firebase/firebase-js-sdk", 119 | "homepage": "https://firebase.google.com/docs/web/setup", 120 | "size": 11149, 121 | "stargazers_count": 429, 122 | "watchers_count": 429, 123 | "language": "JavaScript", 124 | "has_issues": true, 125 | "has_projects": true, 126 | "has_downloads": true, 127 | "has_wiki": true, 128 | "has_pages": false, 129 | "forks_count": 25, 130 | "mirror_url": null, 131 | "open_issues_count": 15, 132 | "forks": 25, 133 | "open_issues": 15, 134 | "watchers": 429, 135 | "default_branch": "master" 136 | }, 137 | "organization": { 138 | "login": "firebase", 139 | "id": 1335026, 140 | "url": "https://api.github.com/orgs/firebase", 141 | "repos_url": "https://api.github.com/orgs/firebase/repos", 142 | "events_url": "https://api.github.com/orgs/firebase/events", 143 | "hooks_url": "https://api.github.com/orgs/firebase/hooks", 144 | "issues_url": "https://api.github.com/orgs/firebase/issues", 145 | "members_url": "https://api.github.com/orgs/firebase/members{/member}", 146 | "public_members_url": "https://api.github.com/orgs/firebase/public_members{/member}", 147 | "avatar_url": "https://avatars1.githubusercontent.com/u/1335026?v=3", 148 | "description": "" 149 | }, 150 | "sender": { 151 | "login": "jsayol", 152 | "id": 2029586, 153 | "avatar_url": "https://avatars2.githubusercontent.com/u/2029586?v=3", 154 | "gravatar_id": "", 155 | "url": "https://api.github.com/users/jsayol", 156 | "html_url": "https://github.com/jsayol", 157 | "followers_url": "https://api.github.com/users/jsayol/followers", 158 | "following_url": "https://api.github.com/users/jsayol/following{/other_user}", 159 | "gists_url": "https://api.github.com/users/jsayol/gists{/gist_id}", 160 | "starred_url": "https://api.github.com/users/jsayol/starred{/owner}{/repo}", 161 | "subscriptions_url": "https://api.github.com/users/jsayol/subscriptions", 162 | "organizations_url": "https://api.github.com/users/jsayol/orgs", 163 | "repos_url": "https://api.github.com/users/jsayol/repos", 164 | "events_url": "https://api.github.com/users/jsayol/events{/privacy}", 165 | "received_events_url": "https://api.github.com/users/jsayol/received_events", 166 | "type": "User", 167 | "site_admin": false 168 | } 169 | } -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_template_empty.md: -------------------------------------------------------------------------------- 1 | ### [READ] Step 1: Are you in the right place? 2 | 3 | * For issues or feature requests related to __the code in this repository__ 4 | file a GitHub issue. 5 | * If this is a __feature request__ make sure the issue title starts with "FR:". 6 | * For general technical questions, post a question on [StackOverflow](http://stackoverflow.com/) 7 | with the firebase tag. 8 | * For general Firebase discussion, use the [firebase-talk](https://groups.google.com/forum/#!forum/firebase-talk) 9 | google group. 10 | * For help troubleshooting your application that does not fall under one 11 | of the above categories, reach out to the personalized 12 | [Firebase support channel](https://firebase.google.com/support/). 13 | 14 | ### [REQUIRED] Step 2: Describe your environment 15 | 16 | * Operating System version: _____ 17 | * Firebase SDK version: _____ 18 | * Library version: _____ 19 | * Firebase Product: _____ (auth, database, storage, etc) 20 | 21 | ### [REQUIRED] Step 3: Describe the problem 22 | 23 | #### Steps to reproduce: 24 | 25 | What happened? How can we make the problem occur? 26 | This could be a description, log/console output, etc. 27 | 28 | #### Relevant Code: 29 | 30 | ``` 31 | // TODO(you): code here to reproduce the problem 32 | ``` -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_template_empty_with_opts.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ### [REQUIRED] Step 1: Fill out this section 7 | 8 | * Favorite food: _____ 9 | 10 | ### [REQUIRED] Step 2: Describe the problem 11 | 12 | ``` 13 | // TODO(you): code here to reproduce the problem 14 | ``` -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_template_filled.md: -------------------------------------------------------------------------------- 1 | ### [READ] Step 1: Are you in the right place? 2 | 3 | * For issues or feature requests related to __the code in this repository__ 4 | file a GitHub issue. 5 | * If this is a __feature request__ make sure the issue title starts with "FR:". 6 | * For general technical questions, post a question on [StackOverflow](http://stackoverflow.com/) 7 | with the firebase tag. 8 | * For general Firebase discussion, use the [firebase-talk](https://groups.google.com/forum/#!forum/firebase-talk) 9 | google group. 10 | * For help troubleshooting your application that does not fall under one 11 | of the above categories, reach out to the personalized 12 | [Firebase support channel](https://firebase.google.com/support/). 13 | 14 | ### [REQUIRED] Step 2: Describe your environment 15 | 16 | * Operating System version: Ubuntu 17 | * Firebase SDK version: 10.2.1 18 | * Library version: ???? 19 | * Firebase product: Auth 20 | 21 | ### [REQUIRED] Step 3: Describe the problem 22 | 23 | #### Steps to reproduce: 24 | 25 | My app don't work good anymore. 26 | 27 | #### Relevant Code: 28 | 29 | ``` 30 | public static void main(String[] args) { 31 | System.out.println("Hello, world!"); 32 | } 33 | ``` -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_template_filled_no_required.md: -------------------------------------------------------------------------------- 1 | ### [READ] Step 1: Are you in the right place? 2 | 3 | * For issues or feature requests related to __the code in this repository__ 4 | file a GitHub issue. 5 | * If this is a __feature request__ make sure the issue title starts with "FR:". 6 | * For general technical questions, post a question on [StackOverflow](http://stackoverflow.com/) 7 | with the firebase tag. 8 | * For general Firebase discussion, use the [firebase-talk](https://groups.google.com/forum/#!forum/firebase-talk) 9 | google group. 10 | * For help troubleshooting your application that does not fall under one 11 | of the above categories, reach out to the personalized 12 | [Firebase support channel](https://firebase.google.com/support/). 13 | 14 | ### Step 2: Describe your environment 15 | 16 | * Operating System version: Ubuntu 17 | * Firebase SDK version: 10.2.1 18 | * Library version: ???? 19 | * Firebase product: Auth 20 | 21 | ### Step 3: Describe the problem 22 | 23 | #### Steps to reproduce: 24 | 25 | My app don't work good anymore. 26 | 27 | #### Relevant Code: 28 | 29 | ``` 30 | public static void main(String[] args) { 31 | System.out.println("Hello, world!"); 32 | } 33 | ``` -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_template_partial.md: -------------------------------------------------------------------------------- 1 | TACOS 2 | 3 | ### [REQUIRED] Step 2: Describe your environment 4 | 5 | * Operating System version: 1.1 6 | * Firebase SDK version: _____ 7 | * Library version: _____ 8 | * Firebase Product: _____ (auth, database, storage, etc) 9 | 10 | ### [REQUIRED] Step 3: Describe the problem 11 | 12 | #### Steps to reproduce: 13 | 14 | What happened? How can we make the problem occur? 15 | This could be a description, log/console output, etc. 16 | 17 | #### Relevant Code: 18 | 19 | ``` 20 | // TODO(you): code here to reproduce the problem 21 | ``` -------------------------------------------------------------------------------- /functions/src/test/mock_data/issue_with_opts_bad.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ### [REQUIRED] Step 1: Fill out this section 7 | 8 | * Favorite food: _____ 9 | 10 | ### [REQUIRED] Step 2: Describe the problem 11 | 12 | ``` 13 | // TODO(you): code here to reproduce the problem 14 | ``` -------------------------------------------------------------------------------- /functions/src/test/mock_data/old_pull_requests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "number": 1, 4 | "title": "The first one!", 5 | "repo": { 6 | "owner": { 7 | "login": "samtstern" 8 | }, 9 | "name": "BotTest" 10 | } 11 | }, 12 | { 13 | "number": 2, 14 | "title": "The second one!", 15 | "repo": { 16 | "owner": { 17 | "login": "samtstern" 18 | }, 19 | "name": "BotTest" 20 | } 21 | }, 22 | { 23 | "number": 3, 24 | "title": "The third one!", 25 | "repo": { 26 | "owner": { 27 | "login": "samtstern" 28 | }, 29 | "name": "BotTest" 30 | } 31 | } 32 | ] -------------------------------------------------------------------------------- /functions/src/test/mocks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as fs from "fs"; 17 | import * as path from "path"; 18 | 19 | import * as log from "../log"; 20 | import * as config from "../config"; 21 | import * as github from "../github"; 22 | import * as email from "../email"; 23 | 24 | export class MockGitHubClient extends github.GitHubClient { 25 | auth() { 26 | log.debug("mock: github.auth()"); 27 | } 28 | 29 | addLabel(org: string, name: string, number: number, label: string) { 30 | log.debug(`mock: github.addLabel(${org}, ${name}, ${number}, ${label})`); 31 | return Promise.resolve(undefined); 32 | } 33 | 34 | addComment(org: string, name: string, number: number, body: string) { 35 | log.debug(`mock: github.addComment(${org}, ${name}, ${number}, ${body})`); 36 | return Promise.resolve(undefined); 37 | } 38 | 39 | getIssueTemplate(org: string, name: string, file: string) { 40 | log.debug(`mock: github.getIssueTemplate(${org}, ${name}, ${file})`); 41 | 42 | // Just use the file name of the path (ignore directories) and replace the 43 | // default with our designated empty file. 44 | let templatePath = path.basename(file); 45 | if (templatePath === config.BotConfig.getDefaultTemplateConfig("issue")) { 46 | templatePath = "issue_template_empty.md"; 47 | } 48 | 49 | const filePath = path.join(__dirname, "mock_data", templatePath); 50 | const template = fs.readFileSync(filePath).toString(); 51 | return Promise.resolve(template); 52 | } 53 | 54 | closeIssue(org: string, name: string, number: number) { 55 | log.debug(`mock: github.closeIssue(${org}, ${name}, ${number})`); 56 | return Promise.resolve(undefined); 57 | } 58 | } 59 | 60 | export class MockEmailClient extends email.EmailClient { 61 | sendEmail(recipient: string, subject: string, body: string) { 62 | log.debug(`mock: email.sendEmail(${recipient}, ${subject}, ...)`); 63 | return Promise.resolve(undefined); 64 | } 65 | 66 | getSmartMailMarkup(url: string, title: string) { 67 | log.debug(`mock: email.getSmartMailMarkup(${url}, ${title})`); 68 | return "
MOCK
"; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /functions/src/test/test-util.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import * as types from "../types"; 3 | 4 | export function actionsEqual(a: types.Action, b: types.Action) { 5 | const aClone = Object.assign({}, a) as types.Action; 6 | const bClone = Object.assign({}, b) as types.Action; 7 | 8 | aClone.reason = ""; 9 | bClone.reason = ""; 10 | 11 | assert.deepEqual(aClone, bClone); 12 | } 13 | 14 | export function actionsListEqual(a: types.Action[], b: types.Action[]) { 15 | assert.equal(a.length, b.length, "Action arrays have same length"); 16 | 17 | for (let i = 0; i < a.length; i++) { 18 | actionsEqual(a[0], b[0]); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /functions/src/test/util-test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import "mocha"; 17 | 18 | import * as assert from "assert"; 19 | import * as log from "../log"; 20 | import * as util from "../util"; 21 | 22 | describe("Configuration", async () => { 23 | before(() => { 24 | log.setLogLevel(log.Level.WARN); 25 | }); 26 | 27 | after(() => { 28 | log.setLogLevel(log.Level.ALL); 29 | }); 30 | 31 | it("should properly calculate days and working days ago", async () => { 32 | const tueDec31 = new Date(Date.parse("2019-12-31")); 33 | const wedJan1 = new Date(Date.parse("2020-01-01")); 34 | const friJan3 = new Date(Date.parse("2020-01-03")); 35 | const monJan6 = new Date(Date.parse("2020-01-06")); 36 | 37 | assert.equal(util.daysAgo(tueDec31, wedJan1), 1, "daysAgo Tues --> Weds"); 38 | assert.equal( 39 | util.workingDaysAgo(tueDec31, wedJan1), 40 | 1, 41 | "workingDaysAgo Tues --> Weds" 42 | ); 43 | 44 | assert.equal(util.daysAgo(wedJan1, friJan3), 2, "daysAgo Weds --> Fri"); 45 | assert.equal( 46 | util.workingDaysAgo(wedJan1, friJan3), 47 | 2, 48 | "workingDaysAgo Weds --> Fri" 49 | ); 50 | 51 | assert.equal(util.daysAgo(wedJan1, monJan6), 5, "daysAgo Weds --> Mon"); 52 | assert.equal( 53 | util.workingDaysAgo(wedJan1, monJan6), 54 | 3, 55 | "workingDaysAgo Weds --> Mon" 56 | ); 57 | 58 | assert.equal(util.daysAgo(friJan3, monJan6), 3, "daysAgo Fri --> Mon"); 59 | assert.equal( 60 | util.workingDaysAgo(friJan3, monJan6), 61 | 1, 62 | "workingDaysAgo Fri --> Mon" 63 | ); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /functions/src/util.ts: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | import * as log from "./log"; 3 | import * as types from "./types"; 4 | 5 | export const FUNCTION_OPTS = { 6 | timeoutSeconds: 540, 7 | memory: "2GB" as "2GB" 8 | }; 9 | 10 | export async function delay(seconds: number) { 11 | return new Promise((resolve, reject) => { 12 | setTimeout(resolve, seconds * 1000); 13 | }); 14 | } 15 | 16 | export function DateSlug(date: Date): string { 17 | return format(date, "YY-MM-DD"); 18 | } 19 | 20 | const timers: { [s: string]: number } = {}; 21 | 22 | function getTime(): number { 23 | return new Date().getTime(); 24 | } 25 | 26 | export function setDiff(a: T[], b: T[]) { 27 | return a.filter((x: T) => { 28 | return b.indexOf(x) < 0; 29 | }); 30 | } 31 | 32 | export function startTimer(label: string) { 33 | timers[label] = getTime(); 34 | } 35 | 36 | export function endTimer(label: string) { 37 | const start = timers[label]; 38 | if (!start) { 39 | return; 40 | } 41 | 42 | const end = getTime(); 43 | const diff = end - start; 44 | 45 | log.logData({ 46 | event: "timer", 47 | label: label, 48 | val: diff, 49 | message: `Operation "${label}" took ${diff}ms` 50 | }); 51 | 52 | delete timers[label]; 53 | } 54 | 55 | export function samScore(open: number, closed: number): number { 56 | if (open === 0) { 57 | return 0; 58 | } 59 | 60 | const score = (open / (open + closed)) * Math.log(Math.E + open + closed); 61 | return Math.round(score * 1000) / 1000; 62 | } 63 | 64 | export function createdDate(obj: types.internal.Timestamped): Date { 65 | return new Date(Date.parse(obj.created_at)); 66 | } 67 | 68 | export function daysAgo(past: Date, future?: Date): number { 69 | const msInDay = 24 * 60 * 60 * 1000; 70 | const now = future || new Date(); 71 | 72 | const diff = now.getTime() - past.getTime(); 73 | return Math.floor(diff / msInDay); 74 | } 75 | 76 | export function getDateWorkingDaysBefore(start: Date, days: number): Date { 77 | const msInDay = 24 * 60 * 60 * 1000; 78 | let daysSubtracted = 0; 79 | 80 | let t = start.getTime(); 81 | while (daysSubtracted < days) { 82 | const tDate = new Date(t); 83 | if (isWorkday(tDate)) { 84 | daysSubtracted += 1; 85 | } 86 | 87 | t -= msInDay; 88 | } 89 | 90 | return new Date(t); 91 | } 92 | 93 | export function workingDaysAgo(past: Date, future?: Date): number { 94 | const msInDay = 24 * 60 * 60 * 1000; 95 | const now = (future || new Date()).getTime(); 96 | 97 | let workingDays = 0; 98 | let t = past.getTime(); 99 | 100 | while (t < now) { 101 | t += msInDay; 102 | const tDate = new Date(t); 103 | if (isWorkday(tDate)) { 104 | workingDays += 1; 105 | } 106 | } 107 | 108 | return workingDays; 109 | } 110 | 111 | export function isWorkday(date: Date): boolean { 112 | return date.getDay() !== 0 && date.getDay() !== 6; 113 | } 114 | 115 | export function timeAgo(obj: types.internal.Timestamped): number { 116 | return Date.now() - Date.parse(obj.created_at); 117 | } 118 | 119 | export function split(arr: T[], fn: (arg: T) => boolean) { 120 | const yes = arr.filter(fn); 121 | const no = arr.filter(x => !fn(x)); 122 | 123 | if (yes.length + no.length !== arr.length) { 124 | console.warn( 125 | `util.split() split ${arr.length} into ${yes.length} and ${no.length}` 126 | ); 127 | } 128 | 129 | return [yes, no]; 130 | } 131 | 132 | export function compareTimestamps( 133 | a: types.internal.Timestamped, 134 | b: types.internal.Timestamped 135 | ) { 136 | const aTime = Date.parse(a.created_at); 137 | const bTime = Date.parse(b.created_at); 138 | 139 | if (aTime > bTime) { 140 | return 1; 141 | } else if (bTime > aTime) { 142 | return -1; 143 | } else { 144 | return 0; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /functions/templates/repo-weekly.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Weekly GitHub Report 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Hello friends! It's me, Osscar the OSS bot, and here is what is happening in your GitHub repo this week! 20 | 21 | 22 | 23 | This report covers {{name}} from {{start}} to {{end}}. 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | The SAM score for the repository is: {{sam.after}}. 32 | 33 | 34 | That's a change of {{sam.diff}} this week. Remember, lower is better. For more information see go/samscore. 35 | 36 | 37 | 38 | Here's how your other stats changed this week: 39 |
    40 |
  • 41 | {{open_issues.after}} open issues (Δ={{open_issues.diff}}) 42 |
  • 43 |
  • 44 | {{stars.after}} stars (Δ={{stars.diff}}) 45 |
  • 46 |
  • 47 | {{forks.after}} forks (Δ={{forks.diff}}) 48 |
  • 49 |
50 |
51 | 52 | 53 | These labels have the most open issues: 54 |
    55 | {{#worst_labels}} 56 |
  1. {{name}} - {{open}} open, {{closed}} closed
  2. 57 | {{/worst_labels}} 58 |
59 |
60 | 61 | 62 | These issues were newly opened this week: 63 | 68 | 69 | 70 | 71 | You closed the following issues this week: 72 | 77 | 78 |
79 |
80 | 81 | 82 | 83 | 84 | This is an automated email from morganchen@. Please send any feedback directly to him. 85 | 86 | 87 | 88 |
89 |
90 |
91 | -------------------------------------------------------------------------------- /functions/templates/weekly.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Firebase + GitHub Weekly! 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Hello friends! It's me, Osscar the OSS bot, and here is what is happening in our GitHub organization this week! 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Overall 28 | 29 | 30 | 31 | The SAM score for the entire firebase org is: {{totalSAM}}. 32 | 33 | 34 | 35 | As of today we have... 36 |
    37 |
  • 38 | {{totalStars}} ({{totalStarsDiff}}) total stars 39 |
  • 40 |
  • {{totalOpenIssues}} ({{totalOpenIssuesDiff}}) total open issues
  • 41 | 42 |
  • {{totalOpenIssuesWithNoComments}} ({{totalOpenIssuesWithNoCommentsDiff}}) have no comments or replies.
  • 43 |
  • 44 | {{totalOpenPullRequests}} ({{totalOpenPullRequestsDiff}}) open pull requests 45 |
  • 46 |
  • {{totalPublicRepos}} ({{totalPublicReposDiff}}) public repos
  • 47 |
  • An average of {{averageIssuesPerRepo}} ({{averageIssuesPerRepoDiff}}) open issues per repo
  • 48 |
49 |
50 |
51 |
52 | 53 | 54 | 55 | 56 | Repos Most "In Need of Love" 57 | 58 | 59 | 60 | # 61 | Name 62 | SAM Score 63 | 64 | {{#topSAMs}} 65 | 66 | {{index}} 67 | 68 | {{name}} 69 | 70 | {{sam}} 71 | 72 | {{/topSAMs}} 73 | 74 | 75 | We calculate this category using the Suggested Action Metric (SAM). Lower SAM score is better. For more information see go/samscore. 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | Happiest Repos 84 | 85 | 86 | 87 | # 88 | Name 89 | SAM Score 90 | 91 | {{#bottomSAMs}} 92 | 93 | {{index}} 94 | 95 | {{name}} 96 | 97 | {{sam}} 98 | 99 | {{/bottomSAMs}} 100 | 101 | 102 | We calculate this category using the Suggested Action Metric (SAM). This is a formula which takes into account open issue #, issue age, and other factors to draw attention to repos which need extra love. Higher SAM is worse, lower is better. A SAM of less than 1 considered ideal. 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | Top Repos by # of Stars 111 | 112 | 113 | 114 | # 115 | Name 116 | Stars 117 | 118 | {{#topStars}} 119 | 120 | {{index}} 121 | 122 | {{name}} 123 | 124 | {{stars}} 125 | 126 | {{/topStars}} 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | Top Repos by # of Issues / PRs 135 | 136 | 137 | 138 | # 139 | Name 140 | Issues / PRs 141 | 142 | {{#topIssues}} 143 | 144 | {{index}} 145 | 146 | {{name}} 147 | 148 | {{issues}} 149 | 150 | {{/topIssues}} 151 | 152 | 153 | 154 | 155 | 156 | 157 | This is an automated email from morganchen@. Please send any feedback directly to him. 158 | 159 | 160 | 161 |
162 |
163 |
164 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": true, 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "outDir": "dist", 9 | "baseUrl": ".", 10 | "strictNullChecks": true, 11 | "paths": { 12 | "*": [ 13 | "node_modules/*", 14 | "src/types/*" 15 | ] 16 | }, 17 | "types" : [ 18 | "chai", 19 | "node" 20 | ], 21 | "lib" : [ 22 | "es2017" 23 | ] 24 | }, 25 | "include": [ 26 | "src/**/*" 27 | ], 28 | "exclude": [ 29 | "node_modules/*" 30 | ] 31 | } -------------------------------------------------------------------------------- /functions/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "one-line": [ 13 | true, 14 | "check-open-brace", 15 | "check-whitespace" 16 | ], 17 | "no-var-keyword": true, 18 | "quotemark": [ 19 | true, 20 | "double", 21 | "avoid-escape" 22 | ], 23 | "semicolon": [ 24 | true, 25 | "always", 26 | "ignore-bound-class-methods" 27 | ], 28 | "whitespace": [ 29 | true, 30 | "check-branch", 31 | "check-decl", 32 | "check-operator", 33 | "check-module", 34 | "check-separator", 35 | "check-type" 36 | ], 37 | "typedef-whitespace": [ 38 | true, 39 | { 40 | "call-signature": "nospace", 41 | "index-signature": "nospace", 42 | "parameter": "nospace", 43 | "property-declaration": "nospace", 44 | "variable-declaration": "nospace" 45 | }, 46 | { 47 | "call-signature": "onespace", 48 | "index-signature": "onespace", 49 | "parameter": "onespace", 50 | "property-declaration": "onespace", 51 | "variable-declaration": "onespace" 52 | } 53 | ], 54 | "no-internal-module": true, 55 | "no-trailing-whitespace": true, 56 | "no-null-keyword": true, 57 | "prefer-const": true, 58 | "jsdoc-format": true 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /public/assets/css/fonts/Fixedsys500c.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/oss-bot/f51982e401afff17080bd2eced645c76dbdc30dd/public/assets/css/fonts/Fixedsys500c.eot -------------------------------------------------------------------------------- /public/assets/css/fonts/Fixedsys500c.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/oss-bot/f51982e401afff17080bd2eced645c76dbdc30dd/public/assets/css/fonts/Fixedsys500c.ttf -------------------------------------------------------------------------------- /public/assets/css/fonts/Fixedsys500c.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/oss-bot/f51982e401afff17080bd2eced645c76dbdc30dd/public/assets/css/fonts/Fixedsys500c.woff -------------------------------------------------------------------------------- /public/assets/css/mvp.css: -------------------------------------------------------------------------------- 1 | /* MVP.css v1.3 - https://github.com/andybrewer/mvp */ 2 | 3 | :root { 4 | --border-radius: 5px; 5 | --box-shadow: 2px 2px 10px; 6 | --color: #118bee; 7 | --color-accent: #118bee0b; 8 | --color-bg: #fff; 9 | --color-bg-secondary: #e9e9e9; 10 | --color-secondary: #920de9; 11 | --color-secondary-accent: #920de90b; 12 | --color-shadow: #f4f4f4; 13 | --color-text: #000; 14 | --color-text-secondary: #999; 15 | --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 16 | --hover-brightness: 1.2; 17 | --justify-important: center; 18 | --justify-normal: left; 19 | --line-height: 150%; 20 | --width-card: 285px; 21 | --width-card-medium: 460px; 22 | --width-card-wide: 800px; 23 | --width-content: 1080px; 24 | } 25 | 26 | /* 27 | @media (prefers-color-scheme: dark) { 28 | :root { 29 | --color: #0097fc; 30 | --color-accent: #0097fc4f; 31 | --color-bg: #333; 32 | --color-bg-secondary: #555; 33 | --color-secondary: #e20de9; 34 | --color-secondary-accent: #e20de94f; 35 | --color-shadow: #bbbbbb20; 36 | --color-text: #f7f7f7; 37 | --color-text-secondary: #aaa; 38 | } 39 | } 40 | */ 41 | 42 | /* Layout */ 43 | article aside { 44 | background: var(--color-secondary-accent); 45 | border-left: 4px solid var(--color-secondary); 46 | padding: 0.01rem 0.8rem; 47 | } 48 | 49 | body { 50 | background: var(--color-bg); 51 | color: var(--color-text); 52 | font-family: var(--font); 53 | line-height: var(--line-height); 54 | margin: 0; 55 | overflow-x: hidden; 56 | padding: 1rem 0; 57 | } 58 | 59 | footer, 60 | header, 61 | main { 62 | margin: 0 auto; 63 | max-width: var(--width-content); 64 | padding: 2rem 1rem; 65 | } 66 | 67 | hr { 68 | background-color: var(--color-bg-secondary); 69 | border: none; 70 | height: 1px; 71 | margin: 4rem 0; 72 | } 73 | 74 | section { 75 | display: flex; 76 | flex-wrap: wrap; 77 | justify-content: var(--justify-important); 78 | } 79 | 80 | section aside { 81 | border: 1px solid var(--color-bg-secondary); 82 | border-radius: var(--border-radius); 83 | box-shadow: var(--box-shadow) var(--color-shadow); 84 | margin: 1rem; 85 | padding: 1.25rem; 86 | width: var(--width-card); 87 | } 88 | 89 | section aside:hover { 90 | box-shadow: var(--box-shadow) var(--color-bg-secondary); 91 | } 92 | 93 | section aside img { 94 | max-width: 100%; 95 | } 96 | 97 | /* Headers */ 98 | article header, 99 | div header, 100 | main header { 101 | padding-top: 0; 102 | } 103 | 104 | header { 105 | text-align: var(--justify-important); 106 | } 107 | 108 | header a b, 109 | header a em, 110 | header a i, 111 | header a strong { 112 | margin-left: 1rem; 113 | margin-right: 1rem; 114 | } 115 | 116 | header nav img { 117 | margin: 1rem 0; 118 | } 119 | 120 | section header { 121 | padding-top: 0; 122 | width: 100%; 123 | } 124 | 125 | /* Nav */ 126 | nav { 127 | align-items: center; 128 | display: flex; 129 | font-weight: bold; 130 | justify-content: space-between; 131 | margin-bottom: 7rem; 132 | } 133 | 134 | nav ul { 135 | list-style: none; 136 | padding: 0; 137 | } 138 | 139 | nav ul li { 140 | display: inline-block; 141 | margin: 0 0.5rem; 142 | position: relative; 143 | text-align: left; 144 | } 145 | 146 | /* Nav Dropdown */ 147 | nav ul li:hover ul { 148 | display: block; 149 | } 150 | 151 | nav ul li ul { 152 | background: var(--color-bg); 153 | border: 1px solid var(--color-bg-secondary); 154 | border-radius: var(--border-radius); 155 | box-shadow: var(--box-shadow) var(--color-shadow); 156 | display: none; 157 | height: auto; 158 | padding: .5rem 1rem; 159 | position: absolute; 160 | right: 0; 161 | top: 1.7rem; 162 | width: auto; 163 | } 164 | 165 | nav ul li ul li, 166 | nav ul li ul li a { 167 | display: block; 168 | } 169 | 170 | /* Typography */ 171 | code, 172 | samp { 173 | background-color: var(--color-accent); 174 | border-radius: var(--border-radius); 175 | color: var(--color-text); 176 | display: inline-block; 177 | margin: 0 0.1rem; 178 | padding: 0rem 0.5rem; 179 | text-align: var(--justify-normal); 180 | } 181 | 182 | details { 183 | margin: 1.3rem 0; 184 | } 185 | 186 | details summary { 187 | font-weight: bold; 188 | } 189 | 190 | details summary:focus { 191 | outline: none; 192 | } 193 | 194 | h1, 195 | h2, 196 | h3, 197 | h4, 198 | h5, 199 | h6 { 200 | line-height: var(--line-height); 201 | } 202 | 203 | mark { 204 | padding: 0.1rem; 205 | } 206 | 207 | ol li, 208 | ul li { 209 | padding: 0.2rem 0; 210 | } 211 | 212 | p { 213 | margin: 0.75rem 0; 214 | padding: 0; 215 | } 216 | 217 | pre { 218 | white-space: normal; 219 | } 220 | 221 | pre code, 222 | pre samp { 223 | display: block; 224 | margin: 1rem 0; 225 | max-width: var(--width-card-wide); 226 | padding: 1rem; 227 | } 228 | 229 | small { 230 | color: var(--color-text-secondary); 231 | } 232 | 233 | sup { 234 | background-color: var(--color-secondary); 235 | border-radius: var(--border-radius); 236 | color: var(--color-bg); 237 | font-size: xx-small; 238 | font-weight: bold; 239 | margin: 0.2rem; 240 | padding: 0.2rem 0.3rem; 241 | position: relative; 242 | top: -2px; 243 | } 244 | 245 | /* Links */ 246 | a { 247 | color: var(--color-secondary); 248 | font-weight: bold; 249 | text-decoration: none; 250 | } 251 | 252 | a:hover { 253 | filter: brightness(var(--hover-brightness)); 254 | text-decoration: underline; 255 | } 256 | 257 | a b, 258 | a em, 259 | a i, 260 | a strong, 261 | button { 262 | border-radius: var(--border-radius); 263 | display: inline-block; 264 | font-size: medium; 265 | font-weight: bold; 266 | line-height: var(--line-height); 267 | margin: 1.5rem 0 0.5rem 0; 268 | padding: 1rem 2rem; 269 | } 270 | 271 | button { 272 | font-family: var(--font); 273 | } 274 | 275 | button:hover { 276 | cursor: pointer; 277 | filter: brightness(var(--hover-brightness)); 278 | } 279 | 280 | a b, 281 | a strong, 282 | button { 283 | background-color: var(--color); 284 | border: 2px solid var(--color); 285 | color: var(--color-bg); 286 | } 287 | 288 | a em, 289 | a i { 290 | border: 2px solid var(--color); 291 | border-radius: var(--border-radius); 292 | color: var(--color); 293 | display: inline-block; 294 | padding: 1rem 2rem; 295 | } 296 | 297 | /* Images */ 298 | figure { 299 | margin: 0; 300 | padding: 0; 301 | } 302 | 303 | figure img { 304 | max-width: 100%; 305 | } 306 | 307 | figure figcaption { 308 | color: var(--color-text-secondary); 309 | } 310 | 311 | /* Forms */ 312 | button:focus, 313 | input:focus, 314 | select:focus, 315 | textarea:focus { 316 | outline: none; 317 | } 318 | 319 | button:disabled, 320 | input:disabled { 321 | background: var(--color-bg-secondary); 322 | border-color: var(--color-bg-secondary); 323 | color: var(--color-text-secondary); 324 | cursor: not-allowed; 325 | } 326 | 327 | button[disabled]:hover { 328 | filter: none; 329 | } 330 | 331 | form { 332 | border: 1px solid var(--color-bg-secondary); 333 | border-radius: var(--border-radius); 334 | box-shadow: var(--box-shadow) var(--color-shadow); 335 | display: block; 336 | max-width: var(--width-card-wide); 337 | min-width: var(--width-card); 338 | padding: 1.5rem; 339 | text-align: var(--justify-normal); 340 | } 341 | 342 | form header { 343 | margin: 1.5rem 0; 344 | padding: 1.5rem 0; 345 | } 346 | 347 | input, 348 | label, 349 | select, 350 | textarea { 351 | display: block; 352 | font-size: inherit; 353 | max-width: var(--width-card-wide); 354 | } 355 | 356 | input[type="checkbox"], 357 | input[type="radio"] { 358 | display: inline-block; 359 | } 360 | 361 | input, 362 | select, 363 | textarea { 364 | border: 1px solid var(--color-bg-secondary); 365 | border-radius: var(--border-radius); 366 | margin-bottom: 1rem; 367 | padding: 0.4rem 0.8rem; 368 | } 369 | 370 | input[readonly], 371 | textarea[readonly] { 372 | background-color: var(--color-bg-secondary); 373 | } 374 | 375 | label { 376 | font-weight: bold; 377 | margin-bottom: 0.2rem; 378 | } 379 | 380 | /* Tables */ 381 | table { 382 | border: 1px solid var(--color-bg-secondary); 383 | border-radius: var(--border-radius); 384 | border-spacing: 0; 385 | overflow-x: scroll; 386 | overflow-y: hidden; 387 | padding: 0; 388 | } 389 | 390 | table td, 391 | table th, 392 | table tr { 393 | padding: 0.4rem 0.8rem; 394 | text-align: var(--justify-important); 395 | } 396 | 397 | table thead { 398 | background-color: var(--color); 399 | border-collapse: collapse; 400 | border-radius: var(--border-radius); 401 | color: var(--color-bg); 402 | margin: 0; 403 | padding: 0; 404 | } 405 | 406 | table thead th:first-child { 407 | border-top-left-radius: var(--border-radius); 408 | } 409 | 410 | table thead th:last-child { 411 | border-top-right-radius: var(--border-radius); 412 | } 413 | 414 | table thead th:first-child, 415 | table tr td:first-child { 416 | text-align: var(--justify-normal); 417 | } 418 | 419 | /* Quotes */ 420 | blockquote { 421 | display: block; 422 | font-size: x-large; 423 | line-height: var(--line-height); 424 | margin: 1rem auto; 425 | max-width: var(--width-card-medium); 426 | padding: 1.5rem 1rem; 427 | text-align: var(--justify-important); 428 | } 429 | 430 | blockquote footer { 431 | color: var(--color-text-secondary); 432 | display: block; 433 | font-size: small; 434 | line-height: var(--line-height); 435 | padding: 1.5rem 0; 436 | } 437 | 438 | /* Custom styles */ -------------------------------------------------------------------------------- /public/assets/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/oss-bot/f51982e401afff17080bd2eced645c76dbdc30dd/public/assets/img/loading.gif -------------------------------------------------------------------------------- /public/assets/img/octocat-firebase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/oss-bot/f51982e401afff17080bd2eced645c76dbdc30dd/public/assets/img/octocat-firebase.png -------------------------------------------------------------------------------- /public/audit.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | background: #fafafa; 4 | min-height: 100vh; 5 | } 6 | 7 | #app { 8 | } 9 | 10 | .header { 11 | background-color: rgb(3, 155, 229); 12 | padding: 24px; 13 | margin-bottom: 20px; 14 | } 15 | 16 | .header-inner { 17 | max-width: 1920px; 18 | margin: 0 auto; 19 | } 20 | 21 | .header .repo { 22 | display: block; 23 | color: rgba(255, 255, 255, 0.7); 24 | font-size: 14px; 25 | font-weight: 500; 26 | } 27 | 28 | .header .title { 29 | display: block; 30 | color: white; 31 | font-size: 26px; 32 | font-weight: 500; 33 | margin-bottom: 8px; 34 | } 35 | 36 | #log-table { 37 | width: 100%; 38 | max-width: 1920px; 39 | margin: 0 auto; 40 | } 41 | 42 | td { 43 | align-content: center; 44 | } 45 | 46 | #col-link { 47 | width: 5%; 48 | } 49 | 50 | #col-time { 51 | width: 15%; 52 | } 53 | 54 | #col-event { 55 | width: 10%; 56 | } 57 | 58 | #col-target { 59 | width: 10%; 60 | } 61 | 62 | #col-details { 63 | width: 30%; 64 | } 65 | 66 | #col-reason { 67 | width: 30%; 68 | } 69 | 70 | .details-table { 71 | width: 100%; 72 | } 73 | 74 | .details-table .key { 75 | width: 80px; 76 | background-color: #d7dfff !important; 77 | } 78 | 79 | .details-table .value { 80 | background-color: #ffffff !important; 81 | } 82 | -------------------------------------------------------------------------------- /public/audit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OSS Bot Audit 7 | 8 | 12 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |
34 |
35 | Audit Log 36 | {{org}}/{{repo}} 37 |
38 |
39 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 63 | 73 | 74 | 75 | 76 |
LinkTimeEventTargetDetailsReason
link{{entry.timeString()}}{{entry.data.event}} 59 | {{entry.data.target}} 62 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
{{detail[0]}}{{detail[1]}}
72 |
{{entry.data.reason || "N/A"}}
77 |
78 | 79 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /public/audit.js: -------------------------------------------------------------------------------- 1 | var app; // Vue app 2 | var db; // Firebase database 3 | 4 | var org; // GitHub org 5 | var repo; // GitHub repo 6 | var key; // Specific database key 7 | 8 | var EntryPresenter = function (org, repo, key, data) { 9 | this.org = org; 10 | this.repo = repo; 11 | this.key = key; 12 | this.data = data; 13 | }; 14 | 15 | EntryPresenter.prototype.selfLink = function () { 16 | return ( 17 | "/audit.html?org=" + this.org + "&repo=" + this.repo + "&key=" + this.key 18 | ); 19 | }; 20 | 21 | EntryPresenter.prototype.timeString = function () { 22 | return ( 23 | new Date(this.data.time).toLocaleString("en-US", { 24 | timeZone: "America/Los_Angeles", 25 | }) + " (Pacific)" 26 | ); 27 | }; 28 | 29 | EntryPresenter.prototype.targetLink = function () { 30 | return ( 31 | "https://github.com/" + this.org + "/" + this.repo + "/" + this.data.target 32 | ); 33 | }; 34 | 35 | EntryPresenter.prototype.hasDetails = function () { 36 | return this.data.details || this.data.details !== {}; 37 | }; 38 | 39 | EntryPresenter.prototype.detailsEntries = function () { 40 | var result = []; 41 | 42 | if (!this.data.details) { 43 | return result; 44 | } 45 | 46 | var that = this; 47 | Object.keys(this.data.details).forEach(function (key) { 48 | result.push([key, that.data.details[key]]); 49 | }); 50 | return result; 51 | }; 52 | 53 | window.initializeApp = function () { 54 | var params = new URLSearchParams(window.location.search); 55 | if (!(params.has("repo") && params.has("org"))) { 56 | alert("Please supply both the 'repo' and 'org' query string params."); 57 | return; 58 | } 59 | 60 | org = params.get("org"); 61 | repo = params.get("repo"); 62 | key = params.get("key"); 63 | 64 | app = new Vue({ 65 | el: "#app", 66 | data: { 67 | org: org, 68 | repo: repo, 69 | entries: [], 70 | }, 71 | }); 72 | }; 73 | 74 | window.initializeData = function () { 75 | db = firebase.database(); 76 | if (key && key !== "") { 77 | loadSingleEntry(key); 78 | } else { 79 | loadRepoAudit(); 80 | } 81 | }; 82 | 83 | function addSnapToEntries(snap) { 84 | console.log(snap.val()); 85 | app.entries.unshift(new EntryPresenter(org, repo, snap.key, snap.val())); 86 | } 87 | 88 | function loadRepoAudit() { 89 | var dataRef = db.ref("repo-log").child(org).child(repo).limitToLast(100); 90 | 91 | dataRef.on("child_added", addSnapToEntries); 92 | } 93 | 94 | function loadSingleEntry(key) { 95 | var dataRef = db.ref("repo-log").child(org).child(repo).child(key); 96 | 97 | dataRef.once("value", addSnapToEntries); 98 | } 99 | -------------------------------------------------------------------------------- /public/charts.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Open Sans", "sans-serif"; 3 | } 4 | 5 | #chart-loading { 6 | display: none; 7 | } 8 | 9 | #chart-loading.visible { 10 | display: unset; 11 | } 12 | 13 | td { 14 | text-align: left !important; 15 | vertical-align: middle; 16 | } 17 | 18 | td.right { 19 | text-align: right; 20 | } 21 | 22 | table input { 23 | display: inline-block; 24 | } 25 | 26 | table select { 27 | display: inline-block; 28 | } 29 | 30 | .card { 31 | display: block; 32 | width: 80%; 33 | max-width: 1000px; 34 | padding: 20px; 35 | border-radius: 8px; 36 | box-shadow: rgba(0, 0, 0, 0.4) 0 0 2px; 37 | margin: 8px; 38 | } 39 | -------------------------------------------------------------------------------- /public/charts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Repo Stats 7 | 8 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 |
24 |
25 |

Query

26 | 27 | 28 | 31 | 44 | 45 | 46 | 49 | 57 | 58 | 59 | 62 | 75 | 76 |
29 | Repo 30 | 32 | 38 | 43 |
47 | Stat 48 | 50 | 56 |
60 | Time 61 | 63 | 69 | 74 |
77 |
78 | 79 | 80 |
81 | 82 |
83 |

Results

84 |
85 | 86 | 87 |
88 |
89 |
90 | 91 | 92 | -------------------------------------------------------------------------------- /public/charts.js: -------------------------------------------------------------------------------- 1 | const ISSUES_AXIS = { 2 | id: "issues_axis", 3 | type: "linear", 4 | position: "left", 5 | scaleLabel: { 6 | display: true, 7 | labelString: "Issues", 8 | }, 9 | ticks: { 10 | beginAtZero: false, 11 | }, 12 | }; 13 | 14 | const SAM_AXIS = { 15 | id: "sam_axis", 16 | type: "linear", 17 | position: "left", 18 | scaleLabel: { 19 | display: true, 20 | labelString: "SAM Score", 21 | }, 22 | ticks: { 23 | beginAtZero: false, 24 | }, 25 | }; 26 | 27 | const STARS_AXIS = { 28 | id: "stars_axis", 29 | type: "linear", 30 | position: "left", 31 | scaleLabel: { 32 | display: true, 33 | labelString: "Stars", 34 | }, 35 | ticks: { 36 | beginAtZero: false, 37 | }, 38 | }; 39 | 40 | let chartContext = undefined; 41 | let lastChart = undefined; 42 | 43 | let CHART_AXES = []; 44 | let CHART_DATA = { 45 | datasets: [], 46 | labels: [], 47 | }; 48 | 49 | let CHART_OPTS = []; 50 | 51 | function randomColor() { 52 | const r = Math.floor(Math.random() * 256); 53 | const g = Math.floor(Math.random() * 256); 54 | const b = Math.floor(Math.random() * 256); 55 | 56 | return `rgb(${r}, ${g}, ${b})`; 57 | } 58 | 59 | function selectAxis(field) { 60 | if (field.includes("issue")) { 61 | return ISSUES_AXIS; 62 | } 63 | 64 | if (field.includes("sam")) { 65 | return SAM_AXIS; 66 | } 67 | 68 | if (field.includes("star")) { 69 | return STARS_AXIS; 70 | } 71 | 72 | throw `No axis for field: ${field}`; 73 | } 74 | 75 | function drawChart(ctx) { 76 | clearChart(); 77 | 78 | lastChart = new Chart(ctx, { 79 | type: "line", 80 | data: CHART_DATA, 81 | options: { 82 | responsive: true, 83 | stacked: false, 84 | scales: { 85 | yAxes: CHART_AXES, 86 | }, 87 | }, 88 | }); 89 | } 90 | 91 | async function addSeriesToChart(ctx, opts) { 92 | const resp = await fetchQueryData(opts); 93 | const y_axis = selectAxis(resp.field); 94 | 95 | const seriesColor = randomColor(); 96 | const seriesConfig = { 97 | key: resp.repo, 98 | label: `${resp.repo} - ${resp.field}`, 99 | color: seriesColor, 100 | axis: y_axis, 101 | }; 102 | 103 | // Collect graph info 104 | // TODO: Lock labels 105 | const labels = Object.keys(resp.data).sort(); 106 | 107 | // Create a Chart.js based on the series config 108 | const dataSet = { 109 | label: seriesConfig.label, 110 | type: "line", 111 | borderColor: seriesConfig.color, 112 | fill: false, 113 | data: [], 114 | yAxisID: seriesConfig.axis.id, 115 | }; 116 | 117 | // Accumulate the data 118 | dataSet.data = labels.map((label) => { 119 | return resp.data[label]; 120 | }); 121 | 122 | // TODO: Just this 123 | CHART_AXES.push(y_axis); 124 | CHART_DATA.labels = labels; 125 | CHART_DATA.datasets.push(dataSet); 126 | 127 | // Once we have all data, draw the chart 128 | drawChart(ctx); 129 | } 130 | 131 | function getQueryOptsFromForm() { 132 | const org = document.querySelector("#field-org").value; 133 | const repo = document.querySelector("#field-repo").value; 134 | const field = document.querySelector("#field-field").value; 135 | const points = document.querySelector("#field-points").value; 136 | const daysBetween = document.querySelector("#field-daysBetween").value; 137 | 138 | return { org, repo, field, points, daysBetween }; 139 | } 140 | 141 | function getQueryUrl(opts) { 142 | const { org, repo, field, points, daysBetween } = opts; 143 | 144 | let base = "https://us-central1-ossbot-f0cad.cloudfunctions.net"; 145 | if (window.location.hostname === "localhost") { 146 | base = "http://localhost:5001/ossbot-test/us-central1"; 147 | } 148 | return `${base}/GetRepoTimeSeries?org=${org}&repo=${repo}&field=${field}&points=${points}&daysBetween=${daysBetween}`; 149 | } 150 | 151 | async function fetchQueryData(opts) { 152 | const url = getQueryUrl(opts); 153 | const res = await fetch(url); 154 | return res.json(); 155 | } 156 | 157 | async function withLoading(fn) { 158 | const loading = this.document.querySelector("#chart-loading"); 159 | loading.classList.add("visible"); 160 | lockFields(); 161 | 162 | await fn(); 163 | 164 | unlockFields(); 165 | loading.classList.remove("visible"); 166 | } 167 | 168 | function lockFields() { 169 | const fields = [ 170 | "#field-org", 171 | "#field-repo", 172 | "#field-field", 173 | "#field-points", 174 | "#field-daysBetween" 175 | ]; 176 | 177 | for (const id of fields) { 178 | document.querySelector(id).setAttribute("disabled", true); 179 | } 180 | } 181 | 182 | function unlockFields() { 183 | const fields = [ 184 | "#field-org", 185 | "#field-repo", 186 | "#field-field", 187 | "#field-points", 188 | "#field-daysBetween" 189 | ]; 190 | 191 | for (const id of fields) { 192 | document.querySelector(id).removeAttribute("disabled"); 193 | } 194 | } 195 | 196 | function appendSeriesToState(opts) { 197 | CHART_OPTS.push(opts); 198 | setState(); 199 | } 200 | 201 | function resetSeriesState() { 202 | CHART_OPTS = []; 203 | setState(); 204 | } 205 | 206 | function setState() { 207 | const optsEncoded = btoa(JSON.stringify(CHART_OPTS)); 208 | setQueryStringParameter("series", optsEncoded); 209 | } 210 | 211 | function getQueryStringParameter(name) { 212 | const params = new URLSearchParams(window.location.search); 213 | return params.get(name); 214 | } 215 | 216 | function setQueryStringParameter(name, value) { 217 | const params = new URLSearchParams(window.location.search); 218 | params.set(name, value); 219 | window.history.replaceState({}, "", decodeURIComponent(`${window.location.pathname}?${params}`)); 220 | } 221 | 222 | async function loadChartFromSeriesState() { 223 | const param = getQueryStringParameter("series"); 224 | if (param) { 225 | CHART_OPTS = JSON.parse(atob(param)); 226 | for (const series of CHART_OPTS) { 227 | console.log("Loading series: ", JSON.stringify(series)); 228 | await addSeriesToChart(chartContext, series); 229 | } 230 | } 231 | } 232 | 233 | const onDocReady = function (cb) { 234 | if (document.readyState === "loading") { 235 | document.addEventListener("DOMContentLoaded", function (e) { 236 | cb(); 237 | }) 238 | } else { 239 | cb(); 240 | } 241 | }; 242 | 243 | window.resetChart = function () { 244 | clearChart(); 245 | unlockFields(); 246 | 247 | resetSeriesState(); 248 | CHART_AXES = []; 249 | CHART_DATA.datasets = []; 250 | CHART_DATA.labels = []; 251 | }; 252 | 253 | window.clearChart = function () { 254 | if (lastChart) { 255 | lastChart.destroy(); 256 | lastChart = undefined; 257 | } 258 | }; 259 | 260 | window.addSeriesFromForm = async function () { 261 | withLoading(async () => { 262 | const opts = getQueryOptsFromForm(); 263 | appendSeriesToState(opts); 264 | 265 | await addSeriesToChart(chartContext, opts); 266 | }); 267 | }; 268 | 269 | onDocReady(() => { 270 | const chartCard = this.document.querySelector("#chart-content"); 271 | const chart = chartCard.querySelector("canvas"); 272 | chartContext = chart.getContext("2d"); 273 | 274 | withLoading(async () => { 275 | await loadChartFromSeriesState(); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/oss-bot/f51982e401afff17080bd2eced645c76dbdc30dd/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ossbot.computer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | 37 | 38 | 39 | 40 | 65 | 66 |
67 |

WELCOME TO OSSBOT.COMPUTER

68 | 69 |

70 | I am the oss bot. 71 |
72 | I am your friend. 73 |
74 | Visit my GitHub 75 |
76 |

77 |
78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /public/samscore.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ossbot.computer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 46 | 47 | 48 | 49 | 74 | 75 |
76 |

What is "SAM Score"?

77 | 78 |

79 | The suggested action metric (SAM) score is a single number meant to help maintainers of open-source repositories get an at-a-glance health check for their repo. 80 |

81 |

82 | The formula for SAM score is simple: 83 |

84 |

85 |

 86 | // Simple ratio: what percentage of all issues ever are still open?
 87 | let open_ratio = (open_issues / open_issues + closed_issues)
 88 | 
 89 | // Scale: how many total issues have you ever had?
 90 | let scale_factor = (open_issues + closed_issues)
 91 | 
 92 | // SAM score is the ratio of open to closed issues multiplied by a logarithmic
 93 | // scale factor.
 94 | let score = open_ratio * Math.log(Math.E + scale_factor)
95 |

96 |

97 | The goal is to get the lowest SAM score possible without "gaming" the system through bug bankruptcy or other developer-unfriendly processes. 98 |

99 |

100 | The basic principles are: 101 | 102 |

    103 |
  • Open issues are bad, closed issues are good.
  • 104 |
  • 100 out of 200 issues open is worse than having 1 out of 2.
  • 105 |
  • Repos with more issues require more attention, but not linearly so.
  • 106 |
107 |

108 | 109 |

110 | SAM Scores fall into the following buckets: 111 | 112 |

    113 |
  • < 0.5 - Excellent
  • 114 |
  • < 1.0 - Good
  • 115 |
  • < 2.0 - Okay
  • 116 |
  • > 2.0 - Poor
  • 117 |
118 |

119 | 120 |
121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /scripts/moveconfig.sh: -------------------------------------------------------------------------------- 1 | # Try to cat the file from google3 head 2 | CONFIG=$(cat "/google/src/head/depot/google3/devrel/g3doc/firebase/ossbot/config.json" 2> /dev/null) 3 | RS=$? 4 | 5 | # If the cat failed (due to whatever, print a warning). 6 | if [[ $RS != 0 ]]; 7 | then 8 | echo "WARN: unable to automatically move config, make sure you have the latest." 9 | exit 0 10 | fi 11 | 12 | echo "Writing config to functions/config/config.json" 13 | echo "$CONFIG" > ./functions/config/config.json --------------------------------------------------------------------------------