├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── LICENSE.txt ├── README.adoc ├── deploy-to-openshift.sh ├── documentation └── screenshots │ └── workflow-run-report.png ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── docker │ ├── Dockerfile.fast-jar │ ├── Dockerfile.jvm │ └── Dockerfile.native ├── java │ └── io │ │ └── quarkus │ │ └── bot │ │ ├── AffectKindToPullRequest.java │ │ ├── AffectMilestone.java │ │ ├── AnalyzeWorkflowRunResults.java │ │ ├── ApproveWorkflow.java │ │ ├── CancelWorkflowOnClosedPullRequest.java │ │ ├── CheckIssueEditorialRules.java │ │ ├── CheckPullRequestContributionRules.java │ │ ├── CheckPullRequestEditorialRules.java │ │ ├── CheckTriageBackportContext.java │ │ ├── MarkClosedPullRequestInvalid.java │ │ ├── NotifyQE.java │ │ ├── PingWhenNeedsTriageRemoved.java │ │ ├── PullRequestGuardedBranches.java │ │ ├── PushToProjects.java │ │ ├── QuarkusBot.java │ │ ├── RemoveCiLabelsWhenClosed.java │ │ ├── RemoveInvalidLabelOnReopenAction.java │ │ ├── RemoveNeedsTriageLabelFromClosedIssue.java │ │ ├── SetAreaLabelColor.java │ │ ├── SetTriageBackportLabelColor.java │ │ ├── TriageDiscussion.java │ │ ├── TriageIssue.java │ │ ├── TriagePullRequest.java │ │ ├── config │ │ ├── Feature.java │ │ ├── QuarkusGitHubBotConfig.java │ │ └── QuarkusGitHubBotConfigFile.java │ │ ├── el │ │ ├── Matcher.java │ │ └── SimpleELContext.java │ │ ├── graal │ │ └── SubstituteClassPathResolver.java │ │ ├── util │ │ ├── Branches.java │ │ ├── GHIssues.java │ │ ├── GHPullRequests.java │ │ ├── IssueExtractor.java │ │ ├── Labels.java │ │ ├── Mentions.java │ │ ├── Patterns.java │ │ ├── PullRequestFilesMatcher.java │ │ ├── Strings.java │ │ └── Triage.java │ │ └── workflow │ │ ├── QuarkusStackTraceShortener.java │ │ ├── QuarkusWorkflowConstants.java │ │ ├── QuarkusWorkflowJobLabeller.java │ │ └── report │ │ └── QuarkusWorkflowReportJobIncludeStrategy.java └── resources │ └── application.properties └── test ├── java └── io │ └── quarkus │ └── bot │ ├── it │ ├── CheckIssueEditorialRulesTest.java │ ├── CheckPullRequestContributionRulesTest.java │ ├── CheckTriageBackportContextTest.java │ ├── IssueOpenedTest.java │ ├── MarkClosedPullRequestInvalidTest.java │ ├── MockHelper.java │ ├── PullRequestOpenedTest.java │ ├── PushToProjectsTest.java │ ├── WorkflowApprovalTest.java │ └── util │ │ ├── BranchesTest.java │ │ ├── GHIssuesTest.java │ │ └── GHPullRequestsTest.java │ └── workflow │ └── StackTraceShortenerTest.java └── resources ├── issue-opened-zulip.json ├── issue-opened.json ├── pullrequest-closed.json ├── pullrequest-labeled-no-organization.json ├── pullrequest-opened-description-doc-missing-large-patch.json ├── pullrequest-opened-description-doc-missing-multiple-commits.json ├── pullrequest-opened-description-doc-missing-small-patch.json ├── pullrequest-opened-description-doc-not-missing.json ├── pullrequest-opened-description-missing-bom.json ├── pullrequest-opened-description-non-doc-large-patch.json ├── pullrequest-opened-description-non-doc-medium-patch.json ├── pullrequest-opened-description-non-doc-small-patch.json ├── pullrequest-opened-guarded-branch.json ├── pullrequest-opened-title-contains-issue-number.json ├── pullrequest-opened-title-contains-keyword.json ├── pullrequest-opened-title-contains-test.json ├── pullrequest-opened-title-does-not-contain-keyword.json ├── pullrequest-opened-title-ends-with-dot.json ├── pullrequest-opened-title-for-maintenance-branch-with-no-prefix.json ├── pullrequest-opened-title-starts-with-chore.json ├── pullrequest-opened-title-starts-with-docs.json ├── pullrequest-opened-title-starts-with-feat.json ├── pullrequest-opened-title-starts-with-fix.json ├── pullrequest-opened-title-starts-with-gRPC.json ├── pullrequest-opened-title-starts-with-lowercase.json ├── pullrequest-opened-title-starts-with-maintenance-branch-for-main.json ├── pullrequest-opened-title-starts-with-maintenance-branch-parenthesis.json ├── pullrequest-opened-title-starts-with-maintenance-branch.json ├── workflow-approval-needed.json ├── workflow-from-committer.json └── workflow-unknown-contributor-approval-needed.json /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !target/*-runner 3 | !target/*-runner.jar 4 | !target/lib/* 5 | !target/quarkus-app/* -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: maven 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | paths-ignore: 8 | - '.gitignore' 9 | - 'CODEOWNERS' 10 | - 'LICENSE' 11 | - '*.md' 12 | - '*.adoc' 13 | - '*.txt' 14 | - '.all-contributorsrc' 15 | pull_request: 16 | paths-ignore: 17 | - '.gitignore' 18 | - 'CODEOWNERS' 19 | - 'LICENSE' 20 | - '*.md' 21 | - '*.adoc' 22 | - '*.txt' 23 | - '.all-contributorsrc' 24 | 25 | jobs: 26 | build: 27 | 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Set up JDK 17 34 | uses: actions/setup-java@v4 35 | with: 36 | distribution: temurin 37 | java-version: 17 38 | 39 | - name: Get Date 40 | id: get-date 41 | run: | 42 | echo "::set-output name=date::$(/bin/date -u "+%Y-%m")" 43 | shell: bash 44 | - name: Cache Maven Repository 45 | id: cache-maven 46 | uses: actions/cache@v4 47 | with: 48 | path: ~/.m2/repository 49 | # refresh cache every month to avoid unlimited growth 50 | key: maven-repo-pr-${{ runner.os }}-${{ steps.get-date.outputs.date }} 51 | 52 | - name: Build with Maven 53 | run: mvn -B clean install -Dno-format 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Eclipse 2 | .project 3 | .classpath 4 | .settings/ 5 | bin/ 6 | 7 | # IntelliJ 8 | .idea 9 | *.ipr 10 | *.iml 11 | *.iws 12 | 13 | # NetBeans 14 | nb-configuration.xml 15 | 16 | # Visual Studio Code 17 | .vscode 18 | .factorypath 19 | 20 | # OSX 21 | .DS_Store 22 | 23 | # Vim 24 | *.swp 25 | *.swo 26 | 27 | # patch 28 | *.orig 29 | *.rej 30 | 31 | # Maven 32 | target/ 33 | pom.xml.tag 34 | pom.xml.releaseBackup 35 | pom.xml.versionsBackup 36 | release.properties 37 | 38 | # Impsort 39 | .cache/ 40 | 41 | # Environment 42 | .env 43 | .quarkus 44 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quarkusio/quarkus-github-bot/347b4f73964b2da1b1bd71e2bbf04dc75677bed5/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.7/apache-maven-3.8.7-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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 [yyyy] [name of copyright owner] 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 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Quarkus GitHub Bot 2 | 3 | > A Quarkus-powered GitHub App to simplify issues and pull requests management in the Quarkus project. 4 | 5 | ++++ 6 |

7 | ++++ 8 | 9 | == Introduction 10 | 11 | This GitHub App is based on the https://github.com/quarkiverse/quarkus-github-app[Quarkus GitHub App framework]. 12 | 13 | It can be run as a native executable. 14 | 15 | == Current Actions 16 | 17 | === Check pull request editorial rules 18 | 19 | This action checks that the title of a pull request respects some editorial rules to make Release Notes more consistent. 20 | 21 | === Check pull request contribution rules 22 | 23 | This action checks that pull requests do not contain any merge or fixup commits. 24 | 25 | === Triage issues 26 | 27 | Based on the `.github/quarkus-github-bot.yml` file, this rule affects labels to issues and also pings the appropriate people. 28 | 29 | Syntax of the `.github/quarkus-github-bot.yml` file is as follows: 30 | 31 | [source, yaml] 32 | ---- 33 | triage: 34 | rules: 35 | - labels: [area/amazon-lambda] 36 | title: "lambda" 37 | notify: [patriot1burke, matejvasek] 38 | files: 39 | - extensions/amazon-lambda 40 | - integration-tests/amazon-lambda 41 | - labels: [area/persistence] 42 | title: "db2" 43 | notify: [aguibert] 44 | files: 45 | - extensions/reactive-db2-client/ 46 | - extensions/jdbc/jdbc-db2/ 47 | ---- 48 | 49 | For issues, each rule can be triggered by: 50 | 51 | * `title` - if the title matches this regular expression (case insensitively), trigger the rule 52 | * `body` - if the body (i.e. description) matches this regular expression (case insensitively), trigger the rule 53 | * `titleBody` - if either the title or the body (i.e. description) match this regular expression (case insensitively), trigger the rule 54 | * `expression` - allows to write a Jakarta EL expression testing `title`, `body` or `titleBody`. Be careful when writing expressions, better ping `@gsmet` in the pull request when creating/updating an expression. 55 | 56 | [TIP] 57 | ==== 58 | When writing expressions, you can use the `matches(String pattern, String string)` function that behaves as follows: 59 | 60 | [source,java] 61 | ---- 62 | public static boolean matches(String pattern, String string) { 63 | return Pattern.compile(".*" + pattern + ".*", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(string) 64 | .matches(); 65 | } 66 | ---- 67 | 68 | A rule using an expression based on `matches()` would look like: 69 | 70 | [source,yaml] 71 | ---- 72 | - labels: [area/hibernate-orm, area/persistence] 73 | expression: | 74 | matches("hibernate", title) 75 | && !matches("hibernate.validator", title) 76 | && !matches("hibernate.search", title) 77 | && !matches("hibernate.reactive", title) 78 | notify: [gsmet, Sanne, yrodiere] 79 | ---- 80 | ==== 81 | 82 | If the rule is triggered, the following actions will be executed: 83 | 84 | * `notify` - will create a comment pinging the users listed in the array 85 | * `labels` - will add the labels to the issue 86 | 87 | === Triage pull requests 88 | 89 | The pull requests triage action uses the same configuration file as the issues triage action. 90 | 91 | There are a few differences though as it doesn't behave in the exact same way. 92 | 93 | For pull requests, each rule can be triggered by: 94 | 95 | * `files` - if any file in the commits of the pull requests match, trigger the rule. This is not a regexp (it uses `startsWith`) but glob type expression are supported too `extensions/test/**`. 96 | 97 | If no rule is triggered based on files, or if rules are triggered but they all specify `allowSecondPass: true`, 98 | a second pass will be executed; in that second pass, rules can be triggered by: 99 | 100 | * `title` - if the title matches this regular expression (case insensitively), trigger the rule 101 | * `body` - if the body (i.e. description) matches this regular expression (case insensitively), trigger the rule 102 | * `titleBody` - if either the title or the body (i.e. description) match this regular expression (case insensitively), trigger the rule 103 | * `expression` - allows to write a Jakarta EL expression testing `title`, `body` or `titleBody`. Be careful when writing expressions, better ping `@gsmet` in the pull request when creating/updating an expression. 104 | 105 | If the rule is triggered, the following action will be executed: 106 | 107 | * `labels` - will add the labels to the issue 108 | * `notify` - will create a comment pinging the users listed in the array **only if `notifyInPullRequest` is true** 109 | 110 | `notifyInPullRequest` should be used as follows: 111 | 112 | [source, yaml] 113 | ---- 114 | triage: 115 | rules: 116 | - labels: [area/amazon-lambda] 117 | title: "lambda" 118 | notify: [patriot1burke, matejvasek] 119 | notifyInPullRequest: true 120 | files: 121 | - extensions/amazon-lambda 122 | - integration-tests/amazon-lambda 123 | ---- 124 | 125 | === Push issues/pull requests to a project 126 | 127 | For new projects, you can push issues which gets the label `area/hibernate-validator` with the following configuration: 128 | 129 | [source, yaml] 130 | ---- 131 | projects: 132 | rules: 133 | - labels: [area/hibernate-validator] 134 | project: 1 135 | issues: true 136 | pullRequests: false 137 | status: Todo 138 | ---- 139 | 140 | For classic projects, use the following snippet (note the `projectsClassic` root): 141 | 142 | [source, yaml] 143 | ---- 144 | projectsClassic: 145 | rules: 146 | - labels: [area/hibernate-validator] 147 | project: 1 148 | issues: true 149 | pullRequests: false 150 | status: Todo 151 | ---- 152 | 153 | * `labels` defines the list of labels for which the rule will be applied. Any time one of the labels is added to an issue/pull request, it will be added to the project (if not already in it). 154 | * `project` is the id of the project as seen in the URL 155 | * `issues` and `pullRequests` are false by default 156 | * `status` defines the name of the column in which the item will be added e.g. `Todo`, `Backlog`. It is mandatory. 157 | 158 | === Triage discussions 159 | 160 | The rules applied for issues and pull requests are also applied to discussions, as long as the category is monitored. 161 | Typically, in the case of the Quarkus main repository, we are only monitoring the `Q&A` category. 162 | 163 | Monitoring a category is enabled with: 164 | 165 | [source, yaml] 166 | ---- 167 | triage: 168 | discussions: 169 | monitoredCategories: [33575230] 170 | ---- 171 | 172 | The number is the numeric id as present in the JSON event payload. 173 | 174 | === Notify QE 175 | 176 | When the `triage/qe?` label is added to an issue or a pull request, the QE team is pinged. 177 | 178 | The configuration is done in the `quarkus-github-bot.yml` config file: 179 | 180 | [source,yaml] 181 | ---- 182 | triage: 183 | qe: 184 | notify: [rsvoboda, mjurc] 185 | ---- 186 | 187 | === Affect milestones 188 | 189 | When a pull request is merged, if it targets the `main` branch, it affects the milestone ending with ` - main` to the pull request and the issues resolved by the pull request (e.g. `Fixes #1234`). 190 | 191 | It only affects the milestone is no milestone has been affected prior to the merge. 192 | If the milestone cannot be affected, we add a comment to the pull request indicating the items for which we haven't affected the milestone. 193 | 194 | === Workflow run report 195 | 196 | When a workflow run associated to a pull request is completed, a report is generated and added as a comment in the pull request: 197 | 198 | > image::documentation/screenshots/workflow-run-report.png[] 199 | 200 | === Approve workflow runs 201 | 202 | This rule applies more fine-grained protections to workflow runs 203 | than is provided by the basic GitHub settings. If a repository 204 | is https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository[set up to only allow workflow runs from committers], 205 | the bot can automatically approve some workflows which meet a set of rules. 206 | 207 | Syntax of the `.github/quarkus-github-bot.yml` file is as follows: 208 | 209 | [source, yaml] 210 | ---- 211 | features: [ APPROVE_WORKFLOWS ] 212 | workflows: 213 | rules: 214 | - allow: 215 | files: 216 | - ./src 217 | - ./doc* 218 | - "**/README.md" 219 | users: 220 | minContributions: 5 221 | unless: 222 | files: 223 | - ./.github 224 | - "**/pom.xml" 225 | ---- 226 | 227 | Workflows will be allowed if they meet one of the rules in the `allow` section, 228 | unless one of the rules in the `unless` section is triggered. 229 | 230 | In the example above, any file called `README.md` would be allowed, except for `./github/README.md`. 231 | Users who had made at least 5 commits to the repository would be allowed to make any changes, 232 | except to a `pom.xml` or any files in `.github`. Other users could make changes to `./src` or directories whose name started with `./doc`. 233 | 234 | If the rule is triggered, the following actions will be executed: 235 | 236 | * `approve` - will approve the workflow which needs approval 237 | 238 | If the workflow is not approved, it will be left untouched, for a human approver to look at. 239 | 240 | === Mark closed pull requests as invalid 241 | 242 | If a pull request is closed without being merged, we automatically add the `triage/invalid` label to the pull request. 243 | 244 | === Automatically remove outdated labels 245 | 246 | The bot will automatically remove these labels when they are outdated: 247 | 248 | * `triage/needs-triage` from closed issues 249 | * `waiting-for-ci` from closed pull requests 250 | 251 | === Enforce color for specific labels 252 | 253 | The bot enforces a specific color for any label created that starts with `area/` so that all these labels are consistent. 254 | 255 | == Contributing 256 | 257 | To participate to the development of this GitHub App, create a playground project in your own org and 258 | follow the steps outlined in https://quarkiverse.github.io/quarkiverse-docs/quarkus-github-app/dev/index.html[the Quarkus GitHub App documentation]. 259 | 260 | GitHub permissions required: 261 | 262 | * Actions - `Read & Write` 263 | * Checks - `Read & Write` 264 | * Contents - `Read only` 265 | * Discussions - `Read & Write` 266 | * Issues - `Read & Write` 267 | * Pull Requests - `Read & Write` 268 | 269 | Events to subscribe to: 270 | 271 | * Discussions 272 | * Issues 273 | * Label 274 | * Pull Request 275 | * Workflow run 276 | * Workflow dispatch 277 | 278 | By default, in dev mode, the Bot runs in dry-run so it's logging its actions but do not perform them. 279 | You can override this behavior by adding `_DEV_QUARKUS_GITHUB_BOT_DRY_RUN=false` to your `.env` file. 280 | 281 | == Deployment 282 | 283 | Once logged in to the OpenShift cluster (using `oc login...`), just run: 284 | 285 | [source, bash] 286 | ---- 287 | $ ./deploy-to-openshift.sh 288 | ---- 289 | 290 | == License 291 | 292 | This project is licensed under the Apache License Version 2.0. 293 | -------------------------------------------------------------------------------- /deploy-to-openshift.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # login to the OpenShift cluster before launching this script 4 | 5 | # switch to the right project 6 | oc project prod-quarkus-bot 7 | 8 | # delete problematic image 9 | oc delete is ubi-quarkus-native-binary-s2i 10 | 11 | mvn clean package -Dquarkus.kubernetes.deploy=true -Dquarkus.native.container-build=true -Dnative -DskipTests -DskipITs 12 | 13 | # add kubernetes.io/tls-acme: 'true' to the route to renew the SSL certificate automatically 14 | -------------------------------------------------------------------------------- /documentation/screenshots/workflow-run-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quarkusio/quarkus-github-bot/347b4f73964b2da1b1bd71e2bbf04dc75677bed5/documentation/screenshots/workflow-run-report.png -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM http://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Apache Maven Wrapper startup batch script, version 3.2.0 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 28 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 29 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 30 | @REM e.g. to debug Maven itself, use 31 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 32 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 33 | @REM ---------------------------------------------------------------------------- 34 | 35 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 36 | @echo off 37 | @REM set title of command window 38 | title %0 39 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 40 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 41 | 42 | @REM set %HOME% to equivalent of $HOME 43 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 44 | 45 | @REM Execute a user defined script before this one 46 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 47 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 48 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 49 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 50 | :skipRcPre 51 | 52 | @setlocal 53 | 54 | set ERROR_CODE=0 55 | 56 | @REM To isolate internal variables from possible post scripts, we use another setlocal 57 | @setlocal 58 | 59 | @REM ==== START VALIDATION ==== 60 | if not "%JAVA_HOME%" == "" goto OkJHome 61 | 62 | echo. 63 | echo Error: JAVA_HOME not found in your environment. >&2 64 | echo Please set the JAVA_HOME variable in your environment to match the >&2 65 | echo location of your Java installation. >&2 66 | echo. 67 | goto error 68 | 69 | :OkJHome 70 | if exist "%JAVA_HOME%\bin\java.exe" goto init 71 | 72 | echo. 73 | echo Error: JAVA_HOME is set to an invalid directory. >&2 74 | echo JAVA_HOME = "%JAVA_HOME%" >&2 75 | echo Please set the JAVA_HOME variable in your environment to match the >&2 76 | echo location of your Java installation. >&2 77 | echo. 78 | goto error 79 | 80 | @REM ==== END VALIDATION ==== 81 | 82 | :init 83 | 84 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 85 | @REM Fallback to current working directory if not found. 86 | 87 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 88 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 89 | 90 | set EXEC_DIR=%CD% 91 | set WDIR=%EXEC_DIR% 92 | :findBaseDir 93 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 94 | cd .. 95 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 96 | set WDIR=%CD% 97 | goto findBaseDir 98 | 99 | :baseDirFound 100 | set MAVEN_PROJECTBASEDIR=%WDIR% 101 | cd "%EXEC_DIR%" 102 | goto endDetectBaseDir 103 | 104 | :baseDirNotFound 105 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 106 | cd "%EXEC_DIR%" 107 | 108 | :endDetectBaseDir 109 | 110 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 111 | 112 | @setlocal EnableExtensions EnableDelayedExpansion 113 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 114 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 115 | 116 | :endReadAdditionalConfig 117 | 118 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 123 | 124 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 125 | IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B 126 | ) 127 | 128 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 129 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 130 | if exist %WRAPPER_JAR% ( 131 | if "%MVNW_VERBOSE%" == "true" ( 132 | echo Found %WRAPPER_JAR% 133 | ) 134 | ) else ( 135 | if not "%MVNW_REPOURL%" == "" ( 136 | SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 137 | ) 138 | if "%MVNW_VERBOSE%" == "true" ( 139 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 140 | echo Downloading from: %WRAPPER_URL% 141 | ) 142 | 143 | powershell -Command "&{"^ 144 | "$webclient = new-object System.Net.WebClient;"^ 145 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 146 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 147 | "}"^ 148 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ 149 | "}" 150 | if "%MVNW_VERBOSE%" == "true" ( 151 | echo Finished downloading %WRAPPER_JAR% 152 | ) 153 | ) 154 | @REM End of extension 155 | 156 | @REM If specified, validate the SHA-256 sum of the Maven wrapper jar file 157 | SET WRAPPER_SHA_256_SUM="" 158 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 159 | IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B 160 | ) 161 | IF NOT %WRAPPER_SHA_256_SUM%=="" ( 162 | powershell -Command "&{"^ 163 | "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ 164 | "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ 165 | " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ 166 | " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ 167 | " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ 168 | " exit 1;"^ 169 | "}"^ 170 | "}" 171 | if ERRORLEVEL 1 goto error 172 | ) 173 | 174 | @REM Provide a "standardized" way to retrieve the CLI args that will 175 | @REM work with both Windows and non-Windows executions. 176 | set MAVEN_CMD_LINE_ARGS=%* 177 | 178 | %MAVEN_JAVA_EXE% ^ 179 | %JVM_CONFIG_MAVEN_PROPS% ^ 180 | %MAVEN_OPTS% ^ 181 | %MAVEN_DEBUG_OPTS% ^ 182 | -classpath %WRAPPER_JAR% ^ 183 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 184 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 185 | if ERRORLEVEL 1 goto error 186 | goto end 187 | 188 | :error 189 | set ERROR_CODE=1 190 | 191 | :end 192 | @endlocal & set ERROR_CODE=%ERROR_CODE% 193 | 194 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 195 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 196 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 197 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 198 | :skipRcPost 199 | 200 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 201 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 202 | 203 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 204 | 205 | cmd /C exit /B %ERROR_CODE% 206 | -------------------------------------------------------------------------------- /src/main/docker/Dockerfile.fast-jar: -------------------------------------------------------------------------------- 1 | #### 2 | # This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode 3 | # 4 | # Before building the container image run: 5 | # 6 | # mvn package -Dquarkus.package.type=fast-jar 7 | # 8 | # Then, build the image with: 9 | # 10 | # docker build -f src/main/docker/Dockerfile.fast-jar -t quarkus/quarkus-bot-fast-jar . 11 | # 12 | # Then run the container using: 13 | # 14 | # docker run -i --rm -p 8080:8080 quarkus/quarkus-bot-fast-jar 15 | # 16 | # If you want to include the debug port into your docker image 17 | # you will have to expose the debug port (default 5005) like this : EXPOSE 8080 5050 18 | # 19 | # Then run the container using : 20 | # 21 | # docker run -i --rm -p 8080:8080 -p 5005:5005 -e JAVA_ENABLE_DEBUG="true" quarkus/quarkus-bot-fast-jar 22 | # 23 | ### 24 | FROM registry.access.redhat.com/ubi8/ubi-minimal:8.1 25 | 26 | ARG JAVA_PACKAGE=java-11-openjdk-headless 27 | ARG RUN_JAVA_VERSION=1.3.8 28 | 29 | ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' 30 | 31 | # Install java and the run-java script 32 | # Also set up permissions for user `1001` 33 | RUN microdnf install curl ca-certificates ${JAVA_PACKAGE} \ 34 | && microdnf update \ 35 | && microdnf clean all \ 36 | && mkdir /deployments \ 37 | && chown 1001 /deployments \ 38 | && chmod "g+rwX" /deployments \ 39 | && chown 1001:root /deployments \ 40 | && curl https://repo1.maven.org/maven2/io/fabric8/run-java-sh/${RUN_JAVA_VERSION}/run-java-sh-${RUN_JAVA_VERSION}-sh.sh -o /deployments/run-java.sh \ 41 | && chown 1001 /deployments/run-java.sh \ 42 | && chmod 540 /deployments/run-java.sh \ 43 | && echo "securerandom.source=file:/dev/urandom" >> /etc/alternatives/jre/lib/security/java.security 44 | 45 | # Configure the JAVA_OPTIONS, you can add -XshowSettings:vm to also display the heap size. 46 | ENV JAVA_OPTIONS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" 47 | 48 | # We make four distinct layers so if there are application changes the library layers can be re-used 49 | COPY --chown=1001 target/quarkus-app/lib/ /deployments/lib/ 50 | COPY --chown=1001 target/quarkus-app/*.jar /deployments/ 51 | COPY --chown=1001 target/quarkus-app/app/ /deployments/app/ 52 | COPY --chown=1001 target/quarkus-app/quarkus/ /deployments/quarkus/ 53 | 54 | EXPOSE 8080 55 | USER 1001 56 | 57 | ENTRYPOINT [ "/deployments/run-java.sh" ] -------------------------------------------------------------------------------- /src/main/docker/Dockerfile.jvm: -------------------------------------------------------------------------------- 1 | #### 2 | # This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode 3 | # 4 | # Before building the container image run: 5 | # 6 | # mvn package 7 | # 8 | # Then, build the image with: 9 | # 10 | # docker build -f src/main/docker/Dockerfile.jvm -t quarkus/quarkus-bot-jvm . 11 | # 12 | # Then run the container using: 13 | # 14 | # docker run -i --rm -p 8080:8080 quarkus/quarkus-bot-jvm 15 | # 16 | # If you want to include the debug port into your docker image 17 | # you will have to expose the debug port (default 5005) like this : EXPOSE 8080 5050 18 | # 19 | # Then run the container using : 20 | # 21 | # docker run -i --rm -p 8080:8080 -p 5005:5005 -e JAVA_ENABLE_DEBUG="true" quarkus/quarkus-bot-jvm 22 | # 23 | ### 24 | FROM registry.access.redhat.com/ubi8/ubi-minimal:8.1 25 | 26 | ARG JAVA_PACKAGE=java-11-openjdk-headless 27 | ARG RUN_JAVA_VERSION=1.3.8 28 | 29 | ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' 30 | 31 | # Install java and the run-java script 32 | # Also set up permissions for user `1001` 33 | RUN microdnf install curl ca-certificates ${JAVA_PACKAGE} \ 34 | && microdnf update \ 35 | && microdnf clean all \ 36 | && mkdir /deployments \ 37 | && chown 1001 /deployments \ 38 | && chmod "g+rwX" /deployments \ 39 | && chown 1001:root /deployments \ 40 | && curl https://repo1.maven.org/maven2/io/fabric8/run-java-sh/${RUN_JAVA_VERSION}/run-java-sh-${RUN_JAVA_VERSION}-sh.sh -o /deployments/run-java.sh \ 41 | && chown 1001 /deployments/run-java.sh \ 42 | && chmod 540 /deployments/run-java.sh \ 43 | && echo "securerandom.source=file:/dev/urandom" >> /etc/alternatives/jre/lib/security/java.security 44 | 45 | # Configure the JAVA_OPTIONS, you can add -XshowSettings:vm to also display the heap size. 46 | ENV JAVA_OPTIONS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" 47 | 48 | COPY target/lib/* /deployments/lib/ 49 | COPY target/*-runner.jar /deployments/app.jar 50 | 51 | EXPOSE 8080 52 | USER 1001 53 | 54 | ENTRYPOINT [ "/deployments/run-java.sh" ] -------------------------------------------------------------------------------- /src/main/docker/Dockerfile.native: -------------------------------------------------------------------------------- 1 | #### 2 | # This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode 3 | # 4 | # Before building the container image run: 5 | # 6 | # mvn package -Pnative -Dquarkus.native.container-build=true 7 | # 8 | # Then, build the image with: 9 | # 10 | # docker build -f src/main/docker/Dockerfile.native -t quarkus/quarkus-bot . 11 | # 12 | # Then run the container using: 13 | # 14 | # docker run -i --rm -p 8080:8080 quarkus/quarkus-bot 15 | # 16 | ### 17 | FROM registry.access.redhat.com/ubi9/ubi-minimal:9.5 18 | WORKDIR /work/ 19 | RUN chown 1001 /work \ 20 | && chmod "g+rwX" /work \ 21 | && chown 1001:root /work 22 | COPY --chown=1001:root target/*-runner /work/application 23 | 24 | EXPOSE 8080 25 | USER 1001 26 | 27 | ENV QUARKUS_OPTS=-Dquarkus.http.host=0.0.0.0 -Xmx150m 28 | 29 | CMD ["./application", "-Dquarkus.http.host=0.0.0.0", "-Xmx150m"] 30 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/AffectKindToPullRequest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.HashSet; 5 | import java.util.Set; 6 | 7 | import jakarta.inject.Inject; 8 | 9 | import org.jboss.logging.Logger; 10 | import org.kohsuke.github.GHEventPayload; 11 | import org.kohsuke.github.GHPullRequest; 12 | 13 | import io.quarkiverse.githubapp.ConfigFile; 14 | import io.quarkiverse.githubapp.event.PullRequest; 15 | import io.quarkus.bot.config.Feature; 16 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 17 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 18 | import io.quarkus.bot.util.IssueExtractor; 19 | import io.quarkus.bot.util.Labels; 20 | import io.quarkus.bot.util.Strings; 21 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 22 | 23 | class AffectKindToPullRequest { 24 | 25 | private static final Logger LOG = Logger.getLogger(AffectKindToPullRequest.class); 26 | 27 | private static final String DEPENDABOT = "dependabot"; 28 | 29 | @Inject 30 | QuarkusGitHubBotConfig quarkusBotConfig; 31 | 32 | void dependabotComponentUpgrade(@PullRequest.Closed GHEventPayload.PullRequest pullRequestPayload, 33 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 34 | if (!Feature.QUARKUS_REPOSITORY_WORKFLOW.isEnabled(quarkusBotConfigFile)) { 35 | return; 36 | } 37 | 38 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 39 | 40 | if (!pullRequest.isMerged()) { 41 | return; 42 | } 43 | 44 | if (pullRequest.getUser() == null || pullRequest.getUser().getLogin() == null 45 | || !pullRequest.getUser().getLogin().startsWith(DEPENDABOT)) { 46 | return; 47 | } 48 | 49 | if (!quarkusBotConfig.isDryRun()) { 50 | pullRequest.addLabels(Labels.KIND_COMPONENT_UPGRADE); 51 | } else { 52 | LOG.info("Pull Request #" + pullRequest.getNumber() + " - Add label: " + Labels.KIND_COMPONENT_UPGRADE); 53 | } 54 | } 55 | 56 | void fromIssues(@PullRequest.Closed GHEventPayload.PullRequest pullRequestPayload, 57 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 58 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 59 | if (!Feature.QUARKUS_REPOSITORY_WORKFLOW.isEnabled(quarkusBotConfigFile)) { 60 | return; 61 | } 62 | 63 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 64 | 65 | if (!pullRequest.isMerged() || Strings.isBlank(pullRequest.getBody())) { 66 | return; 67 | } 68 | 69 | IssueExtractor issueExtractor = new IssueExtractor(pullRequest.getRepository().getFullName()); 70 | Set issueNumbers = issueExtractor.extractIssueNumbers(pullRequest, gitHubGraphQLClient); 71 | 72 | Set labels = new HashSet<>(); 73 | for (Integer issueNumber : issueNumbers) { 74 | try { 75 | pullRequest.getRepository().getIssue(issueNumber).getLabels().stream() 76 | .map(l -> l.getName()) 77 | .filter(l -> Labels.KIND_LABELS.contains(l)) 78 | .map(AffectKindToPullRequest::mapLabel) 79 | .forEach(l -> labels.add(l)); 80 | } catch (Exception e) { 81 | // ignoring 82 | } 83 | } 84 | 85 | if (!labels.isEmpty()) { 86 | if (!quarkusBotConfig.isDryRun()) { 87 | pullRequest.addLabels(labels.toArray(new String[0])); 88 | } else { 89 | LOG.info("Pull Request #" + pullRequest.getNumber() + " - Add labels: " + labels); 90 | } 91 | } 92 | } 93 | 94 | private static String mapLabel(String originalLabel) { 95 | if (Labels.KIND_BUG.equals(originalLabel)) { 96 | return Labels.KIND_BUGFIX; 97 | } 98 | 99 | return originalLabel; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/AffectMilestone.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.Collections; 5 | import java.util.Set; 6 | import java.util.TreeSet; 7 | 8 | import jakarta.inject.Inject; 9 | 10 | import org.jboss.logging.Logger; 11 | import org.kohsuke.github.GHEventPayload; 12 | import org.kohsuke.github.GHIssue; 13 | import org.kohsuke.github.GHIssueState; 14 | import org.kohsuke.github.GHMilestone; 15 | import org.kohsuke.github.GHPullRequest; 16 | import org.kohsuke.github.GHRepository; 17 | 18 | import io.quarkiverse.githubapp.ConfigFile; 19 | import io.quarkiverse.githubapp.event.PullRequest; 20 | import io.quarkus.bot.config.Feature; 21 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 22 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 23 | import io.quarkus.bot.util.GHIssues; 24 | import io.quarkus.bot.util.IssueExtractor; 25 | import io.quarkus.bot.util.Labels; 26 | import io.quarkus.bot.util.Strings; 27 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 28 | 29 | class AffectMilestone { 30 | 31 | private static final Logger LOG = Logger.getLogger(AffectMilestone.class); 32 | 33 | private static final String MASTER_BRANCH = "master"; 34 | private static final String MAIN_BRANCH = "main"; 35 | private static final String MAIN_MILESTONE_SUFFIX = "- main"; 36 | 37 | @Inject 38 | QuarkusGitHubBotConfig quarkusBotConfig; 39 | 40 | void affectMilestone(@PullRequest.Closed GHEventPayload.PullRequest pullRequestPayload, 41 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 42 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 43 | if (!Feature.QUARKUS_REPOSITORY_WORKFLOW.isEnabled(quarkusBotConfigFile)) { 44 | return; 45 | } 46 | 47 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 48 | GHRepository repository = pullRequestPayload.getRepository(); 49 | String targetBranch = pullRequest.getBase().getRef(); 50 | 51 | if (!pullRequest.isMerged()) { 52 | return; 53 | } 54 | if (!MASTER_BRANCH.equals(targetBranch) && !MAIN_BRANCH.equals(targetBranch)) { 55 | return; 56 | } 57 | 58 | if (GHIssues.hasLabel(pullRequest, Labels.TRIAGE_INVALID)) { 59 | pullRequest.removeLabels(Labels.TRIAGE_INVALID); 60 | } 61 | 62 | GHMilestone mainMilestone = getMainMilestone(pullRequestPayload.getRepository()); 63 | if (mainMilestone == null) { 64 | LOG.error("Unable to find the main milestone"); 65 | return; 66 | } 67 | 68 | GHMilestone currentMilestone = pullRequest.getMilestone(); 69 | if (currentMilestone == null && !GHIssues.hasLabel(pullRequest, Labels.AREA_INFRA)) { 70 | if (!quarkusBotConfig.isDryRun()) { 71 | pullRequest.setMilestone(mainMilestone); 72 | } else { 73 | LOG.info("Pull request #" + pullRequest.getNumber() + " - Affect milestone: " + mainMilestone.getTitle()); 74 | } 75 | } 76 | 77 | Set resolvedIssueNumbers = extractCurrentRepositoryIssueNumbers(pullRequest, gitHubGraphQLClient); 78 | Set alreadyAffectedIssues = new TreeSet<>(); 79 | 80 | for (Integer resolvedIssueNumber : resolvedIssueNumbers) { 81 | GHIssue resolvedIssue = repository.getIssue(resolvedIssueNumber); 82 | if (resolvedIssue == null) { 83 | continue; 84 | } 85 | 86 | if (resolvedIssue.getMilestone() != null 87 | && (resolvedIssue.getMilestone().getNumber() != mainMilestone.getNumber())) { 88 | alreadyAffectedIssues.add(resolvedIssueNumber); 89 | } else { 90 | if (!quarkusBotConfig.isDryRun()) { 91 | resolvedIssue.setMilestone(mainMilestone); 92 | } else { 93 | LOG.info("Issue #" + resolvedIssueNumber + " - Affect milestone: " + mainMilestone.getTitle()); 94 | } 95 | } 96 | } 97 | 98 | // Add a comment if some of the items were already affected to a different milestone 99 | String comment = ""; 100 | if (currentMilestone != null && (currentMilestone.getNumber() != mainMilestone.getNumber())) { 101 | comment += "* The pull request itself\n"; 102 | } 103 | for (Integer alreadyAffectedIssue : alreadyAffectedIssues) { 104 | comment += "* Issue #" + alreadyAffectedIssue + "\n"; 105 | } 106 | if (!comment.isEmpty()) { 107 | comment = "Milestone is already set for some of the items:\n\n" + comment; 108 | comment += "\nWe haven't automatically updated the milestones for these items."; 109 | 110 | comment = Strings.commentByBot(comment); 111 | 112 | if (!quarkusBotConfig.isDryRun()) { 113 | pullRequest.comment(comment); 114 | } else { 115 | LOG.info("Pull request #" + pullRequest.getNumber() + " - Add comment " + comment.toString()); 116 | } 117 | } 118 | } 119 | 120 | private static GHMilestone getMainMilestone(GHRepository repository) { 121 | for (GHMilestone milestone : repository.listMilestones(GHIssueState.OPEN)) { 122 | if (milestone.getTitle().endsWith(MAIN_MILESTONE_SUFFIX)) { 123 | return milestone; 124 | } 125 | } 126 | return null; 127 | } 128 | 129 | private Set extractCurrentRepositoryIssueNumbers(GHPullRequest pullRequest, 130 | DynamicGraphQLClient gitHubGraphQLClient) { 131 | String pullRequestBody = pullRequest.getBody(); 132 | if (pullRequestBody == null || pullRequestBody.trim().isEmpty()) { 133 | return Collections.emptySet(); 134 | } 135 | 136 | return new IssueExtractor(pullRequest.getRepository().getFullName()).extractIssueNumbers(pullRequest, 137 | gitHubGraphQLClient); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/AnalyzeWorkflowRunResults.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.Comparator; 5 | 6 | import jakarta.inject.Inject; 7 | 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHWorkflowJob; 10 | import org.kohsuke.github.GitHub; 11 | 12 | import io.quarkiverse.githubapp.ConfigFile; 13 | import io.quarkiverse.githubapp.event.WorkflowRun; 14 | import io.quarkus.bot.buildreporter.githubactions.BuildReporterConfig; 15 | import io.quarkus.bot.buildreporter.githubactions.BuildReporterEventHandler; 16 | import io.quarkus.bot.config.Feature; 17 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 18 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 19 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 20 | 21 | public class AnalyzeWorkflowRunResults { 22 | 23 | @Inject 24 | BuildReporterEventHandler buildReporterEventHandler; 25 | 26 | @Inject 27 | QuarkusGitHubBotConfig quarkusBotConfig; 28 | 29 | void analyzeWorkflowResults(@WorkflowRun.Completed @WorkflowRun.Requested GHEventPayload.WorkflowRun workflowRunPayload, 30 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 31 | GitHub gitHub, DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 32 | if (!Feature.ANALYZE_WORKFLOW_RUN_RESULTS.isEnabled(quarkusBotConfigFile)) { 33 | return; 34 | } 35 | 36 | Comparator workflowJobComparator; 37 | 38 | switch (workflowRunPayload.getRepository().getFullName()) { 39 | case "quarkusio/quarkus": 40 | workflowJobComparator = QuarkusWorkflowJobComparator.INSTANCE; 41 | break; 42 | case "quarkiverse/quarkus-langchain4j": 43 | workflowJobComparator = QuarkusLangChain4jWorkflowJobComparator.INSTANCE; 44 | break; 45 | default: 46 | // it will use the default ones 47 | workflowJobComparator = null; 48 | } 49 | 50 | BuildReporterConfig buildReporterConfig = BuildReporterConfig.builder() 51 | .dryRun(quarkusBotConfig.isDryRun()) 52 | .monitoredWorkflows(quarkusBotConfigFile.workflowRunAnalysis.workflows) 53 | .ignoredFlakyTests(quarkusBotConfigFile.workflowRunAnalysis.ignoredFlakyTests) 54 | .workflowJobComparator(workflowJobComparator) 55 | .enableDevelocity(quarkusBotConfigFile.develocity.enabled) 56 | .develocityUrl(quarkusBotConfigFile.develocity.url) 57 | .build(); 58 | 59 | buildReporterEventHandler.handle(workflowRunPayload, buildReporterConfig, gitHub, gitHubGraphQLClient); 60 | } 61 | 62 | private final static class QuarkusWorkflowJobComparator implements Comparator { 63 | 64 | private static final QuarkusWorkflowJobComparator INSTANCE = new QuarkusWorkflowJobComparator(); 65 | 66 | @Override 67 | public int compare(GHWorkflowJob o1, GHWorkflowJob o2) { 68 | int order1 = getOrder(o1.getName()); 69 | int order2 = getOrder(o2.getName()); 70 | 71 | if (order1 == order2) { 72 | return o1.getName().compareToIgnoreCase(o2.getName()); 73 | } 74 | 75 | return order1 - order2; 76 | } 77 | 78 | private static int getOrder(String jobName) { 79 | if (jobName.startsWith("Initial JDK")) { 80 | return 1; 81 | } 82 | if (jobName.startsWith("Calculate Test Jobs")) { 83 | return 2; 84 | } 85 | if (jobName.startsWith("JVM Tests - ")) { 86 | if (jobName.contains("Windows")) { 87 | return 12; 88 | } 89 | return 11; 90 | } 91 | if (jobName.startsWith("Maven Tests - ")) { 92 | if (jobName.contains("Windows")) { 93 | return 22; 94 | } 95 | return 21; 96 | } 97 | if (jobName.startsWith("Gradle Tests - ")) { 98 | if (jobName.contains("Windows")) { 99 | return 32; 100 | } 101 | return 31; 102 | } 103 | if (jobName.startsWith("Devtools Tests - ")) { 104 | if (jobName.contains("Windows")) { 105 | return 42; 106 | } 107 | return 41; 108 | } 109 | if (jobName.startsWith("Kubernetes Tests - ")) { 110 | if (jobName.contains("Windows")) { 111 | return 52; 112 | } 113 | return 51; 114 | } 115 | if (jobName.startsWith("Quickstarts Compilation")) { 116 | return 61; 117 | } 118 | if (jobName.startsWith("MicroProfile TCKs Tests")) { 119 | return 71; 120 | } 121 | if (jobName.startsWith("Native Tests - ")) { 122 | if (jobName.contains("Windows")) { 123 | return 82; 124 | } 125 | return 81; 126 | } 127 | 128 | return 200; 129 | } 130 | } 131 | 132 | private final static class QuarkusLangChain4jWorkflowJobComparator implements Comparator { 133 | 134 | private static final QuarkusLangChain4jWorkflowJobComparator INSTANCE = new QuarkusLangChain4jWorkflowJobComparator(); 135 | 136 | @Override 137 | public int compare(GHWorkflowJob o1, GHWorkflowJob o2) { 138 | int order1 = getOrder(o1.getName()); 139 | int order2 = getOrder(o2.getName()); 140 | 141 | if (order1 == order2) { 142 | return o1.getName().compareToIgnoreCase(o2.getName()); 143 | } 144 | 145 | return order1 - order2; 146 | } 147 | 148 | private static int getOrder(String jobName) { 149 | if (jobName.startsWith("Quick Build")) { 150 | return 1; 151 | } 152 | if (jobName.startsWith("JVM tests - ")) { 153 | return 2; 154 | } 155 | if (jobName.startsWith("Native tests - ")) { 156 | return 12; 157 | } 158 | if (jobName.startsWith("In process embedding model tests - ")) { 159 | return 22; 160 | } 161 | 162 | return 200; 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/ApproveWorkflow.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.List; 5 | import java.util.Map; 6 | import java.util.function.Function; 7 | import java.util.stream.Collectors; 8 | 9 | import jakarta.inject.Inject; 10 | 11 | import org.jboss.logging.Logger; 12 | import org.kohsuke.github.GHEventPayload; 13 | import org.kohsuke.github.GHPullRequest; 14 | import org.kohsuke.github.GHRepository; 15 | import org.kohsuke.github.GHRepositoryStatistics; 16 | import org.kohsuke.github.GHWorkflowRun; 17 | import org.kohsuke.github.PagedIterable; 18 | 19 | import io.quarkiverse.githubapp.ConfigFile; 20 | import io.quarkiverse.githubapp.event.WorkflowRun; 21 | import io.quarkus.bot.config.Feature; 22 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 23 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 24 | import io.quarkus.bot.util.PullRequestFilesMatcher; 25 | import io.quarkus.cache.CacheKey; 26 | import io.quarkus.cache.CacheResult; 27 | 28 | class ApproveWorkflow { 29 | 30 | private static final Logger LOG = Logger.getLogger(ApproveWorkflow.class); 31 | 32 | @Inject 33 | QuarkusGitHubBotConfig quarkusBotConfig; 34 | 35 | void evaluatePullRequest( 36 | @WorkflowRun.Requested GHEventPayload.WorkflowRun workflowPayload, 37 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 38 | if (!Feature.APPROVE_WORKFLOWS.isEnabled(quarkusBotConfigFile)) { 39 | return; 40 | } 41 | 42 | // Don't bother checking if there are no rules 43 | if (quarkusBotConfigFile.workflows.rules != null && quarkusBotConfigFile.workflows.rules.isEmpty()) { 44 | return; 45 | } 46 | GHWorkflowRun workflowRun = workflowPayload.getWorkflowRun(); 47 | 48 | // Only check workflows which need action 49 | if (!GHWorkflowRun.Conclusion.ACTION_REQUIRED.equals(workflowRun.getConclusion())) { 50 | return; 51 | } 52 | 53 | ApprovalStatus approval = new ApprovalStatus(); 54 | 55 | checkUser(workflowPayload, quarkusBotConfigFile, approval); 56 | 57 | // Don't bother checking more if we have a red flag 58 | // (but don't return because we need to do stuff with the answer) 59 | if (!approval.hasRedFlag()) { 60 | checkFiles(quarkusBotConfigFile, workflowRun, approval); 61 | } 62 | 63 | if (approval.isApproved()) { 64 | processApproval(workflowRun); 65 | } 66 | } 67 | 68 | private void processApproval(GHWorkflowRun workflowRun) throws IOException { 69 | // We could also do things here like adding comments, subject to config 70 | if (!quarkusBotConfig.isDryRun()) { 71 | workflowRun.approve(); 72 | } 73 | } 74 | 75 | private void checkUser(GHEventPayload.WorkflowRun workflowPayload, QuarkusGitHubBotConfigFile quarkusBotConfigFile, 76 | ApprovalStatus approval) { 77 | for (QuarkusGitHubBotConfigFile.WorkflowApprovalRule rule : quarkusBotConfigFile.workflows.rules) { 78 | // We allow if the files or directories match the allow rule ... 79 | if ((rule.allow != null && rule.allow.users != null) || (rule.unless != null && rule.unless.users != null)) { 80 | GHRepositoryStatistics.ContributorStats stats = getStatsForUser(workflowPayload); 81 | if (matchRuleForUser(stats, rule.allow)) { 82 | approval.shouldApprove = true; 83 | } 84 | 85 | if (matchRuleForUser(stats, rule.unless)) { 86 | approval.shouldNotApprove = true; 87 | } 88 | } 89 | } 90 | } 91 | 92 | private void checkFiles(QuarkusGitHubBotConfigFile quarkusBotConfigFile, GHWorkflowRun workflowRun, 93 | ApprovalStatus approval) { 94 | String sha = workflowRun.getHeadSha(); 95 | 96 | // Now we want to get the pull request we're supposed to be checking. 97 | // It would be nice to use commit.listPullRequests() but that only returns something if the 98 | // base and head of the PR are from the same repository, which rules out most scenarios where we would want to do an approval 99 | 100 | String fullyQualifiedBranchName = workflowRun.getHeadRepository().getOwnerName() + ":" + workflowRun.getHeadBranch(); 101 | 102 | PagedIterable pullRequestsForThisBranch = workflowRun.getRepository().queryPullRequests() 103 | .head(fullyQualifiedBranchName) 104 | .list(); 105 | 106 | // The number of PRs with matching branch name should be exactly one, but if the PR 107 | // has been closed it sometimes disappears from the list; also, if two branch names 108 | // start with the same string, both will turn up in the query. 109 | for (GHPullRequest pullRequest : pullRequestsForThisBranch) { 110 | 111 | // Only look at PRs whose commit sha matches 112 | if (sha.equals(pullRequest.getHead().getSha())) { 113 | 114 | for (QuarkusGitHubBotConfigFile.WorkflowApprovalRule rule : quarkusBotConfigFile.workflows.rules) { 115 | // We allow if the files or directories match the allow rule ... 116 | if (matchRuleFromChangedFiles(pullRequest, rule.allow)) { 117 | approval.shouldApprove = true; 118 | } 119 | // ... unless we also match the unless rule 120 | if (matchRuleFromChangedFiles(pullRequest, rule.unless)) { 121 | approval.shouldNotApprove = true; 122 | } 123 | } 124 | } 125 | } 126 | } 127 | 128 | public static boolean matchRuleFromChangedFiles(GHPullRequest pullRequest, 129 | QuarkusGitHubBotConfigFile.WorkflowApprovalCondition rule) { 130 | // for now, we only use the files but we could also use the other rules at some point 131 | if (rule == null) { 132 | return false; 133 | } 134 | 135 | if (rule.files == null || rule.files.isEmpty()) { 136 | return false; 137 | } 138 | 139 | PullRequestFilesMatcher prMatcher = new PullRequestFilesMatcher(pullRequest); 140 | return prMatcher.changedFilesMatch(rule.files); 141 | } 142 | 143 | private boolean matchRuleForUser(GHRepositoryStatistics.ContributorStats stats, 144 | QuarkusGitHubBotConfigFile.WorkflowApprovalCondition rule) { 145 | if (rule == null || stats == null) { 146 | return false; 147 | } 148 | 149 | if (rule.users == null) { 150 | return false; 151 | } 152 | 153 | if (rule.users.minContributions != null && stats.getTotal() >= rule.users.minContributions) { 154 | return true; 155 | } 156 | 157 | // We can add more rules here, for example how long the user has been contributing 158 | 159 | return false; 160 | } 161 | 162 | private GHRepositoryStatistics.ContributorStats getStatsForUser(GHEventPayload.WorkflowRun workflowPayload) { 163 | 164 | String login = workflowPayload.getSender().getLogin(); 165 | if (login != null) { 166 | return getStatsForUser(workflowPayload.getRepository(), login); 167 | } 168 | return null; 169 | } 170 | 171 | @CacheResult(cacheName = "contributor-cache") 172 | GHRepositoryStatistics.ContributorStats getStatsForUser(GHRepository repository, @CacheKey String login) { 173 | try { 174 | Map contributorStats = getContributorStats(repository); 175 | return contributorStats.get(login); 176 | } catch (IOException | InterruptedException | NullPointerException e) { 177 | // We sometimes see an NPE from PagedIterator, if a fetch does not complete properly and leaves the object in an inconsistent state 178 | // Catching these errors allows the null result for this contributor to be cached, which is ok 179 | LOG.error("Could not get repository contributor statistics", e); 180 | } 181 | 182 | return null; 183 | } 184 | 185 | // We throw errors at this level to force the cache to retry and populate itself on the next request 186 | @CacheResult(cacheName = "stats-cache") 187 | Map getContributorStats(GHRepository repository) 188 | throws IOException, InterruptedException { 189 | GHRepositoryStatistics statistics = repository.getStatistics(); 190 | if (statistics != null) { 191 | PagedIterable contributors = statistics.getContributorStats(); 192 | // Pull the iterable into a list object to force the traversal of the entire list, 193 | // since then we get a fully-warmed cache on our first request 194 | // Convert to a map for convenience of retrieval 195 | List statsList = contributors.toList(); 196 | return statsList.stream() 197 | .collect( 198 | Collectors.toMap(contributorStats -> contributorStats.getAuthor().getLogin(), Function.identity())); 199 | } 200 | return null; 201 | } 202 | 203 | private static class ApprovalStatus { 204 | // There are two variables here because we check a number of indicators and a number of counter-indicators 205 | // (ie green flags and red flags) 206 | boolean shouldApprove = false; 207 | boolean shouldNotApprove = false; 208 | 209 | boolean isApproved() { 210 | return shouldApprove && !shouldNotApprove; 211 | } 212 | 213 | public boolean hasRedFlag() { 214 | return shouldNotApprove; 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/CancelWorkflowOnClosedPullRequest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.List; 5 | import java.util.stream.Collectors; 6 | 7 | import jakarta.inject.Inject; 8 | 9 | import org.jboss.logging.Logger; 10 | import org.kohsuke.github.GHEventPayload; 11 | import org.kohsuke.github.GHPullRequest; 12 | import org.kohsuke.github.GHWorkflowRun; 13 | 14 | import io.quarkiverse.githubapp.ConfigFile; 15 | import io.quarkiverse.githubapp.event.PullRequest; 16 | import io.quarkus.bot.config.Feature; 17 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 18 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 19 | import io.quarkus.bot.workflow.QuarkusWorkflowConstants; 20 | 21 | class CancelWorkflowOnClosedPullRequest { 22 | private static final Logger LOG = Logger.getLogger(CancelWorkflowOnClosedPullRequest.class); 23 | 24 | @Inject 25 | QuarkusGitHubBotConfig quarkusBotConfig; 26 | 27 | public void onClose(@PullRequest.Closed GHEventPayload.PullRequest pullRequestPayload, 28 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 29 | if (!Feature.QUARKUS_REPOSITORY_WORKFLOW.isEnabled(quarkusBotConfigFile)) { 30 | return; 31 | } 32 | 33 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 34 | 35 | List ghWorkflowRuns = pullRequest.getRepository() 36 | .queryWorkflowRuns() 37 | .branch(pullRequest.getHead().getRef()) 38 | .list() 39 | .toList() 40 | .stream() 41 | .filter(workflowRun -> workflowRun.getHeadRepository().getOwnerName() 42 | .equals(pullRequest.getHead().getRepository().getOwnerName())) 43 | .filter(workflowRun -> QuarkusWorkflowConstants.QUARKUS_CI_WORKFLOW_NAME.equals(workflowRun.getName()) || 44 | QuarkusWorkflowConstants.QUARKUS_DOCUMENTATION_CI_WORKFLOW_NAME.equals(workflowRun.getName())) 45 | .filter(workflowRun -> workflowRun.getStatus() == GHWorkflowRun.Status.QUEUED 46 | || workflowRun.getStatus() == GHWorkflowRun.Status.IN_PROGRESS) 47 | .collect(Collectors.toList()); 48 | 49 | for (GHWorkflowRun workflowRun : ghWorkflowRuns) { 50 | if (!quarkusBotConfig.isDryRun()) { 51 | workflowRun.cancel(); 52 | } else { 53 | LOG.info("Workflow run #" + workflowRun.getId() + " - Cancelling as pull request #" + pullRequest.getNumber() 54 | + " is now closed"); 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/CheckIssueEditorialRules.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import org.jboss.logging.Logger; 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHIssue; 10 | 11 | import io.quarkiverse.githubapp.ConfigFile; 12 | import io.quarkiverse.githubapp.event.Issue; 13 | import io.quarkus.bot.config.Feature; 14 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 15 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 16 | import io.quarkus.bot.util.Strings; 17 | 18 | public class CheckIssueEditorialRules { 19 | private static final Logger LOG = Logger.getLogger(CheckIssueEditorialRules.class); 20 | 21 | private static final String ZULIP_URL = "https://quarkusio.zulipchat.com/"; 22 | public static final String ZULIP_WARNING = Strings.commentByBot( 23 | "You added a link to a Zulip discussion, please make sure the description of the issue is comprehensive and doesn't require accessing Zulip"); 24 | 25 | @Inject 26 | QuarkusGitHubBotConfig quarkusBotConfig; 27 | 28 | void onOpen(@Issue.Opened GHEventPayload.Issue issuePayload, 29 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 30 | if (!Feature.CHECK_EDITORIAL_RULES.isEnabled(quarkusBotConfigFile)) { 31 | return; 32 | } 33 | 34 | GHIssue issue = issuePayload.getIssue(); 35 | String body = issue.getBody(); 36 | 37 | if (body == null || !body.contains(ZULIP_URL)) { 38 | return; 39 | } 40 | 41 | if (!quarkusBotConfig.isDryRun()) { 42 | issue.comment(ZULIP_WARNING); 43 | } else { 44 | LOG.info("Issue #" + issue.getNumber() + " - Add comment " + ZULIP_WARNING); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/CheckPullRequestContributionRules.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.time.Instant; 5 | import java.util.ArrayList; 6 | import java.util.Date; 7 | import java.util.List; 8 | 9 | import jakarta.inject.Inject; 10 | 11 | import org.jboss.logging.Logger; 12 | import org.kohsuke.github.GHCheckRun.AnnotationLevel; 13 | import org.kohsuke.github.GHCheckRun.Conclusion; 14 | import org.kohsuke.github.GHCheckRun.Status; 15 | import org.kohsuke.github.GHCheckRunBuilder; 16 | import org.kohsuke.github.GHCheckRunBuilder.Annotation; 17 | import org.kohsuke.github.GHCheckRunBuilder.Output; 18 | import org.kohsuke.github.GHCommit; 19 | import org.kohsuke.github.GHEventPayload; 20 | import org.kohsuke.github.GHPullRequest; 21 | import org.kohsuke.github.GHPullRequestCommitDetail; 22 | import org.kohsuke.github.GHRepository; 23 | 24 | import io.quarkiverse.githubapp.ConfigFile; 25 | import io.quarkiverse.githubapp.event.PullRequest; 26 | import io.quarkus.bot.config.Feature; 27 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 28 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 29 | 30 | public class CheckPullRequestContributionRules { 31 | 32 | private static final Logger LOG = Logger.getLogger(CheckPullRequestContributionRules.class); 33 | 34 | public static final String FIXUP_COMMIT_PREFIX = "fixup!"; 35 | 36 | public static final String MERGE_COMMIT_CHECK_RUN_NAME = "Check Pull Request - Merge commits"; 37 | public static final String MERGE_COMMIT_ERROR_OUTPUT_TITLE = "PR contains merge commits"; 38 | public static final String MERGE_COMMIT_ERROR_OUTPUT_SUMMARY = "Pull request that contains merge commits can not be merged"; 39 | 40 | public static final String FIXUP_COMMIT_CHECK_RUN_NAME = "Check Pull Request - Fixup commits"; 41 | public static final String FIXUP_COMMIT_ERROR_OUTPUT_TITLE = "PR contains fixup commits"; 42 | public static final String FIXUP_COMMIT_ERROR_OUTPUT_SUMMARY = "Pull request that contains fixup commits can not be merged"; 43 | 44 | public static final String ERROR_ANNOTATION_TITLE = "Error - Pull request commit check"; 45 | public static final String ERROR_ANNOTATION_MSG = "[sha=%s ; message=\"%s\"]"; 46 | 47 | @Inject 48 | QuarkusGitHubBotConfig quarkusBotConfig; 49 | 50 | void checkPullRequestContributionRules( 51 | @PullRequest.Opened @PullRequest.Reopened @PullRequest.Synchronize GHEventPayload.PullRequest pullRequestPayload, 52 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 53 | 54 | if (!Feature.CHECK_CONTRIBUTION_RULES.isEnabled(quarkusBotConfigFile)) { 55 | return; 56 | } 57 | 58 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 59 | CheckCommitData checkCommitData = getCheckCommitData(pullRequest); 60 | 61 | if (!quarkusBotConfig.isDryRun()) { 62 | 63 | GHRepository repostitory = pullRequest.getRepository(); 64 | GHCommit headCommit = pullRequest.getHead().getCommit(); 65 | 66 | // Merge commits 67 | buildCheckRun(checkCommitData.getMergeCommitDetails(), repostitory, headCommit, 68 | MERGE_COMMIT_CHECK_RUN_NAME, MERGE_COMMIT_ERROR_OUTPUT_TITLE, MERGE_COMMIT_ERROR_OUTPUT_SUMMARY); 69 | 70 | // Fixup commits 71 | buildCheckRun(checkCommitData.getFixupCommitDetails(), repostitory, headCommit, 72 | FIXUP_COMMIT_CHECK_RUN_NAME, FIXUP_COMMIT_ERROR_OUTPUT_TITLE, FIXUP_COMMIT_ERROR_OUTPUT_SUMMARY); 73 | } else { 74 | LOG.info("Pull request #" + pullRequest.getNumber()); 75 | LOG.info(buildDryRunLogMessage(checkCommitData.getMergeCommitDetails(), MERGE_COMMIT_CHECK_RUN_NAME, 76 | MERGE_COMMIT_ERROR_OUTPUT_SUMMARY)); 77 | LOG.info(buildDryRunLogMessage(checkCommitData.getFixupCommitDetails(), FIXUP_COMMIT_CHECK_RUN_NAME, 78 | FIXUP_COMMIT_ERROR_OUTPUT_SUMMARY)); 79 | } 80 | } 81 | 82 | public static final class CheckCommitData { 83 | 84 | private List mergeCommitDetails; 85 | private List fixupCommitDetails; 86 | 87 | public CheckCommitData(List mergeCommitDetails, 88 | List fixupCommitDetails) { 89 | this.mergeCommitDetails = mergeCommitDetails; 90 | this.fixupCommitDetails = fixupCommitDetails; 91 | } 92 | 93 | public List getMergeCommitDetails() { 94 | return mergeCommitDetails; 95 | } 96 | 97 | public List getFixupCommitDetails() { 98 | return fixupCommitDetails; 99 | } 100 | } 101 | 102 | public static CheckCommitData getCheckCommitData(GHPullRequest pullRequest) { 103 | 104 | List listMergeCommitDetail = new ArrayList<>(); 105 | List listFixupCommitDetail = new ArrayList<>(); 106 | 107 | for (GHPullRequestCommitDetail commitDetail : pullRequest.listCommits()) { 108 | 109 | if (isMergeCommit(commitDetail)) { 110 | listMergeCommitDetail.add(commitDetail); 111 | } 112 | 113 | if (isFixupCommit(commitDetail)) { 114 | listFixupCommitDetail.add(commitDetail); 115 | } 116 | } 117 | 118 | return new CheckCommitData(listMergeCommitDetail, listFixupCommitDetail); 119 | } 120 | 121 | private static boolean isMergeCommit(GHPullRequestCommitDetail commitDetail) { 122 | return commitDetail.getParents().length > 1; 123 | } 124 | 125 | private static boolean isFixupCommit(GHPullRequestCommitDetail commitDetail) { 126 | GHPullRequestCommitDetail.Commit commit = commitDetail.getCommit(); 127 | return commit.getMessage().startsWith(FIXUP_COMMIT_PREFIX); 128 | } 129 | 130 | public static void buildCheckRun(List commitDetails, GHRepository repostitory, 131 | GHCommit headCommit, String checkRunName, String errorOutputTitle, String errorOutputSummary) 132 | throws IOException { 133 | 134 | if (commitDetails.isEmpty()) { 135 | repostitory.createCheckRun(checkRunName, headCommit.getSHA1()) 136 | .withStatus(Status.COMPLETED) 137 | .withStartedAt(Date.from(Instant.now())) 138 | .withConclusion(Conclusion.SUCCESS) 139 | .create(); 140 | } else { 141 | 142 | List annotations = new ArrayList<>(); 143 | for (GHPullRequestCommitDetail commitDetail : commitDetails) { 144 | GHPullRequestCommitDetail.Commit commit = commitDetail.getCommit(); 145 | 146 | String msg = String.format(ERROR_ANNOTATION_MSG, commitDetail.getSha(), commit.getMessage()); 147 | Annotation annotation = new Annotation(".", 0, AnnotationLevel.FAILURE, msg) 148 | .withTitle(ERROR_ANNOTATION_TITLE); 149 | annotations.add(annotation); 150 | } 151 | 152 | Output output = new Output(errorOutputTitle, errorOutputSummary); 153 | for (Annotation annotation : annotations) { 154 | output.add(annotation); 155 | } 156 | 157 | GHCheckRunBuilder check = repostitory 158 | .createCheckRun(checkRunName, headCommit.getSHA1()) 159 | .withStatus(Status.COMPLETED) 160 | .withStartedAt(Date.from(Instant.now())) 161 | .withConclusion(Conclusion.FAILURE); 162 | check.add(output); 163 | check.create(); 164 | } 165 | } 166 | 167 | private static String buildDryRunLogMessage(List commitDetails, String checkRunName, 168 | String errorOutputSummary) { 169 | StringBuilder comment = new StringBuilder(); 170 | comment.append(checkRunName); 171 | if (commitDetails.isEmpty()) { 172 | comment.append(">>> SUCCESS"); 173 | } else { 174 | comment.append(">>> FAILURE"); 175 | comment.append(errorOutputSummary); 176 | for (GHPullRequestCommitDetail commitDetail : commitDetails) { 177 | GHPullRequestCommitDetail.Commit commit = commitDetail.getCommit(); 178 | comment.append(String.format(ERROR_ANNOTATION_MSG, commitDetail.getSha(), commit.getMessage())); 179 | } 180 | } 181 | return comment.toString(); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/CheckPullRequestEditorialRules.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.ArrayList; 5 | import java.util.Arrays; 6 | import java.util.Collections; 7 | import java.util.List; 8 | import java.util.Locale; 9 | import java.util.regex.Pattern; 10 | 11 | import jakarta.inject.Inject; 12 | 13 | import org.jboss.logging.Logger; 14 | import org.kohsuke.github.GHEventPayload; 15 | import org.kohsuke.github.GHPullRequest; 16 | 17 | import io.quarkiverse.githubapp.ConfigFile; 18 | import io.quarkiverse.githubapp.event.PullRequest; 19 | import io.quarkus.bot.config.Feature; 20 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 21 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 22 | import io.quarkus.bot.util.GHPullRequests; 23 | import io.quarkus.bot.util.PullRequestFilesMatcher; 24 | import io.quarkus.bot.util.Strings; 25 | 26 | class CheckPullRequestEditorialRules { 27 | 28 | private static final Logger LOG = Logger.getLogger(CheckPullRequestEditorialRules.class); 29 | 30 | private static final Pattern SPACE_PATTERN = Pattern.compile("\\s+"); 31 | private static final Pattern ISSUE_PATTERN = Pattern.compile("#[0-9]+"); 32 | private static final Pattern FIX_FEAT_CHORE = Pattern.compile("^(fix|chore|feat|docs|refactor)[(:].*"); 33 | 34 | private static final List UPPER_CASE_EXCEPTIONS = Arrays.asList("gRPC"); 35 | private static final List BOMS = List.of("bom/application/pom.xml"); 36 | private static final List DOC_CHANGES = List.of("docs/src/main/asciidoc/", "README.md", "LICENSE", 37 | "CONTRIBUTING.md"); 38 | 39 | @Inject 40 | QuarkusGitHubBotConfig quarkusBotConfig; 41 | 42 | void checkPullRequestEditorialRules(@PullRequest.Opened GHEventPayload.PullRequest pullRequestPayload, 43 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 44 | if (!Feature.CHECK_EDITORIAL_RULES.isEnabled(quarkusBotConfigFile)) { 45 | return; 46 | } 47 | 48 | String baseBranch = pullRequestPayload.getPullRequest().getBase().getRef(); 49 | 50 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 51 | String body = pullRequest.getBody(); 52 | String originalTitle = pullRequest.getTitle(); 53 | String normalizedTitle = GHPullRequests.normalizeTitle(originalTitle, baseBranch); 54 | 55 | if (!originalTitle.equals(normalizedTitle)) { 56 | pullRequest.setTitle(normalizedTitle); 57 | } 58 | 59 | // we remove the potential version prefix before checking the editorial rules 60 | String title = GHPullRequests.dropVersionSuffix(normalizedTitle, baseBranch); 61 | 62 | List titleErrorMessages = getTitleErrorMessages(title); 63 | List bodyErrorMessages = getBodyErrorMessages(body, pullRequest); 64 | 65 | if (titleErrorMessages.isEmpty() && bodyErrorMessages.isEmpty()) { 66 | return; 67 | } 68 | 69 | StringBuilder comment = new StringBuilder(""" 70 | Thanks for your pull request! 71 | 72 | Your pull request does not follow our editorial rules. Could you have a look? 73 | 74 | """); 75 | for (String errorMessage : titleErrorMessages) { 76 | comment.append("- ").append(errorMessage).append("\n"); 77 | } 78 | for (String errorMessage : bodyErrorMessages) { 79 | comment.append("- ").append(errorMessage).append("\n"); 80 | } 81 | 82 | if (!quarkusBotConfig.isDryRun()) { 83 | pullRequest.comment(Strings.commentByBot(comment.toString())); 84 | } else { 85 | LOG.info("Pull request #" + pullRequest.getNumber() + " - Add comment " + comment.toString()); 86 | } 87 | } 88 | 89 | private static List getTitleErrorMessages(String title) { 90 | List errorMessages = new ArrayList<>(); 91 | 92 | if (title == null || title.isEmpty()) { 93 | return Collections.singletonList("title should not be empty"); 94 | } 95 | 96 | if (title.endsWith(".")) { 97 | errorMessages.add("title should not end up with dot"); 98 | } 99 | if (title.endsWith("…")) { 100 | errorMessages.add("title should not end up with ellipsis (make sure the title is complete)"); 101 | } 102 | if (SPACE_PATTERN.split(title.trim()).length < 2) { 103 | errorMessages.add("title should count at least 2 words to describe the change properly"); 104 | } 105 | if (!Character.isDigit(title.codePointAt(0)) && !Character.isUpperCase(title.codePointAt(0)) 106 | && !isUpperCaseException(title)) { 107 | errorMessages.add("title should preferably start with an uppercase character (if it makes sense!)"); 108 | } 109 | if (ISSUE_PATTERN.matcher(title).find()) { 110 | errorMessages.add("title should not contain an issue number (use `Fix #1234` in the description instead)"); 111 | } 112 | if (FIX_FEAT_CHORE.matcher(title).matches()) { 113 | errorMessages.add("title should not start with chore/docs/feat/fix/refactor but be a proper sentence"); 114 | } 115 | 116 | return errorMessages; 117 | } 118 | 119 | private static List getBodyErrorMessages(String body, GHPullRequest pullRequest) throws IOException { 120 | List errorMessages = new ArrayList<>(); 121 | 122 | if ((body == null || body.isBlank()) && isMeaningfulPullRequest(pullRequest)) { 123 | return List.of( 124 | "description should not be empty, describe your intent or provide links to the issues this PR is fixing (using `Fixes #NNNNN`) or changelogs"); 125 | } 126 | 127 | return errorMessages; 128 | } 129 | 130 | private static boolean isUpperCaseException(String title) { 131 | for (String exception : UPPER_CASE_EXCEPTIONS) { 132 | if (title.toLowerCase(Locale.ROOT).startsWith(exception.toLowerCase(Locale.ROOT))) { 133 | return true; 134 | } 135 | } 136 | 137 | return false; 138 | } 139 | 140 | private static boolean isMeaningfulPullRequest(GHPullRequest pullRequest) throws IOException { 141 | // Note: these rules will have to be adjusted depending on how it goes 142 | // we don't want to annoy people fixing a typo or require a description for a one liner explained in the title 143 | 144 | // if we have more than one commit, then it's meaningful 145 | if (pullRequest.getCommits() > 1) { 146 | return true; 147 | } 148 | 149 | PullRequestFilesMatcher filesMatcher = new PullRequestFilesMatcher(pullRequest); 150 | 151 | // for changes to the BOM, we are stricter 152 | if (filesMatcher.changedFilesMatch(BOMS)) { 153 | return true; 154 | } 155 | 156 | // for one liner/two liners, let's be a little more lenient 157 | if (pullRequest.getAdditions() <= 2 && pullRequest.getDeletions() <= 2) { 158 | return false; 159 | } 160 | 161 | // let's be a little more flexible for doc changes 162 | if (filesMatcher.changedFilesOnlyMatch(DOC_CHANGES) 163 | && pullRequest.getAdditions() <= 10 && pullRequest.getDeletions() <= 10) { 164 | return false; 165 | } 166 | 167 | return true; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/CheckTriageBackportContext.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import org.jboss.logging.Logger; 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHIssue; 10 | import org.kohsuke.github.GHLabel; 11 | 12 | import io.quarkiverse.githubapp.ConfigFile; 13 | import io.quarkiverse.githubapp.event.Issue; 14 | import io.quarkus.bot.config.Feature; 15 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 16 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 17 | import io.quarkus.bot.util.Labels; 18 | import io.quarkus.bot.util.Strings; 19 | 20 | public class CheckTriageBackportContext { 21 | 22 | private static final Logger LOG = Logger.getLogger(CheckTriageBackportContext.class); 23 | 24 | public static final String LABEL_BACKPORT_WARNING = "triage/backport* labels may not be added to an issue. Please add them to the corresponding pull request."; 25 | 26 | @Inject 27 | QuarkusGitHubBotConfig quarkusBotConfig; 28 | 29 | void onLabel(@Issue.Labeled GHEventPayload.Issue issuePayload, 30 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 31 | if (!Feature.CHECK_EDITORIAL_RULES.isEnabled(quarkusBotConfigFile)) { 32 | return; 33 | } 34 | 35 | GHLabel label = issuePayload.getLabel(); 36 | 37 | if (label.getName().startsWith(Labels.TRIAGE_BACKPORT_PREFIX)) { 38 | GHIssue issue = issuePayload.getIssue(); 39 | String warningMsg = String.format(LABEL_BACKPORT_WARNING, label.getName()); 40 | if (!quarkusBotConfig.isDryRun()) { 41 | issue.comment(Strings.commentByBot("@" + issuePayload.getSender().getLogin() + " " + warningMsg)); 42 | } else { 43 | LOG.warn("Issue #" + issue.getNumber() + " - Add comment: " + warningMsg); 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/MarkClosedPullRequestInvalid.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import org.jboss.logging.Logger; 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHLabel; 10 | import org.kohsuke.github.GHPullRequest; 11 | 12 | import io.quarkiverse.githubapp.ConfigFile; 13 | import io.quarkiverse.githubapp.event.PullRequest; 14 | import io.quarkus.bot.config.Feature; 15 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 16 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 17 | import io.quarkus.bot.util.Labels; 18 | 19 | class MarkClosedPullRequestInvalid { 20 | 21 | private static final Logger LOG = Logger.getLogger(MarkClosedPullRequestInvalid.class); 22 | 23 | @Inject 24 | QuarkusGitHubBotConfig quarkusBotConfig; 25 | 26 | void markClosedPullRequestInvalid(@PullRequest.Closed GHEventPayload.PullRequest pullRequestPayload, 27 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 28 | if (!Feature.QUARKUS_REPOSITORY_WORKFLOW.isEnabled(quarkusBotConfigFile)) { 29 | return; 30 | } 31 | 32 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 33 | 34 | if (pullRequest.isMerged()) { 35 | return; 36 | } 37 | 38 | if (!quarkusBotConfig.isDryRun()) { 39 | pullRequest.addLabels(Labels.TRIAGE_INVALID); 40 | } else { 41 | LOG.info("Pull request #" + pullRequest.getNumber() + " - Add label: " + Labels.TRIAGE_INVALID); 42 | } 43 | 44 | for (GHLabel label : pullRequest.getLabels()) { 45 | if (label.getName().startsWith(Labels.TRIAGE_BACKPORT_PREFIX)) { 46 | if (!quarkusBotConfig.isDryRun()) { 47 | pullRequest.removeLabel(label.getName()); 48 | } else { 49 | LOG.info("Pull request #" + pullRequest.getNumber() + " - Remove label: " + label.getName()); 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/NotifyQE.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import org.jboss.logging.Logger; 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHIssue; 10 | import org.kohsuke.github.GHLabel; 11 | import org.kohsuke.github.GHPullRequest; 12 | 13 | import io.quarkiverse.githubapp.ConfigFile; 14 | import io.quarkiverse.githubapp.event.Issue; 15 | import io.quarkiverse.githubapp.event.PullRequest; 16 | import io.quarkus.bot.config.Feature; 17 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 18 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 19 | import io.quarkus.bot.util.GHIssues; 20 | import io.quarkus.bot.util.Labels; 21 | import io.quarkus.bot.util.Mentions; 22 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 23 | 24 | public class NotifyQE { 25 | 26 | private static final Logger LOG = Logger.getLogger(NotifyQE.class); 27 | 28 | @Inject 29 | QuarkusGitHubBotConfig quarkusBotConfig; 30 | 31 | void commentOnIssue(@Issue.Labeled GHEventPayload.Issue issuePayload, 32 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 33 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 34 | if (!Feature.NOTIFY_QE.isEnabled(quarkusBotConfigFile)) { 35 | return; 36 | } 37 | 38 | comment(quarkusBotConfigFile, issuePayload.getIssue(), issuePayload.getLabel(), gitHubGraphQLClient); 39 | } 40 | 41 | void commentOnPullRequest(@PullRequest.Labeled GHEventPayload.PullRequest pullRequestPayload, 42 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 43 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 44 | if (!Feature.NOTIFY_QE.isEnabled(quarkusBotConfigFile)) { 45 | return; 46 | } 47 | 48 | comment(quarkusBotConfigFile, pullRequestPayload.getPullRequest(), pullRequestPayload.getLabel(), gitHubGraphQLClient); 49 | } 50 | 51 | private void comment(QuarkusGitHubBotConfigFile quarkusBotConfigFile, GHIssue issue, GHLabel label, 52 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 53 | if (quarkusBotConfigFile == null) { 54 | LOG.error("Unable to find triage configuration."); 55 | return; 56 | } 57 | 58 | if (label.getName().equals(Labels.TRIAGE_QE)) { 59 | if (!quarkusBotConfigFile.triage.qe.notify.isEmpty()) { 60 | if (!quarkusBotConfig.isDryRun()) { 61 | Mentions mentions = new Mentions(); 62 | mentions.add(quarkusBotConfigFile.triage.qe.notify, "qe"); 63 | mentions.removeAlreadyParticipating(GHIssues.getParticipatingUsers(issue, gitHubGraphQLClient)); 64 | 65 | if (!mentions.isEmpty()) { 66 | issue.comment("/cc " + mentions.getMentionsString()); 67 | } 68 | } else { 69 | LOG.info((issue instanceof GHPullRequest ? "Pull Request #" : "Issue #") + issue.getNumber() + 70 | " - Added label: " + Labels.TRIAGE_QE + 71 | " - Mentioning QE: " + quarkusBotConfigFile.triage.qe.notify); 72 | } 73 | } else { 74 | LOG.warn("Added label: " + Labels.TRIAGE_QE + ", but no QE config is available"); 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/PingWhenNeedsTriageRemoved.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import org.jboss.logging.Logger; 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHIssue; 10 | import org.kohsuke.github.GHLabel; 11 | 12 | import io.quarkiverse.githubapp.ConfigFile; 13 | import io.quarkiverse.githubapp.event.Issue; 14 | import io.quarkus.bot.config.Feature; 15 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 16 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 17 | import io.quarkus.bot.util.GHIssues; 18 | import io.quarkus.bot.util.Labels; 19 | import io.quarkus.bot.util.Mentions; 20 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 21 | 22 | public class PingWhenNeedsTriageRemoved { 23 | private static final Logger LOG = Logger.getLogger(PingWhenNeedsTriageRemoved.class); 24 | 25 | @Inject 26 | QuarkusGitHubBotConfig quarkusBotConfig; 27 | 28 | void pingWhenNeedsTriageRemoved(@Issue.Unlabeled GHEventPayload.Issue issuePayload, 29 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 30 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 31 | if (!Feature.TRIAGE_ISSUES_AND_PULL_REQUESTS.isEnabled(quarkusBotConfigFile)) { 32 | return; 33 | } 34 | 35 | GHIssue issue = issuePayload.getIssue(); 36 | GHLabel removedLabel = issuePayload.getLabel(); 37 | 38 | if (!Labels.TRIAGE_NEEDS_TRIAGE.equals(removedLabel.getName())) { 39 | return; 40 | } 41 | if (issue.getLabels().isEmpty()) { 42 | return; 43 | } 44 | 45 | Mentions mentions = new Mentions(); 46 | 47 | for (QuarkusGitHubBotConfigFile.TriageRule rule : quarkusBotConfigFile.triage.rules) { 48 | if (matchRule(issue, rule)) { 49 | if (!rule.notify.isEmpty()) { 50 | for (String mention : rule.notify) { 51 | if (mention.equals(issue.getUser().getLogin()) || mention.equals(issuePayload.getSender().getLogin())) { 52 | continue; 53 | } 54 | mentions.add(mention, rule.id); 55 | } 56 | } 57 | } 58 | } 59 | 60 | mentions.removeAlreadyParticipating(GHIssues.getParticipatingUsers(issue, gitHubGraphQLClient)); 61 | 62 | if (mentions.isEmpty()) { 63 | return; 64 | } 65 | 66 | if (!quarkusBotConfig.isDryRun()) { 67 | issue.comment("/cc " + mentions.getMentionsString()); 68 | } else { 69 | LOG.info("Issue #" + issue.getNumber() + " - Ping: " + mentions.getMentionsString()); 70 | } 71 | } 72 | 73 | private static boolean matchRule(GHIssue issue, QuarkusGitHubBotConfigFile.TriageRule rule) { 74 | if (rule.labels.isEmpty() || rule.notify.isEmpty()) { 75 | return false; 76 | } 77 | 78 | return issue.getLabels().stream().anyMatch(l -> rule.labels.contains(l.getName())); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/PullRequestGuardedBranches.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import org.jboss.logging.Logger; 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHPullRequest; 10 | 11 | import io.quarkiverse.githubapp.ConfigFile; 12 | import io.quarkiverse.githubapp.event.PullRequest; 13 | import io.quarkus.bot.config.Feature; 14 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 15 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 16 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile.GuardedBranch; 17 | import io.quarkus.bot.util.GHIssues; 18 | import io.quarkus.bot.util.Mentions; 19 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 20 | 21 | class PullRequestGuardedBranches { 22 | 23 | private static final Logger LOG = Logger.getLogger(PullRequestGuardedBranches.class); 24 | 25 | @Inject 26 | QuarkusGitHubBotConfig quarkusBotConfig; 27 | 28 | void triagePullRequest( 29 | @PullRequest.Opened GHEventPayload.PullRequest pullRequestPayload, 30 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 31 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 32 | if (!Feature.TRIAGE_ISSUES_AND_PULL_REQUESTS.isEnabled(quarkusBotConfigFile)) { 33 | return; 34 | } 35 | 36 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 37 | Mentions mentions = new Mentions(); 38 | 39 | for (GuardedBranch guardedBranch : quarkusBotConfigFile.triage.guardedBranches) { 40 | if (guardedBranch.ref.equals(pullRequest.getBase().getRef())) { 41 | for (String mention : guardedBranch.notify) { 42 | mentions.add(mention, guardedBranch.ref); 43 | } 44 | } 45 | } 46 | 47 | if (mentions.isEmpty()) { 48 | return; 49 | } 50 | 51 | mentions.removeAlreadyParticipating(GHIssues.getParticipatingUsers(pullRequest, gitHubGraphQLClient)); 52 | 53 | if (mentions.isEmpty()) { 54 | return; 55 | } 56 | 57 | String comment = "/cc " + mentions.getMentionsString(); 58 | if (!quarkusBotConfig.isDryRun()) { 59 | pullRequest.comment(comment); 60 | } else { 61 | LOG.info("Pull Request #" + pullRequest.getNumber() + " - Add comment: " + comment); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/QuarkusBot.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import jakarta.enterprise.event.Observes; 4 | import jakarta.inject.Inject; 5 | 6 | import org.jboss.logging.Logger; 7 | 8 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 9 | import io.quarkus.runtime.StartupEvent; 10 | 11 | public class QuarkusBot { 12 | 13 | private static final Logger LOG = Logger.getLogger(QuarkusBot.class); 14 | 15 | @Inject 16 | QuarkusGitHubBotConfig quarkusBotConfig; 17 | 18 | void init(@Observes StartupEvent startupEvent) { 19 | if (quarkusBotConfig.isDryRun()) { 20 | LOG.warn("››› Quarkus Bot running in dry-run mode"); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/RemoveCiLabelsWhenClosed.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.Collection; 5 | 6 | import jakarta.inject.Inject; 7 | 8 | import org.jboss.logging.Logger; 9 | import org.kohsuke.github.GHEventPayload; 10 | import org.kohsuke.github.GHLabel; 11 | import org.kohsuke.github.GHPullRequest; 12 | 13 | import io.quarkiverse.githubapp.ConfigFile; 14 | import io.quarkiverse.githubapp.event.PullRequest; 15 | import io.quarkus.bot.config.Feature; 16 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 17 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 18 | import io.quarkus.bot.util.Labels; 19 | 20 | public class RemoveCiLabelsWhenClosed { 21 | 22 | private static final Logger LOG = Logger.getLogger(RemoveCiLabelsWhenClosed.class); 23 | 24 | @Inject 25 | QuarkusGitHubBotConfig quarkusBotConfig; 26 | 27 | void removeWaitingForCiLabelWhenClosed(@PullRequest.Closed GHEventPayload.PullRequest pullRequestPayload, 28 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 29 | if (!Feature.QUARKUS_REPOSITORY_WORKFLOW.isEnabled(quarkusBotConfigFile)) { 30 | return; 31 | } 32 | 33 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 34 | Collection labels = pullRequest.getLabels(); 35 | 36 | for (GHLabel label : labels) { 37 | if (label.getName().equals(Labels.TRIAGE_WAITING_FOR_CI) 38 | || label.getName().startsWith(Labels.CI_PREFIX)) { 39 | if (!quarkusBotConfig.isDryRun()) { 40 | pullRequest.removeLabels(label.getName()); 41 | } else { 42 | LOG.info("Pull request #" + pullRequest.getNumber() + " - Remove label: " 43 | + label.getName()); 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/RemoveInvalidLabelOnReopenAction.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import org.jboss.logging.Logger; 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHIssue; 10 | import org.kohsuke.github.GHPullRequest; 11 | 12 | import io.quarkiverse.githubapp.ConfigFile; 13 | import io.quarkiverse.githubapp.event.Issue; 14 | import io.quarkiverse.githubapp.event.PullRequest; 15 | import io.quarkus.bot.config.Feature; 16 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 17 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 18 | import io.quarkus.bot.util.GHIssues; 19 | import io.quarkus.bot.util.GHPullRequests; 20 | import io.quarkus.bot.util.Labels; 21 | 22 | class RemoveInvalidLabelOnReopenAction { 23 | private static final Logger LOG = Logger.getLogger(RemoveInvalidLabelOnReopenAction.class); 24 | 25 | @Inject 26 | QuarkusGitHubBotConfig quarkusBotConfig; 27 | 28 | public void onIssueReopen(@Issue.Reopened GHEventPayload.Issue issuePayload, 29 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 30 | if (!Feature.QUARKUS_REPOSITORY_WORKFLOW.isEnabled(quarkusBotConfigFile)) { 31 | return; 32 | } 33 | 34 | GHIssue issue = issuePayload.getIssue(); 35 | 36 | if (GHIssues.hasLabel(issue, Labels.TRIAGE_INVALID)) { 37 | if (!quarkusBotConfig.isDryRun()) { 38 | issue.removeLabel(Labels.TRIAGE_INVALID); 39 | } else { 40 | LOG.info("Issue #" + issue.getNumber() + " - Remove label: " + Labels.TRIAGE_INVALID); 41 | } 42 | } 43 | } 44 | 45 | public void onPullRequestReopen(@PullRequest.Reopened GHEventPayload.PullRequest pullRequestPayload) throws IOException { 46 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 47 | 48 | if (GHPullRequests.hasLabel(pullRequest, Labels.TRIAGE_INVALID)) { 49 | if (!quarkusBotConfig.isDryRun()) { 50 | pullRequest.removeLabel(Labels.TRIAGE_INVALID); 51 | } else { 52 | LOG.info("Pull request #" + pullRequest.getNumber() + " - Remove label: " + Labels.TRIAGE_INVALID); 53 | } 54 | } 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/RemoveNeedsTriageLabelFromClosedIssue.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import org.jboss.logging.Logger; 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHIssue; 10 | import org.kohsuke.github.GHLabel; 11 | 12 | import io.quarkiverse.githubapp.ConfigFile; 13 | import io.quarkiverse.githubapp.event.Issue; 14 | import io.quarkus.bot.config.Feature; 15 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 16 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 17 | import io.quarkus.bot.util.Labels; 18 | 19 | public class RemoveNeedsTriageLabelFromClosedIssue { 20 | 21 | private static final Logger LOG = Logger.getLogger(RemoveNeedsTriageLabelFromClosedIssue.class); 22 | 23 | @Inject 24 | QuarkusGitHubBotConfig quarkusBotConfig; 25 | 26 | void onClose(@Issue.Closed GHEventPayload.Issue issuePayload, 27 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 28 | if (!Feature.TRIAGE_ISSUES_AND_PULL_REQUESTS.isEnabled(quarkusBotConfigFile)) { 29 | return; 30 | } 31 | 32 | GHIssue issue = issuePayload.getIssue(); 33 | for (GHLabel label : issue.getLabels()) { 34 | if (label.getName().equals(Labels.TRIAGE_NEEDS_TRIAGE)) { 35 | if (!quarkusBotConfig.isDryRun()) { 36 | issue.removeLabels(Labels.TRIAGE_NEEDS_TRIAGE); 37 | } else { 38 | LOG.info("Issue #" + issue.getNumber() + " - Remove label: " + Labels.TRIAGE_NEEDS_TRIAGE); 39 | } 40 | break; 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/SetAreaLabelColor.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.Locale; 5 | 6 | import jakarta.inject.Inject; 7 | 8 | import org.jboss.logging.Logger; 9 | import org.kohsuke.github.GHEventPayload; 10 | import org.kohsuke.github.GHLabel; 11 | 12 | import io.quarkiverse.githubapp.ConfigFile; 13 | import io.quarkiverse.githubapp.event.Label; 14 | import io.quarkus.bot.config.Feature; 15 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 16 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 17 | import io.quarkus.bot.util.Labels; 18 | 19 | public class SetAreaLabelColor { 20 | 21 | private static final Logger LOG = Logger.getLogger(SetAreaLabelColor.class); 22 | 23 | @Inject 24 | QuarkusGitHubBotConfig quarkusBotConfig; 25 | 26 | private static final String AREA_LABEL_COLOR = "0366d6"; 27 | 28 | void setAreaLabelColor(@Label.Created GHEventPayload.Label labelPayload, 29 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 30 | if (!Feature.SET_AREA_LABEL_COLOR.isEnabled(quarkusBotConfigFile)) { 31 | return; 32 | } 33 | 34 | GHLabel label = labelPayload.getLabel(); 35 | 36 | if (!label.getName().startsWith(Labels.AREA_PREFIX) 37 | || AREA_LABEL_COLOR.equals(label.getColor().toLowerCase(Locale.ROOT))) { 38 | return; 39 | } 40 | 41 | if (!quarkusBotConfig.isDryRun()) { 42 | label.set().color(AREA_LABEL_COLOR); 43 | } else { 44 | LOG.info("Label " + label.getName() + " - Set color: #" + AREA_LABEL_COLOR); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/SetTriageBackportLabelColor.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | 5 | import jakarta.inject.Inject; 6 | 7 | import org.jboss.logging.Logger; 8 | import org.kohsuke.github.GHEventPayload; 9 | import org.kohsuke.github.GHLabel; 10 | 11 | import io.quarkiverse.githubapp.ConfigFile; 12 | import io.quarkiverse.githubapp.event.Label; 13 | import io.quarkus.bot.config.Feature; 14 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 15 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 16 | import io.quarkus.bot.util.Labels; 17 | 18 | public class SetTriageBackportLabelColor { 19 | 20 | private static final Logger LOG = Logger.getLogger(SetTriageBackportLabelColor.class); 21 | 22 | @Inject 23 | QuarkusGitHubBotConfig quarkusBotConfig; 24 | 25 | private static final String TRIAGE_BACKPORT_LABEL_COLOR = "7fe8cd"; 26 | 27 | void setAreaLabelColor(@Label.Created GHEventPayload.Label labelPayload, 28 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile) throws IOException { 29 | if (!Feature.SET_TRIAGE_BACKPORT_LABEL_COLOR.isEnabled(quarkusBotConfigFile)) { 30 | return; 31 | } 32 | 33 | GHLabel label = labelPayload.getLabel(); 34 | 35 | if (!label.getName().startsWith(Labels.TRIAGE_BACKPORT_PREFIX) 36 | || TRIAGE_BACKPORT_LABEL_COLOR.equalsIgnoreCase(label.getColor())) { 37 | return; 38 | } 39 | 40 | if (!quarkusBotConfig.isDryRun()) { 41 | label.set().color(TRIAGE_BACKPORT_LABEL_COLOR); 42 | } else { 43 | LOG.info("Label " + label.getName() + " - Set color: #" + TRIAGE_BACKPORT_LABEL_COLOR); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/TriageDiscussion.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.ArrayList; 5 | import java.util.Collection; 6 | import java.util.HashMap; 7 | import java.util.HashSet; 8 | import java.util.List; 9 | import java.util.Map; 10 | import java.util.Set; 11 | import java.util.TreeSet; 12 | import java.util.concurrent.ExecutionException; 13 | 14 | import jakarta.inject.Inject; 15 | 16 | import org.jboss.logging.Logger; 17 | import org.kohsuke.github.GHEventPayload; 18 | import org.kohsuke.github.GHRepository; 19 | import org.kohsuke.github.GHRepositoryDiscussion; 20 | 21 | import io.quarkiverse.githubapp.ConfigFile; 22 | import io.quarkiverse.githubapp.event.Discussion; 23 | import io.quarkus.bot.config.Feature; 24 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 25 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 26 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile.TriageRule; 27 | import io.quarkus.bot.util.Labels; 28 | import io.quarkus.bot.util.Mentions; 29 | import io.quarkus.bot.util.Strings; 30 | import io.quarkus.bot.util.Triage; 31 | import io.smallrye.graphql.client.Response; 32 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 33 | 34 | class TriageDiscussion { 35 | 36 | private static final Logger LOG = Logger.getLogger(TriageDiscussion.class); 37 | 38 | @Inject 39 | QuarkusGitHubBotConfig quarkusBotConfig; 40 | 41 | void triageDiscussion(@Discussion.Created @Discussion.CategoryChanged GHEventPayload.Discussion discussionPayload, 42 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 43 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 44 | if (!Feature.TRIAGE_DISCUSSIONS.isEnabled(quarkusBotConfigFile)) { 45 | return; 46 | } 47 | 48 | if (quarkusBotConfigFile.triage.rules.isEmpty()) { 49 | return; 50 | } 51 | 52 | GHRepositoryDiscussion discussion = discussionPayload.getDiscussion(); 53 | 54 | if (!quarkusBotConfigFile.triage.discussions.monitoredCategories.contains(discussion.getCategory().getId())) { 55 | if (quarkusBotConfigFile.triage.discussions.logCategories) { 56 | LOG.info("Discussion category " + discussion.getCategory().getId() + " - " + discussion.getCategory().getName() 57 | + " is not monitored, ignoring discussion."); 58 | } 59 | return; 60 | } 61 | 62 | Set labels = new TreeSet<>(); 63 | Mentions mentions = new Mentions(); 64 | List comments = new ArrayList<>(); 65 | 66 | for (TriageRule rule : quarkusBotConfigFile.triage.rules) { 67 | if (Triage.matchRuleFromDescription(discussion.getTitle(), discussion.getBody(), rule)) { 68 | if (!rule.labels.isEmpty()) { 69 | labels.addAll(rule.labels); 70 | } 71 | if (!rule.notify.isEmpty()) { 72 | for (String mention : rule.notify) { 73 | if (!mention.equals(discussion.getUser().getLogin())) { 74 | mentions.add(mention, rule.id); 75 | } 76 | } 77 | } 78 | if (Strings.isNotBlank(rule.comment)) { 79 | comments.add(rule.comment); 80 | } 81 | } 82 | } 83 | 84 | if (!labels.isEmpty()) { 85 | if (!quarkusBotConfig.isDryRun()) { 86 | addLabels(gitHubGraphQLClient, discussion, discussionPayload.getRepository(), Labels.limit(labels)); 87 | } else { 88 | LOG.info("Discussion #" + discussion.getNumber() + " - Add labels: " + String.join(", ", Labels.limit(labels))); 89 | } 90 | } 91 | 92 | if (!mentions.isEmpty()) { 93 | comments.add("/cc " + mentions.getMentionsString()); 94 | } 95 | 96 | for (String comment : comments) { 97 | if (!quarkusBotConfig.isDryRun()) { 98 | addComment(gitHubGraphQLClient, discussion, comment); 99 | } else { 100 | LOG.info("Discussion #" + discussion.getNumber() + " - Add comment: " + comment); 101 | } 102 | } 103 | 104 | // TODO: we would need to get the labels via GraphQL. For now, let's see if we can avoid one more query. 105 | // if (mentions.isEmpty() && !Labels.hasAreaLabels(labels) && !GHIssues.hasAreaLabel(issue)) { 106 | // if (!quarkusBotConfig.isDryRun()) { 107 | // issue.addLabels(Labels.TRIAGE_NEEDS_TRIAGE); 108 | // } else { 109 | // LOG.info("Discussion #" + discussion.getNumber() + " - Add label: " + Labels.TRIAGE_NEEDS_TRIAGE); 110 | // } 111 | // } 112 | } 113 | 114 | private static void addLabels(DynamicGraphQLClient gitHubGraphQLClient, GHRepositoryDiscussion discussion, 115 | GHRepository repository, Collection labels) { 116 | // unfortunately, we need to get the ids of the labels 117 | Set labelIds = new HashSet<>(); 118 | for (String label : labels) { 119 | try { 120 | labelIds.add(repository.getLabel(label).getNodeId()); 121 | } catch (IOException e) { 122 | LOG.error("Discussion #" + discussion.getNumber() + " - Unable to get id for label: " + label); 123 | } 124 | } 125 | 126 | if (labelIds.isEmpty()) { 127 | return; 128 | } 129 | 130 | try { 131 | Map variables = new HashMap<>(); 132 | variables.put("labelableId", discussion.getNodeId()); 133 | variables.put("labelIds", labelIds.toArray(new String[0])); 134 | 135 | Response response = gitHubGraphQLClient.executeSync(""" 136 | mutation AddLabels($labelableId: ID!, $labelIds: [ID!]!) { 137 | addLabelsToLabelable(input: { 138 | labelableId: $labelableId, 139 | labelIds: $labelIds}) { 140 | clientMutationId 141 | } 142 | }""", variables); 143 | 144 | if (response.hasError()) { 145 | LOG.info("Discussion #" + discussion.getNumber() + " - Unable to add labels: " + String.join(", ", labels) 146 | + " - " + response.getErrors()); 147 | } 148 | } catch (ExecutionException | InterruptedException e) { 149 | LOG.info("Discussion #" + discussion.getNumber() + " - Unable to add labels: " + String.join(", ", labels)); 150 | } 151 | } 152 | 153 | private static void addComment(DynamicGraphQLClient gitHubGraphQLClient, GHRepositoryDiscussion discussion, 154 | String comment) { 155 | try { 156 | Map variables = new HashMap<>(); 157 | variables.put("discussionId", discussion.getNodeId()); 158 | variables.put("comment", comment); 159 | 160 | Response response = gitHubGraphQLClient.executeSync(""" 161 | mutation AddComment($discussionId: ID!, $comment: String!) { 162 | addDiscussionComment(input: { 163 | discussionId: $discussionId, 164 | body: $comment }) { 165 | clientMutationId 166 | } 167 | }""", variables); 168 | 169 | if (response.hasError()) { 170 | LOG.info("Discussion #" + discussion.getNumber() + " - Unable to add comment: " + comment 171 | + " - " + response.getErrors()); 172 | } 173 | } catch (ExecutionException | InterruptedException e) { 174 | LOG.info("Discussion #" + discussion.getNumber() + " - Unable to add comment: " + comment); 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/TriageIssue.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.ArrayList; 5 | import java.util.List; 6 | import java.util.Set; 7 | import java.util.TreeSet; 8 | 9 | import jakarta.inject.Inject; 10 | 11 | import org.jboss.logging.Logger; 12 | import org.kohsuke.github.GHEventPayload; 13 | import org.kohsuke.github.GHIssue; 14 | import org.kohsuke.github.GHLabel; 15 | 16 | import io.quarkiverse.githubapp.ConfigFile; 17 | import io.quarkiverse.githubapp.event.Issue; 18 | import io.quarkus.bot.config.Feature; 19 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 20 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 21 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile.TriageRule; 22 | import io.quarkus.bot.util.GHIssues; 23 | import io.quarkus.bot.util.Labels; 24 | import io.quarkus.bot.util.Mentions; 25 | import io.quarkus.bot.util.Strings; 26 | import io.quarkus.bot.util.Triage; 27 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 28 | 29 | class TriageIssue { 30 | 31 | private static final Logger LOG = Logger.getLogger(TriageIssue.class); 32 | 33 | @Inject 34 | QuarkusGitHubBotConfig quarkusBotConfig; 35 | 36 | void triageIssue(@Issue.Opened GHEventPayload.Issue issuePayload, 37 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 38 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 39 | if (!Feature.TRIAGE_ISSUES_AND_PULL_REQUESTS.isEnabled(quarkusBotConfigFile)) { 40 | return; 41 | } 42 | 43 | if (quarkusBotConfigFile.triage.rules.isEmpty()) { 44 | return; 45 | } 46 | 47 | GHIssue issue = issuePayload.getIssue(); 48 | Set labels = new TreeSet<>(); 49 | Mentions mentions = new Mentions(); 50 | List comments = new ArrayList<>(); 51 | 52 | for (TriageRule rule : quarkusBotConfigFile.triage.rules) { 53 | if (Triage.matchRuleFromDescription(issue.getTitle(), issue.getBody(), rule)) { 54 | if (!rule.labels.isEmpty()) { 55 | labels.addAll(rule.labels); 56 | } 57 | if (!rule.notify.isEmpty()) { 58 | for (String mention : rule.notify) { 59 | if (!mention.equals(issue.getUser().getLogin())) { 60 | mentions.add(mention, rule.id); 61 | } 62 | } 63 | } 64 | if (Strings.isNotBlank(rule.comment)) { 65 | comments.add(rule.comment); 66 | } 67 | } 68 | } 69 | 70 | // remove from the set the labels already present on the pull request 71 | issue.getLabels().stream().map(GHLabel::getName).forEach(labels::remove); 72 | 73 | if (!labels.isEmpty()) { 74 | if (!quarkusBotConfig.isDryRun()) { 75 | issue.addLabels(Labels.limit(labels).toArray(new String[0])); 76 | } else { 77 | LOG.info("Issue #" + issue.getNumber() + " - Add labels: " + String.join(", ", Labels.limit(labels))); 78 | } 79 | } 80 | 81 | mentions.removeAlreadyParticipating(GHIssues.getParticipatingUsers(issue, gitHubGraphQLClient)); 82 | if (!mentions.isEmpty()) { 83 | comments.add("/cc " + mentions.getMentionsString()); 84 | } 85 | 86 | for (String comment : comments) { 87 | if (!quarkusBotConfig.isDryRun()) { 88 | issue.comment(comment); 89 | } else { 90 | LOG.info("Issue #" + issue.getNumber() + " - Add comment: " + comment); 91 | } 92 | } 93 | 94 | if (mentions.isEmpty() && !Labels.hasAreaLabels(labels) && !GHIssues.hasAreaLabel(issue) 95 | && !GHIssues.hasLabel(issue, Labels.KIND_EXTENSION_PROPOSAL)) { 96 | if (!quarkusBotConfig.isDryRun()) { 97 | issue.addLabels(Labels.TRIAGE_NEEDS_TRIAGE); 98 | } else { 99 | LOG.info("Issue #" + issue.getNumber() + " - Add label: " + Labels.TRIAGE_NEEDS_TRIAGE); 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/TriagePullRequest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot; 2 | 3 | import java.io.IOException; 4 | import java.util.ArrayList; 5 | import java.util.Collection; 6 | import java.util.List; 7 | import java.util.Set; 8 | import java.util.TreeSet; 9 | 10 | import jakarta.inject.Inject; 11 | 12 | import org.jboss.logging.Logger; 13 | import org.kohsuke.github.GHEventPayload; 14 | import org.kohsuke.github.GHLabel; 15 | import org.kohsuke.github.GHPullRequest; 16 | 17 | import io.quarkiverse.githubapp.ConfigFile; 18 | import io.quarkiverse.githubapp.event.PullRequest; 19 | import io.quarkus.bot.config.Feature; 20 | import io.quarkus.bot.config.QuarkusGitHubBotConfig; 21 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile; 22 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile.TriageRule; 23 | import io.quarkus.bot.util.GHIssues; 24 | import io.quarkus.bot.util.Mentions; 25 | import io.quarkus.bot.util.Strings; 26 | import io.quarkus.bot.util.Triage; 27 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 28 | 29 | class TriagePullRequest { 30 | 31 | private static final Logger LOG = Logger.getLogger(TriagePullRequest.class); 32 | 33 | private static final String BACKPORTS_BRANCH = "-backports-"; 34 | 35 | /** 36 | * We cannot add more than 100 labels and we have some other automatic labels such as kind/bug. 37 | */ 38 | private static final int LABEL_SIZE_LIMIT = 95; 39 | 40 | @Inject 41 | QuarkusGitHubBotConfig quarkusBotConfig; 42 | 43 | void triagePullRequest( 44 | @PullRequest.Opened @PullRequest.Edited @PullRequest.Synchronize GHEventPayload.PullRequest pullRequestPayload, 45 | @ConfigFile("quarkus-github-bot.yml") QuarkusGitHubBotConfigFile quarkusBotConfigFile, 46 | DynamicGraphQLClient gitHubGraphQLClient) throws IOException { 47 | if (!Feature.TRIAGE_ISSUES_AND_PULL_REQUESTS.isEnabled(quarkusBotConfigFile)) { 48 | return; 49 | } 50 | 51 | if (quarkusBotConfigFile.triage.rules.isEmpty()) { 52 | return; 53 | } 54 | 55 | GHPullRequest pullRequest = pullRequestPayload.getPullRequest(); 56 | Set labels = new TreeSet<>(); 57 | Mentions mentions = new Mentions(); 58 | List comments = new ArrayList<>(); 59 | boolean isBackportsBranch = pullRequest.getHead().getRef().contains(BACKPORTS_BRANCH); 60 | // The second pass is allowed if either: 61 | // - no rule matched in the first pass 62 | // - OR all matching rules from the first pass explicitly allow the second pass 63 | boolean allowSecondPass = true; 64 | 65 | for (TriageRule rule : quarkusBotConfigFile.triage.rules) { 66 | if (Triage.matchRuleFromChangedFiles(pullRequest, rule)) { 67 | allowSecondPass = allowSecondPass && rule.allowSecondPass; 68 | applyRule(pullRequestPayload, pullRequest, isBackportsBranch, rule, labels, mentions, comments); 69 | } 70 | } 71 | 72 | if (allowSecondPass) { 73 | // Do a second pass, triaging according to the PR title/body 74 | for (TriageRule rule : quarkusBotConfigFile.triage.rules) { 75 | if (Triage.matchRuleFromDescription(pullRequest.getTitle(), pullRequest.getBody(), rule)) { 76 | applyRule(pullRequestPayload, pullRequest, isBackportsBranch, rule, labels, mentions, comments); 77 | } 78 | } 79 | } 80 | 81 | // remove from the set the labels already present on the pull request 82 | pullRequest.getLabels().stream().map(GHLabel::getName).forEach(labels::remove); 83 | 84 | if (!labels.isEmpty()) { 85 | if (!quarkusBotConfig.isDryRun()) { 86 | pullRequest.addLabels(limit(labels).toArray(new String[0])); 87 | } else { 88 | LOG.info("Pull Request #" + pullRequest.getNumber() + " - Add labels: " + String.join(", ", limit(labels))); 89 | } 90 | } 91 | 92 | mentions.removeAlreadyParticipating(GHIssues.getParticipatingUsers(pullRequest, gitHubGraphQLClient)); 93 | if (!mentions.isEmpty()) { 94 | comments.add("/cc " + mentions.getMentionsString()); 95 | } 96 | 97 | for (String comment : comments) { 98 | if (!quarkusBotConfig.isDryRun()) { 99 | pullRequest.comment(comment); 100 | } else { 101 | LOG.info("Pull Request #" + pullRequest.getNumber() + " - Add comment: " + comment); 102 | } 103 | } 104 | } 105 | 106 | private void applyRule(GHEventPayload.PullRequest pullRequestPayload, GHPullRequest pullRequest, boolean isBackportsBranch, 107 | TriageRule rule, Set labels, Mentions mentions, List comments) throws IOException { 108 | if (!rule.labels.isEmpty()) { 109 | labels.addAll(rule.labels); 110 | } 111 | 112 | if (!rule.notify.isEmpty() && rule.notifyInPullRequest 113 | && PullRequest.Opened.NAME.equals(pullRequestPayload.getAction()) 114 | && !isBackportsBranch) { 115 | for (String mention : rule.notify) { 116 | if (!mention.equals(pullRequest.getUser().getLogin())) { 117 | mentions.add(mention, rule.id); 118 | } 119 | } 120 | } 121 | if (Strings.isNotBlank(rule.comment)) { 122 | comments.add(rule.comment); 123 | } 124 | } 125 | 126 | private static Collection limit(Set labels) { 127 | if (labels.size() <= LABEL_SIZE_LIMIT) { 128 | return labels; 129 | } 130 | 131 | return new ArrayList<>(labels).subList(0, LABEL_SIZE_LIMIT); 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/config/Feature.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.config; 2 | 3 | public enum Feature { 4 | 5 | ALL, 6 | ANALYZE_WORKFLOW_RUN_RESULTS, 7 | CHECK_EDITORIAL_RULES, 8 | CHECK_CONTRIBUTION_RULES, 9 | NOTIFY_QE, 10 | QUARKUS_REPOSITORY_WORKFLOW, 11 | SET_AREA_LABEL_COLOR, 12 | SET_TRIAGE_BACKPORT_LABEL_COLOR, 13 | TRIAGE_ISSUES_AND_PULL_REQUESTS, 14 | TRIAGE_DISCUSSIONS, 15 | PUSH_TO_PROJECTS, 16 | APPROVE_WORKFLOWS; 17 | 18 | public boolean isEnabled(QuarkusGitHubBotConfigFile quarkusBotConfigFile) { 19 | if (quarkusBotConfigFile == null) { 20 | return false; 21 | } 22 | 23 | return quarkusBotConfigFile.isFeatureEnabled(this); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/config/QuarkusGitHubBotConfig.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.config; 2 | 3 | import java.util.Optional; 4 | 5 | import io.smallrye.config.ConfigMapping; 6 | 7 | @ConfigMapping(prefix = "quarkus-github-bot") 8 | public interface QuarkusGitHubBotConfig { 9 | 10 | Optional dryRun(); 11 | 12 | public default boolean isDryRun() { 13 | Optional dryRun = dryRun(); 14 | return dryRun.isPresent() && dryRun.get(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/config/QuarkusGitHubBotConfigFile.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.config; 2 | 3 | import java.util.ArrayList; 4 | import java.util.HashSet; 5 | import java.util.List; 6 | import java.util.Set; 7 | import java.util.TreeSet; 8 | 9 | import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 10 | 11 | /** 12 | * Note: a subset of this class is present in action-build-reporter, 13 | * so be careful when updating existing features. 14 | */ 15 | public class QuarkusGitHubBotConfigFile { 16 | 17 | @JsonDeserialize(as = HashSet.class) 18 | Set features = new HashSet<>(); 19 | 20 | public TriageConfig triage = new TriageConfig(); 21 | 22 | public WorkflowRunAnalysisConfig workflowRunAnalysis = new WorkflowRunAnalysisConfig(); 23 | 24 | public Projects projects = new Projects(); 25 | 26 | public ProjectsClassic projectsClassic = new ProjectsClassic(); 27 | 28 | public Workflows workflows = new Workflows(); 29 | 30 | public Develocity develocity = new Develocity(); 31 | 32 | public static class TriageConfig { 33 | 34 | public List rules = new ArrayList<>(); 35 | 36 | public List guardedBranches = new ArrayList<>(); 37 | 38 | public QE qe = new QE(); 39 | 40 | public Discussions discussions = new Discussions(); 41 | } 42 | 43 | public static class TriageRule { 44 | 45 | public String id; 46 | 47 | public String title; 48 | 49 | public String body; 50 | 51 | public String titleBody; 52 | 53 | public String expression; 54 | 55 | /** 56 | * @deprecated use files instead 57 | */ 58 | @JsonDeserialize(as = TreeSet.class) 59 | @Deprecated(forRemoval = true) 60 | public Set directories = new TreeSet<>(); 61 | 62 | @JsonDeserialize(as = TreeSet.class) 63 | public Set files = new TreeSet<>(); 64 | 65 | @JsonDeserialize(as = TreeSet.class) 66 | public Set labels = new TreeSet<>(); 67 | 68 | @JsonDeserialize(as = TreeSet.class) 69 | public Set notify = new TreeSet<>(); 70 | 71 | public String comment; 72 | 73 | public boolean notifyInPullRequest; 74 | 75 | public boolean allowSecondPass = false; 76 | } 77 | 78 | public static class QE { 79 | @JsonDeserialize(as = TreeSet.class) 80 | public Set notify = new TreeSet<>(); 81 | } 82 | 83 | public static class Discussions { 84 | 85 | /** 86 | * This is a list of numeric ids. 87 | *

88 | * Note that it's a bit tricky to get this id as it's not present in the GraphQL API. You have to generate an event and 89 | * have a look at what is in the payload. 90 | */ 91 | @JsonDeserialize(as = TreeSet.class) 92 | public Set monitoredCategories = new TreeSet<>(); 93 | 94 | public boolean logCategories = false; 95 | } 96 | 97 | public static class WorkflowRunAnalysisConfig { 98 | 99 | @JsonDeserialize(as = HashSet.class) 100 | public Set workflows = new HashSet<>(); 101 | 102 | @JsonDeserialize(as = HashSet.class) 103 | public Set ignoredFlakyTests = new HashSet<>(); 104 | } 105 | 106 | public static class Workflows { 107 | 108 | public List rules = new ArrayList<>(); 109 | } 110 | 111 | public static class Projects { 112 | 113 | public List rules = new ArrayList<>(); 114 | } 115 | 116 | public static class ProjectsClassic { 117 | 118 | public List rules = new ArrayList<>(); 119 | } 120 | 121 | public static class ProjectTriageRule { 122 | 123 | @JsonDeserialize(as = TreeSet.class) 124 | public Set labels = new TreeSet<>(); 125 | 126 | public Integer project; 127 | 128 | public boolean issues = false; 129 | 130 | public boolean pullRequests = false; 131 | 132 | public String status; 133 | } 134 | 135 | public static class WorkflowApprovalRule { 136 | 137 | public WorkflowApprovalCondition allow; 138 | public WorkflowApprovalCondition unless; 139 | 140 | } 141 | 142 | public static class WorkflowApprovalCondition { 143 | @JsonDeserialize(as = TreeSet.class) 144 | public Set files = new TreeSet<>(); 145 | 146 | public UserRule users; 147 | 148 | } 149 | 150 | public static class UserRule { 151 | public Integer minContributions; 152 | } 153 | 154 | public static class Develocity { 155 | 156 | public boolean enabled = false; 157 | 158 | public String url; 159 | } 160 | 161 | public static class GuardedBranch { 162 | 163 | public String ref; 164 | 165 | @JsonDeserialize(as = TreeSet.class) 166 | public Set notify = new TreeSet<>(); 167 | } 168 | 169 | boolean isFeatureEnabled(Feature feature) { 170 | return features.contains(Feature.ALL) || features.contains(feature); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/el/Matcher.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.el; 2 | 3 | import io.quarkus.bot.util.Patterns; 4 | import io.quarkus.bot.util.Strings; 5 | 6 | public class Matcher { 7 | 8 | public static boolean matches(String pattern, String string) { 9 | if (Strings.isNotBlank(string)) { 10 | return Patterns.find(pattern, string); 11 | } 12 | 13 | return false; 14 | } 15 | 16 | private Matcher() { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/el/SimpleELContext.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.el; 2 | 3 | import jakarta.el.CompositeELResolver; 4 | import jakarta.el.ELResolver; 5 | import jakarta.el.ExpressionFactory; 6 | import jakarta.el.StandardELContext; 7 | 8 | public class SimpleELContext extends StandardELContext { 9 | 10 | private static final ELResolver DEFAULT_RESOLVER = new CompositeELResolver(); 11 | 12 | public SimpleELContext(ExpressionFactory expressionFactory) throws NoSuchMethodException, SecurityException { 13 | super(expressionFactory); 14 | putContext(ExpressionFactory.class, expressionFactory); 15 | 16 | getFunctionMapper().mapFunction("", "matches", Matcher.class.getDeclaredMethod("matches", String.class, String.class)); 17 | } 18 | 19 | @Override 20 | public void addELResolver(ELResolver cELResolver) { 21 | throw new UnsupportedOperationException(getClass().getSimpleName() + " does not support addELResolver."); 22 | } 23 | 24 | @Override 25 | public ELResolver getELResolver() { 26 | return DEFAULT_RESOLVER; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/graal/SubstituteClassPathResolver.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.graal; 2 | 3 | import org.awaitility.classpath.ClassPathResolver; 4 | 5 | import com.oracle.svm.core.annotate.Alias; 6 | import com.oracle.svm.core.annotate.Substitute; 7 | import com.oracle.svm.core.annotate.TargetClass; 8 | 9 | @TargetClass(ClassPathResolver.class) 10 | public final class SubstituteClassPathResolver { 11 | 12 | @Substitute 13 | public static boolean existInCP(String className) { 14 | if ("java.lang.management.ManagementFactory".equals(className)) { 15 | return false; 16 | } 17 | 18 | return existsInCP(className, ClassPathResolver.class.getClassLoader()) 19 | || existsInCP(className, Thread.currentThread().getContextClassLoader()); 20 | } 21 | 22 | @Alias 23 | private static boolean existsInCP(String className, ClassLoader classLoader) { 24 | return false; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/Branches.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | import java.util.regex.Pattern; 4 | 5 | public final class Branches { 6 | 7 | private static final Pattern VERSION_BRANCH_PATTERN = Pattern.compile("[0-9]+\\.[0-9]+"); 8 | 9 | private Branches() { 10 | } 11 | 12 | public static boolean isVersionBranch(String branch) { 13 | if (branch == null || branch.isBlank()) { 14 | return false; 15 | } 16 | 17 | return VERSION_BRANCH_PATTERN.matcher(branch).matches(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/GHIssues.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | import java.io.IOException; 4 | import java.util.Collections; 5 | import java.util.HashMap; 6 | import java.util.Map; 7 | import java.util.Set; 8 | import java.util.concurrent.ExecutionException; 9 | import java.util.stream.Collectors; 10 | 11 | import jakarta.json.JsonObject; 12 | 13 | import org.kohsuke.github.GHIssue; 14 | import org.kohsuke.github.GHLabel; 15 | import org.kohsuke.github.GHPullRequest; 16 | import org.kohsuke.github.GHRepository; 17 | 18 | import io.smallrye.graphql.client.Response; 19 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 20 | 21 | public final class GHIssues { 22 | 23 | public static final String QUERY_PARTICIPANT_ERROR = "Unable to get participants for %s #%s of repository %s"; 24 | 25 | public static final String ISSUE_TYPE = "issue"; 26 | 27 | public static final String PULL_REQUEST_TYPE = "pullRequest"; 28 | 29 | public static boolean hasLabel(GHIssue issue, String labelName) throws IOException { 30 | for (GHLabel label : issue.getLabels()) { 31 | if (labelName.equals(label.getName())) { 32 | return true; 33 | } 34 | } 35 | return false; 36 | } 37 | 38 | public static boolean hasAreaLabel(GHIssue issue) throws IOException { 39 | for (GHLabel label : issue.getLabels()) { 40 | if (label.getName().startsWith(Labels.AREA_PREFIX)) { 41 | return true; 42 | } 43 | } 44 | return false; 45 | } 46 | 47 | public static Set getParticipatingUsers(GHIssue issue, DynamicGraphQLClient gitHubGraphQLClient) { 48 | GHRepository repository = issue.getRepository(); 49 | 50 | String objectType = (issue instanceof GHPullRequest) ? PULL_REQUEST_TYPE : ISSUE_TYPE; 51 | 52 | try { 53 | Map variables = new HashMap<>(); 54 | variables.put("owner", repository.getOwnerName()); 55 | variables.put("repoName", repository.getName()); 56 | variables.put("prNumber", issue.getNumber()); 57 | 58 | String graphqlRequest = """ 59 | query($owner: String! $repoName: String! $prNumber: Int!) { 60 | repository(owner: $owner, name: $repoName) { 61 | $objectType(number: $prNumber) { 62 | participants(first: 50) { 63 | edges { 64 | node { 65 | login 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | """.replace("$objectType", objectType); 73 | 74 | Response response = gitHubGraphQLClient.executeSync(graphqlRequest, variables); 75 | 76 | if (response == null) { 77 | // typically in tests where we don't mock the GraphQL client 78 | return Collections.emptySet(); 79 | } 80 | 81 | if (response.hasError()) { 82 | String errorMsg = String.format(QUERY_PARTICIPANT_ERROR, objectType, issue.getNumber(), 83 | repository.getFullName()); 84 | throw new IllegalStateException(errorMsg + " : " + response.getErrors()); 85 | } 86 | 87 | return response.getData().getJsonObject("repository") 88 | .getJsonObject(objectType) 89 | .getJsonObject("participants") 90 | .getJsonArray("edges").stream() 91 | .map(JsonObject.class::cast) 92 | .map(obj -> obj.getJsonObject("node").getString("login")) 93 | .collect(Collectors.toSet()); 94 | } catch (InterruptedException | ExecutionException e) { 95 | throw new IllegalStateException( 96 | String.format(QUERY_PARTICIPANT_ERROR, objectType, issue.getNumber(), repository.getFullName()), e); 97 | } 98 | } 99 | 100 | private GHIssues() { 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/GHPullRequests.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | import java.util.regex.Pattern; 4 | 5 | import org.kohsuke.github.GHLabel; 6 | import org.kohsuke.github.GHPullRequest; 7 | 8 | public final class GHPullRequests { 9 | 10 | private static final Pattern CLEAN_VERSION_PATTERN = Pattern.compile("^\\[?\\(?[0-9]+\\.[0-9]+\\]?\\)?(?![\\.0-9])[ -]*"); 11 | 12 | public static boolean hasLabel(GHPullRequest pullRequest, String labelName) { 13 | for (GHLabel label : pullRequest.getLabels()) { 14 | if (labelName.equals(label.getName())) { 15 | return true; 16 | } 17 | } 18 | return false; 19 | } 20 | 21 | public static String dropVersionSuffix(String title, String branch) { 22 | if (title == null || title.isBlank()) { 23 | return title; 24 | } 25 | if (!Branches.isVersionBranch(branch)) { 26 | return title; 27 | } 28 | 29 | return CLEAN_VERSION_PATTERN.matcher(title).replaceFirst(""); 30 | } 31 | 32 | public static String normalizeTitle(String title, String branch) { 33 | if (title == null || title.isBlank()) { 34 | return title; 35 | } 36 | if (!Branches.isVersionBranch(branch)) { 37 | return title; 38 | } 39 | 40 | return "[" + branch + "] " + dropVersionSuffix(title, branch); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/IssueExtractor.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | import java.util.HashMap; 4 | import java.util.Map; 5 | import java.util.Set; 6 | import java.util.TreeSet; 7 | import java.util.concurrent.ExecutionException; 8 | import java.util.regex.Matcher; 9 | import java.util.regex.Pattern; 10 | import java.util.stream.Collectors; 11 | 12 | import jakarta.json.JsonObject; 13 | 14 | import org.kohsuke.github.GHPullRequest; 15 | import org.kohsuke.github.GHRepository; 16 | 17 | import io.smallrye.graphql.client.Response; 18 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 19 | 20 | public class IssueExtractor { 21 | 22 | private final Pattern pattern; 23 | 24 | public IssueExtractor(String repository) { 25 | pattern = Pattern.compile( 26 | "\\b(?:(?:fix(?:e[sd])?|(?:(?:resolve|close)[sd]?))):?\\s+(?:https?:\\/\\/github.com\\/" 27 | + Pattern.quote(repository) + "\\/issues\\/|#)(\\d+)", 28 | Pattern.CASE_INSENSITIVE); 29 | } 30 | 31 | public Set extractIssueNumbers(GHPullRequest pullRequest, DynamicGraphQLClient gitHubGraphQLClient) { 32 | Set result = new TreeSet<>(); 33 | 34 | String prBody = pullRequest.getBody(); 35 | result.addAll(extractFromPRContent(prBody)); 36 | result.addAll(extractWithGraphQLApi(pullRequest, gitHubGraphQLClient)); 37 | 38 | return result; 39 | } 40 | 41 | public Set extractFromPRContent(String content) { 42 | Set result = new TreeSet<>(); 43 | Matcher matcher = pattern.matcher(content); 44 | while (matcher.find()) { 45 | Integer issueNumber = Integer.valueOf(matcher.group(1)); 46 | result.add(issueNumber); 47 | } 48 | return result; 49 | } 50 | 51 | public Set extractWithGraphQLApi(GHPullRequest pullRequest, DynamicGraphQLClient gitHubGraphQLClient) { 52 | 53 | GHRepository repository = pullRequest.getRepository(); 54 | try { 55 | Map variables = new HashMap<>(); 56 | variables.put("owner", repository.getOwnerName()); 57 | variables.put("repoName", repository.getName()); 58 | variables.put("prNumber", pullRequest.getNumber()); 59 | 60 | Response response = gitHubGraphQLClient.executeSync(""" 61 | query($owner: String! $repoName: String! $prNumber: Int!) { 62 | repository(owner: $owner, name: $repoName) { 63 | pullRequest(number: $prNumber) { 64 | closingIssuesReferences(first: 50) { 65 | edges { 66 | node { 67 | number 68 | repository { 69 | nameWithOwner 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } 77 | """, variables); 78 | 79 | if (response.hasError()) { 80 | throw new IllegalStateException( 81 | "Unable to get closingIssuesReferences for PR #" + pullRequest.getNumber() + " of repository " 82 | + repository.getFullName() + ": " + response.getErrors()); 83 | } 84 | 85 | Set issueNumbers = response.getData().getJsonObject("repository") 86 | .getJsonObject("pullRequest") 87 | .getJsonObject("closingIssuesReferences") 88 | .getJsonArray("edges").stream() 89 | .map(JsonObject.class::cast) 90 | .filter(obj -> pullRequest.getRepository().getFullName() 91 | .equals(obj.getJsonObject("node").getJsonObject("repository").getString("nameWithOwner"))) 92 | .map(obj -> obj.getJsonObject("node").getInt("number")) 93 | .collect(Collectors.toSet()); 94 | 95 | return issueNumbers; 96 | } catch (InterruptedException | ExecutionException e) { 97 | throw new IllegalStateException( 98 | "Unable to get closingIssuesReferences for PR #" + pullRequest.getNumber() + " of repository " 99 | + repository.getFullName(), 100 | e); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/Labels.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collection; 5 | import java.util.Set; 6 | 7 | import org.kohsuke.github.GHLabel; 8 | 9 | public class Labels { 10 | 11 | /** 12 | * We cannot add more than 100 labels and we have some other automatic labels such as kind/bug. 13 | */ 14 | private static final int LABEL_SIZE_LIMIT = 95; 15 | 16 | public static final String AREA_PREFIX = "area/"; 17 | public static final String AREA_INFRA = "area/infra"; 18 | public static final String CI_PREFIX = "ci/"; 19 | public static final String TRIAGE_INVALID = "triage/invalid"; 20 | public static final String TRIAGE_NEEDS_TRIAGE = "triage/needs-triage"; 21 | public static final String TRIAGE_WAITING_FOR_CI = "triage/waiting-for-ci"; 22 | public static final String TRIAGE_QE = "triage/qe?"; 23 | public static final String TRIAGE_BACKPORT_PREFIX = "triage/backport"; 24 | 25 | public static final String KIND_BUG = "kind/bug"; 26 | public static final String KIND_ENHANCEMENT = "kind/enhancement"; 27 | public static final String KIND_NEW_FEATURE = "kind/new-feature"; 28 | public static final String KIND_COMPONENT_UPGRADE = "kind/component-upgrade"; 29 | public static final String KIND_BUGFIX = "kind/bugfix"; 30 | 31 | public static final String KIND_EXTENSION_PROPOSAL = "kind/extension-proposal"; 32 | public static final Set KIND_LABELS = Set.of(KIND_BUG, KIND_ENHANCEMENT, KIND_NEW_FEATURE, KIND_EXTENSION_PROPOSAL); 33 | 34 | private Labels() { 35 | } 36 | 37 | public static boolean hasAreaLabels(Set labels) { 38 | for (String label : labels) { 39 | if (label.startsWith(Labels.AREA_PREFIX)) { 40 | return true; 41 | } 42 | } 43 | 44 | return false; 45 | } 46 | 47 | public static Collection limit(Set labels) { 48 | if (labels.size() <= LABEL_SIZE_LIMIT) { 49 | return labels; 50 | } 51 | 52 | return new ArrayList<>(labels).subList(0, LABEL_SIZE_LIMIT); 53 | } 54 | 55 | public static boolean matchesName(Collection labels, String labelCandidate) { 56 | for (String label : labels) { 57 | if (label.equals(labelCandidate)) { 58 | return true; 59 | } 60 | } 61 | 62 | return false; 63 | } 64 | 65 | public static boolean matches(Collection labels, String labelCandidate) { 66 | for (GHLabel label : labels) { 67 | if (label.getName().equals(labelCandidate)) { 68 | return true; 69 | } 70 | } 71 | 72 | return false; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/Mentions.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | import java.util.Collection; 4 | import java.util.Map; 5 | import java.util.Set; 6 | import java.util.TreeMap; 7 | import java.util.TreeSet; 8 | import java.util.stream.Collectors; 9 | 10 | public class Mentions { 11 | 12 | /** 13 | * Map of mention/username to list of reasons. 14 | */ 15 | private Map> mentions = new TreeMap<>(); 16 | 17 | public void add(String mention, String reason) { 18 | Set reasons = mentions.get(mention); 19 | if (reasons == null) { 20 | reasons = new TreeSet<>(); 21 | } 22 | if (reason != null) { 23 | reasons.add(reason); 24 | } 25 | mentions.put(mention, reasons); 26 | } 27 | 28 | public void add(Collection mentions, String reason) { 29 | for (String mention : mentions) { 30 | add(mention, reason); 31 | } 32 | } 33 | 34 | public void removeAlreadyParticipating(Collection usersAlreadyParticipating) { 35 | mentions.keySet().removeAll(usersAlreadyParticipating); 36 | } 37 | 38 | public boolean isEmpty() { 39 | return mentions.isEmpty(); 40 | } 41 | 42 | /** 43 | * 44 | * @return string of form "@mention1, @mention2(reason1,reason2), @mention3(reason1)" 45 | */ 46 | public String getMentionsString() { 47 | if (mentions.isEmpty()) { 48 | return null; 49 | } 50 | 51 | return mentions.entrySet().stream() 52 | .map(es -> { 53 | Set reasons = es.getValue(); 54 | if (reasons.isEmpty()) { 55 | return "@" + es.getKey(); 56 | } else { 57 | return "@" + es.getKey() + " " + reasons.stream().collect(Collectors.joining(",", "(", ")")); 58 | } 59 | }) 60 | .collect(Collectors.joining(", ")); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/Patterns.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | import java.util.regex.Pattern; 4 | 5 | public class Patterns { 6 | 7 | public static boolean find(String pattern, String string) { 8 | if (Strings.isBlank(pattern)) { 9 | return false; 10 | } 11 | if (Strings.isBlank(string)) { 12 | return false; 13 | } 14 | 15 | return Pattern.compile(pattern, Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(string) 16 | .find(); 17 | } 18 | 19 | private Patterns() { 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/PullRequestFilesMatcher.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | import java.util.Collection; 4 | 5 | import org.jboss.logging.Logger; 6 | import org.kohsuke.github.GHPullRequest; 7 | import org.kohsuke.github.GHPullRequestFileDetail; 8 | import org.kohsuke.github.PagedIterable; 9 | 10 | import com.hrakaroo.glob.GlobPattern; 11 | import com.hrakaroo.glob.MatchingEngine; 12 | 13 | import io.quarkus.cache.CacheResult; 14 | 15 | public class PullRequestFilesMatcher { 16 | 17 | private static final Logger LOG = Logger.getLogger(PullRequestFilesMatcher.class); 18 | 19 | private final GHPullRequest pullRequest; 20 | 21 | public PullRequestFilesMatcher(GHPullRequest pullRequest) { 22 | this.pullRequest = pullRequest; 23 | } 24 | 25 | public boolean changedFilesMatch(Collection filenamePatterns) { 26 | if (filenamePatterns.isEmpty()) { 27 | return false; 28 | } 29 | 30 | PagedIterable prFiles = pullRequest.listFiles(); 31 | if (prFiles != null) { 32 | for (GHPullRequestFileDetail changedFile : prFiles) { 33 | for (String filenamePattern : filenamePatterns) { 34 | 35 | if (!filenamePattern.contains("*")) { 36 | if (changedFile.getFilename().startsWith(filenamePattern)) { 37 | return true; 38 | } 39 | } else { 40 | try { 41 | MatchingEngine matchingEngine = compileGlob(filenamePattern); 42 | if (matchingEngine.matches(changedFile.getFilename())) { 43 | return true; 44 | } 45 | } catch (Exception e) { 46 | LOG.error("Error evaluating glob expression: " + filenamePattern, e); 47 | } 48 | } 49 | } 50 | } 51 | } 52 | return false; 53 | } 54 | 55 | public boolean changedFilesOnlyMatch(Collection filenamePatterns) { 56 | if (filenamePatterns.isEmpty()) { 57 | return false; 58 | } 59 | 60 | PagedIterable prFiles = pullRequest.listFiles(); 61 | if (prFiles != null) { 62 | for (GHPullRequestFileDetail changedFile : prFiles) { 63 | if (filenamePatterns.stream().anyMatch(p -> matchFilenamePattern(p, changedFile.getFilename()))) { 64 | continue; 65 | } 66 | 67 | return false; 68 | } 69 | } 70 | 71 | return true; 72 | } 73 | 74 | private boolean matchFilenamePattern(String filenamePattern, String changedFile) { 75 | if (!filenamePattern.contains("*")) { 76 | if (changedFile.startsWith(filenamePattern)) { 77 | return true; 78 | } 79 | } else { 80 | try { 81 | MatchingEngine matchingEngine = compileGlob(filenamePattern); 82 | if (matchingEngine.matches(changedFile)) { 83 | return true; 84 | } 85 | } catch (Exception e) { 86 | LOG.error("Error evaluating glob expression: " + filenamePattern, e); 87 | } 88 | } 89 | 90 | return false; 91 | } 92 | 93 | @CacheResult(cacheName = "glob-cache") 94 | MatchingEngine compileGlob(String filenamePattern) { 95 | return GlobPattern.compile(filenamePattern); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/Strings.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | public class Strings { 4 | 5 | public static boolean isNotBlank(String string) { 6 | return string != null && !string.trim().isEmpty(); 7 | } 8 | 9 | public static boolean isBlank(String string) { 10 | return string == null || string.trim().isEmpty(); 11 | } 12 | 13 | public static String commentByBot(String originalComment) { 14 | StringBuilder sb = new StringBuilder(originalComment); 15 | if (!originalComment.endsWith("\n")) { 16 | sb.append("\n"); 17 | } 18 | sb.append("\nThis message is automatically generated by a bot."); 19 | return sb.toString(); 20 | } 21 | 22 | private Strings() { 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/util/Triage.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.util; 2 | 3 | import jakarta.el.ELContext; 4 | import jakarta.el.ELManager; 5 | import jakarta.el.ExpressionFactory; 6 | import jakarta.el.ValueExpression; 7 | 8 | import org.jboss.logging.Logger; 9 | import org.kohsuke.github.GHPullRequest; 10 | 11 | import io.quarkus.bot.config.QuarkusGitHubBotConfigFile.TriageRule; 12 | import io.quarkus.bot.el.SimpleELContext; 13 | 14 | public final class Triage { 15 | 16 | private static final Logger LOG = Logger.getLogger(Triage.class); 17 | 18 | private Triage() { 19 | } 20 | 21 | public static boolean matchRuleFromDescription(String title, String body, TriageRule rule) { 22 | try { 23 | if (Strings.isNotBlank(rule.title)) { 24 | if (Patterns.find(rule.title, title)) { 25 | return true; 26 | } 27 | } 28 | } catch (Exception e) { 29 | LOG.error("Error evaluating regular expression: " + rule.title, e); 30 | } 31 | 32 | try { 33 | if (Strings.isNotBlank(rule.body)) { 34 | if (Patterns.find(rule.body, body)) { 35 | return true; 36 | } 37 | } 38 | } catch (Exception e) { 39 | LOG.error("Error evaluating regular expression: " + rule.body, e); 40 | } 41 | 42 | try { 43 | if (Strings.isNotBlank(rule.titleBody)) { 44 | if (Patterns.find(rule.titleBody, title) || Patterns.find(rule.titleBody, body)) { 45 | return true; 46 | } 47 | } 48 | } catch (Exception e) { 49 | LOG.error("Error evaluating regular expression: " + rule.titleBody, e); 50 | } 51 | 52 | try { 53 | if (Strings.isNotBlank(rule.expression)) { 54 | String expression = "${" + rule.expression + "}"; 55 | 56 | ExpressionFactory expressionFactory = ELManager.getExpressionFactory(); 57 | 58 | ELContext context = new SimpleELContext(expressionFactory); 59 | context.getVariableMapper().setVariable("title", 60 | expressionFactory.createValueExpression(title, String.class)); 61 | context.getVariableMapper().setVariable("body", 62 | expressionFactory.createValueExpression(body, String.class)); 63 | context.getVariableMapper().setVariable("titleBody", 64 | expressionFactory.createValueExpression(title + "\n\n" + body, String.class)); 65 | 66 | ValueExpression valueExpression = expressionFactory.createValueExpression(context, expression, Boolean.class); 67 | 68 | Boolean value = (Boolean) valueExpression.getValue(context); 69 | if (Boolean.TRUE.equals(value)) { 70 | return true; 71 | } 72 | } 73 | } catch (Exception e) { 74 | LOG.error("Error evaluating expression: " + rule.expression, e); 75 | } 76 | 77 | return false; 78 | } 79 | 80 | public static boolean matchRuleFromChangedFiles(GHPullRequest pullRequest, TriageRule rule) { 81 | // for now, we only use the files but we could also use the other rules at some point 82 | if (rule.directories.isEmpty() && rule.files.isEmpty()) { 83 | return false; 84 | } 85 | 86 | PullRequestFilesMatcher prMatcher = new PullRequestFilesMatcher(pullRequest); 87 | if (prMatcher.changedFilesMatch(rule.files)) { 88 | return true; 89 | } 90 | if (prMatcher.changedFilesMatch(rule.directories)) { 91 | return true; 92 | } 93 | 94 | return false; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/workflow/QuarkusStackTraceShortener.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.workflow; 2 | 3 | import java.util.regex.Matcher; 4 | import java.util.regex.Pattern; 5 | 6 | import jakarta.inject.Singleton; 7 | 8 | import org.apache.commons.lang3.StringUtils; 9 | 10 | import io.quarkus.bot.buildreporter.githubactions.StackTraceShortener; 11 | 12 | @Singleton 13 | public final class QuarkusStackTraceShortener implements StackTraceShortener { 14 | 15 | private static final String HTML_INTERNAL_ERROR_MARKER = "Internal Server Error"; 16 | private static final Pattern STACK_TRACE_PATTERN = Pattern.compile("Actual: <!doctype html>.*?<pre>(.*?)</pre>", 17 | Pattern.DOTALL); 18 | private static final String QUARKUS_TEST_EXTENSION = " at io.quarkus.test.junit.QuarkusTestExtension.runExtensionMethod("; 19 | 20 | @Override 21 | public String shorten(String stacktrace, int length) { 22 | if (StringUtils.isBlank(stacktrace)) { 23 | return null; 24 | } 25 | 26 | if (stacktrace.contains(HTML_INTERNAL_ERROR_MARKER)) { 27 | // this is an HTML error, let's get to the stacktrace 28 | Matcher matcher = STACK_TRACE_PATTERN.matcher(stacktrace); 29 | StringBuilder sb = new StringBuilder(); 30 | if (matcher.find()) { 31 | matcher.appendReplacement(sb, "Actual: An Internal Server Error with stack trace:\n$1"); 32 | stacktrace = sb.toString(); 33 | } 34 | } 35 | 36 | int quarkusTestExtensionIndex = stacktrace.indexOf(QUARKUS_TEST_EXTENSION); 37 | if (quarkusTestExtensionIndex > 0) { 38 | stacktrace = stacktrace.substring(0, stacktrace.lastIndexOf('\n', quarkusTestExtensionIndex)); 39 | } 40 | 41 | return StringUtils.abbreviate(stacktrace, length); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/workflow/QuarkusWorkflowConstants.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.workflow; 2 | 3 | public class QuarkusWorkflowConstants { 4 | 5 | public static final String QUARKUS_CI_WORKFLOW_NAME = "Quarkus CI"; 6 | public static final String QUARKUS_DOCUMENTATION_CI_WORKFLOW_NAME = "Quarkus Documentation CI"; 7 | public static final String JOB_NAME_DELIMITER = " - "; 8 | public static final String JOB_NAME_INITIAL_JDK_PREFIX = "Initial JDK "; 9 | public static final String JOB_NAME_JVM_TESTS_PREFIX = "JVM Tests"; 10 | public static final String JOB_NAME_JDK_PREFIX = "JDK"; 11 | public static final String JOB_NAME_JAVA_PREFIX = "Java"; 12 | public static final String JOB_NAME_WINDOWS = "Windows"; 13 | public static final String JOB_NAME_BUILD_REPORT = "Build report"; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/workflow/QuarkusWorkflowJobLabeller.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.workflow; 2 | 3 | import jakarta.inject.Singleton; 4 | 5 | import io.quarkus.bot.buildreporter.githubactions.WorkflowJobLabeller; 6 | 7 | @Singleton 8 | public class QuarkusWorkflowJobLabeller implements WorkflowJobLabeller { 9 | 10 | @Override 11 | public String label(String name) { 12 | if (name == null || name.isBlank()) { 13 | return name; 14 | } 15 | 16 | StringBuilder label = new StringBuilder(); 17 | String[] tokens = name.split(QuarkusWorkflowConstants.JOB_NAME_DELIMITER); 18 | 19 | for (int i = 0; i < tokens.length; i++) { 20 | if (tokens[i].startsWith(QuarkusWorkflowConstants.JOB_NAME_JDK_PREFIX) 21 | || tokens[i].startsWith(QuarkusWorkflowConstants.JOB_NAME_JAVA_PREFIX)) { 22 | break; 23 | } 24 | 25 | if (!label.isEmpty()) { 26 | label.append(QuarkusWorkflowConstants.JOB_NAME_DELIMITER); 27 | } 28 | label.append(tokens[i]); 29 | } 30 | 31 | return label.toString(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/io/quarkus/bot/workflow/report/QuarkusWorkflowReportJobIncludeStrategy.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.workflow.report; 2 | 3 | import java.util.Locale; 4 | 5 | import jakarta.inject.Singleton; 6 | 7 | import io.quarkus.bot.buildreporter.githubactions.WorkflowConstants; 8 | import io.quarkus.bot.buildreporter.githubactions.report.WorkflowReport; 9 | import io.quarkus.bot.buildreporter.githubactions.report.WorkflowReportJob; 10 | import io.quarkus.bot.buildreporter.githubactions.report.WorkflowReportJobIncludeStrategy; 11 | import io.quarkus.bot.workflow.QuarkusWorkflowConstants; 12 | 13 | @Singleton 14 | public class QuarkusWorkflowReportJobIncludeStrategy implements WorkflowReportJobIncludeStrategy { 15 | 16 | @Override 17 | public boolean include(WorkflowReport report, WorkflowReportJob job) { 18 | if (job.getName().startsWith(WorkflowConstants.BUILD_SUMMARY_CHECK_RUN_PREFIX)) { 19 | return false; 20 | } 21 | if (job.isFailing()) { 22 | return true; 23 | } 24 | if (QuarkusWorkflowConstants.JOB_NAME_BUILD_REPORT.equals(job.getName())) { 25 | return false; 26 | } 27 | 28 | // in this particular case, we exclude the Windows job as it does not run the containers job 29 | // (no Docker support on Windows) and thus does not provide a similar coverage as the Linux 30 | // jobs. Having it green does not mean that things were OK globally. 31 | if (isJvmTests(job)) { 32 | if (isWindows(job)) { 33 | return false; 34 | } 35 | 36 | return hasJobWithSameLabelFailing(report, job); 37 | } 38 | 39 | return hasJobWithSameLabelFailing(report, job); 40 | } 41 | 42 | private static boolean isJvmTests(WorkflowReportJob job) { 43 | return job.getName().toLowerCase(Locale.ROOT) 44 | .startsWith(QuarkusWorkflowConstants.JOB_NAME_JVM_TESTS_PREFIX.toLowerCase(Locale.ROOT)); 45 | } 46 | 47 | private static boolean isWindows(WorkflowReportJob job) { 48 | return job.getName().contains(QuarkusWorkflowConstants.JOB_NAME_WINDOWS); 49 | } 50 | 51 | private static boolean hasJobWithSameLabelFailing(WorkflowReport report, WorkflowReportJob job) { 52 | return report.getJobs().stream() 53 | .filter(j -> j.getLabel().equals(job.getLabel())) 54 | .anyMatch(j -> j.isFailing()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | quarkus.application.name=quarkus-bot 2 | quarkus.application.version=${buildNumber:999-SNAPSHOT} 3 | 4 | quarkus.live-reload.instrumentation=false 5 | 6 | quarkus.qute.suffixes=md 7 | quarkus.qute.content-types."md"=text/markdown 8 | 9 | quarkus.cache.caffeine."glob-cache".maximum-size=200 10 | 11 | quarkus.cache.caffeine."PushToProject.getStatusFieldValue".initial-capacity=10 12 | quarkus.cache.caffeine."PushToProject.getStatusFieldValue".maximum-size=100 13 | quarkus.cache.caffeine."PushToProject.getStatusFieldValue".expire-after-write=2H 14 | 15 | quarkus.cache.caffeine."contributor-cache".expire-after-write=P2D 16 | quarkus.cache.caffeine."stats-cache".expire-after-write=P2D 17 | 18 | quarkus.openshift.labels."app"=quarkus-bot 19 | quarkus.openshift.annotations."kubernetes.io/tls-acme"=true 20 | quarkus.openshift.env.vars.QUARKUS_GITHUB_APP_APP_ID=90234 21 | quarkus.openshift.env.vars.QUARKUS_GITHUB_APP_APP_NAME=quarkus-bot 22 | quarkus.openshift.env.vars.QUARKUS_OPTS=-Dquarkus.http.host=0.0.0.0 -Xmx150m 23 | #quarkus.openshift.env.vars.QUARKUS_BOT_DRY_RUN=true 24 | quarkus.openshift.env.secrets=quarkus-bot 25 | quarkus.openshift.add-version-to-label-selectors=false 26 | quarkus.openshift.replicas=2 27 | 28 | %dev.quarkus-github-bot.dry-run=true 29 | 30 | %test.quarkus.github-app.app-id=0 31 | %test.quarkus.github-app.private-key=-----BEGIN RSA PRIVATE KEY-----\ 32 | MIIEogIBAAKCAQEA30YvyuZAd+kGDT0nm/XAa93CqsDvC/iYOc4KsKsfBQs1MWjH\ 33 | royuVDfQj2fJvueFnOgZApM3viaCz188D/j3tUMNByIKOfMLiEm/R1tqe7Q6xRRn\ 34 | uwpfT+wv+/x4cNvPxTscwo43LVR9Pno71UfZZywnYN03GS71ttNCiiBKXwCSnHez\ 35 | /t79iAmMnym7ViNsKzA0aS5EwAw9A3GeTnxpRef0y0vDNE2aXBNCe+f1ZnFq1Fhe\ 36 | PJIlKs/qlM136A2co+WRaPghacZJMuwQr1vajuMSBjMEroIPOfSG3x3Oitvnukjp\ 37 | EwuhXjmZeaLc+60rYaMRwf+bje8KmaAVOMWkHQIDAQABAoIBAA+d8SnYARpiCjJS\ 38 | 3Lpj7hmdYUhgRlgoAz3H06eX0IuhxQ63rX/gBzGM1eGx+MKJnybidR1g/r0mJHAs\ 39 | 0R6s42aiUf71upFjFqNpxR9QnZoZeSLf0oGasB/+/Tw65JHATkAVamWRXPqmtjvw\ 40 | gM7iP6qfxAFad8gjKLyo+jZ/G7SZTCMwnp+sRynirNpycxaAn/xK6Pe43+nyQVWT\ 41 | E0J8bvCzrFD47CM5zZaBQlLWTMjY4Rr3U6BMTGwQWJzGkeGn+2JsHVUch0k7+NRa\ 42 | e3FKjT+57dZqQTnGPVSpBFWEXVO9KLEuLBLyRx0348TZBHzIM9IigN4QS2AaWTJw\ 43 | 1kp3VWECgYEA/3P/nsL+RL/yqYvENZ7XqEkXRNH6YHOe8h/lFoYHStCl9y0T8O+z\ 44 | ooJq9hEq7QcYs2bHvBWj8B9he7+bZ5ZOMAM6oIgrgB5FzSvL7JzXhEdONxe/j2TI\ 45 | GbQuC+NxdJtx4Y6yF9Lrb1UyKX+HzR4de+v6b5hER7x8x4gQn1sCYmsCgYEA38CN\ 46 | bTtE3RKY98m33a1Cd6hNXHSyy5GOK5/XGDn0XoGfFe5YJnnh2lia2V4xqUH9d1Mu\ 47 | bB0bEUhfbac5SX5SIW+NBVxzehqfMkrZj/rzN8Wd7TrYAHSldSMhkPTuwuuzfnHL\ 48 | sJLe2gyoqq+sooeE7eCH2fpPIN0wg5U+jc60hZcCgYBHtmrGSPtUlYYr7p6wZt0n\ 49 | 0w0DNudQ+GRgmG+ZeRrG9/f/gdodQ01si6w3U+53CAz5IBtmQ7T4Dfcx5EJePCXK\ 50 | +L0Wn+OGXfk+ddMTo5wk+FeOw831FVfPT3O1xq3tDE5WAdchNQb/BC3G1JRtEs04\ 51 | IrD1bwuMD+//m8T+12+97QKBgDko0XhEGdV3+MfkKiphJoe24Pxre3lxl6YhUSuJ\ 52 | Mpop9t/9YVuC62WCGRzKaVlZ2ExxXXyU+uMxX999Rq81q/mKq7Xg5kcdIeoRIP8d\ 53 | FqD6xNtjmuaS5enErcCAMbZtzA7TNzvGaVO+xB/GfQ2QHS8/mrTesvQsTUZwC+ji\ 54 | E0/FAoGATJvuAfgy9uiKR7za7MigYVacE0u4aD1sF7v6D4AFqBOGquPQQhePSdz9\ 55 | G/UUwySoo+AQ+rd2EPhyexjqXBhRGe+EDGFVFivaQzTT8/5bt/VddbTcw2IpmXYj\ 56 | LW6V8BbcP5MRhd2JQSRh16nWwSQJ2BdpUZFwayEEQ6UcrMfqvA0=\ 57 | -----END RSA PRIVATE KEY----- 58 | -------------------------------------------------------------------------------- /src/test/java/io/quarkus/bot/it/CheckIssueEditorialRulesTest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.it; 2 | 3 | import static io.quarkiverse.githubapp.testing.GitHubAppTesting.given; 4 | import static org.mockito.Mockito.verify; 5 | import static org.mockito.Mockito.verifyNoMoreInteractions; 6 | 7 | import java.io.IOException; 8 | 9 | import org.junit.jupiter.api.Test; 10 | import org.kohsuke.github.GHEvent; 11 | 12 | import io.quarkiverse.githubapp.testing.GitHubAppTest; 13 | import io.quarkus.bot.CheckIssueEditorialRules; 14 | import io.quarkus.test.junit.QuarkusTest; 15 | 16 | @QuarkusTest 17 | @GitHubAppTest 18 | public class CheckIssueEditorialRulesTest { 19 | @Test 20 | void validZulipLinkConfirmation() throws IOException { 21 | given().github(mocks -> mocks.configFile("quarkus-github-bot.yml").fromString("features: [ ALL ]\n")) 22 | .when().payloadFromClasspath("/issue-opened-zulip.json") 23 | .event(GHEvent.ISSUES) 24 | .then().github(mocks -> { 25 | verify(mocks.issue(942074921)) 26 | .comment(CheckIssueEditorialRules.ZULIP_WARNING); 27 | verifyNoMoreInteractions(mocks.ghObjects()); 28 | }); 29 | 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/io/quarkus/bot/it/CheckTriageBackportContextTest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.it; 2 | 3 | import static io.quarkiverse.githubapp.testing.GitHubAppTesting.given; 4 | import static org.mockito.Mockito.verify; 5 | import static org.mockito.Mockito.verifyNoMoreInteractions; 6 | 7 | import java.io.IOException; 8 | 9 | import org.junit.jupiter.api.Test; 10 | import org.kohsuke.github.GHEvent; 11 | 12 | import io.quarkiverse.githubapp.testing.GitHubAppTest; 13 | import io.quarkus.bot.CheckTriageBackportContext; 14 | import io.quarkus.bot.util.Strings; 15 | import io.quarkus.test.junit.QuarkusTest; 16 | 17 | @QuarkusTest 18 | @GitHubAppTest 19 | public class CheckTriageBackportContextTest { 20 | 21 | @Test 22 | void testLabelBackportWarningConfirmation() throws IOException { 23 | String warningMsg = String.format(CheckTriageBackportContext.LABEL_BACKPORT_WARNING, "triage/backport-whatever"); 24 | String expectedComment = Strings.commentByBot("@test-github-user " + warningMsg); 25 | 26 | given().github(mocks -> mocks.configFile("quarkus-github-bot.yml").fromString("features: [ ALL ]\n")) 27 | .when().payloadFromString(getSampleIssueLabelTriageBackportPayload()) 28 | .event(GHEvent.ISSUES) 29 | .then().github(mocks -> { 30 | verify(mocks.issue(1234567890)) 31 | .comment(expectedComment.toString()); 32 | verifyNoMoreInteractions(mocks.ghObjects()); 33 | }); 34 | 35 | } 36 | 37 | private static String getSampleIssueLabelTriageBackportPayload() { 38 | return """ 39 | { 40 | "action": "labeled", 41 | "issue": { 42 | "id": 1234567890, 43 | "number": 123, 44 | "labels": [ 45 | { 46 | "name": "triage/backport-whatever" 47 | } 48 | ] 49 | }, 50 | "label": { 51 | "name": "triage/backport-whatever" 52 | }, 53 | "repository": { 54 | 55 | }, 56 | "sender": { 57 | "login": "test-github-user" 58 | }, 59 | "installation" : { 60 | "id" : 28125889, 61 | "node_id" : "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uMjgxMjU4ODk=" 62 | } 63 | }"""; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/test/java/io/quarkus/bot/it/IssueOpenedTest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.it; 2 | 3 | import static io.quarkiverse.githubapp.testing.GitHubAppTesting.given; 4 | import static org.mockito.Mockito.verify; 5 | import static org.mockito.Mockito.verifyNoMoreInteractions; 6 | 7 | import java.io.IOException; 8 | 9 | import org.junit.jupiter.api.Test; 10 | import org.kohsuke.github.GHEvent; 11 | 12 | import io.quarkiverse.githubapp.testing.GitHubAppTest; 13 | import io.quarkus.bot.util.Labels; 14 | import io.quarkus.test.junit.QuarkusTest; 15 | 16 | @QuarkusTest 17 | @GitHubAppTest 18 | public class IssueOpenedTest { 19 | 20 | @Test 21 | void triage() throws IOException { 22 | given().github(mocks -> mocks.configFile("quarkus-github-bot.yml") 23 | .fromString("features: [ ALL ]\n" 24 | + "triage:\n" 25 | + " rules:\n" 26 | + " - title: test\n" 27 | + " labels: [area/test1, area/test2]")) 28 | .when().payloadFromClasspath("/issue-opened.json") 29 | .event(GHEvent.ISSUES) 30 | .then().github(mocks -> { 31 | verify(mocks.issue(750705278)) 32 | .addLabels("area/test1", "area/test2"); 33 | verifyNoMoreInteractions(mocks.ghObjects()); 34 | }); 35 | } 36 | 37 | @Test 38 | void triageComment() throws IOException { 39 | given().github(mocks -> mocks.configFile("quarkus-github-bot.yml") 40 | .fromString("features: [ ALL ]\n" 41 | + "triage:\n" 42 | + " rules:\n" 43 | + " - title: test\n" 44 | + " comment: 'This is a security issue'")) 45 | .when().payloadFromClasspath("/issue-opened.json") 46 | .event(GHEvent.ISSUES) 47 | .then().github(mocks -> { 48 | verify(mocks.issue(750705278)) 49 | .comment("This is a security issue"); 50 | verify(mocks.issue(750705278)) 51 | .addLabels(Labels.TRIAGE_NEEDS_TRIAGE); 52 | verifyNoMoreInteractions(mocks.ghObjects()); 53 | }); 54 | } 55 | 56 | @Test 57 | void triageBasicNotify() throws IOException { 58 | given().github(mocks -> mocks.configFile("quarkus-github-bot.yml") 59 | .fromString("features: [ ALL ]\n" 60 | + "triage:\n" 61 | + " rules:\n" 62 | + " - title: test\n" 63 | + " notify: [prodsec]\n" 64 | + " comment: 'This is a security issue'")) 65 | .when().payloadFromClasspath("/issue-opened.json") 66 | .event(GHEvent.ISSUES) 67 | .then().github(mocks -> { 68 | verify(mocks.issue(750705278)) 69 | .comment("/cc @prodsec"); 70 | verify(mocks.issue(750705278)) 71 | .comment("This is a security issue"); 72 | verifyNoMoreInteractions(mocks.ghObjects()); 73 | }); 74 | } 75 | 76 | @Test 77 | void triageIdNotify() throws IOException { 78 | given().github(mocks -> mocks.configFile("quarkus-github-bot.yml") 79 | .fromString("features: [ ALL ]\n" 80 | + "triage:\n" 81 | + " rules:\n" 82 | + " - id: 'security'\n" 83 | + " title: test\n" 84 | + " notify: [prodsec,max]\n" 85 | + " comment: 'This is a security issue'\n" 86 | + " - id: 'devtools'\n" 87 | + " title: test\n" 88 | + " notify: [max]\n")) 89 | .when().payloadFromClasspath("/issue-opened.json") 90 | .event(GHEvent.ISSUES) 91 | .then().github(mocks -> { 92 | verify(mocks.issue(750705278)) 93 | .comment("This is a security issue"); 94 | verify(mocks.issue(750705278)).comment("/cc @max (devtools,security), @prodsec (security)"); 95 | verifyNoMoreInteractions(mocks.ghObjects()); 96 | }); 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /src/test/java/io/quarkus/bot/it/MarkClosedPullRequestInvalidTest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.it; 2 | 3 | import static io.quarkiverse.githubapp.testing.GitHubAppTesting.given; 4 | import static org.mockito.Mockito.mock; 5 | import static org.mockito.Mockito.verify; 6 | import static org.mockito.Mockito.when; 7 | import static org.mockito.Mockito.withSettings; 8 | 9 | import java.io.IOException; 10 | import java.util.Collections; 11 | import java.util.Iterator; 12 | import java.util.List; 13 | 14 | import org.junit.jupiter.api.Test; 15 | import org.kohsuke.github.GHEvent; 16 | import org.kohsuke.github.GHRepository; 17 | import org.kohsuke.github.GHWorkflowRun; 18 | import org.kohsuke.github.GHWorkflowRunQueryBuilder; 19 | import org.kohsuke.github.PagedIterable; 20 | import org.kohsuke.github.PagedIterator; 21 | import org.mockito.Answers; 22 | 23 | import io.quarkiverse.githubapp.testing.GitHubAppTest; 24 | import io.quarkus.bot.util.Labels; 25 | import io.quarkus.test.junit.QuarkusTest; 26 | 27 | @QuarkusTest 28 | @GitHubAppTest 29 | public class MarkClosedPullRequestInvalidTest { 30 | 31 | @Test 32 | void handleLabels() throws IOException { 33 | given().github(mocks -> { 34 | mocks.configFile("quarkus-github-bot.yml").fromString("features: [ ALL ]\n"); 35 | // this is necessary because this payload also triggers CancelWorkflowOnClosedPullRequest 36 | GHRepository repoMock = mocks.repository("Luke1432/GitHubTestAppRepo"); 37 | GHWorkflowRunQueryBuilder workflowRunQueryBuilderMock = mock(GHWorkflowRunQueryBuilder.class, 38 | withSettings().defaultAnswer(Answers.RETURNS_SELF)); 39 | when(repoMock.queryWorkflowRuns()) 40 | .thenReturn(workflowRunQueryBuilderMock); 41 | PagedIterable<GHWorkflowRun> iterableMock = mockPagedIterable(Collections.emptyList()); 42 | when(workflowRunQueryBuilderMock.list()) 43 | .thenReturn(iterableMock); 44 | }) 45 | .when() 46 | .payloadFromClasspath("/pullrequest-closed.json") 47 | .event(GHEvent.PULL_REQUEST) 48 | .then().github(mocks -> { 49 | verify(mocks.pullRequest(691467750)) 50 | .addLabels(Labels.TRIAGE_INVALID); 51 | verify(mocks.pullRequest(691467750)) 52 | .removeLabel(Labels.TRIAGE_BACKPORT_PREFIX); 53 | }); 54 | } 55 | 56 | @SuppressWarnings("unchecked") 57 | private static <T> PagedIterable<T> mockPagedIterable(List<T> contentMocks) { 58 | PagedIterable<T> iterableMock = mock(PagedIterable.class); 59 | Iterator<T> actualIterator = contentMocks.iterator(); 60 | PagedIterator<T> iteratorMock = mock(PagedIterator.class); 61 | when(iterableMock.iterator()).thenAnswer(ignored -> iteratorMock); 62 | when(iteratorMock.next()).thenAnswer(ignored -> actualIterator.next()); 63 | when(iteratorMock.hasNext()).thenAnswer(ignored -> actualIterator.hasNext()); 64 | return iterableMock; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/java/io/quarkus/bot/it/MockHelper.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.it; 2 | 3 | import static org.mockito.Mockito.lenient; 4 | import static org.mockito.Mockito.mock; 5 | import static org.mockito.Mockito.when; 6 | 7 | import java.io.IOException; 8 | import java.util.Iterator; 9 | import java.util.List; 10 | 11 | import org.kohsuke.github.GHPullRequestFileDetail; 12 | import org.kohsuke.github.GHUser; 13 | import org.kohsuke.github.PagedIterable; 14 | import org.kohsuke.github.PagedIterator; 15 | 16 | public class MockHelper { 17 | 18 | public static GHPullRequestFileDetail mockGHPullRequestFileDetail(String filename) { 19 | GHPullRequestFileDetail mock = mock(GHPullRequestFileDetail.class); 20 | lenient().when(mock.getFilename()).thenReturn(filename); 21 | return mock; 22 | } 23 | 24 | @SafeVarargs 25 | @SuppressWarnings("unchecked") 26 | public static <T> PagedIterable<T> mockPagedIterable(T... contentMocks) { 27 | PagedIterable<T> iterableMock = mock(PagedIterable.class); 28 | try { 29 | lenient().when(iterableMock.toList()).thenAnswer(ignored2 -> List.of(contentMocks)); 30 | } catch (IOException e) { 31 | // This should never happen 32 | // That's a classic unwise comment, but it's a mock, so surely we're safe? :) 33 | throw new RuntimeException(e); 34 | } 35 | lenient().when(iterableMock.iterator()).thenAnswer(ignored -> { 36 | PagedIterator<T> iteratorMock = mock(PagedIterator.class); 37 | Iterator<T> actualIterator = List.of(contentMocks).iterator(); 38 | when(iteratorMock.next()).thenAnswer(ignored2 -> actualIterator.next()); 39 | lenient().when(iteratorMock.hasNext()).thenAnswer(ignored2 -> actualIterator.hasNext()); 40 | 41 | return iteratorMock; 42 | }); 43 | return iterableMock; 44 | } 45 | 46 | public static GHUser mockUser(String login) { 47 | GHUser user = mock(GHUser.class); 48 | when(user.getLogin()).thenReturn(login); 49 | return user; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/test/java/io/quarkus/bot/it/PushToProjectsTest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.it; 2 | 3 | import static io.quarkiverse.githubapp.testing.GitHubAppTesting.given; 4 | import static org.mockito.Mockito.verifyNoMoreInteractions; 5 | 6 | import java.io.IOException; 7 | 8 | import org.junit.jupiter.api.Test; 9 | import org.kohsuke.github.GHEvent; 10 | 11 | import io.quarkiverse.githubapp.testing.GitHubAppTest; 12 | import io.quarkus.test.junit.QuarkusTest; 13 | 14 | @QuarkusTest 15 | @GitHubAppTest 16 | public class PushToProjectsTest { 17 | 18 | /** 19 | * This is a small little test which ensures that if a payload does not have an organisation, PushToProjects does not crash. 20 | */ 21 | @Test 22 | void pullRequestLabeledWithNullOrganization() throws IOException { 23 | 24 | given() 25 | .github(mocks -> mocks.configFile("quarkus-github-bot.yml") 26 | .fromString(""" 27 | features: [ PUSH_TO_PROJECTS ] 28 | project: 29 | rules: 30 | - labels: [area/hibernate-validator] 31 | project: 1 32 | issues: true 33 | pullRequests: false 34 | status: Todo""")) 35 | .when().payloadFromClasspath("/pullrequest-labeled-no-organization.json") 36 | .event(GHEvent.PULL_REQUEST) 37 | .then().github(mocks -> { 38 | // Without an organization, nothing should happen except a not-crash 39 | verifyNoMoreInteractions(mocks.ghObjects()); 40 | }); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/test/java/io/quarkus/bot/it/util/BranchesTest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.it.util; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.jupiter.api.Test; 6 | 7 | import io.quarkus.bot.util.Branches; 8 | 9 | public class BranchesTest { 10 | 11 | @Test 12 | public void testIsVersionBranch() { 13 | assertThat(Branches.isVersionBranch("3.8")).isTrue(); 14 | assertThat(Branches.isVersionBranch("3.10")).isTrue(); 15 | assertThat(Branches.isVersionBranch("10.10")).isTrue(); 16 | assertThat(Branches.isVersionBranch("2.5")).isTrue(); 17 | assertThat(Branches.isVersionBranch("4.0")).isTrue(); 18 | assertThat(Branches.isVersionBranch("main")).isFalse(); 19 | assertThat(Branches.isVersionBranch("feat-4.0")).isFalse(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/java/io/quarkus/bot/it/util/GHIssuesTest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.it.util; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertThrows; 5 | import static org.mockito.ArgumentMatchers.anyMap; 6 | import static org.mockito.ArgumentMatchers.anyString; 7 | import static org.mockito.Mockito.times; 8 | import static org.mockito.Mockito.verify; 9 | import static org.mockito.Mockito.when; 10 | 11 | import java.io.StringReader; 12 | import java.util.List; 13 | import java.util.concurrent.ExecutionException; 14 | import java.util.stream.Stream; 15 | 16 | import jakarta.json.Json; 17 | import jakarta.json.JsonObject; 18 | import jakarta.json.JsonReader; 19 | 20 | import org.junit.jupiter.params.ParameterizedTest; 21 | import org.junit.jupiter.params.provider.Arguments; 22 | import org.junit.jupiter.params.provider.MethodSource; 23 | import org.junit.jupiter.params.provider.ValueSource; 24 | import org.kohsuke.github.GHIssue; 25 | import org.kohsuke.github.GHPullRequest; 26 | import org.kohsuke.github.GHRepository; 27 | import org.mockito.Mockito; 28 | 29 | import io.quarkus.bot.util.GHIssues; 30 | import io.quarkus.bot.util.Mentions; 31 | import io.smallrye.graphql.client.Response; 32 | import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; 33 | 34 | public class GHIssuesTest { 35 | 36 | @ParameterizedTest 37 | @ValueSource(strings = { GHIssues.ISSUE_TYPE, GHIssues.PULL_REQUEST_TYPE }) 38 | public void usersListIsFilteredWithGraphqlApiResult(String ghObjectType) throws ExecutionException, InterruptedException { 39 | 40 | DynamicGraphQLClient mockDynamicGraphQLClient = Mockito.mock(DynamicGraphQLClient.class); 41 | 42 | GHIssue mockGhObject = prepareGHIssueMock(ghObjectType); 43 | 44 | Response mockResponse = Mockito.mock(Response.class); 45 | when(mockResponse.getData()).thenReturn(buildMockJsonData(ghObjectType)); 46 | when(mockResponse.hasError()).thenReturn(false); 47 | when(mockDynamicGraphQLClient.executeSync(anyString(), anyMap())).thenReturn(mockResponse); 48 | 49 | Mentions mentions = new Mentions(); 50 | mentions.add(List.of("testUser1", "testUser2"), null); 51 | mentions.removeAlreadyParticipating(GHIssues.getParticipatingUsers(mockGhObject, mockDynamicGraphQLClient)); 52 | 53 | verify(mockDynamicGraphQLClient, times(1)).executeSync(anyString(), anyMap()); 54 | assertThat(mentions.getMentionsString()).isEqualTo("@testUser2"); 55 | } 56 | 57 | @ParameterizedTest 58 | @MethodSource("provideExceptionTestData") 59 | public void exceptionIsThrownWhenGraphqlApiResponseHasError(String ghObjectType, String expectedErrorMesage) 60 | throws ExecutionException, InterruptedException { 61 | 62 | DynamicGraphQLClient mockDynamicGraphQLClient = Mockito.mock(DynamicGraphQLClient.class); 63 | 64 | GHIssue mockGhObject = prepareGHIssueMock(ghObjectType); 65 | 66 | Response mockResponse = Mockito.mock(Response.class); 67 | when(mockResponse.hasError()).thenReturn(true); 68 | when(mockDynamicGraphQLClient.executeSync(anyString(), anyMap())).thenReturn(mockResponse); 69 | 70 | Exception exception = assertThrows(IllegalStateException.class, 71 | () -> GHIssues.getParticipatingUsers(mockGhObject, mockDynamicGraphQLClient)); 72 | assertThat(exception.getMessage().contains(expectedErrorMesage)); 73 | 74 | verify(mockDynamicGraphQLClient, times(1)).executeSync(anyString(), anyMap()); 75 | } 76 | 77 | @ParameterizedTest 78 | @MethodSource("provideExceptionTestData") 79 | public void exceptionIsThrownWhenGraphqlApiThrowsException(String ghObjectType, String expectedErrorMesage) 80 | throws ExecutionException, InterruptedException { 81 | 82 | DynamicGraphQLClient mockDynamicGraphQLClient = Mockito.mock(DynamicGraphQLClient.class); 83 | 84 | GHIssue mockGhObject = prepareGHIssueMock(ghObjectType); 85 | 86 | when(mockDynamicGraphQLClient.executeSync(anyString(), anyMap())).thenThrow(ExecutionException.class); 87 | 88 | Exception exception = assertThrows(IllegalStateException.class, 89 | () -> GHIssues.getParticipatingUsers(mockGhObject, mockDynamicGraphQLClient)); 90 | assertThat(exception.getMessage().contains(expectedErrorMesage)); 91 | 92 | verify(mockDynamicGraphQLClient, times(1)).executeSync(anyString(), anyMap()); 93 | } 94 | 95 | private static Stream<Arguments> provideExceptionTestData() { 96 | 97 | String issueErrorMessage = String.format(GHIssues.QUERY_PARTICIPANT_ERROR, GHIssues.ISSUE_TYPE, TEST_ISSUE_NUMBER, 98 | TEST_OWNER_NAME + ":" + TEST_REPO_NAME); 99 | String pullRequestErrorMessage = String.format(GHIssues.QUERY_PARTICIPANT_ERROR, GHIssues.PULL_REQUEST_TYPE, 100 | TEST_ISSUE_NUMBER, 101 | TEST_OWNER_NAME + ":" + TEST_REPO_NAME); 102 | 103 | return Stream.of( 104 | Arguments.of(GHIssues.ISSUE_TYPE, issueErrorMessage), 105 | Arguments.of(GHIssues.PULL_REQUEST_TYPE, pullRequestErrorMessage)); 106 | } 107 | 108 | private final static String TEST_OWNER_NAME = "testOwnerName"; 109 | 110 | private final static String TEST_REPO_NAME = "testOwnerName"; 111 | 112 | private final static Integer TEST_ISSUE_NUMBER = 123456; 113 | 114 | private GHIssue prepareGHIssueMock(String ghObjectType) { 115 | 116 | GHIssue mockGhObject = null; 117 | if (GHIssues.ISSUE_TYPE.equals(ghObjectType)) { 118 | mockGhObject = Mockito.mock(GHIssue.class); 119 | } else if (GHIssues.PULL_REQUEST_TYPE.equals(ghObjectType)) { 120 | mockGhObject = Mockito.mock(GHPullRequest.class); 121 | } else { 122 | throw new IllegalArgumentException("Input type must be either issue or pullRequest"); 123 | } 124 | 125 | GHRepository mockGHRepository = Mockito.mock(GHRepository.class); 126 | when(mockGHRepository.getOwnerName()).thenReturn(TEST_OWNER_NAME); 127 | when(mockGHRepository.getName()).thenReturn(TEST_REPO_NAME); 128 | when(mockGHRepository.getFullName()).thenReturn(TEST_OWNER_NAME + ":" + TEST_REPO_NAME); 129 | 130 | when(mockGhObject.getRepository()).thenReturn(mockGHRepository); 131 | when(mockGhObject.getNumber()).thenReturn(TEST_ISSUE_NUMBER); 132 | 133 | return mockGhObject; 134 | } 135 | 136 | private JsonObject buildMockJsonData(String ghObjectType) { 137 | 138 | String mockJsonStr = """ 139 | { 140 | "repository": { 141 | "$ghObjectType": { 142 | "participants": { 143 | "edges": [ 144 | { 145 | "node": { 146 | "login": "testUser1" 147 | } 148 | } 149 | ] 150 | } 151 | } 152 | } 153 | } 154 | """.replace("$ghObjectType", ghObjectType); 155 | 156 | JsonReader jsonReader = Json.createReader(new StringReader(mockJsonStr)); 157 | return jsonReader.readObject(); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/test/java/io/quarkus/bot/it/util/GHPullRequestsTest.java: -------------------------------------------------------------------------------- 1 | package io.quarkus.bot.it.util; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.jupiter.api.Test; 6 | 7 | import io.quarkus.bot.util.GHPullRequests; 8 | 9 | public class GHPullRequestsTest { 10 | 11 | @Test 12 | public void testDropVersionSuffix() { 13 | assertThat(GHPullRequests.dropVersionSuffix("My PR", "3.8")).isEqualTo(("My PR")); 14 | assertThat(GHPullRequests.dropVersionSuffix("My PR", "main")).isEqualTo(("My PR")); 15 | assertThat(GHPullRequests.dropVersionSuffix("(3.8) My PR", "main")).isEqualTo(("(3.8) My PR")); 16 | assertThat(GHPullRequests.dropVersionSuffix("[3.8] My PR", "main")).isEqualTo(("[3.8] My PR")); 17 | assertThat(GHPullRequests.dropVersionSuffix("[3.8] My PR", "3.8")).isEqualTo(("My PR")); 18 | assertThat(GHPullRequests.dropVersionSuffix("[3.9] My PR", "3.8")).isEqualTo(("My PR")); 19 | assertThat(GHPullRequests.dropVersionSuffix("(3.9) My PR", "3.8")).isEqualTo(("My PR")); 20 | assertThat(GHPullRequests.dropVersionSuffix("My PR [3.7]", "3.8")).isEqualTo(("My PR [3.7]")); 21 | assertThat(GHPullRequests.dropVersionSuffix("3.10.4 Backports 1", "3.10")).isEqualTo(("3.10.4 Backports 1")); 22 | assertThat(GHPullRequests.dropVersionSuffix("(3.10) My PR", "3.10")).isEqualTo(("My PR")); 23 | assertThat(GHPullRequests.dropVersionSuffix("[3.10] My PR", "3.10")).isEqualTo(("My PR")); 24 | } 25 | 26 | @Test 27 | public void testNormalizeTitle() { 28 | assertThat(GHPullRequests.normalizeTitle("My PR", "3.8")).isEqualTo(("[3.8] My PR")); 29 | assertThat(GHPullRequests.normalizeTitle("My PR", "3.10")).isEqualTo(("[3.10] My PR")); 30 | assertThat(GHPullRequests.normalizeTitle("My PR", "main")).isEqualTo(("My PR")); 31 | assertThat(GHPullRequests.normalizeTitle("(3.8) My PR", "main")).isEqualTo(("(3.8) My PR")); 32 | assertThat(GHPullRequests.normalizeTitle("3.8.4 backports 1", "3.8")).isEqualTo(("[3.8] 3.8.4 backports 1")); 33 | assertThat(GHPullRequests.normalizeTitle("(3.10) My PR", "3.10")).isEqualTo(("[3.10] My PR")); 34 | assertThat(GHPullRequests.normalizeTitle("[3.8] My PR", "main")).isEqualTo(("[3.8] My PR")); 35 | assertThat(GHPullRequests.normalizeTitle("[3.8] My PR", "3.8")).isEqualTo(("[3.8] My PR")); 36 | assertThat(GHPullRequests.normalizeTitle("[3.9] My PR", "3.8")).isEqualTo(("[3.8] My PR")); 37 | assertThat(GHPullRequests.normalizeTitle("(3.9) My PR", "3.8")).isEqualTo(("[3.8] My PR")); 38 | assertThat(GHPullRequests.normalizeTitle("My PR [3.7]", "3.8")).isEqualTo(("[3.8] My PR [3.7]")); 39 | assertThat(GHPullRequests.normalizeTitle("2.10 - My PR", "2.10")).isEqualTo(("[2.10] My PR")); 40 | assertThat(GHPullRequests.normalizeTitle("3.10.4 Backports 1", "3.10")).isEqualTo(("[3.10] 3.10.4 Backports 1")); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/test/resources/issue-opened-zulip.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/issues/22", 5 | "repository_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo", 6 | "labels_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/issues/22/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/issues/22/comments", 8 | "events_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/issues/22/events", 9 | "html_url": "https://github.com/Luke1432/GitHubTestAppRepo/issues/22", 10 | "id": 942074921, 11 | "node_id": "MDU6SXNzdWU5NDIwNzQ5MjY=", 12 | "number": 22, 13 | "title": "Zulip test Issue", 14 | "user": { 15 | "login": "Luke1432", 16 | "id": 33066961, 17 | "node_id": "MDQ6VXNlcjMzMDY2OTYx", 18 | "avatar_url": "https://avatars.githubusercontent.com/u/33066961?v=4", 19 | "gravatar_id": "", 20 | "url": "https://api.github.com/users/Luke1432", 21 | "html_url": "https://github.com/Luke1432", 22 | "followers_url": "https://api.github.com/users/Luke1432/followers", 23 | "following_url": "https://api.github.com/users/Luke1432/following{/other_user}", 24 | "gists_url": "https://api.github.com/users/Luke1432/gists{/gist_id}", 25 | "starred_url": "https://api.github.com/users/Luke1432/starred{/owner}{/repo}", 26 | "subscriptions_url": "https://api.github.com/users/Luke1432/subscriptions", 27 | "organizations_url": "https://api.github.com/users/Luke1432/orgs", 28 | "repos_url": "https://api.github.com/users/Luke1432/repos", 29 | "events_url": "https://api.github.com/users/Luke1432/events{/privacy}", 30 | "received_events_url": "https://api.github.com/users/Luke1432/received_events", 31 | "type": "User", 32 | "site_admin": false 33 | }, 34 | "labels": [], 35 | "state": "open", 36 | "locked": false, 37 | "assignee": null, 38 | "assignees": [], 39 | "milestone": null, 40 | "comments": 0, 41 | "created_at": "2021-07-12T13:38:48Z", 42 | "updated_at": "2021-07-12T13:38:48Z", 43 | "closed_at": null, 44 | "author_association": "OWNER", 45 | "active_lock_reason": null, 46 | "body": "https://quarkusio.zulipchat.com/", 47 | "performed_via_github_app": null 48 | }, 49 | "repository": { 50 | "id": 384996143, 51 | "node_id": "MDEwOlJlcG9zaXRvcnkzODQ5OTYxNDM=", 52 | "name": "GitHubTestAppRepo", 53 | "full_name": "Luke1432/GitHubTestAppRepo", 54 | "private": false, 55 | "owner": { 56 | "login": "Luke1432", 57 | "id": 33066961, 58 | "node_id": "MDQ6VXNlcjMzMDY2OTYx", 59 | "avatar_url": "https://avatars.githubusercontent.com/u/33066961?v=4", 60 | "gravatar_id": "", 61 | "url": "https://api.github.com/users/Luke1432", 62 | "html_url": "https://github.com/Luke1432", 63 | "followers_url": "https://api.github.com/users/Luke1432/followers", 64 | "following_url": "https://api.github.com/users/Luke1432/following{/other_user}", 65 | "gists_url": "https://api.github.com/users/Luke1432/gists{/gist_id}", 66 | "starred_url": "https://api.github.com/users/Luke1432/starred{/owner}{/repo}", 67 | "subscriptions_url": "https://api.github.com/users/Luke1432/subscriptions", 68 | "organizations_url": "https://api.github.com/users/Luke1432/orgs", 69 | "repos_url": "https://api.github.com/users/Luke1432/repos", 70 | "events_url": "https://api.github.com/users/Luke1432/events{/privacy}", 71 | "received_events_url": "https://api.github.com/users/Luke1432/received_events", 72 | "type": "User", 73 | "site_admin": false 74 | }, 75 | "html_url": "https://github.com/Luke1432/GitHubTestAppRepo", 76 | "description": null, 77 | "fork": false, 78 | "url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo", 79 | "forks_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/forks", 80 | "keys_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/keys{/key_id}", 81 | "collaborators_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/collaborators{/collaborator}", 82 | "teams_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/teams", 83 | "hooks_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/hooks", 84 | "issue_events_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/issues/events{/number}", 85 | "events_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/events", 86 | "assignees_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/assignees{/user}", 87 | "branches_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/branches{/branch}", 88 | "tags_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/tags", 89 | "blobs_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/git/blobs{/sha}", 90 | "git_tags_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/git/tags{/sha}", 91 | "git_refs_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/git/refs{/sha}", 92 | "trees_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/git/trees{/sha}", 93 | "statuses_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/statuses/{sha}", 94 | "languages_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/languages", 95 | "stargazers_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/stargazers", 96 | "contributors_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/contributors", 97 | "subscribers_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/subscribers", 98 | "subscription_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/subscription", 99 | "commits_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/commits{/sha}", 100 | "git_commits_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/git/commits{/sha}", 101 | "comments_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/comments{/number}", 102 | "issue_comment_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/issues/comments{/number}", 103 | "contents_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/contents/{+path}", 104 | "compare_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/compare/{base}...{head}", 105 | "merges_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/merges", 106 | "archive_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/{archive_format}{/ref}", 107 | "downloads_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/downloads", 108 | "issues_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/issues{/number}", 109 | "pulls_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/pulls{/number}", 110 | "milestones_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/milestones{/number}", 111 | "notifications_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/notifications{?since,all,participating}", 112 | "labels_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/labels{/name}", 113 | "releases_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/releases{/id}", 114 | "deployments_url": "https://api.github.com/repos/Luke1432/GitHubTestAppRepo/deployments", 115 | "created_at": "2021-07-11T16:18:42Z", 116 | "updated_at": "2021-07-12T12:34:09Z", 117 | "pushed_at": "2021-07-12T12:34:07Z", 118 | "git_url": "git://github.com/Luke1432/GitHubTestAppRepo.git", 119 | "ssh_url": "git@github.com:Luke1432/GitHubTestAppRepo.git", 120 | "clone_url": "https://github.com/Luke1432/GitHubTestAppRepo.git", 121 | "svn_url": "https://github.com/Luke1432/GitHubTestAppRepo", 122 | "homepage": null, 123 | "size": 6, 124 | "stargazers_count": 0, 125 | "watchers_count": 0, 126 | "language": null, 127 | "has_issues": true, 128 | "has_projects": true, 129 | "has_downloads": true, 130 | "has_wiki": true, 131 | "has_pages": false, 132 | "forks_count": 0, 133 | "mirror_url": null, 134 | "archived": false, 135 | "disabled": false, 136 | "open_issues_count": 1, 137 | "license": null, 138 | "forks": 0, 139 | "open_issues": 1, 140 | "watchers": 0, 141 | "default_branch": "main" 142 | }, 143 | "sender": { 144 | "login": "Luke1432", 145 | "id": 33066961, 146 | "node_id": "MDQ6VXNlcjMzMDY2OTYx", 147 | "avatar_url": "https://avatars.githubusercontent.com/u/33066961?v=4", 148 | "gravatar_id": "", 149 | "url": "https://api.github.com/users/Luke1432", 150 | "html_url": "https://github.com/Luke1432", 151 | "followers_url": "https://api.github.com/users/Luke1432/followers", 152 | "following_url": "https://api.github.com/users/Luke1432/following{/other_user}", 153 | "gists_url": "https://api.github.com/users/Luke1432/gists{/gist_id}", 154 | "starred_url": "https://api.github.com/users/Luke1432/starred{/owner}{/repo}", 155 | "subscriptions_url": "https://api.github.com/users/Luke1432/subscriptions", 156 | "organizations_url": "https://api.github.com/users/Luke1432/orgs", 157 | "repos_url": "https://api.github.com/users/Luke1432/repos", 158 | "events_url": "https://api.github.com/users/Luke1432/events{/privacy}", 159 | "received_events_url": "https://api.github.com/users/Luke1432/received_events", 160 | "type": "User", 161 | "site_admin": false 162 | }, 163 | "installation": { 164 | "id": 18219326, 165 | "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uMTgyMTkzMjY=" 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/test/resources/issue-opened.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "issue": { 4 | "url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/issues/2", 5 | "repository_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground", 6 | "labels_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/issues/2/labels{/name}", 7 | "comments_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/issues/2/comments", 8 | "events_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/issues/2/events", 9 | "html_url": "https://github.com/yrodiere/quarkus-bot-java-playground/issues/2", 10 | "id": 750705278, 11 | "node_id": "MDU6SXNzdWU3NTA3MDUyNzg=", 12 | "number": 2, 13 | "title": "test", 14 | "user": { 15 | "login": "yrodiere", 16 | "id": 412878, 17 | "node_id": "MDQ6VXNlcjQxMjg3OA==", 18 | "avatar_url": "https://avatars1.githubusercontent.com/u/412878?v=4", 19 | "gravatar_id": "", 20 | "url": "https://api.github.com/users/yrodiere", 21 | "html_url": "https://github.com/yrodiere", 22 | "followers_url": "https://api.github.com/users/yrodiere/followers", 23 | "following_url": "https://api.github.com/users/yrodiere/following{/other_user}", 24 | "gists_url": "https://api.github.com/users/yrodiere/gists{/gist_id}", 25 | "starred_url": "https://api.github.com/users/yrodiere/starred{/owner}{/repo}", 26 | "subscriptions_url": "https://api.github.com/users/yrodiere/subscriptions", 27 | "organizations_url": "https://api.github.com/users/yrodiere/orgs", 28 | "repos_url": "https://api.github.com/users/yrodiere/repos", 29 | "events_url": "https://api.github.com/users/yrodiere/events{/privacy}", 30 | "received_events_url": "https://api.github.com/users/yrodiere/received_events", 31 | "type": "User", 32 | "site_admin": false 33 | }, 34 | "labels": [], 35 | "state": "open", 36 | "locked": false, 37 | "assignee": null, 38 | "assignees": [], 39 | "milestone": null, 40 | "comments": 0, 41 | "created_at": "2020-11-25T10:44:46Z", 42 | "updated_at": "2020-11-25T10:44:46Z", 43 | "closed_at": null, 44 | "author_association": "OWNER", 45 | "active_lock_reason": null, 46 | "body": "", 47 | "performed_via_github_app": null 48 | }, 49 | "repository": { 50 | "id": 315892888, 51 | "node_id": "MDEwOlJlcG9zaXRvcnkzMTU4OTI4ODg=", 52 | "name": "quarkus-bot-java-playground", 53 | "full_name": "yrodiere/quarkus-bot-java-playground", 54 | "private": true, 55 | "owner": { 56 | "login": "yrodiere", 57 | "id": 412878, 58 | "node_id": "MDQ6VXNlcjQxMjg3OA==", 59 | "avatar_url": "https://avatars1.githubusercontent.com/u/412878?v=4", 60 | "gravatar_id": "", 61 | "url": "https://api.github.com/users/yrodiere", 62 | "html_url": "https://github.com/yrodiere", 63 | "followers_url": "https://api.github.com/users/yrodiere/followers", 64 | "following_url": "https://api.github.com/users/yrodiere/following{/other_user}", 65 | "gists_url": "https://api.github.com/users/yrodiere/gists{/gist_id}", 66 | "starred_url": "https://api.github.com/users/yrodiere/starred{/owner}{/repo}", 67 | "subscriptions_url": "https://api.github.com/users/yrodiere/subscriptions", 68 | "organizations_url": "https://api.github.com/users/yrodiere/orgs", 69 | "repos_url": "https://api.github.com/users/yrodiere/repos", 70 | "events_url": "https://api.github.com/users/yrodiere/events{/privacy}", 71 | "received_events_url": "https://api.github.com/users/yrodiere/received_events", 72 | "type": "User", 73 | "site_admin": false 74 | }, 75 | "html_url": "https://github.com/yrodiere/quarkus-bot-java-playground", 76 | "description": null, 77 | "fork": true, 78 | "url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground", 79 | "forks_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/forks", 80 | "keys_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/keys{/key_id}", 81 | "collaborators_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/collaborators{/collaborator}", 82 | "teams_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/teams", 83 | "hooks_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/hooks", 84 | "issue_events_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/issues/events{/number}", 85 | "events_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/events", 86 | "assignees_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/assignees{/user}", 87 | "branches_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/branches{/branch}", 88 | "tags_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/tags", 89 | "blobs_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/git/blobs{/sha}", 90 | "git_tags_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/git/tags{/sha}", 91 | "git_refs_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/git/refs{/sha}", 92 | "trees_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/git/trees{/sha}", 93 | "statuses_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/statuses/{sha}", 94 | "languages_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/languages", 95 | "stargazers_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/stargazers", 96 | "contributors_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/contributors", 97 | "subscribers_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/subscribers", 98 | "subscription_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/subscription", 99 | "commits_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/commits{/sha}", 100 | "git_commits_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/git/commits{/sha}", 101 | "comments_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/comments{/number}", 102 | "issue_comment_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/issues/comments{/number}", 103 | "contents_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/contents/{+path}", 104 | "compare_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/compare/{base}...{head}", 105 | "merges_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/merges", 106 | "archive_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/{archive_format}{/ref}", 107 | "downloads_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/downloads", 108 | "issues_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/issues{/number}", 109 | "pulls_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/pulls{/number}", 110 | "milestones_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/milestones{/number}", 111 | "notifications_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/notifications{?since,all,participating}", 112 | "labels_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/labels{/name}", 113 | "releases_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/releases{/id}", 114 | "deployments_url": "https://api.github.com/repos/yrodiere/quarkus-bot-java-playground/deployments", 115 | "created_at": "2020-11-25T09:40:12Z", 116 | "updated_at": "2020-11-25T10:44:38Z", 117 | "pushed_at": "2020-11-25T10:44:00Z", 118 | "git_url": "git://github.com/yrodiere/quarkus-bot-java-playground.git", 119 | "ssh_url": "git@github.com:yrodiere/quarkus-bot-java-playground.git", 120 | "clone_url": "https://github.com/yrodiere/quarkus-bot-java-playground.git", 121 | "svn_url": "https://github.com/yrodiere/quarkus-bot-java-playground", 122 | "homepage": null, 123 | "size": 11, 124 | "stargazers_count": 0, 125 | "watchers_count": 0, 126 | "language": null, 127 | "has_issues": true, 128 | "has_projects": true, 129 | "has_downloads": true, 130 | "has_wiki": false, 131 | "has_pages": false, 132 | "forks_count": 0, 133 | "mirror_url": null, 134 | "archived": false, 135 | "disabled": false, 136 | "open_issues_count": 2, 137 | "license": null, 138 | "forks": 0, 139 | "open_issues": 2, 140 | "watchers": 0, 141 | "default_branch": "main" 142 | }, 143 | "sender": { 144 | "login": "yrodiere", 145 | "id": 412878, 146 | "node_id": "MDQ6VXNlcjQxMjg3OA==", 147 | "avatar_url": "https://avatars1.githubusercontent.com/u/412878?v=4", 148 | "gravatar_id": "", 149 | "url": "https://api.github.com/users/yrodiere", 150 | "html_url": "https://github.com/yrodiere", 151 | "followers_url": "https://api.github.com/users/yrodiere/followers", 152 | "following_url": "https://api.github.com/users/yrodiere/following{/other_user}", 153 | "gists_url": "https://api.github.com/users/yrodiere/gists{/gist_id}", 154 | "starred_url": "https://api.github.com/users/yrodiere/starred{/owner}{/repo}", 155 | "subscriptions_url": "https://api.github.com/users/yrodiere/subscriptions", 156 | "organizations_url": "https://api.github.com/users/yrodiere/orgs", 157 | "repos_url": "https://api.github.com/users/yrodiere/repos", 158 | "events_url": "https://api.github.com/users/yrodiere/events{/privacy}", 159 | "received_events_url": "https://api.github.com/users/yrodiere/received_events", 160 | "type": "User", 161 | "site_admin": false 162 | }, 163 | "installation": { 164 | "id": 13173124, 165 | "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uMTMxNzMxMjQ=" 166 | } 167 | } --------------------------------------------------------------------------------