├── .github └── workflows │ ├── ci.yml │ ├── dry-run.yml │ └── run.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── rust-toolchain.toml └── src ├── github ├── api │ ├── mod.rs │ ├── read.rs │ ├── tokens.rs │ ├── url.rs │ └── write.rs ├── mod.rs └── tests │ ├── mod.rs │ └── test_utils.rs ├── mailgun ├── api.rs └── mod.rs ├── main.rs ├── team_api.rs ├── utils.rs └── zulip ├── api.rs └── mod.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | merge_group: 5 | 6 | jobs: 7 | test: 8 | name: Test 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - uses: Swatinem/rust-cache@v2 14 | 15 | - name: Build 16 | run: cargo build 17 | 18 | - name: Run tests 19 | run: cargo test 20 | 21 | - name: Run rustfmt 22 | run: cargo fmt -- --check 23 | 24 | - name: Run clippy 25 | run: cargo clippy -- -Dwarnings 26 | deploy: 27 | name: Deploy 28 | needs: [ test ] 29 | environment: deploy 30 | permissions: 31 | id-token: write 32 | runs-on: ubuntu-latest 33 | if: github.event_name == 'merge_group' 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - name: Build the Docker container 38 | run: docker build -t sync-team . 39 | 40 | - name: Configure AWS credentials 41 | uses: aws-actions/configure-aws-credentials@v4 42 | with: 43 | role-to-assume: arn:aws:iam::890664054962:role/ci--rust-lang--sync-team 44 | aws-region: us-west-1 45 | 46 | - name: Login to Amazon ECR Private 47 | id: login-ecr 48 | uses: aws-actions/amazon-ecr-login@v1 49 | 50 | - name: Build, tag, and push docker image to Amazon ECR 51 | env: 52 | REGISTRY: ${{ steps.login-ecr.outputs.registry }} 53 | REPOSITORY: sync-team 54 | run: | 55 | docker tag sync-team $REGISTRY/$REPOSITORY:latest 56 | docker push $REGISTRY/$REPOSITORY:latest 57 | 58 | - name: Start the synchronization tool 59 | run: | 60 | aws --region us-west-1 lambda invoke --function-name start-sync-team output.json 61 | cat output.json | python3 -m json.tool 62 | 63 | # Summary job for the merge queue. 64 | # ALL THE PREVIOUS JOBS NEED TO BE ADDED TO THE `needs` SECTION OF THIS JOB! 65 | conclusion: 66 | name: CI 67 | needs: [ test, deploy ] 68 | # We need to ensure this job does *not* get skipped if its dependencies fail, 69 | # because a skipped job is considered a success by GitHub. So we have to 70 | # overwrite `if:`. We use `!cancelled()` to ensure the job does still not get run 71 | # when the workflow is canceled manually. 72 | if: ${{ !cancelled() }} 73 | runs-on: ubuntu-latest 74 | steps: 75 | # Manually check the status of all dependencies. `if: failure()` does not work. 76 | - name: Conclusion 77 | run: | 78 | # Print the dependent jobs to see them in the CI log 79 | jq -C <<< '${{ toJson(needs) }}' 80 | # Check if all jobs that we depend on (in the needs array) were successful. 81 | jq --exit-status 'all(.result == "success" or .result == "skipped")' <<< '${{ toJson(needs) }}' 82 | -------------------------------------------------------------------------------- /.github/workflows/dry-run.yml: -------------------------------------------------------------------------------- 1 | name: Dry Run 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | schedule: 7 | # Run the dry run every 4 hours 8 | - cron: "0 */4 * * *" 9 | 10 | jobs: 11 | dry-run: 12 | name: Run sync-team dry-run 13 | runs-on: ubuntu-24.04 14 | if: ${{ github.repository_owner == 'rust-lang' }} 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | # We don't need to do authenticated `git` operations, since we use the GitHub API. 19 | persist-credentials: false 20 | 21 | - uses: Swatinem/rust-cache@v2 22 | 23 | # GitHub tokens generated from GitHub Apps can access resources from one organization, 24 | # so we need to generate a token for each organization. 25 | - name: Generate GitHub token (rust-lang) 26 | uses: actions/create-github-app-token@v1 27 | id: rust-lang-token 28 | with: 29 | # GitHub App ID secret name 30 | app-id: ${{ secrets.GH_APP_ID }} 31 | # GitHub App private key secret name 32 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 33 | # Set the owner, so the token can be used in all repositories 34 | owner: rust-lang 35 | 36 | - name: Generate GitHub token (rust-lang-ci) 37 | uses: actions/create-github-app-token@v1 38 | id: rust-lang-ci-token 39 | with: 40 | app-id: ${{ secrets.GH_APP_ID }} 41 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 42 | owner: rust-lang-ci 43 | 44 | - name: Generate GitHub token (rust-lang-deprecated) 45 | uses: actions/create-github-app-token@v1 46 | id: rust-lang-deprecated-token 47 | with: 48 | app-id: ${{ secrets.GH_APP_ID }} 49 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 50 | owner: rust-lang-deprecated 51 | 52 | - name: Generate GitHub token (rust-lang-nursery) 53 | uses: actions/create-github-app-token@v1 54 | id: rust-lang-nursery-token 55 | with: 56 | app-id: ${{ secrets.GH_APP_ID }} 57 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 58 | owner: rust-lang-nursery 59 | 60 | - name: Generate GitHub token (bors-rs) 61 | uses: actions/create-github-app-token@v1 62 | id: bors-rs-token 63 | with: 64 | app-id: ${{ secrets.GH_APP_ID }} 65 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 66 | owner: bors-rs 67 | 68 | - name: Generate GitHub token (rust-analyzer) 69 | uses: actions/create-github-app-token@v1 70 | id: rust-analyzer-token 71 | with: 72 | app-id: ${{ secrets.GH_APP_ID }} 73 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 74 | owner: rust-analyzer 75 | 76 | - name: Generate GitHub token (rust-embedded) 77 | uses: actions/create-github-app-token@v1 78 | id: rust-embedded-token 79 | with: 80 | app-id: ${{ secrets.GH_APP_ID }} 81 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 82 | owner: rust-embedded 83 | 84 | - name: Generate GitHub token (rust-dev-tools) 85 | uses: actions/create-github-app-token@v1 86 | id: rust-dev-tools-token 87 | with: 88 | app-id: ${{ secrets.GH_APP_ID }} 89 | private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} 90 | owner: rust-dev-tools 91 | 92 | - name: Dry run 93 | env: 94 | GITHUB_TOKEN_RUST_LANG: ${{ steps.rust-lang-token.outputs.token }} 95 | GITHUB_TOKEN_RUST_LANG_CI: ${{ steps.rust-lang-ci-token.outputs.token }} 96 | GITHUB_TOKEN_RUST_LANG_DEPRECATED: ${{ steps.rust-lang-deprecated-token.outputs.token }} 97 | GITHUB_TOKEN_RUST_LANG_NURSERY: ${{ steps.rust-lang-nursery-token.outputs.token }} 98 | GITHUB_TOKEN_BORS_RS: ${{ steps.bors-rs-token.outputs.token }} 99 | GITHUB_TOKEN_RUST_ANALYZER: ${{ steps.rust-analyzer-token.outputs.token }} 100 | GITHUB_TOKEN_RUST_EMBEDDED: ${{ steps.rust-embedded-token.outputs.token }} 101 | GITHUB_TOKEN_RUST_DEV_TOOLS: ${{ steps.rust-dev-tools-token.outputs.token }} 102 | run: cargo run -- print-plan --services github 103 | -------------------------------------------------------------------------------- /.github/workflows/run.yml: -------------------------------------------------------------------------------- 1 | name: Run 2 | on: 3 | workflow_dispatch: { } 4 | 5 | jobs: 6 | run: 7 | name: Run the sync-team tool 8 | runs-on: ubuntu-latest 9 | permissions: 10 | id-token: write 11 | steps: 12 | - name: Configure AWS credentials 13 | uses: aws-actions/configure-aws-credentials@v4 14 | with: 15 | role-to-assume: arn:aws:iam::890664054962:role/ci--rust-lang--sync-team 16 | aws-region: us-west-1 17 | 18 | - name: Start the synchronization tool 19 | run: | 20 | aws --region us-west-1 lambda invoke --function-name start-sync-team output.json 21 | cat output.json | python3 -m json.tool 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sync-team" 3 | version = "0.1.0" 4 | authors = ["Pietro Albini "] 5 | edition = "2024" 6 | 7 | [dependencies] 8 | clap = { version = "4.5", features = ["derive", "env"] } 9 | reqwest = { version = "0.12.8", features = ["blocking", "json", "rustls-tls", "charset", "http2", "macos-system-configuration"], default-features = false } 10 | log = "0.4" 11 | env_logger = "0.11" 12 | rust_team_data = { git = "https://github.com/rust-lang/team", features = ["email-encryption"] } 13 | serde = { version = "1.0", features = ["derive"] } 14 | anyhow = "1.0" 15 | base64 = "0.22" 16 | hyper-old-types = "0.11" 17 | tempfile = "3.13" 18 | serde_json = "1.0" 19 | secrecy = "0.10" 20 | 21 | [dev-dependencies] 22 | indexmap = "2.6.0" 23 | derive_builder = "0.20.2" 24 | insta = "1.40.0" 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal AS build 2 | 3 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ 4 | ca-certificates \ 5 | curl \ 6 | build-essential \ 7 | pkg-config \ 8 | libssl-dev 9 | 10 | # Install the stable toolchain with rustup 11 | RUN curl https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init >/tmp/rustup-init && \ 12 | chmod +x /tmp/rustup-init && \ 13 | /tmp/rustup-init -y --no-modify-path --default-toolchain stable 14 | ENV PATH=/root/.cargo/bin:$PATH 15 | 16 | # Build the dependencies in a separate step to avoid rebuilding all of them 17 | # every time the source code changes. This takes advantage of Docker's layer 18 | # caching, and it works by copying the Cargo.{toml,lock} with dummy source code 19 | # and doing a full build with it. 20 | WORKDIR /tmp/source 21 | COPY Cargo.lock Cargo.toml /tmp/source/ 22 | RUN mkdir -p /tmp/source/src && \ 23 | echo "fn main() {}" > /tmp/source/src/main.rs 24 | RUN cargo fetch 25 | RUN cargo build --release 26 | 27 | # Dependencies are now cached, copy the actual source code and do another full 28 | # build. The touch on all the .rs files is needed, otherwise cargo assumes the 29 | # source code didn't change thanks to mtime weirdness. 30 | RUN rm -rf /tmp/source/src 31 | COPY src /tmp/source/src 32 | RUN find -name "*.rs" -exec touch {} \; && cargo build --release 33 | 34 | ################## 35 | # Output image # 36 | ################## 37 | 38 | FROM ubuntu:focal AS binary 39 | 40 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ 41 | ca-certificates 42 | 43 | COPY --from=build /tmp/source/target/release/sync-team /usr/local/bin/ 44 | 45 | CMD ["/usr/local/bin/sync-team", "apply"] 46 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This repository was moved to https://github.com/rust-lang/team 3 | 4 | # Team synchronization tool 5 | 6 | This repository contains the CLI tool used to synchronize the contents of the 7 | [rust-lang/team] repository with some of the services the Rust Team uses. There 8 | is usually no need to run this tool manually, and running it requires elevated 9 | privileges on our infrastructure. 10 | 11 | | Service name | Description | Environment variables | 12 | |--------------|-------------------------------------------------|---------------------------------------------| 13 | | github | Synchronize GitHub teams and repo configuration | `GITHUB_TOKEN` | 14 | | mailgun | Synchronize mailing lists on Mailgun | `MAILGUN_API_TOKEN`, `EMAIL_ENCRYPTION_KEY` | 15 | | zulip | Synchronize Zulip user groups | `ZULIP_USERNAME`, `ZULIP_API_TOKEN` | 16 | 17 | The contents of this repository are available under both the MIT and Apache 2.0 18 | license. 19 | 20 | ## Running the tool 21 | 22 | By default the tool will run in *dry mode* on all the services we synchronize, 23 | meaning that the changes will be previewed on the console output but no actual 24 | change will be applied: 25 | 26 | ``` 27 | cargo run 28 | ``` 29 | 30 | Once you're satisfied with the changes you can run the full synchronization by 31 | passing the `apply` subcommand: 32 | 33 | ``` 34 | cargo run apply 35 | ``` 36 | 37 | You can also limit the services to synchronize on by passing a list of all the 38 | service names you want to sync. For example, to synchronize only GitHub and 39 | Mailgun you can run: 40 | 41 | ``` 42 | cargo run -- --services github,mailgun 43 | cargo run -- --services github,mailgun apply 44 | ``` 45 | 46 | ## Using a local copy of the team repository 47 | 48 | By default, this tool works on the production dataset, pulled from 49 | [rust-lang/team]. When making changes to the tool it might be useful to test 50 | with dummy data though. You can do that by making the changes in a local copy 51 | of the team repository and passing the `--team-repo` flag to the CLI: 52 | 53 | ``` 54 | cargo run -- --team-repo ~/code/rust-lang/team 55 | ``` 56 | 57 | When `--team-repo` is passed, the CLI will build the Static API in a temporary 58 | directory, and fetch the data from it instead of the production instance. 59 | 60 | You can also use `--team-json` to directly pass a path to a directory containing 61 | the JSON output generated by the `team` repo. 62 | 63 | [rust-lang/team]: https://github.com/rust-lang/team 64 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.85.0" 3 | components = ["rustfmt", "clippy"] 4 | -------------------------------------------------------------------------------- /src/github/api/mod.rs: -------------------------------------------------------------------------------- 1 | mod read; 2 | mod tokens; 3 | mod url; 4 | mod write; 5 | 6 | use crate::utils::ResponseExt; 7 | use anyhow::{Context, bail}; 8 | use base64::Engine as _; 9 | use base64::prelude::BASE64_STANDARD; 10 | use hyper_old_types::header::{Link, RelationType}; 11 | use log::{debug, trace}; 12 | use reqwest::header::HeaderMap; 13 | use reqwest::{ 14 | Method, StatusCode, 15 | blocking::{Client, RequestBuilder, Response}, 16 | header::{self, HeaderValue}, 17 | }; 18 | use secrecy::ExposeSecret; 19 | use serde::{Deserialize, de::DeserializeOwned}; 20 | use std::fmt; 21 | use tokens::GitHubTokens; 22 | use url::GitHubUrl; 23 | 24 | pub(crate) use read::{GitHubApiRead, GithubRead}; 25 | pub(crate) use write::GitHubWrite; 26 | 27 | #[derive(Clone)] 28 | pub(crate) struct HttpClient { 29 | client: Client, 30 | github_tokens: GitHubTokens, 31 | } 32 | 33 | impl HttpClient { 34 | pub(crate) fn new() -> anyhow::Result { 35 | let mut builder = reqwest::blocking::ClientBuilder::default(); 36 | let mut map = HeaderMap::default(); 37 | 38 | map.insert( 39 | header::USER_AGENT, 40 | HeaderValue::from_static(crate::USER_AGENT), 41 | ); 42 | builder = builder.default_headers(map); 43 | 44 | Ok(Self { 45 | client: builder.build()?, 46 | github_tokens: GitHubTokens::from_env()?, 47 | }) 48 | } 49 | 50 | fn auth_header(&self, org: &str) -> anyhow::Result { 51 | let token = self.github_tokens.get_token(org)?; 52 | let mut auth = HeaderValue::from_str(&format!("token {}", token.expose_secret()))?; 53 | auth.set_sensitive(true); 54 | Ok(auth) 55 | } 56 | 57 | fn req(&self, method: Method, url: &GitHubUrl) -> anyhow::Result { 58 | trace!("http request: {} {}", method, url.url()); 59 | let token = self.auth_header(url.org())?; 60 | let client = self 61 | .client 62 | .request(method, url.url()) 63 | .header(header::AUTHORIZATION, token); 64 | Ok(client) 65 | } 66 | 67 | fn send( 68 | &self, 69 | method: Method, 70 | url: &GitHubUrl, 71 | body: &T, 72 | ) -> Result { 73 | let resp = self.req(method, url)?.json(body).send()?; 74 | resp.custom_error_for_status() 75 | } 76 | 77 | fn send_option( 78 | &self, 79 | method: Method, 80 | url: &GitHubUrl, 81 | ) -> Result, anyhow::Error> { 82 | let resp = self.req(method.clone(), url)?.send()?; 83 | match resp.status() { 84 | StatusCode::OK => Ok(Some(resp.json_annotated().with_context(|| { 85 | format!( 86 | "Failed to decode response body on {method} request to '{}'", 87 | url.url() 88 | ) 89 | })?)), 90 | StatusCode::NOT_FOUND => Ok(None), 91 | _ => Err(resp.custom_error_for_status().unwrap_err()), 92 | } 93 | } 94 | 95 | /// Send a request to the GitHub API and return the response. 96 | fn graphql(&self, query: &str, variables: V, org: &str) -> anyhow::Result 97 | where 98 | R: serde::de::DeserializeOwned, 99 | V: serde::Serialize, 100 | { 101 | let res = self.send_graphql_req(query, variables, org)?; 102 | 103 | if let Some(error) = res.errors.first() { 104 | bail!("graphql error: {}", error.message); 105 | } 106 | 107 | read_graphql_data(res) 108 | } 109 | 110 | /// Send a request to the GitHub API and return the response. 111 | /// If the request contains the error type `NOT_FOUND`, this method returns `Ok(None)`. 112 | fn graphql_opt(&self, query: &str, variables: V, org: &str) -> anyhow::Result> 113 | where 114 | R: serde::de::DeserializeOwned, 115 | V: serde::Serialize, 116 | { 117 | let res = self.send_graphql_req(query, variables, org)?; 118 | 119 | if let Some(error) = res.errors.first() { 120 | if error.type_ == Some(GraphErrorType::NotFound) { 121 | return Ok(None); 122 | } 123 | bail!("graphql error: {}", error.message); 124 | } 125 | 126 | read_graphql_data(res) 127 | } 128 | 129 | fn send_graphql_req( 130 | &self, 131 | query: &str, 132 | variables: V, 133 | org: &str, 134 | ) -> anyhow::Result> 135 | where 136 | R: serde::de::DeserializeOwned, 137 | V: serde::Serialize, 138 | { 139 | #[derive(serde::Serialize)] 140 | struct Request<'a, V> { 141 | query: &'a str, 142 | variables: V, 143 | } 144 | let resp = self 145 | .req(Method::POST, &GitHubUrl::new("graphql", org))? 146 | .json(&Request { query, variables }) 147 | .send() 148 | .context("failed to send graphql request")? 149 | .custom_error_for_status()?; 150 | 151 | resp.json_annotated().with_context(|| { 152 | format!("Failed to decode response body on graphql request with query '{query}'") 153 | }) 154 | } 155 | 156 | fn rest_paginated(&self, method: &Method, url: &GitHubUrl, mut f: F) -> anyhow::Result<()> 157 | where 158 | F: FnMut(T) -> anyhow::Result<()>, 159 | T: DeserializeOwned, 160 | { 161 | let mut next = Some(url.clone()); 162 | while let Some(next_url) = next.take() { 163 | let resp = self 164 | .req(method.clone(), &next_url)? 165 | .send() 166 | .with_context(|| format!("failed to send request to {}", next_url.url()))? 167 | .custom_error_for_status()?; 168 | 169 | // Extract the next page 170 | if let Some(links) = resp.headers().get(header::LINK) { 171 | let links: Link = links.to_str()?.parse()?; 172 | for link in links.values() { 173 | if link 174 | .rel() 175 | .map(|r| r.iter().any(|r| *r == RelationType::Next)) 176 | .unwrap_or(false) 177 | { 178 | next = Some(GitHubUrl::new(link.link(), next_url.org())); 179 | break; 180 | } 181 | } 182 | } 183 | 184 | f(resp.json_annotated().with_context(|| { 185 | format!( 186 | "Failed to deserialize response body for {method} request to '{}'", 187 | next_url.url() 188 | ) 189 | })?)?; 190 | } 191 | Ok(()) 192 | } 193 | } 194 | 195 | fn read_graphql_data(res: GraphResult) -> anyhow::Result 196 | where 197 | R: serde::de::DeserializeOwned, 198 | { 199 | if let Some(data) = res.data { 200 | Ok(data) 201 | } else { 202 | bail!("missing graphql data"); 203 | } 204 | } 205 | 206 | fn allow_not_found(resp: Response, method: Method, url: &str) -> Result<(), anyhow::Error> { 207 | match resp.status() { 208 | StatusCode::NOT_FOUND => { 209 | debug!("Response from {method} {url} returned 404 which is treated as success"); 210 | } 211 | _ => { 212 | resp.custom_error_for_status()?; 213 | } 214 | } 215 | Ok(()) 216 | } 217 | 218 | #[derive(Debug, serde::Deserialize)] 219 | struct GraphResult { 220 | data: Option, 221 | #[serde(default)] 222 | errors: Vec, 223 | } 224 | 225 | #[derive(Debug, serde::Deserialize)] 226 | struct GraphError { 227 | #[serde(rename = "type")] 228 | type_: Option, 229 | message: String, 230 | } 231 | 232 | #[derive(Debug, serde::Deserialize, PartialEq, Eq)] 233 | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] 234 | enum GraphErrorType { 235 | NotFound, 236 | #[serde(other)] 237 | Other, 238 | } 239 | 240 | #[derive(serde::Deserialize)] 241 | struct GraphNodes { 242 | nodes: Vec>, 243 | } 244 | 245 | #[derive(serde::Deserialize)] 246 | struct GraphNode { 247 | node: Option, 248 | } 249 | 250 | #[derive(serde::Deserialize)] 251 | #[serde(rename_all = "camelCase")] 252 | struct GraphPageInfo { 253 | end_cursor: Option, 254 | has_next_page: bool, 255 | } 256 | 257 | impl GraphPageInfo { 258 | fn start() -> Self { 259 | GraphPageInfo { 260 | end_cursor: None, 261 | has_next_page: true, 262 | } 263 | } 264 | } 265 | 266 | #[derive(serde::Deserialize, Debug, Clone)] 267 | pub(crate) struct Team { 268 | /// The ID returned by the GitHub API can't be empty, but the None marks teams "created" during 269 | /// a dry run and not actually present on GitHub, so other methods can avoid acting on them. 270 | pub(crate) id: Option, 271 | pub(crate) name: String, 272 | pub(crate) description: Option, 273 | pub(crate) privacy: TeamPrivacy, 274 | /// The slug usually matches the name but can differ. 275 | /// For example, a team named rustup.rs would have a slug rustup-rs. 276 | pub(crate) slug: String, 277 | } 278 | 279 | #[derive(serde::Deserialize, Debug, Clone)] 280 | pub(crate) struct RepoTeam { 281 | pub(crate) name: String, 282 | pub(crate) permission: RepoPermission, 283 | } 284 | 285 | #[derive(serde::Deserialize, Clone)] 286 | pub(crate) struct RepoUser { 287 | #[serde(alias = "login")] 288 | pub(crate) name: String, 289 | #[serde(rename = "role_name")] 290 | pub(crate) permission: RepoPermission, 291 | } 292 | 293 | #[derive(Copy, Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq)] 294 | #[serde(rename_all = "snake_case")] 295 | pub(crate) enum RepoPermission { 296 | // While the GitHub UI uses the term 'write', the API still uses the older term 'push' 297 | #[serde(rename(serialize = "push"), alias = "push")] 298 | Write, 299 | Admin, 300 | Maintain, 301 | Triage, 302 | #[serde(alias = "pull")] 303 | Read, 304 | } 305 | 306 | impl fmt::Display for RepoPermission { 307 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 308 | match self { 309 | Self::Write => write!(f, "write"), 310 | Self::Admin => write!(f, "admin"), 311 | Self::Maintain => write!(f, "maintain"), 312 | Self::Triage => write!(f, "triage"), 313 | Self::Read => write!(f, "read"), 314 | } 315 | } 316 | } 317 | 318 | #[derive(serde::Deserialize, Debug, Clone)] 319 | pub(crate) struct Repo { 320 | pub(crate) node_id: String, 321 | pub(crate) name: String, 322 | #[serde(alias = "owner", deserialize_with = "repo_owner")] 323 | pub(crate) org: String, 324 | #[serde(deserialize_with = "repo_description")] 325 | pub(crate) description: String, 326 | pub(crate) homepage: Option, 327 | pub(crate) archived: bool, 328 | #[serde(default)] 329 | pub(crate) allow_auto_merge: Option, 330 | } 331 | 332 | fn repo_owner<'de, D>(deserializer: D) -> Result 333 | where 334 | D: serde::de::Deserializer<'de>, 335 | { 336 | let owner = Login::deserialize(deserializer)?; 337 | Ok(owner.login) 338 | } 339 | 340 | /// We represent repository description with just a string, 341 | /// to avoid two default states (`None` or `Some("")`) and to simplify code. 342 | /// However, GitHub can return the description as `null`. 343 | /// So using this function, we treat both an empty string and `null` as an 344 | /// empty string. 345 | fn repo_description<'de, D>(deserializer: D) -> Result 346 | where 347 | D: serde::de::Deserializer<'de>, 348 | { 349 | let description = >::deserialize(deserializer)?; 350 | Ok(description.unwrap_or_default()) 351 | } 352 | 353 | /// An object with a `login` field 354 | #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] 355 | pub(crate) struct Login { 356 | pub(crate) login: String, 357 | } 358 | 359 | #[derive(serde::Serialize, serde::Deserialize, Debug, Eq, PartialEq, Copy, Clone)] 360 | #[serde(rename_all = "snake_case")] 361 | pub(crate) enum TeamPrivacy { 362 | Closed, 363 | Secret, 364 | } 365 | 366 | #[derive(serde::Serialize, serde::Deserialize, Debug, Eq, PartialEq, Copy, Clone)] 367 | #[serde(rename_all(serialize = "snake_case", deserialize = "SCREAMING_SNAKE_CASE"))] 368 | pub(crate) enum TeamRole { 369 | Member, 370 | Maintainer, 371 | } 372 | 373 | impl fmt::Display for TeamRole { 374 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 375 | match self { 376 | TeamRole::Member => write!(f, "member"), 377 | TeamRole::Maintainer => write!(f, "maintainer"), 378 | } 379 | } 380 | } 381 | 382 | #[derive(Debug, Clone)] 383 | pub(crate) struct TeamMember { 384 | pub(crate) username: String, 385 | pub(crate) role: TeamRole, 386 | } 387 | 388 | fn user_node_id(id: u64) -> String { 389 | BASE64_STANDARD.encode(format!("04:User{id}")) 390 | } 391 | 392 | fn team_node_id(id: u64) -> String { 393 | BASE64_STANDARD.encode(format!("04:Team{id}")) 394 | } 395 | 396 | #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] 397 | #[serde(rename_all = "camelCase")] 398 | pub(crate) struct BranchProtection { 399 | pub(crate) pattern: String, 400 | pub(crate) is_admin_enforced: bool, 401 | pub(crate) dismisses_stale_reviews: bool, 402 | #[serde(default, deserialize_with = "nullable")] 403 | pub(crate) required_approving_review_count: u8, 404 | #[serde(default, deserialize_with = "nullable")] 405 | pub(crate) required_status_check_contexts: Vec, 406 | #[serde(deserialize_with = "allowances")] 407 | pub(crate) push_allowances: Vec, 408 | pub(crate) requires_approving_reviews: bool, 409 | } 410 | 411 | fn nullable<'de, D, T>(deserializer: D) -> Result 412 | where 413 | D: serde::de::Deserializer<'de>, 414 | T: Default + DeserializeOwned, 415 | { 416 | let opt = Option::deserialize(deserializer)?; 417 | Ok(opt.unwrap_or_default()) 418 | } 419 | 420 | fn allowances<'de, D>(deserializer: D) -> Result, D::Error> 421 | where 422 | D: serde::de::Deserializer<'de>, 423 | { 424 | #[derive(Deserialize)] 425 | struct Allowances { 426 | nodes: Vec, 427 | } 428 | #[derive(Deserialize)] 429 | struct Actor { 430 | actor: PushAllowanceActor, 431 | } 432 | let allowances = Allowances::deserialize(deserializer)?; 433 | Ok(allowances.nodes.into_iter().map(|a| a.actor).collect()) 434 | } 435 | 436 | /// Entities that can be allowed to push to a branch in a repo 437 | #[derive(Clone, Deserialize, Debug, PartialEq, Eq)] 438 | #[serde(untagged)] 439 | pub(crate) enum PushAllowanceActor { 440 | User(UserPushAllowanceActor), 441 | Team(TeamPushAllowanceActor), 442 | } 443 | 444 | /// User who can be allowed to push to a branch in a repo 445 | #[derive(Clone, Deserialize, Debug, PartialEq, Eq)] 446 | pub(crate) struct UserPushAllowanceActor { 447 | pub(crate) login: String, 448 | } 449 | 450 | /// Team that can be allowed to push to a branch in a repo 451 | #[derive(Clone, Deserialize, Debug, PartialEq, Eq)] 452 | pub(crate) struct TeamPushAllowanceActor { 453 | pub(crate) organization: Login, 454 | pub(crate) name: String, 455 | } 456 | 457 | pub(crate) enum BranchProtectionOp { 458 | CreateForRepo(String), 459 | UpdateBranchProtection(String), 460 | } 461 | 462 | #[derive(PartialEq, Debug)] 463 | pub(crate) struct RepoSettings { 464 | pub description: String, 465 | pub homepage: Option, 466 | pub archived: bool, 467 | pub auto_merge_enabled: bool, 468 | } 469 | -------------------------------------------------------------------------------- /src/github/api/read.rs: -------------------------------------------------------------------------------- 1 | use crate::github::api::{ 2 | BranchProtection, GraphNode, GraphNodes, GraphPageInfo, HttpClient, Login, Repo, RepoTeam, 3 | RepoUser, Team, TeamMember, TeamRole, team_node_id, url::GitHubUrl, user_node_id, 4 | }; 5 | use anyhow::Context as _; 6 | use reqwest::Method; 7 | use std::collections::{HashMap, HashSet}; 8 | 9 | pub(crate) trait GithubRead { 10 | /// Get user names by user ids 11 | fn usernames(&self, ids: &[u64]) -> anyhow::Result>; 12 | 13 | /// Get the owners of an org 14 | fn org_owners(&self, org: &str) -> anyhow::Result>; 15 | 16 | /// Get all teams associated with a org 17 | /// 18 | /// Returns a list of tuples of team name and slug 19 | fn org_teams(&self, org: &str) -> anyhow::Result>; 20 | 21 | /// Get the team by name and org 22 | fn team(&self, org: &str, team: &str) -> anyhow::Result>; 23 | 24 | fn team_memberships(&self, team: &Team, org: &str) -> anyhow::Result>; 25 | 26 | /// The GitHub names of users invited to the given team 27 | fn team_membership_invitations(&self, org: &str, team: &str) 28 | -> anyhow::Result>; 29 | 30 | /// Get a repo by org and name 31 | fn repo(&self, org: &str, repo: &str) -> anyhow::Result>; 32 | 33 | /// Get teams in a repo 34 | fn repo_teams(&self, org: &str, repo: &str) -> anyhow::Result>; 35 | 36 | /// Get collaborators in a repo 37 | /// 38 | /// Only fetches those who are direct collaborators (i.e., not a collaborator through a repo team) 39 | fn repo_collaborators(&self, org: &str, repo: &str) -> anyhow::Result>; 40 | 41 | /// Get branch_protections 42 | /// Returns a map branch pattern -> (protection ID, protection data) 43 | fn branch_protections( 44 | &self, 45 | org: &str, 46 | repo: &str, 47 | ) -> anyhow::Result>; 48 | } 49 | 50 | pub(crate) struct GitHubApiRead { 51 | client: HttpClient, 52 | } 53 | 54 | impl GitHubApiRead { 55 | pub(crate) fn from_client(client: HttpClient) -> anyhow::Result { 56 | Ok(Self { client }) 57 | } 58 | } 59 | 60 | impl GithubRead for GitHubApiRead { 61 | fn usernames(&self, ids: &[u64]) -> anyhow::Result> { 62 | #[derive(serde::Deserialize)] 63 | #[serde(rename_all = "camelCase")] 64 | struct Usernames { 65 | database_id: u64, 66 | login: String, 67 | } 68 | #[derive(serde::Serialize)] 69 | struct Params { 70 | ids: Vec, 71 | } 72 | static QUERY: &str = " 73 | query($ids: [ID!]!) { 74 | nodes(ids: $ids) { 75 | ... on User { 76 | databaseId 77 | login 78 | } 79 | } 80 | } 81 | "; 82 | 83 | let mut result = HashMap::new(); 84 | for chunk in ids.chunks(100) { 85 | let res: GraphNodes = self.client.graphql( 86 | QUERY, 87 | Params { 88 | ids: chunk.iter().map(|id| user_node_id(*id)).collect(), 89 | }, 90 | "rust-lang", // any of our orgs will work for this query 91 | )?; 92 | for node in res.nodes.into_iter().flatten() { 93 | result.insert(node.database_id, node.login); 94 | } 95 | } 96 | Ok(result) 97 | } 98 | 99 | fn org_owners(&self, org: &str) -> anyhow::Result> { 100 | #[derive(serde::Deserialize, Eq, PartialEq, Hash)] 101 | struct User { 102 | id: u64, 103 | } 104 | let mut owners = HashSet::new(); 105 | self.client.rest_paginated( 106 | &Method::GET, 107 | &GitHubUrl::orgs(org, "members?role=admin")?, 108 | |resp: Vec| { 109 | owners.extend(resp.into_iter().map(|u| u.id)); 110 | Ok(()) 111 | }, 112 | )?; 113 | Ok(owners) 114 | } 115 | 116 | fn org_teams(&self, org: &str) -> anyhow::Result> { 117 | let mut teams = Vec::new(); 118 | 119 | self.client.rest_paginated( 120 | &Method::GET, 121 | &GitHubUrl::orgs(org, "teams")?, 122 | |resp: Vec| { 123 | teams.extend(resp.into_iter().map(|t| (t.name, t.slug))); 124 | Ok(()) 125 | }, 126 | )?; 127 | 128 | Ok(teams) 129 | } 130 | 131 | fn team(&self, org: &str, team: &str) -> anyhow::Result> { 132 | self.client.send_option( 133 | Method::GET, 134 | &GitHubUrl::orgs(org, &format!("teams/{team}"))?, 135 | ) 136 | } 137 | 138 | fn team_memberships(&self, team: &Team, org: &str) -> anyhow::Result> { 139 | #[derive(serde::Deserialize)] 140 | struct RespTeam { 141 | members: RespMembers, 142 | } 143 | #[derive(serde::Deserialize)] 144 | #[serde(rename_all = "camelCase")] 145 | struct RespMembers { 146 | page_info: GraphPageInfo, 147 | edges: Vec, 148 | } 149 | #[derive(serde::Deserialize)] 150 | struct RespEdge { 151 | role: TeamRole, 152 | node: RespNode, 153 | } 154 | #[derive(serde::Deserialize)] 155 | #[serde(rename_all = "camelCase")] 156 | struct RespNode { 157 | database_id: u64, 158 | login: String, 159 | } 160 | #[derive(serde::Serialize)] 161 | struct Params<'a> { 162 | team: String, 163 | cursor: Option<&'a str>, 164 | } 165 | static QUERY: &str = " 166 | query($team: ID!, $cursor: String) { 167 | node(id: $team) { 168 | ... on Team { 169 | members(after: $cursor) { 170 | pageInfo { 171 | endCursor 172 | hasNextPage 173 | } 174 | edges { 175 | role 176 | node { 177 | databaseId 178 | login 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | "; 186 | 187 | let mut memberships = HashMap::new(); 188 | // Return the empty HashMap on new teams from dry runs 189 | if let Some(id) = team.id { 190 | let mut page_info = GraphPageInfo::start(); 191 | while page_info.has_next_page { 192 | let res: GraphNode = self.client.graphql( 193 | QUERY, 194 | Params { 195 | team: team_node_id(id), 196 | cursor: page_info.end_cursor.as_deref(), 197 | }, 198 | org, 199 | )?; 200 | if let Some(team) = res.node { 201 | page_info = team.members.page_info; 202 | for edge in team.members.edges.into_iter() { 203 | memberships.insert( 204 | edge.node.database_id, 205 | TeamMember { 206 | username: edge.node.login, 207 | role: edge.role, 208 | }, 209 | ); 210 | } 211 | } 212 | } 213 | } 214 | 215 | Ok(memberships) 216 | } 217 | 218 | fn team_membership_invitations( 219 | &self, 220 | org: &str, 221 | team: &str, 222 | ) -> anyhow::Result> { 223 | let mut invites = HashSet::new(); 224 | 225 | self.client.rest_paginated( 226 | &Method::GET, 227 | &GitHubUrl::orgs(org, &format!("teams/{team}/invitations"))?, 228 | |resp: Vec| { 229 | invites.extend(resp.into_iter().map(|l| l.login)); 230 | Ok(()) 231 | }, 232 | )?; 233 | 234 | Ok(invites) 235 | } 236 | 237 | fn repo(&self, org: &str, repo: &str) -> anyhow::Result> { 238 | // We use the GraphQL API instead of REST because of 239 | // this bug: https://github.com/orgs/community/discussions/153258 240 | #[derive(serde::Serialize)] 241 | struct Params<'a> { 242 | owner: &'a str, 243 | name: &'a str, 244 | } 245 | 246 | static QUERY: &str = r#" 247 | query($owner: String!, $name: String!) { 248 | repository(owner: $owner, name: $name) { 249 | id 250 | autoMergeAllowed 251 | description 252 | homepageUrl 253 | isArchived 254 | } 255 | } 256 | "#; 257 | 258 | #[derive(serde::Deserialize)] 259 | struct Wrapper { 260 | repository: Option, 261 | } 262 | 263 | #[derive(serde::Deserialize)] 264 | #[serde(rename_all = "camelCase")] 265 | struct RepoResponse { 266 | // Equivalent of `node_id` of the Rest API 267 | id: String, 268 | // Equivalent of `id` of the Rest API 269 | auto_merge_allowed: Option, 270 | description: Option, 271 | homepage_url: Option, 272 | is_archived: bool, 273 | } 274 | 275 | let result: Option = self 276 | .client 277 | .graphql_opt( 278 | QUERY, 279 | Params { 280 | owner: org, 281 | name: repo, 282 | }, 283 | org, 284 | ) 285 | .with_context(|| format!("failed to retrieve repo `{org}/{repo}`"))?; 286 | 287 | let repo = result.and_then(|r| r.repository).map(|repo_response| Repo { 288 | node_id: repo_response.id, 289 | name: repo.to_string(), 290 | description: repo_response.description.unwrap_or_default(), 291 | allow_auto_merge: repo_response.auto_merge_allowed, 292 | archived: repo_response.is_archived, 293 | homepage: repo_response.homepage_url, 294 | org: org.to_string(), 295 | }); 296 | 297 | Ok(repo) 298 | } 299 | 300 | fn repo_teams(&self, org: &str, repo: &str) -> anyhow::Result> { 301 | let mut teams = Vec::new(); 302 | 303 | self.client.rest_paginated( 304 | &Method::GET, 305 | &GitHubUrl::repos(org, repo, "teams")?, 306 | |resp: Vec| { 307 | teams.extend(resp); 308 | Ok(()) 309 | }, 310 | )?; 311 | 312 | Ok(teams) 313 | } 314 | 315 | fn repo_collaborators(&self, org: &str, repo: &str) -> anyhow::Result> { 316 | let mut users = Vec::new(); 317 | 318 | self.client.rest_paginated( 319 | &Method::GET, 320 | &GitHubUrl::repos(org, repo, "collaborators?affiliation=direct")?, 321 | |resp: Vec| { 322 | users.extend(resp); 323 | Ok(()) 324 | }, 325 | )?; 326 | 327 | Ok(users) 328 | } 329 | 330 | fn branch_protections( 331 | &self, 332 | org: &str, 333 | repo: &str, 334 | ) -> anyhow::Result> { 335 | #[derive(serde::Serialize)] 336 | struct Params<'a> { 337 | org: &'a str, 338 | repo: &'a str, 339 | } 340 | static QUERY: &str = " 341 | query($org:String!,$repo:String!) { 342 | repository(owner:$org, name:$repo) { 343 | branchProtectionRules(first:100) { 344 | nodes { 345 | id, 346 | pattern, 347 | isAdminEnforced, 348 | dismissesStaleReviews, 349 | requiredStatusCheckContexts, 350 | requiredApprovingReviewCount, 351 | requiresApprovingReviews 352 | pushAllowances(first: 100) { 353 | nodes { 354 | actor { 355 | ... on Actor { 356 | login 357 | } 358 | ... on Team { 359 | organization { 360 | login 361 | }, 362 | name 363 | } 364 | } 365 | } 366 | } 367 | } 368 | } 369 | } 370 | } 371 | "; 372 | 373 | #[derive(serde::Deserialize)] 374 | struct Wrapper { 375 | repository: Repository, 376 | } 377 | #[derive(serde::Deserialize)] 378 | #[serde(rename_all = "camelCase")] 379 | struct Repository { 380 | branch_protection_rules: GraphNodes, 381 | } 382 | #[derive(serde::Deserialize)] 383 | #[serde(rename_all = "camelCase")] 384 | struct BranchProtectionWrapper { 385 | id: String, 386 | #[serde(flatten)] 387 | protection: BranchProtection, 388 | } 389 | 390 | let mut result = HashMap::new(); 391 | let res: Wrapper = self.client.graphql(QUERY, Params { org, repo }, org)?; 392 | for mut node in res 393 | .repository 394 | .branch_protection_rules 395 | .nodes 396 | .into_iter() 397 | .flatten() 398 | { 399 | // Normalize check order to avoid diffs based only on the ordering difference 400 | node.protection.required_status_check_contexts.sort(); 401 | result.insert(node.protection.pattern.clone(), (node.id, node.protection)); 402 | } 403 | Ok(result) 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /src/github/api/tokens.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Context as _; 4 | use secrecy::SecretString; 5 | 6 | #[derive(Clone)] 7 | pub enum GitHubTokens { 8 | /// One token per organization (used with GitHub App). 9 | Orgs(HashMap), 10 | /// One token for all API calls (used with Personal Access Token). 11 | Pat(SecretString), 12 | } 13 | 14 | impl GitHubTokens { 15 | /// Returns a HashMap of GitHub organization names mapped to their API tokens. 16 | /// 17 | /// Parses environment variables in the format GITHUB_TOKEN_{ORG_NAME} 18 | /// to retrieve GitHub tokens. 19 | pub fn from_env() -> anyhow::Result { 20 | let mut tokens = HashMap::new(); 21 | 22 | for (key, value) in std::env::vars() { 23 | if let Some(org_name) = org_name_from_env_var(&key) { 24 | tokens.insert(org_name, SecretString::from(value)); 25 | } 26 | } 27 | 28 | if tokens.is_empty() { 29 | let pat_token = std::env::var("GITHUB_TOKEN") 30 | .context("failed to get any GitHub token environment variable")?; 31 | Ok(GitHubTokens::Pat(SecretString::from(pat_token))) 32 | } else { 33 | Ok(GitHubTokens::Orgs(tokens)) 34 | } 35 | } 36 | 37 | /// Get a token for a GitHub organization. 38 | /// Return an error if not present. 39 | pub fn get_token(&self, org: &str) -> anyhow::Result<&SecretString> { 40 | match self { 41 | GitHubTokens::Orgs(orgs) => orgs.get(org).with_context(|| { 42 | format!( 43 | "failed to get the GitHub token environment variable for organization {org}" 44 | ) 45 | }), 46 | GitHubTokens::Pat(pat) => Ok(pat), 47 | } 48 | } 49 | } 50 | 51 | fn org_name_from_env_var(env_var: &str) -> Option { 52 | env_var.strip_prefix("GITHUB_TOKEN_").map(|org| { 53 | // GitHub environment variables can't contain `-`, while GitHub organizations 54 | // can't contain `_`. 55 | // Here we are retrieving the org name from the environment variable, so we replace `_` with `-`. 56 | // E.g. the token for the `rust-lang` organization, is stored 57 | // in the `GITHUB_TOKEN_RUST_LANG` environment variable. 58 | org.to_lowercase().replace('_', "-") 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /src/github/api/url.rs: -------------------------------------------------------------------------------- 1 | /// A URL to a GitHub API endpoint. 2 | /// When using a GitHub App instead of a PAT, the token depends on the organization. 3 | /// So storing the token together with the URL is convenient. 4 | #[derive(Clone)] 5 | pub struct GitHubUrl { 6 | url: String, 7 | org: String, 8 | } 9 | 10 | impl GitHubUrl { 11 | pub fn new(url: &str, org: &str) -> Self { 12 | let https = "https://"; 13 | let url = if url.starts_with(https) { 14 | url.to_string() 15 | } else { 16 | format!("{https}api.github.com/{url}") 17 | }; 18 | Self { 19 | url, 20 | org: org.to_string(), 21 | } 22 | } 23 | 24 | pub fn repos(org: &str, repo: &str, remaining_endpoint: &str) -> anyhow::Result { 25 | let remaining_endpoint = if remaining_endpoint.is_empty() { 26 | "".to_string() 27 | } else { 28 | validate_remaining_endpoint(remaining_endpoint)?; 29 | format!("/{remaining_endpoint}") 30 | }; 31 | let url = format!("repos/{org}/{repo}{remaining_endpoint}"); 32 | Ok(Self::new(&url, org)) 33 | } 34 | 35 | pub fn orgs(org: &str, remaining_endpoint: &str) -> anyhow::Result { 36 | validate_remaining_endpoint(remaining_endpoint)?; 37 | let url = format!("orgs/{org}/{remaining_endpoint}"); 38 | Ok(Self::new(&url, org)) 39 | } 40 | 41 | pub fn url(&self) -> &str { 42 | &self.url 43 | } 44 | 45 | pub fn org(&self) -> &str { 46 | &self.org 47 | } 48 | } 49 | 50 | fn validate_remaining_endpoint(endpoint: &str) -> anyhow::Result<()> { 51 | anyhow::ensure!( 52 | !endpoint.starts_with('/'), 53 | "remaining endpoint {endpoint} should not start with a slash" 54 | ); 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /src/github/api/write.rs: -------------------------------------------------------------------------------- 1 | use log::debug; 2 | use reqwest::Method; 3 | 4 | use crate::github::api::url::GitHubUrl; 5 | use crate::github::api::{ 6 | BranchProtection, BranchProtectionOp, HttpClient, Login, PushAllowanceActor, Repo, 7 | RepoPermission, RepoSettings, Team, TeamPrivacy, TeamPushAllowanceActor, TeamRole, 8 | UserPushAllowanceActor, allow_not_found, 9 | }; 10 | use crate::utils::ResponseExt; 11 | 12 | pub(crate) struct GitHubWrite { 13 | client: HttpClient, 14 | dry_run: bool, 15 | } 16 | 17 | impl GitHubWrite { 18 | pub(crate) fn new(client: HttpClient, dry_run: bool) -> anyhow::Result { 19 | Ok(Self { 20 | client: client.clone(), 21 | dry_run, 22 | }) 23 | } 24 | 25 | fn user_id(&self, name: &str, org: &str) -> anyhow::Result { 26 | #[derive(serde::Serialize)] 27 | struct Params<'a> { 28 | name: &'a str, 29 | } 30 | let query = " 31 | query($name: String!) { 32 | user(login: $name) { 33 | id 34 | } 35 | } 36 | "; 37 | #[derive(serde::Deserialize)] 38 | struct Data { 39 | user: User, 40 | } 41 | #[derive(serde::Deserialize)] 42 | struct User { 43 | id: String, 44 | } 45 | 46 | let data: Data = self.client.graphql(query, Params { name }, org)?; 47 | Ok(data.user.id) 48 | } 49 | 50 | fn team_id(&self, org: &str, name: &str) -> anyhow::Result { 51 | #[derive(serde::Serialize)] 52 | struct Params<'a> { 53 | org: &'a str, 54 | team: &'a str, 55 | } 56 | let query = " 57 | query($org: String!, $team: String!) { 58 | organization(login: $org) { 59 | team(slug: $team) { 60 | id 61 | } 62 | } 63 | } 64 | "; 65 | #[derive(serde::Deserialize)] 66 | struct Data { 67 | organization: Organization, 68 | } 69 | #[derive(serde::Deserialize)] 70 | struct Organization { 71 | team: Team, 72 | } 73 | #[derive(serde::Deserialize)] 74 | struct Team { 75 | id: String, 76 | } 77 | 78 | let data: Data = self 79 | .client 80 | .graphql(query, Params { org, team: name }, org)?; 81 | Ok(data.organization.team.id) 82 | } 83 | 84 | /// Create a team in a org 85 | pub(crate) fn create_team( 86 | &self, 87 | org: &str, 88 | name: &str, 89 | description: &str, 90 | privacy: TeamPrivacy, 91 | ) -> anyhow::Result { 92 | #[derive(serde::Serialize, Debug)] 93 | struct Req<'a> { 94 | name: &'a str, 95 | description: &'a str, 96 | privacy: TeamPrivacy, 97 | } 98 | debug!("Creating team '{name}' in '{org}'"); 99 | if self.dry_run { 100 | Ok(Team { 101 | // The `None` marks that the team is "created" by the dry run and 102 | // doesn't actually exist on GitHub 103 | id: None, 104 | name: name.to_string(), 105 | description: Some(description.to_string()), 106 | privacy, 107 | slug: name.to_string(), 108 | }) 109 | } else { 110 | let body = &Req { 111 | name, 112 | description, 113 | privacy, 114 | }; 115 | Ok(self 116 | .client 117 | .send(Method::POST, &GitHubUrl::orgs(org, "teams")?, body)? 118 | .json_annotated()?) 119 | } 120 | } 121 | 122 | /// Edit a team 123 | pub(crate) fn edit_team( 124 | &self, 125 | org: &str, 126 | name: &str, 127 | new_name: Option<&str>, 128 | new_description: Option<&str>, 129 | new_privacy: Option, 130 | ) -> anyhow::Result<()> { 131 | #[derive(serde::Serialize, Debug)] 132 | struct Req<'a> { 133 | #[serde(skip_serializing_if = "Option::is_none")] 134 | name: Option<&'a str>, 135 | #[serde(skip_serializing_if = "Option::is_none")] 136 | description: Option<&'a str>, 137 | #[serde(skip_serializing_if = "Option::is_none")] 138 | privacy: Option, 139 | } 140 | let req = Req { 141 | name: new_name, 142 | description: new_description, 143 | privacy: new_privacy, 144 | }; 145 | debug!( 146 | "Editing team '{name}' in '{org}' with request: {}", 147 | serde_json::to_string(&req).unwrap_or_else(|_| "INVALID_REQUEST".to_string()) 148 | ); 149 | if !self.dry_run { 150 | self.client.send( 151 | Method::PATCH, 152 | &GitHubUrl::orgs(org, &format!("teams/{name}"))?, 153 | &req, 154 | )?; 155 | } 156 | 157 | Ok(()) 158 | } 159 | 160 | /// Delete a team by name and org 161 | pub(crate) fn delete_team(&self, org: &str, slug: &str) -> anyhow::Result<()> { 162 | debug!("Deleting team with slug '{slug}' in '{org}'"); 163 | if !self.dry_run { 164 | let method = Method::DELETE; 165 | let url = GitHubUrl::orgs(org, &format!("teams/{slug}"))?; 166 | let resp = self.client.req(method.clone(), &url)?.send()?; 167 | allow_not_found(resp, method, url.url())?; 168 | } 169 | Ok(()) 170 | } 171 | 172 | /// Set a user's membership in a team to a role 173 | pub(crate) fn set_team_membership( 174 | &self, 175 | org: &str, 176 | team: &str, 177 | user: &str, 178 | role: TeamRole, 179 | ) -> anyhow::Result<()> { 180 | debug!("Setting membership of '{user}' in team '{team}' in org '{org}' to role '{role}'"); 181 | #[derive(serde::Serialize, Debug)] 182 | struct Req { 183 | role: TeamRole, 184 | } 185 | if !self.dry_run { 186 | self.client.send( 187 | Method::PUT, 188 | &GitHubUrl::orgs(org, &format!("teams/{team}/memberships/{user}"))?, 189 | &Req { role }, 190 | )?; 191 | } 192 | 193 | Ok(()) 194 | } 195 | 196 | /// Remove a user from a team 197 | pub(crate) fn remove_team_membership( 198 | &self, 199 | org: &str, 200 | team: &str, 201 | user: &str, 202 | ) -> anyhow::Result<()> { 203 | debug!("Removing membership of '{user}' from team '{team}' in org '{org}'"); 204 | if !self.dry_run { 205 | let url = &GitHubUrl::orgs(org, &format!("teams/{team}/memberships/{user}"))?; 206 | let method = Method::DELETE; 207 | let resp = self.client.req(method.clone(), url)?.send()?; 208 | allow_not_found(resp, method, url.url())?; 209 | } 210 | 211 | Ok(()) 212 | } 213 | 214 | /// Create a repo 215 | pub(crate) fn create_repo( 216 | &self, 217 | org: &str, 218 | name: &str, 219 | settings: &RepoSettings, 220 | ) -> anyhow::Result { 221 | #[derive(serde::Serialize, Debug)] 222 | struct Req<'a> { 223 | name: &'a str, 224 | description: &'a str, 225 | homepage: &'a Option<&'a str>, 226 | auto_init: bool, 227 | allow_auto_merge: bool, 228 | } 229 | let req = &Req { 230 | name, 231 | description: &settings.description, 232 | homepage: &settings.homepage.as_deref(), 233 | auto_init: true, 234 | allow_auto_merge: settings.auto_merge_enabled, 235 | }; 236 | debug!("Creating the repo {org}/{name} with {req:?}"); 237 | if self.dry_run { 238 | Ok(Repo { 239 | node_id: String::from("ID"), 240 | name: name.to_string(), 241 | org: org.to_string(), 242 | description: settings.description.clone(), 243 | homepage: settings.homepage.clone(), 244 | archived: false, 245 | allow_auto_merge: Some(settings.auto_merge_enabled), 246 | }) 247 | } else { 248 | Ok(self 249 | .client 250 | .send(Method::POST, &GitHubUrl::orgs(org, "repos")?, req)? 251 | .json_annotated()?) 252 | } 253 | } 254 | 255 | pub(crate) fn edit_repo( 256 | &self, 257 | org: &str, 258 | repo_name: &str, 259 | settings: &RepoSettings, 260 | ) -> anyhow::Result<()> { 261 | #[derive(serde::Serialize, Debug)] 262 | struct Req<'a> { 263 | description: &'a str, 264 | homepage: &'a Option<&'a str>, 265 | archived: bool, 266 | allow_auto_merge: bool, 267 | } 268 | let req = Req { 269 | description: &settings.description, 270 | homepage: &settings.homepage.as_deref(), 271 | archived: settings.archived, 272 | allow_auto_merge: settings.auto_merge_enabled, 273 | }; 274 | debug!("Editing repo {}/{} with {:?}", org, repo_name, req); 275 | if !self.dry_run { 276 | self.client 277 | .send(Method::PATCH, &GitHubUrl::repos(org, repo_name, "")?, &req)?; 278 | } 279 | Ok(()) 280 | } 281 | 282 | /// Update a team's permissions to a repo 283 | pub(crate) fn update_team_repo_permissions( 284 | &self, 285 | org: &str, 286 | repo: &str, 287 | team: &str, 288 | permission: &RepoPermission, 289 | ) -> anyhow::Result<()> { 290 | #[derive(serde::Serialize, Debug)] 291 | struct Req<'a> { 292 | permission: &'a RepoPermission, 293 | } 294 | debug!("Updating permission for team {team} on {org}/{repo} to {permission:?}"); 295 | if !self.dry_run { 296 | self.client.send( 297 | Method::PUT, 298 | &GitHubUrl::orgs(org, &format!("teams/{team}/repos/{org}/{repo}"))?, 299 | &Req { permission }, 300 | )?; 301 | } 302 | 303 | Ok(()) 304 | } 305 | 306 | /// Update a user's permissions to a repo 307 | pub(crate) fn update_user_repo_permissions( 308 | &self, 309 | org: &str, 310 | repo: &str, 311 | user: &str, 312 | permission: &RepoPermission, 313 | ) -> anyhow::Result<()> { 314 | #[derive(serde::Serialize, Debug)] 315 | struct Req<'a> { 316 | permission: &'a RepoPermission, 317 | } 318 | debug!("Updating permission for user {user} on {org}/{repo} to {permission:?}"); 319 | if !self.dry_run { 320 | self.client.send( 321 | Method::PUT, 322 | &GitHubUrl::repos(org, repo, &format!("collaborators/{user}"))?, 323 | &Req { permission }, 324 | )?; 325 | } 326 | Ok(()) 327 | } 328 | 329 | /// Remove a team from a repo 330 | pub(crate) fn remove_team_from_repo( 331 | &self, 332 | org: &str, 333 | repo: &str, 334 | team: &str, 335 | ) -> anyhow::Result<()> { 336 | debug!("Removing team {team} from repo {org}/{repo}"); 337 | if !self.dry_run { 338 | let method = Method::DELETE; 339 | let url = GitHubUrl::orgs(org, &format!("teams/{team}/repos/{org}/{repo}"))?; 340 | let resp = self.client.req(method.clone(), &url)?.send()?; 341 | allow_not_found(resp, method, url.url())?; 342 | } 343 | 344 | Ok(()) 345 | } 346 | 347 | /// Remove a collaborator from a repo 348 | pub(crate) fn remove_collaborator_from_repo( 349 | &self, 350 | org: &str, 351 | repo: &str, 352 | collaborator: &str, 353 | ) -> anyhow::Result<()> { 354 | debug!("Removing collaborator {collaborator} from repo {org}/{repo}"); 355 | if !self.dry_run { 356 | let method = Method::DELETE; 357 | let url = &GitHubUrl::repos(org, repo, &format!("collaborators/{collaborator}"))?; 358 | let resp = self.client.req(method.clone(), url)?.send()?; 359 | allow_not_found(resp, method, url.url())?; 360 | } 361 | Ok(()) 362 | } 363 | 364 | /// Create or update a branch protection. 365 | pub(crate) fn upsert_branch_protection( 366 | &self, 367 | op: BranchProtectionOp, 368 | pattern: &str, 369 | branch_protection: &BranchProtection, 370 | org: &str, 371 | ) -> anyhow::Result<()> { 372 | debug!("Updating '{}' branch protection", pattern); 373 | #[derive(Debug, serde::Serialize)] 374 | #[serde(rename_all = "camelCase")] 375 | struct Params<'a> { 376 | id: &'a str, 377 | pattern: &'a str, 378 | contexts: &'a [String], 379 | dismiss_stale: bool, 380 | review_count: u8, 381 | restricts_pushes: bool, 382 | // Is a PR required to push into this branch? 383 | requires_approving_reviews: bool, 384 | push_actor_ids: &'a [String], 385 | } 386 | let mutation_name = match op { 387 | BranchProtectionOp::CreateForRepo(_) => "createBranchProtectionRule", 388 | BranchProtectionOp::UpdateBranchProtection(_) => "updateBranchProtectionRule", 389 | }; 390 | let id_field = match op { 391 | BranchProtectionOp::CreateForRepo(_) => "repositoryId", 392 | BranchProtectionOp::UpdateBranchProtection(_) => "branchProtectionRuleId", 393 | }; 394 | let id = &match op { 395 | BranchProtectionOp::CreateForRepo(id) => id, 396 | BranchProtectionOp::UpdateBranchProtection(id) => id, 397 | }; 398 | let query = format!(" 399 | mutation($id: ID!, $pattern:String!, $contexts: [String!], $dismissStale: Boolean, $reviewCount: Int, $pushActorIds: [ID!], $restrictsPushes: Boolean, $requiresApprovingReviews: Boolean) {{ 400 | {mutation_name}(input: {{ 401 | {id_field}: $id, 402 | pattern: $pattern, 403 | requiresStatusChecks: true, 404 | requiredStatusCheckContexts: $contexts, 405 | # Disable 'Require branch to be up-to-date before merging' 406 | requiresStrictStatusChecks: false, 407 | isAdminEnforced: true, 408 | requiredApprovingReviewCount: $reviewCount, 409 | dismissesStaleReviews: $dismissStale, 410 | requiresApprovingReviews: $requiresApprovingReviews, 411 | restrictsPushes: $restrictsPushes, 412 | pushActorIds: $pushActorIds 413 | }}) {{ 414 | branchProtectionRule {{ 415 | id 416 | }} 417 | }} 418 | }} 419 | "); 420 | let mut push_actor_ids = vec![]; 421 | for actor in &branch_protection.push_allowances { 422 | match actor { 423 | PushAllowanceActor::User(UserPushAllowanceActor { login: name }) => { 424 | push_actor_ids.push(self.user_id(name, org)?); 425 | } 426 | PushAllowanceActor::Team(TeamPushAllowanceActor { 427 | organization: Login { login: org }, 428 | name, 429 | }) => push_actor_ids.push(self.team_id(org, name)?), 430 | } 431 | } 432 | 433 | if !self.dry_run { 434 | let _: serde_json::Value = self.client.graphql( 435 | &query, 436 | Params { 437 | id, 438 | pattern, 439 | contexts: &branch_protection.required_status_check_contexts, 440 | dismiss_stale: branch_protection.dismisses_stale_reviews, 441 | review_count: branch_protection.required_approving_review_count, 442 | // We restrict merges, if we have explicitly set some actors to be 443 | // able to merge (i.e., we allow allow those with write permissions 444 | // to merge *or* we only allow those in `push_actor_ids`) 445 | restricts_pushes: !push_actor_ids.is_empty(), 446 | push_actor_ids: &push_actor_ids, 447 | requires_approving_reviews: branch_protection.requires_approving_reviews, 448 | }, 449 | org, 450 | )?; 451 | } 452 | Ok(()) 453 | } 454 | 455 | /// Delete a branch protection 456 | pub(crate) fn delete_branch_protection( 457 | &self, 458 | org: &str, 459 | repo_name: &str, 460 | id: &str, 461 | ) -> anyhow::Result<()> { 462 | debug!("Removing protection in {}/{}", org, repo_name); 463 | println!("Remove protection {id}"); 464 | if !self.dry_run { 465 | #[derive(serde::Serialize)] 466 | #[serde(rename_all = "camelCase")] 467 | struct Params<'a> { 468 | id: &'a str, 469 | } 470 | let query = " 471 | mutation($id: ID!) { 472 | deleteBranchProtectionRule(input: { branchProtectionRuleId: $id }) { 473 | clientMutationId 474 | } 475 | } 476 | "; 477 | let _: serde_json::Value = self.client.graphql(query, Params { id }, org)?; 478 | } 479 | Ok(()) 480 | } 481 | } 482 | -------------------------------------------------------------------------------- /src/github/mod.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | #[cfg(test)] 3 | mod tests; 4 | 5 | use self::api::{BranchProtectionOp, TeamPrivacy, TeamRole}; 6 | use crate::github::api::{GithubRead, Login, PushAllowanceActor, RepoPermission, RepoSettings}; 7 | use log::debug; 8 | use rust_team_data::v1::{Bot, BranchProtectionMode, MergeBot}; 9 | use std::collections::{HashMap, HashSet}; 10 | use std::fmt::Write; 11 | 12 | pub(crate) use self::api::{GitHubApiRead, GitHubWrite, HttpClient}; 13 | 14 | static DEFAULT_DESCRIPTION: &str = "Managed by the rust-lang/team repository."; 15 | static DEFAULT_PRIVACY: TeamPrivacy = TeamPrivacy::Closed; 16 | 17 | pub(crate) fn create_diff( 18 | github: Box, 19 | teams: Vec, 20 | repos: Vec, 21 | ) -> anyhow::Result { 22 | let github = SyncGitHub::new(github, teams, repos)?; 23 | github.diff_all() 24 | } 25 | 26 | type OrgName = String; 27 | 28 | struct SyncGitHub { 29 | github: Box, 30 | teams: Vec, 31 | repos: Vec, 32 | usernames_cache: HashMap, 33 | org_owners: HashMap>, 34 | } 35 | 36 | impl SyncGitHub { 37 | pub(crate) fn new( 38 | github: Box, 39 | teams: Vec, 40 | repos: Vec, 41 | ) -> anyhow::Result { 42 | debug!("caching mapping between user ids and usernames"); 43 | let users = teams 44 | .iter() 45 | .filter_map(|t| t.github.as_ref().map(|gh| &gh.teams)) 46 | .flatten() 47 | .flat_map(|team| &team.members) 48 | .copied() 49 | .collect::>() 50 | .into_iter() 51 | .collect::>(); 52 | let usernames_cache = github.usernames(&users)?; 53 | 54 | debug!("caching organization owners"); 55 | let orgs = teams 56 | .iter() 57 | .filter_map(|t| t.github.as_ref()) 58 | .flat_map(|gh| &gh.teams) 59 | .map(|gh_team| &gh_team.org) 60 | .collect::>(); 61 | 62 | let mut org_owners = HashMap::new(); 63 | 64 | for org in &orgs { 65 | org_owners.insert((*org).to_string(), github.org_owners(org)?); 66 | } 67 | 68 | Ok(SyncGitHub { 69 | github, 70 | teams, 71 | repos, 72 | usernames_cache, 73 | org_owners, 74 | }) 75 | } 76 | 77 | pub(crate) fn diff_all(&self) -> anyhow::Result { 78 | let team_diffs = self.diff_teams()?; 79 | let repo_diffs = self.diff_repos()?; 80 | 81 | Ok(Diff { 82 | team_diffs, 83 | repo_diffs, 84 | }) 85 | } 86 | 87 | fn diff_teams(&self) -> anyhow::Result> { 88 | let mut diffs = Vec::new(); 89 | let mut unseen_github_teams = HashMap::new(); 90 | for team in &self.teams { 91 | if let Some(gh) = &team.github { 92 | for github_team in &gh.teams { 93 | // Get existing teams we haven't seen yet 94 | let unseen_github_teams = match unseen_github_teams.get_mut(&github_team.org) { 95 | Some(ts) => ts, 96 | None => { 97 | let ts: HashMap<_, _> = self 98 | .github 99 | .org_teams(&github_team.org)? 100 | .into_iter() 101 | .collect(); 102 | unseen_github_teams 103 | .entry(github_team.org.clone()) 104 | .or_insert(ts) 105 | } 106 | }; 107 | // Remove the current team from the collection of unseen GitHub teams 108 | unseen_github_teams.remove(&github_team.name); 109 | 110 | let diff_team = self.diff_team(github_team)?; 111 | if !diff_team.noop() { 112 | diffs.push(diff_team); 113 | } 114 | } 115 | } 116 | } 117 | 118 | let delete_diffs = unseen_github_teams 119 | .into_iter() 120 | .filter(|(org, _)| matches!(org.as_str(), "rust-lang" | "rust-lang-nursery")) // Only delete unmanaged teams in `rust-lang` and `rust-lang-nursery` for now 121 | .flat_map(|(org, remaining_github_teams)| { 122 | remaining_github_teams 123 | .into_iter() 124 | .map(move |t| (org.clone(), t)) 125 | }) 126 | // Don't delete the special bot teams 127 | .filter(|(_, (remaining_github_team, _))| { 128 | !BOTS_TEAMS.contains(&remaining_github_team.as_str()) 129 | }) 130 | .map(|(org, (name, slug))| TeamDiff::Delete(DeleteTeamDiff { org, name, slug })); 131 | 132 | diffs.extend(delete_diffs); 133 | 134 | Ok(diffs) 135 | } 136 | 137 | fn diff_team(&self, github_team: &rust_team_data::v1::GitHubTeam) -> anyhow::Result { 138 | // Ensure the team exists and is consistent 139 | let team = match self.github.team(&github_team.org, &github_team.name)? { 140 | Some(team) => team, 141 | None => { 142 | let members = github_team 143 | .members 144 | .iter() 145 | .map(|member| { 146 | let expected_role = self.expected_role(&github_team.org, *member); 147 | (self.usernames_cache[member].clone(), expected_role) 148 | }) 149 | .collect(); 150 | return Ok(TeamDiff::Create(CreateTeamDiff { 151 | org: github_team.org.clone(), 152 | name: github_team.name.clone(), 153 | description: DEFAULT_DESCRIPTION.to_owned(), 154 | privacy: DEFAULT_PRIVACY, 155 | members, 156 | })); 157 | } 158 | }; 159 | let mut name_diff = None; 160 | if team.name != github_team.name { 161 | name_diff = Some(github_team.name.clone()) 162 | } 163 | let mut description_diff = None; 164 | match &team.description { 165 | Some(description) => { 166 | if description != DEFAULT_DESCRIPTION { 167 | description_diff = Some((description.clone(), DEFAULT_DESCRIPTION.to_owned())); 168 | } 169 | } 170 | None => { 171 | description_diff = Some((String::new(), DEFAULT_DESCRIPTION.to_owned())); 172 | } 173 | } 174 | let mut privacy_diff = None; 175 | if team.privacy != DEFAULT_PRIVACY { 176 | privacy_diff = Some((team.privacy, DEFAULT_PRIVACY)) 177 | } 178 | 179 | let mut member_diffs = Vec::new(); 180 | 181 | let mut current_members = self.github.team_memberships(&team, &github_team.org)?; 182 | let invites = self 183 | .github 184 | .team_membership_invitations(&github_team.org, &github_team.name)?; 185 | 186 | // Ensure all expected members are in the team 187 | for member in &github_team.members { 188 | let expected_role = self.expected_role(&github_team.org, *member); 189 | let username = &self.usernames_cache[member]; 190 | if let Some(member) = current_members.remove(member) { 191 | if member.role != expected_role { 192 | member_diffs.push(( 193 | username.clone(), 194 | MemberDiff::ChangeRole((member.role, expected_role)), 195 | )); 196 | } else { 197 | member_diffs.push((username.clone(), MemberDiff::Noop)); 198 | } 199 | } else { 200 | // Check if the user has been invited already 201 | if invites.contains(username) { 202 | member_diffs.push((username.clone(), MemberDiff::Noop)); 203 | } else { 204 | member_diffs.push((username.clone(), MemberDiff::Create(expected_role))); 205 | } 206 | } 207 | } 208 | 209 | // The previous cycle removed expected members from current_members, so it only contains 210 | // members to delete now. 211 | for member in current_members.values() { 212 | member_diffs.push((member.username.clone(), MemberDiff::Delete)); 213 | } 214 | 215 | Ok(TeamDiff::Edit(EditTeamDiff { 216 | org: github_team.org.clone(), 217 | name: team.name, 218 | name_diff, 219 | description_diff, 220 | privacy_diff, 221 | member_diffs, 222 | })) 223 | } 224 | 225 | fn diff_repos(&self) -> anyhow::Result> { 226 | let mut diffs = Vec::new(); 227 | for repo in &self.repos { 228 | let repo_diff = self.diff_repo(repo)?; 229 | if !repo_diff.noop() { 230 | diffs.push(repo_diff); 231 | } 232 | } 233 | Ok(diffs) 234 | } 235 | 236 | fn diff_repo(&self, expected_repo: &rust_team_data::v1::Repo) -> anyhow::Result { 237 | let actual_repo = match self.github.repo(&expected_repo.org, &expected_repo.name)? { 238 | Some(r) => r, 239 | None => { 240 | let permissions = calculate_permission_diffs( 241 | expected_repo, 242 | Default::default(), 243 | Default::default(), 244 | )?; 245 | let mut branch_protections = Vec::new(); 246 | for branch_protection in &expected_repo.branch_protections { 247 | branch_protections.push(( 248 | branch_protection.pattern.clone(), 249 | construct_branch_protection(expected_repo, branch_protection), 250 | )); 251 | } 252 | 253 | return Ok(RepoDiff::Create(CreateRepoDiff { 254 | org: expected_repo.org.clone(), 255 | name: expected_repo.name.clone(), 256 | settings: RepoSettings { 257 | description: expected_repo.description.clone(), 258 | homepage: expected_repo.homepage.clone(), 259 | archived: false, 260 | auto_merge_enabled: expected_repo.auto_merge_enabled, 261 | }, 262 | permissions, 263 | branch_protections, 264 | })); 265 | } 266 | }; 267 | 268 | let permission_diffs = self.diff_permissions(expected_repo)?; 269 | let branch_protection_diffs = self.diff_branch_protections(&actual_repo, expected_repo)?; 270 | let old_settings = RepoSettings { 271 | description: actual_repo.description.clone(), 272 | homepage: actual_repo.homepage.clone(), 273 | archived: actual_repo.archived, 274 | auto_merge_enabled: actual_repo.allow_auto_merge.unwrap_or(false), 275 | }; 276 | let new_settings = RepoSettings { 277 | description: expected_repo.description.clone(), 278 | homepage: expected_repo.homepage.clone(), 279 | archived: expected_repo.archived, 280 | auto_merge_enabled: expected_repo.auto_merge_enabled, 281 | }; 282 | 283 | Ok(RepoDiff::Update(UpdateRepoDiff { 284 | org: expected_repo.org.clone(), 285 | name: actual_repo.name, 286 | repo_node_id: actual_repo.node_id, 287 | settings_diff: (old_settings, new_settings), 288 | permission_diffs, 289 | branch_protection_diffs, 290 | })) 291 | } 292 | 293 | fn diff_permissions( 294 | &self, 295 | expected_repo: &rust_team_data::v1::Repo, 296 | ) -> anyhow::Result> { 297 | let actual_teams: HashMap<_, _> = self 298 | .github 299 | .repo_teams(&expected_repo.org, &expected_repo.name)? 300 | .into_iter() 301 | .map(|t| (t.name.clone(), t)) 302 | .collect(); 303 | let actual_collaborators: HashMap<_, _> = self 304 | .github 305 | .repo_collaborators(&expected_repo.org, &expected_repo.name)? 306 | .into_iter() 307 | .map(|u| (u.name.clone(), u)) 308 | .collect(); 309 | 310 | calculate_permission_diffs(expected_repo, actual_teams, actual_collaborators) 311 | } 312 | 313 | fn diff_branch_protections( 314 | &self, 315 | actual_repo: &api::Repo, 316 | expected_repo: &rust_team_data::v1::Repo, 317 | ) -> anyhow::Result> { 318 | let mut branch_protection_diffs = Vec::new(); 319 | let mut actual_protections = self 320 | .github 321 | .branch_protections(&actual_repo.org, &actual_repo.name)?; 322 | for branch_protection in &expected_repo.branch_protections { 323 | let actual_branch_protection = actual_protections.remove(&branch_protection.pattern); 324 | let expected_branch_protection = 325 | construct_branch_protection(expected_repo, branch_protection); 326 | let operation = { 327 | match actual_branch_protection { 328 | Some((database_id, bp)) if bp != expected_branch_protection => { 329 | BranchProtectionDiffOperation::Update( 330 | database_id, 331 | bp, 332 | expected_branch_protection, 333 | ) 334 | } 335 | None => BranchProtectionDiffOperation::Create(expected_branch_protection), 336 | // The branch protection doesn't need to change 337 | Some(_) => continue, 338 | } 339 | }; 340 | branch_protection_diffs.push(BranchProtectionDiff { 341 | pattern: branch_protection.pattern.clone(), 342 | operation, 343 | }) 344 | } 345 | 346 | // `actual_branch_protections` now contains the branch protections that were not expected 347 | // but are still on GitHub. We want to delete them. 348 | branch_protection_diffs.extend(actual_protections.into_iter().map(|(name, (id, _))| { 349 | BranchProtectionDiff { 350 | pattern: name, 351 | operation: BranchProtectionDiffOperation::Delete(id), 352 | } 353 | })); 354 | 355 | Ok(branch_protection_diffs) 356 | } 357 | 358 | fn expected_role(&self, org: &str, user: u64) -> TeamRole { 359 | if let Some(true) = self 360 | .org_owners 361 | .get(org) 362 | .map(|owners| owners.contains(&user)) 363 | { 364 | TeamRole::Maintainer 365 | } else { 366 | TeamRole::Member 367 | } 368 | } 369 | } 370 | 371 | fn calculate_permission_diffs( 372 | expected_repo: &rust_team_data::v1::Repo, 373 | mut actual_teams: HashMap, 374 | mut actual_collaborators: HashMap, 375 | ) -> anyhow::Result> { 376 | let mut permissions = Vec::new(); 377 | // Team permissions 378 | for expected_team in &expected_repo.teams { 379 | let permission = convert_permission(&expected_team.permission); 380 | let actual_team = actual_teams.remove(&expected_team.name); 381 | let collaborator = RepoCollaborator::Team(expected_team.name.clone()); 382 | 383 | let diff = match actual_team { 384 | Some(t) if t.permission != permission => RepoPermissionAssignmentDiff { 385 | collaborator, 386 | diff: RepoPermissionDiff::Update(t.permission, permission), 387 | }, 388 | // Team permission does not need to change 389 | Some(_) => continue, 390 | None => RepoPermissionAssignmentDiff { 391 | collaborator, 392 | diff: RepoPermissionDiff::Create(permission), 393 | }, 394 | }; 395 | permissions.push(diff); 396 | } 397 | // Bot permissions 398 | let bots = expected_repo.bots.iter().filter_map(|b| { 399 | let bot_user_name = bot_user_name(b)?; 400 | actual_teams.remove(bot_user_name); 401 | Some((bot_user_name, RepoPermission::Write)) 402 | }); 403 | // Member permissions 404 | let members = expected_repo 405 | .members 406 | .iter() 407 | .map(|m| (m.name.as_str(), convert_permission(&m.permission))); 408 | for (name, permission) in bots.chain(members) { 409 | let actual_collaborator = actual_collaborators.remove(name); 410 | let collaborator = RepoCollaborator::User(name.to_owned()); 411 | let diff = match actual_collaborator { 412 | Some(t) if t.permission != permission => RepoPermissionAssignmentDiff { 413 | collaborator, 414 | diff: RepoPermissionDiff::Update(t.permission, permission), 415 | }, 416 | // Collaborator permission does not need to change 417 | Some(_) => continue, 418 | None => RepoPermissionAssignmentDiff { 419 | collaborator, 420 | diff: RepoPermissionDiff::Create(permission), 421 | }, 422 | }; 423 | permissions.push(diff); 424 | } 425 | // `actual_teams` now contains the teams that were not expected 426 | // but are still on GitHub. We now remove them. 427 | for (team, t) in actual_teams { 428 | if t.name == "security" && expected_repo.org == "rust-lang" { 429 | // Skip removing access permissions from security. 430 | // If we're in this branch we know that the team repo doesn't mention this team at all, 431 | // so this shouldn't remove intentionally granted non-read access. Security is granted 432 | // read access to all repositories in the org by GitHub (via a "security manager" 433 | // role), and we can't remove that access. 434 | // 435 | // (FIXME: If we find security with non-read access, *that* probably should get dropped 436 | // to read access. But not worth doing in this commit, want to get us unblocked first). 437 | continue; 438 | } 439 | permissions.push(RepoPermissionAssignmentDiff { 440 | collaborator: RepoCollaborator::Team(team), 441 | diff: RepoPermissionDiff::Delete(t.permission), 442 | }); 443 | } 444 | // `actual_collaborators` now contains the collaborators that were not expected 445 | // but are still on GitHub. We now remove them. 446 | for (collaborator, u) in actual_collaborators { 447 | permissions.push(RepoPermissionAssignmentDiff { 448 | collaborator: RepoCollaborator::User(collaborator), 449 | diff: RepoPermissionDiff::Delete(u.permission), 450 | }); 451 | } 452 | Ok(permissions) 453 | } 454 | 455 | /// Returns `None` if the bot is not an actual bot user, but rather a GitHub app. 456 | fn bot_user_name(bot: &Bot) -> Option<&str> { 457 | match bot { 458 | // FIXME: set this to `None` once homu is removed completely 459 | Bot::Bors => Some("bors"), 460 | Bot::Highfive => Some("rust-highfive"), 461 | Bot::RustTimer => Some("rust-timer"), 462 | Bot::Rustbot => Some("rustbot"), 463 | Bot::Rfcbot => Some("rfcbot"), 464 | Bot::Renovate => None, 465 | } 466 | } 467 | 468 | pub fn convert_permission(p: &rust_team_data::v1::RepoPermission) -> RepoPermission { 469 | use rust_team_data::v1; 470 | match *p { 471 | v1::RepoPermission::Write => RepoPermission::Write, 472 | v1::RepoPermission::Admin => RepoPermission::Admin, 473 | v1::RepoPermission::Maintain => RepoPermission::Maintain, 474 | v1::RepoPermission::Triage => RepoPermission::Triage, 475 | } 476 | } 477 | 478 | pub fn construct_branch_protection( 479 | expected_repo: &rust_team_data::v1::Repo, 480 | branch_protection: &rust_team_data::v1::BranchProtection, 481 | ) -> api::BranchProtection { 482 | let uses_homu = branch_protection.merge_bots.contains(&MergeBot::Homu); 483 | let required_approving_review_count: u8 = if uses_homu { 484 | 0 485 | } else { 486 | match branch_protection.mode { 487 | BranchProtectionMode::PrRequired { 488 | required_approvals, .. 489 | } => required_approvals 490 | .try_into() 491 | .expect("Too large required approval count"), 492 | BranchProtectionMode::PrNotRequired => 0, 493 | } 494 | }; 495 | let mut push_allowances: Vec = branch_protection 496 | .allowed_merge_teams 497 | .iter() 498 | .map(|team| { 499 | api::PushAllowanceActor::Team(api::TeamPushAllowanceActor { 500 | organization: Login { 501 | login: expected_repo.org.clone(), 502 | }, 503 | name: team.to_string(), 504 | }) 505 | }) 506 | .collect(); 507 | 508 | if uses_homu { 509 | push_allowances.push(PushAllowanceActor::User(api::UserPushAllowanceActor { 510 | login: "bors".to_owned(), 511 | })); 512 | } 513 | 514 | let mut checks = match &branch_protection.mode { 515 | BranchProtectionMode::PrRequired { ci_checks, .. } => ci_checks.clone(), 516 | BranchProtectionMode::PrNotRequired => { 517 | vec![] 518 | } 519 | }; 520 | // Normalize check order to avoid diffs based only on the ordering difference 521 | checks.sort(); 522 | 523 | api::BranchProtection { 524 | pattern: branch_protection.pattern.clone(), 525 | is_admin_enforced: true, 526 | dismisses_stale_reviews: branch_protection.dismiss_stale_review, 527 | required_approving_review_count, 528 | required_status_check_contexts: checks, 529 | push_allowances, 530 | requires_approving_reviews: matches!( 531 | branch_protection.mode, 532 | BranchProtectionMode::PrRequired { .. } 533 | ), 534 | } 535 | } 536 | 537 | /// The special bot teams 538 | const BOTS_TEAMS: &[&str] = &["bors", "highfive", "rfcbot", "bots"]; 539 | 540 | /// A diff between the team repo and the state on GitHub 541 | pub(crate) struct Diff { 542 | team_diffs: Vec, 543 | repo_diffs: Vec, 544 | } 545 | 546 | impl Diff { 547 | /// Apply the diff to GitHub 548 | pub(crate) fn apply(self, sync: &GitHubWrite) -> anyhow::Result<()> { 549 | for team_diff in self.team_diffs { 550 | team_diff.apply(sync)?; 551 | } 552 | for repo_diff in self.repo_diffs { 553 | repo_diff.apply(sync)?; 554 | } 555 | 556 | Ok(()) 557 | } 558 | 559 | pub(crate) fn is_empty(&self) -> bool { 560 | self.team_diffs.is_empty() && self.repo_diffs.is_empty() 561 | } 562 | } 563 | 564 | impl std::fmt::Display for Diff { 565 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 566 | if !self.team_diffs.is_empty() { 567 | writeln!(f, "💻 Team Diffs:")?; 568 | for team_diff in &self.team_diffs { 569 | write!(f, "{team_diff}")?; 570 | } 571 | } 572 | 573 | if !&self.repo_diffs.is_empty() { 574 | writeln!(f, "💻 Repo Diffs:")?; 575 | for repo_diff in &self.repo_diffs { 576 | write!(f, "{repo_diff}")?; 577 | } 578 | } 579 | 580 | Ok(()) 581 | } 582 | } 583 | 584 | #[derive(Debug)] 585 | enum RepoDiff { 586 | Create(CreateRepoDiff), 587 | Update(UpdateRepoDiff), 588 | } 589 | 590 | impl RepoDiff { 591 | fn apply(&self, sync: &GitHubWrite) -> anyhow::Result<()> { 592 | match self { 593 | RepoDiff::Create(c) => c.apply(sync), 594 | RepoDiff::Update(u) => u.apply(sync), 595 | } 596 | } 597 | 598 | fn noop(&self) -> bool { 599 | match self { 600 | RepoDiff::Create(_c) => false, 601 | RepoDiff::Update(u) => u.noop(), 602 | } 603 | } 604 | } 605 | 606 | impl std::fmt::Display for RepoDiff { 607 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 608 | match self { 609 | Self::Create(c) => write!(f, "{c}"), 610 | Self::Update(u) => write!(f, "{u}"), 611 | } 612 | } 613 | } 614 | 615 | #[derive(Debug)] 616 | struct CreateRepoDiff { 617 | org: String, 618 | name: String, 619 | settings: RepoSettings, 620 | permissions: Vec, 621 | branch_protections: Vec<(String, api::BranchProtection)>, 622 | } 623 | 624 | impl CreateRepoDiff { 625 | fn apply(&self, sync: &GitHubWrite) -> anyhow::Result<()> { 626 | let repo = sync.create_repo(&self.org, &self.name, &self.settings)?; 627 | 628 | for permission in &self.permissions { 629 | permission.apply(sync, &self.org, &self.name)?; 630 | } 631 | 632 | for (branch, protection) in &self.branch_protections { 633 | BranchProtectionDiff { 634 | pattern: branch.clone(), 635 | operation: BranchProtectionDiffOperation::Create(protection.clone()), 636 | } 637 | .apply(sync, &self.org, &self.name, &repo.node_id)?; 638 | } 639 | 640 | Ok(()) 641 | } 642 | } 643 | 644 | impl std::fmt::Display for CreateRepoDiff { 645 | fn fmt(&self, mut f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 646 | let RepoSettings { 647 | description, 648 | homepage, 649 | archived: _, 650 | auto_merge_enabled, 651 | } = &self.settings; 652 | 653 | writeln!(f, "➕ Creating repo:")?; 654 | writeln!(f, " Org: {}", self.org)?; 655 | writeln!(f, " Name: {}", self.name)?; 656 | writeln!(f, " Description: {:?}", description)?; 657 | writeln!(f, " Homepage: {:?}", homepage)?; 658 | writeln!(f, " Auto-merge: {}", auto_merge_enabled)?; 659 | writeln!(f, " Permissions:")?; 660 | for diff in &self.permissions { 661 | write!(f, "{diff}")?; 662 | } 663 | writeln!(f, " Branch Protections:")?; 664 | for (branch_name, branch_protection) in &self.branch_protections { 665 | writeln!(&mut f, " {branch_name}")?; 666 | log_branch_protection(branch_protection, None, &mut f)?; 667 | } 668 | Ok(()) 669 | } 670 | } 671 | 672 | #[derive(Debug)] 673 | struct UpdateRepoDiff { 674 | org: String, 675 | name: String, 676 | repo_node_id: String, 677 | // old, new 678 | settings_diff: (RepoSettings, RepoSettings), 679 | permission_diffs: Vec, 680 | branch_protection_diffs: Vec, 681 | } 682 | 683 | impl UpdateRepoDiff { 684 | pub(crate) fn noop(&self) -> bool { 685 | if !self.can_be_modified() { 686 | return true; 687 | } 688 | 689 | self.settings_diff.0 == self.settings_diff.1 690 | && self.permission_diffs.is_empty() 691 | && self.branch_protection_diffs.is_empty() 692 | } 693 | 694 | fn can_be_modified(&self) -> bool { 695 | // Archived repositories cannot be modified 696 | // If the repository should be archived, and we do not change its archival status, 697 | // we should not change any other properties of the repo. 698 | if self.settings_diff.1.archived && self.settings_diff.0.archived { 699 | return false; 700 | } 701 | true 702 | } 703 | 704 | fn apply(&self, sync: &GitHubWrite) -> anyhow::Result<()> { 705 | if !self.can_be_modified() { 706 | return Ok(()); 707 | } 708 | 709 | if self.settings_diff.0 != self.settings_diff.1 { 710 | sync.edit_repo(&self.org, &self.name, &self.settings_diff.1)?; 711 | } 712 | for permission in &self.permission_diffs { 713 | permission.apply(sync, &self.org, &self.name)?; 714 | } 715 | 716 | for branch_protection in &self.branch_protection_diffs { 717 | branch_protection.apply(sync, &self.org, &self.name, &self.repo_node_id)?; 718 | } 719 | 720 | Ok(()) 721 | } 722 | } 723 | 724 | impl std::fmt::Display for UpdateRepoDiff { 725 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 726 | if self.noop() { 727 | return Ok(()); 728 | } 729 | writeln!(f, "📝 Editing repo '{}/{}':", self.org, self.name)?; 730 | let (settings_old, settings_new) = &self.settings_diff; 731 | let RepoSettings { 732 | description, 733 | homepage, 734 | archived, 735 | auto_merge_enabled, 736 | } = settings_old; 737 | match (description.as_str(), settings_new.description.as_str()) { 738 | ("", "") => {} 739 | ("", new) => writeln!(f, " Set description: '{new}'")?, 740 | (old, "") => writeln!(f, " Remove description: '{old}'")?, 741 | (old, new) if old != new => writeln!(f, " New description: '{old}' => '{new}'")?, 742 | _ => {} 743 | } 744 | match (homepage, &settings_new.homepage) { 745 | (None, Some(new)) => writeln!(f, " Set homepage: '{new}'")?, 746 | (Some(old), None) => writeln!(f, " Remove homepage: '{old}'")?, 747 | (Some(old), Some(new)) if old != new => { 748 | writeln!(f, " New homepage: '{old}' => '{new}'")? 749 | } 750 | _ => {} 751 | } 752 | match (archived, &settings_new.archived) { 753 | (false, true) => writeln!(f, " Archive")?, 754 | (true, false) => writeln!(f, " Unarchive")?, 755 | _ => {} 756 | } 757 | match (auto_merge_enabled, &settings_new.auto_merge_enabled) { 758 | (false, true) => writeln!(f, " Enable auto-merge")?, 759 | (true, false) => writeln!(f, " Disable auto-merge")?, 760 | _ => {} 761 | } 762 | if !self.permission_diffs.is_empty() { 763 | writeln!(f, " Permission Changes:")?; 764 | } 765 | for permission_diff in &self.permission_diffs { 766 | write!(f, "{permission_diff}")?; 767 | } 768 | if !self.branch_protection_diffs.is_empty() { 769 | writeln!(f, " Branch Protections:")?; 770 | } 771 | for branch_protection_diff in &self.branch_protection_diffs { 772 | write!(f, "{branch_protection_diff}")?; 773 | } 774 | 775 | Ok(()) 776 | } 777 | } 778 | 779 | #[derive(Debug)] 780 | struct RepoPermissionAssignmentDiff { 781 | collaborator: RepoCollaborator, 782 | diff: RepoPermissionDiff, 783 | } 784 | 785 | impl RepoPermissionAssignmentDiff { 786 | fn apply(&self, sync: &GitHubWrite, org: &str, repo_name: &str) -> anyhow::Result<()> { 787 | match &self.diff { 788 | RepoPermissionDiff::Create(p) | RepoPermissionDiff::Update(_, p) => { 789 | match &self.collaborator { 790 | RepoCollaborator::Team(team_name) => { 791 | sync.update_team_repo_permissions(org, repo_name, team_name, p)? 792 | } 793 | RepoCollaborator::User(user_name) => { 794 | sync.update_user_repo_permissions(org, repo_name, user_name, p)? 795 | } 796 | } 797 | } 798 | RepoPermissionDiff::Delete(_) => match &self.collaborator { 799 | RepoCollaborator::Team(team_name) => { 800 | sync.remove_team_from_repo(org, repo_name, team_name)? 801 | } 802 | RepoCollaborator::User(user_name) => { 803 | sync.remove_collaborator_from_repo(org, repo_name, user_name)? 804 | } 805 | }, 806 | } 807 | Ok(()) 808 | } 809 | } 810 | 811 | impl std::fmt::Display for RepoPermissionAssignmentDiff { 812 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 813 | let name = match &self.collaborator { 814 | RepoCollaborator::Team(name) => format!("team '{name}'"), 815 | RepoCollaborator::User(name) => format!("user '{name}'"), 816 | }; 817 | match &self.diff { 818 | RepoPermissionDiff::Create(p) => { 819 | writeln!(f, " Giving {name} {p} permission") 820 | } 821 | RepoPermissionDiff::Update(old, new) => { 822 | writeln!(f, " Changing {name}'s permission from {old} to {new}") 823 | } 824 | RepoPermissionDiff::Delete(p) => { 825 | writeln!(f, " Removing {name}'s {p} permission ") 826 | } 827 | } 828 | } 829 | } 830 | 831 | #[derive(Debug)] 832 | enum RepoPermissionDiff { 833 | Create(RepoPermission), 834 | Update(RepoPermission, RepoPermission), 835 | Delete(RepoPermission), 836 | } 837 | 838 | #[derive(Clone, Debug)] 839 | enum RepoCollaborator { 840 | Team(String), 841 | User(String), 842 | } 843 | 844 | #[derive(Debug)] 845 | struct BranchProtectionDiff { 846 | pattern: String, 847 | operation: BranchProtectionDiffOperation, 848 | } 849 | 850 | impl BranchProtectionDiff { 851 | fn apply( 852 | &self, 853 | sync: &GitHubWrite, 854 | org: &str, 855 | repo_name: &str, 856 | repo_id: &str, 857 | ) -> anyhow::Result<()> { 858 | match &self.operation { 859 | BranchProtectionDiffOperation::Create(bp) => { 860 | sync.upsert_branch_protection( 861 | BranchProtectionOp::CreateForRepo(repo_id.to_string()), 862 | &self.pattern, 863 | bp, 864 | org, 865 | )?; 866 | } 867 | BranchProtectionDiffOperation::Update(id, _, bp) => { 868 | sync.upsert_branch_protection( 869 | BranchProtectionOp::UpdateBranchProtection(id.clone()), 870 | &self.pattern, 871 | bp, 872 | org, 873 | )?; 874 | } 875 | BranchProtectionDiffOperation::Delete(id) => { 876 | debug!( 877 | "Deleting branch protection '{}' on '{}/{}' as \ 878 | the protection is not in the team repo", 879 | self.pattern, org, repo_name 880 | ); 881 | sync.delete_branch_protection(org, repo_name, id)?; 882 | } 883 | } 884 | 885 | Ok(()) 886 | } 887 | } 888 | 889 | impl std::fmt::Display for BranchProtectionDiff { 890 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 891 | writeln!(f, " {}", self.pattern)?; 892 | match &self.operation { 893 | BranchProtectionDiffOperation::Create(bp) => log_branch_protection(bp, None, f), 894 | BranchProtectionDiffOperation::Update(_, old, new) => { 895 | log_branch_protection(old, Some(new), f) 896 | } 897 | BranchProtectionDiffOperation::Delete(_) => { 898 | writeln!(f, " Deleting branch protection") 899 | } 900 | } 901 | } 902 | } 903 | 904 | fn log_branch_protection( 905 | current: &api::BranchProtection, 906 | new: Option<&api::BranchProtection>, 907 | mut result: impl Write, 908 | ) -> std::fmt::Result { 909 | macro_rules! log { 910 | ($str:literal, $field1:ident) => { 911 | let old = ¤t.$field1; 912 | let new = new.map(|n| &n.$field1); 913 | log!($str, old, new); 914 | }; 915 | ($str:literal, $old:expr, $new:expr) => { 916 | if Some($old) != $new { 917 | if let Some(n) = $new.as_ref() { 918 | writeln!(result, " {}: {:?} => {:?}", $str, $old, n)?; 919 | } else { 920 | writeln!(result, " {}: {:?}", $str, $old)?; 921 | }; 922 | } 923 | }; 924 | } 925 | 926 | log!("Dismiss Stale Reviews", dismisses_stale_reviews); 927 | log!( 928 | "Required Approving Review Count", 929 | required_approving_review_count 930 | ); 931 | log!("Required Checks", required_status_check_contexts); 932 | log!("Allowances", push_allowances); 933 | Ok(()) 934 | } 935 | 936 | #[derive(Debug)] 937 | enum BranchProtectionDiffOperation { 938 | Create(api::BranchProtection), 939 | Update(String, api::BranchProtection, api::BranchProtection), 940 | Delete(String), 941 | } 942 | 943 | #[derive(Debug)] 944 | enum TeamDiff { 945 | Create(CreateTeamDiff), 946 | Edit(EditTeamDiff), 947 | Delete(DeleteTeamDiff), 948 | } 949 | 950 | impl TeamDiff { 951 | fn apply(self, sync: &GitHubWrite) -> anyhow::Result<()> { 952 | match self { 953 | TeamDiff::Create(c) => c.apply(sync)?, 954 | TeamDiff::Edit(e) => e.apply(sync)?, 955 | TeamDiff::Delete(d) => d.apply(sync)?, 956 | } 957 | 958 | Ok(()) 959 | } 960 | 961 | fn noop(&self) -> bool { 962 | match self { 963 | TeamDiff::Create(_) | TeamDiff::Delete(_) => false, 964 | TeamDiff::Edit(e) => e.noop(), 965 | } 966 | } 967 | } 968 | 969 | impl std::fmt::Display for TeamDiff { 970 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 971 | match self { 972 | TeamDiff::Create(c) => write!(f, "{c}"), 973 | TeamDiff::Edit(e) => write!(f, "{e}"), 974 | TeamDiff::Delete(d) => write!(f, "{d}"), 975 | } 976 | } 977 | } 978 | 979 | #[derive(Debug)] 980 | struct CreateTeamDiff { 981 | org: String, 982 | name: String, 983 | description: String, 984 | privacy: TeamPrivacy, 985 | members: Vec<(String, TeamRole)>, 986 | } 987 | 988 | impl CreateTeamDiff { 989 | fn apply(self, sync: &GitHubWrite) -> anyhow::Result<()> { 990 | sync.create_team(&self.org, &self.name, &self.description, self.privacy)?; 991 | for (member_name, role) in self.members { 992 | MemberDiff::Create(role).apply(&self.org, &self.name, &member_name, sync)?; 993 | } 994 | 995 | Ok(()) 996 | } 997 | } 998 | 999 | impl std::fmt::Display for CreateTeamDiff { 1000 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 1001 | writeln!(f, "➕ Creating team:")?; 1002 | writeln!(f, " Org: {}", self.org)?; 1003 | writeln!(f, " Name: {}", self.name)?; 1004 | writeln!(f, " Description: {}", self.description)?; 1005 | writeln!( 1006 | f, 1007 | " Privacy: {}", 1008 | match self.privacy { 1009 | TeamPrivacy::Secret => "secret", 1010 | TeamPrivacy::Closed => "closed", 1011 | } 1012 | )?; 1013 | writeln!(f, " Members:")?; 1014 | for (name, role) in &self.members { 1015 | writeln!(f, " {name}: {role}")?; 1016 | } 1017 | Ok(()) 1018 | } 1019 | } 1020 | 1021 | #[derive(Debug)] 1022 | struct EditTeamDiff { 1023 | org: String, 1024 | name: String, 1025 | name_diff: Option, 1026 | description_diff: Option<(String, String)>, 1027 | privacy_diff: Option<(TeamPrivacy, TeamPrivacy)>, 1028 | member_diffs: Vec<(String, MemberDiff)>, 1029 | } 1030 | 1031 | impl EditTeamDiff { 1032 | fn apply(self, sync: &GitHubWrite) -> anyhow::Result<()> { 1033 | if self.name_diff.is_some() 1034 | || self.description_diff.is_some() 1035 | || self.privacy_diff.is_some() 1036 | { 1037 | sync.edit_team( 1038 | &self.org, 1039 | &self.name, 1040 | self.name_diff.as_deref(), 1041 | self.description_diff.as_ref().map(|(_, d)| d.as_str()), 1042 | self.privacy_diff.map(|(_, p)| p), 1043 | )?; 1044 | } 1045 | 1046 | for (member_name, member_diff) in self.member_diffs { 1047 | member_diff.apply(&self.org, &self.name, &member_name, sync)?; 1048 | } 1049 | 1050 | Ok(()) 1051 | } 1052 | 1053 | fn noop(&self) -> bool { 1054 | self.name_diff.is_none() 1055 | && self.description_diff.is_none() 1056 | && self.privacy_diff.is_none() 1057 | && self.member_diffs.iter().all(|(_, d)| d.is_noop()) 1058 | } 1059 | } 1060 | 1061 | impl std::fmt::Display for EditTeamDiff { 1062 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 1063 | if self.noop() { 1064 | return Ok(()); 1065 | } 1066 | writeln!(f, "📝 Editing team '{}/{}':", self.org, self.name)?; 1067 | if let Some(n) = &self.name_diff { 1068 | writeln!(f, " New name: {n}")?; 1069 | } 1070 | if let Some((old, new)) = &self.description_diff { 1071 | writeln!(f, " New description: '{old}' => '{new}'")?; 1072 | } 1073 | if let Some((old, new)) = &self.privacy_diff { 1074 | let display = |privacy: &TeamPrivacy| match privacy { 1075 | TeamPrivacy::Secret => "secret", 1076 | TeamPrivacy::Closed => "closed", 1077 | }; 1078 | writeln!(f, " New privacy: '{}' => '{}'", display(old), display(new))?; 1079 | } 1080 | for (member, diff) in &self.member_diffs { 1081 | match diff { 1082 | MemberDiff::Create(r) => { 1083 | writeln!(f, " Adding member '{member}' with {r} role")?; 1084 | } 1085 | MemberDiff::ChangeRole((o, n)) => { 1086 | writeln!(f, " Changing '{member}' role from {o} to {n}")?; 1087 | } 1088 | MemberDiff::Delete => { 1089 | writeln!(f, " Deleting member '{member}'")?; 1090 | } 1091 | MemberDiff::Noop => {} 1092 | } 1093 | } 1094 | Ok(()) 1095 | } 1096 | } 1097 | 1098 | #[derive(Debug)] 1099 | enum MemberDiff { 1100 | Create(TeamRole), 1101 | ChangeRole((TeamRole, TeamRole)), 1102 | Delete, 1103 | Noop, 1104 | } 1105 | 1106 | impl MemberDiff { 1107 | fn apply(self, org: &str, team: &str, member: &str, sync: &GitHubWrite) -> anyhow::Result<()> { 1108 | match self { 1109 | MemberDiff::Create(role) | MemberDiff::ChangeRole((_, role)) => { 1110 | sync.set_team_membership(org, team, member, role)?; 1111 | } 1112 | MemberDiff::Delete => sync.remove_team_membership(org, team, member)?, 1113 | MemberDiff::Noop => {} 1114 | } 1115 | 1116 | Ok(()) 1117 | } 1118 | 1119 | fn is_noop(&self) -> bool { 1120 | matches!(self, Self::Noop) 1121 | } 1122 | } 1123 | 1124 | #[derive(Debug)] 1125 | struct DeleteTeamDiff { 1126 | org: String, 1127 | name: String, 1128 | slug: String, 1129 | } 1130 | 1131 | impl DeleteTeamDiff { 1132 | fn apply(self, sync: &GitHubWrite) -> anyhow::Result<()> { 1133 | sync.delete_team(&self.org, &self.slug)?; 1134 | Ok(()) 1135 | } 1136 | } 1137 | 1138 | impl std::fmt::Display for DeleteTeamDiff { 1139 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 1140 | writeln!(f, "❌ Deleting team '{}/{}'", self.org, self.name)?; 1141 | Ok(()) 1142 | } 1143 | } 1144 | -------------------------------------------------------------------------------- /src/github/tests/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::github::tests::test_utils::{ 2 | BranchProtectionBuilder, DEFAULT_ORG, DataModel, RepoData, TeamData, 3 | }; 4 | use rust_team_data::v1::{BranchProtectionMode, RepoPermission}; 5 | 6 | mod test_utils; 7 | 8 | #[test] 9 | fn team_noop() { 10 | let model = DataModel::default(); 11 | let gh = model.gh_model(); 12 | let team_diff = model.diff_teams(gh); 13 | assert!(team_diff.is_empty()); 14 | } 15 | 16 | #[test] 17 | fn team_create() { 18 | let mut model = DataModel::default(); 19 | let user = model.create_user("mark"); 20 | let user2 = model.create_user("jan"); 21 | let gh = model.gh_model(); 22 | model.create_team(TeamData::new("admins").gh_team(DEFAULT_ORG, "admins-gh", &[user, user2])); 23 | let team_diff = model.diff_teams(gh); 24 | insta::assert_debug_snapshot!(team_diff, @r###" 25 | [ 26 | Create( 27 | CreateTeamDiff { 28 | org: "rust-lang", 29 | name: "admins-gh", 30 | description: "Managed by the rust-lang/team repository.", 31 | privacy: Closed, 32 | members: [ 33 | ( 34 | "mark", 35 | Member, 36 | ), 37 | ( 38 | "jan", 39 | Member, 40 | ), 41 | ], 42 | }, 43 | ), 44 | ] 45 | "###); 46 | } 47 | 48 | #[test] 49 | fn team_add_member() { 50 | let mut model = DataModel::default(); 51 | let user = model.create_user("mark"); 52 | let user2 = model.create_user("jan"); 53 | model.create_team(TeamData::new("admins").gh_team(DEFAULT_ORG, "admins-gh", &[user])); 54 | let gh = model.gh_model(); 55 | 56 | model.get_team("admins").add_gh_member("admins-gh", user2); 57 | let team_diff = model.diff_teams(gh); 58 | insta::assert_debug_snapshot!(team_diff, @r###" 59 | [ 60 | Edit( 61 | EditTeamDiff { 62 | org: "rust-lang", 63 | name: "admins-gh", 64 | name_diff: None, 65 | description_diff: None, 66 | privacy_diff: None, 67 | member_diffs: [ 68 | ( 69 | "mark", 70 | Noop, 71 | ), 72 | ( 73 | "jan", 74 | Create( 75 | Member, 76 | ), 77 | ), 78 | ], 79 | }, 80 | ), 81 | ] 82 | "###); 83 | } 84 | 85 | #[test] 86 | fn team_dont_add_member_if_invitation_is_pending() { 87 | let mut model = DataModel::default(); 88 | let user = model.create_user("mark"); 89 | let user2 = model.create_user("jan"); 90 | model.create_team(TeamData::new("admins").gh_team(DEFAULT_ORG, "admins-gh", &[user])); 91 | let mut gh = model.gh_model(); 92 | 93 | model.get_team("admins").add_gh_member("admins-gh", user2); 94 | gh.add_invitation(DEFAULT_ORG, "admins-gh", "jan"); 95 | 96 | let team_diff = model.diff_teams(gh); 97 | insta::assert_debug_snapshot!(team_diff, @"[]"); 98 | } 99 | 100 | #[test] 101 | fn team_remove_member() { 102 | let mut model = DataModel::default(); 103 | let user = model.create_user("mark"); 104 | let user2 = model.create_user("jan"); 105 | model.create_team(TeamData::new("admins").gh_team(DEFAULT_ORG, "admins-gh", &[user, user2])); 106 | let gh = model.gh_model(); 107 | 108 | model 109 | .get_team("admins") 110 | .remove_gh_member("admins-gh", user2); 111 | 112 | let team_diff = model.diff_teams(gh); 113 | insta::assert_debug_snapshot!(team_diff, @r###" 114 | [ 115 | Edit( 116 | EditTeamDiff { 117 | org: "rust-lang", 118 | name: "admins-gh", 119 | name_diff: None, 120 | description_diff: None, 121 | privacy_diff: None, 122 | member_diffs: [ 123 | ( 124 | "mark", 125 | Noop, 126 | ), 127 | ( 128 | "jan", 129 | Delete, 130 | ), 131 | ], 132 | }, 133 | ), 134 | ] 135 | "###); 136 | } 137 | 138 | #[test] 139 | fn team_delete() { 140 | let mut model = DataModel::default(); 141 | let user = model.create_user("mark"); 142 | 143 | // We need at least two github teams, otherwise the diff for removing the last GH team 144 | // won't be generated, because no organization is known to scan for existing unmanaged teams. 145 | model.create_team( 146 | TeamData::new("admins") 147 | .gh_team(DEFAULT_ORG, "admins-gh", &[user]) 148 | .gh_team(DEFAULT_ORG, "users-gh", &[user]), 149 | ); 150 | let gh = model.gh_model(); 151 | 152 | model.get_team("admins").remove_gh_team("users-gh"); 153 | 154 | let team_diff = model.diff_teams(gh); 155 | insta::assert_debug_snapshot!(team_diff, @r#" 156 | [ 157 | Delete( 158 | DeleteTeamDiff { 159 | org: "rust-lang", 160 | name: "users-gh", 161 | slug: "users-gh", 162 | }, 163 | ), 164 | ] 165 | "#); 166 | } 167 | 168 | #[test] 169 | fn repo_noop() { 170 | let model = DataModel::default(); 171 | let gh = model.gh_model(); 172 | let diff = model.diff_repos(gh); 173 | assert!(diff.is_empty()); 174 | } 175 | 176 | #[test] 177 | fn repo_change_description() { 178 | let mut model = DataModel::default(); 179 | model.create_repo(RepoData::new("repo1").description("foo".to_string())); 180 | let gh = model.gh_model(); 181 | model.get_repo("repo1").description = "bar".to_string(); 182 | 183 | let diff = model.diff_repos(gh); 184 | insta::assert_debug_snapshot!(diff, @r#" 185 | [ 186 | Update( 187 | UpdateRepoDiff { 188 | org: "rust-lang", 189 | name: "repo1", 190 | repo_node_id: "0", 191 | settings_diff: ( 192 | RepoSettings { 193 | description: "foo", 194 | homepage: None, 195 | archived: false, 196 | auto_merge_enabled: false, 197 | }, 198 | RepoSettings { 199 | description: "bar", 200 | homepage: None, 201 | archived: false, 202 | auto_merge_enabled: false, 203 | }, 204 | ), 205 | permission_diffs: [], 206 | branch_protection_diffs: [], 207 | }, 208 | ), 209 | ] 210 | "#); 211 | } 212 | 213 | #[test] 214 | fn repo_change_homepage() { 215 | let mut model = DataModel::default(); 216 | model.create_repo(RepoData::new("repo1").homepage(Some("https://foo.rs".to_string()))); 217 | let gh = model.gh_model(); 218 | model.get_repo("repo1").homepage = Some("https://bar.rs".to_string()); 219 | 220 | let diff = model.diff_repos(gh); 221 | insta::assert_debug_snapshot!(diff, @r#" 222 | [ 223 | Update( 224 | UpdateRepoDiff { 225 | org: "rust-lang", 226 | name: "repo1", 227 | repo_node_id: "0", 228 | settings_diff: ( 229 | RepoSettings { 230 | description: "", 231 | homepage: Some( 232 | "https://foo.rs", 233 | ), 234 | archived: false, 235 | auto_merge_enabled: false, 236 | }, 237 | RepoSettings { 238 | description: "", 239 | homepage: Some( 240 | "https://bar.rs", 241 | ), 242 | archived: false, 243 | auto_merge_enabled: false, 244 | }, 245 | ), 246 | permission_diffs: [], 247 | branch_protection_diffs: [], 248 | }, 249 | ), 250 | ] 251 | "#); 252 | } 253 | 254 | #[test] 255 | fn repo_create() { 256 | let mut model = DataModel::default(); 257 | let gh = model.gh_model(); 258 | 259 | model.create_repo( 260 | RepoData::new("repo1") 261 | .description("foo".to_string()) 262 | .member("user1", RepoPermission::Write) 263 | .team("team1", RepoPermission::Triage) 264 | .branch_protections(vec![ 265 | BranchProtectionBuilder::pr_required("main", &["test"], 1).build(), 266 | ]), 267 | ); 268 | let diff = model.diff_repos(gh); 269 | insta::assert_debug_snapshot!(diff, @r#" 270 | [ 271 | Create( 272 | CreateRepoDiff { 273 | org: "rust-lang", 274 | name: "repo1", 275 | settings: RepoSettings { 276 | description: "foo", 277 | homepage: None, 278 | archived: false, 279 | auto_merge_enabled: false, 280 | }, 281 | permissions: [ 282 | RepoPermissionAssignmentDiff { 283 | collaborator: Team( 284 | "team1", 285 | ), 286 | diff: Create( 287 | Triage, 288 | ), 289 | }, 290 | RepoPermissionAssignmentDiff { 291 | collaborator: User( 292 | "user1", 293 | ), 294 | diff: Create( 295 | Write, 296 | ), 297 | }, 298 | ], 299 | branch_protections: [ 300 | ( 301 | "main", 302 | BranchProtection { 303 | pattern: "main", 304 | is_admin_enforced: true, 305 | dismisses_stale_reviews: false, 306 | required_approving_review_count: 1, 307 | required_status_check_contexts: [ 308 | "test", 309 | ], 310 | push_allowances: [], 311 | requires_approving_reviews: true, 312 | }, 313 | ), 314 | ], 315 | }, 316 | ), 317 | ] 318 | "#); 319 | } 320 | 321 | #[test] 322 | fn repo_add_member() { 323 | let mut model = DataModel::default(); 324 | model.create_repo( 325 | RepoData::new("repo1") 326 | .member("user1", RepoPermission::Write) 327 | .team("team1", RepoPermission::Triage), 328 | ); 329 | 330 | let gh = model.gh_model(); 331 | model 332 | .get_repo("repo1") 333 | .add_member("user2", RepoPermission::Admin); 334 | 335 | let diff = model.diff_repos(gh); 336 | insta::assert_debug_snapshot!(diff, @r#" 337 | [ 338 | Update( 339 | UpdateRepoDiff { 340 | org: "rust-lang", 341 | name: "repo1", 342 | repo_node_id: "0", 343 | settings_diff: ( 344 | RepoSettings { 345 | description: "", 346 | homepage: None, 347 | archived: false, 348 | auto_merge_enabled: false, 349 | }, 350 | RepoSettings { 351 | description: "", 352 | homepage: None, 353 | archived: false, 354 | auto_merge_enabled: false, 355 | }, 356 | ), 357 | permission_diffs: [ 358 | RepoPermissionAssignmentDiff { 359 | collaborator: User( 360 | "user2", 361 | ), 362 | diff: Create( 363 | Admin, 364 | ), 365 | }, 366 | ], 367 | branch_protection_diffs: [], 368 | }, 369 | ), 370 | ] 371 | "#); 372 | } 373 | 374 | #[test] 375 | fn repo_change_member_permissions() { 376 | let mut model = DataModel::default(); 377 | model.create_repo(RepoData::new("repo1").member("user1", RepoPermission::Write)); 378 | 379 | let gh = model.gh_model(); 380 | model 381 | .get_repo("repo1") 382 | .members 383 | .last_mut() 384 | .unwrap() 385 | .permission = RepoPermission::Triage; 386 | 387 | let diff = model.diff_repos(gh); 388 | insta::assert_debug_snapshot!(diff, @r#" 389 | [ 390 | Update( 391 | UpdateRepoDiff { 392 | org: "rust-lang", 393 | name: "repo1", 394 | repo_node_id: "0", 395 | settings_diff: ( 396 | RepoSettings { 397 | description: "", 398 | homepage: None, 399 | archived: false, 400 | auto_merge_enabled: false, 401 | }, 402 | RepoSettings { 403 | description: "", 404 | homepage: None, 405 | archived: false, 406 | auto_merge_enabled: false, 407 | }, 408 | ), 409 | permission_diffs: [ 410 | RepoPermissionAssignmentDiff { 411 | collaborator: User( 412 | "user1", 413 | ), 414 | diff: Update( 415 | Write, 416 | Triage, 417 | ), 418 | }, 419 | ], 420 | branch_protection_diffs: [], 421 | }, 422 | ), 423 | ] 424 | "#); 425 | } 426 | 427 | #[test] 428 | fn repo_remove_member() { 429 | let mut model = DataModel::default(); 430 | model.create_repo(RepoData::new("repo1").member("user1", RepoPermission::Write)); 431 | 432 | let gh = model.gh_model(); 433 | model.get_repo("repo1").members.clear(); 434 | 435 | let diff = model.diff_repos(gh); 436 | insta::assert_debug_snapshot!(diff, @r#" 437 | [ 438 | Update( 439 | UpdateRepoDiff { 440 | org: "rust-lang", 441 | name: "repo1", 442 | repo_node_id: "0", 443 | settings_diff: ( 444 | RepoSettings { 445 | description: "", 446 | homepage: None, 447 | archived: false, 448 | auto_merge_enabled: false, 449 | }, 450 | RepoSettings { 451 | description: "", 452 | homepage: None, 453 | archived: false, 454 | auto_merge_enabled: false, 455 | }, 456 | ), 457 | permission_diffs: [ 458 | RepoPermissionAssignmentDiff { 459 | collaborator: User( 460 | "user1", 461 | ), 462 | diff: Delete( 463 | Write, 464 | ), 465 | }, 466 | ], 467 | branch_protection_diffs: [], 468 | }, 469 | ), 470 | ] 471 | "#); 472 | } 473 | 474 | #[test] 475 | fn repo_add_team() { 476 | let mut model = DataModel::default(); 477 | model.create_repo(RepoData::new("repo1").member("user1", RepoPermission::Write)); 478 | 479 | let gh = model.gh_model(); 480 | model 481 | .get_repo("repo1") 482 | .add_team("team1", RepoPermission::Triage); 483 | 484 | let diff = model.diff_repos(gh); 485 | insta::assert_debug_snapshot!(diff, @r#" 486 | [ 487 | Update( 488 | UpdateRepoDiff { 489 | org: "rust-lang", 490 | name: "repo1", 491 | repo_node_id: "0", 492 | settings_diff: ( 493 | RepoSettings { 494 | description: "", 495 | homepage: None, 496 | archived: false, 497 | auto_merge_enabled: false, 498 | }, 499 | RepoSettings { 500 | description: "", 501 | homepage: None, 502 | archived: false, 503 | auto_merge_enabled: false, 504 | }, 505 | ), 506 | permission_diffs: [ 507 | RepoPermissionAssignmentDiff { 508 | collaborator: Team( 509 | "team1", 510 | ), 511 | diff: Create( 512 | Triage, 513 | ), 514 | }, 515 | ], 516 | branch_protection_diffs: [], 517 | }, 518 | ), 519 | ] 520 | "#); 521 | } 522 | 523 | #[test] 524 | fn repo_change_team_permissions() { 525 | let mut model = DataModel::default(); 526 | model.create_repo(RepoData::new("repo1").team("team1", RepoPermission::Triage)); 527 | 528 | let gh = model.gh_model(); 529 | model.get_repo("repo1").teams.last_mut().unwrap().permission = RepoPermission::Admin; 530 | 531 | let diff = model.diff_repos(gh); 532 | insta::assert_debug_snapshot!(diff, @r#" 533 | [ 534 | Update( 535 | UpdateRepoDiff { 536 | org: "rust-lang", 537 | name: "repo1", 538 | repo_node_id: "0", 539 | settings_diff: ( 540 | RepoSettings { 541 | description: "", 542 | homepage: None, 543 | archived: false, 544 | auto_merge_enabled: false, 545 | }, 546 | RepoSettings { 547 | description: "", 548 | homepage: None, 549 | archived: false, 550 | auto_merge_enabled: false, 551 | }, 552 | ), 553 | permission_diffs: [ 554 | RepoPermissionAssignmentDiff { 555 | collaborator: Team( 556 | "team1", 557 | ), 558 | diff: Update( 559 | Triage, 560 | Admin, 561 | ), 562 | }, 563 | ], 564 | branch_protection_diffs: [], 565 | }, 566 | ), 567 | ] 568 | "#); 569 | } 570 | 571 | #[test] 572 | fn repo_remove_team() { 573 | let mut model = DataModel::default(); 574 | model.create_repo(RepoData::new("repo1").team("team1", RepoPermission::Write)); 575 | 576 | let gh = model.gh_model(); 577 | model.get_repo("repo1").teams.clear(); 578 | 579 | let diff = model.diff_repos(gh); 580 | insta::assert_debug_snapshot!(diff, @r#" 581 | [ 582 | Update( 583 | UpdateRepoDiff { 584 | org: "rust-lang", 585 | name: "repo1", 586 | repo_node_id: "0", 587 | settings_diff: ( 588 | RepoSettings { 589 | description: "", 590 | homepage: None, 591 | archived: false, 592 | auto_merge_enabled: false, 593 | }, 594 | RepoSettings { 595 | description: "", 596 | homepage: None, 597 | archived: false, 598 | auto_merge_enabled: false, 599 | }, 600 | ), 601 | permission_diffs: [ 602 | RepoPermissionAssignmentDiff { 603 | collaborator: Team( 604 | "team1", 605 | ), 606 | diff: Delete( 607 | Write, 608 | ), 609 | }, 610 | ], 611 | branch_protection_diffs: [], 612 | }, 613 | ), 614 | ] 615 | "#); 616 | } 617 | 618 | #[test] 619 | fn repo_archive_repo() { 620 | let mut model = DataModel::default(); 621 | model.create_repo(RepoData::new("repo1")); 622 | 623 | let gh = model.gh_model(); 624 | model.get_repo("repo1").archived = true; 625 | 626 | let diff = model.diff_repos(gh); 627 | insta::assert_debug_snapshot!(diff, @r#" 628 | [ 629 | Update( 630 | UpdateRepoDiff { 631 | org: "rust-lang", 632 | name: "repo1", 633 | repo_node_id: "0", 634 | settings_diff: ( 635 | RepoSettings { 636 | description: "", 637 | homepage: None, 638 | archived: false, 639 | auto_merge_enabled: false, 640 | }, 641 | RepoSettings { 642 | description: "", 643 | homepage: None, 644 | archived: true, 645 | auto_merge_enabled: false, 646 | }, 647 | ), 648 | permission_diffs: [], 649 | branch_protection_diffs: [], 650 | }, 651 | ), 652 | ] 653 | "#); 654 | } 655 | 656 | #[test] 657 | fn repo_add_branch_protection() { 658 | let mut model = DataModel::default(); 659 | model.create_repo(RepoData::new("repo1").team("team1", RepoPermission::Write)); 660 | 661 | let gh = model.gh_model(); 662 | model.get_repo("repo1").branch_protections.extend([ 663 | BranchProtectionBuilder::pr_required("master", &["test", "test 2"], 0).build(), 664 | BranchProtectionBuilder::pr_not_required("beta").build(), 665 | ]); 666 | 667 | let diff = model.diff_repos(gh); 668 | insta::assert_debug_snapshot!(diff, @r#" 669 | [ 670 | Update( 671 | UpdateRepoDiff { 672 | org: "rust-lang", 673 | name: "repo1", 674 | repo_node_id: "0", 675 | settings_diff: ( 676 | RepoSettings { 677 | description: "", 678 | homepage: None, 679 | archived: false, 680 | auto_merge_enabled: false, 681 | }, 682 | RepoSettings { 683 | description: "", 684 | homepage: None, 685 | archived: false, 686 | auto_merge_enabled: false, 687 | }, 688 | ), 689 | permission_diffs: [], 690 | branch_protection_diffs: [ 691 | BranchProtectionDiff { 692 | pattern: "master", 693 | operation: Create( 694 | BranchProtection { 695 | pattern: "master", 696 | is_admin_enforced: true, 697 | dismisses_stale_reviews: false, 698 | required_approving_review_count: 0, 699 | required_status_check_contexts: [ 700 | "test", 701 | "test 2", 702 | ], 703 | push_allowances: [], 704 | requires_approving_reviews: true, 705 | }, 706 | ), 707 | }, 708 | BranchProtectionDiff { 709 | pattern: "beta", 710 | operation: Create( 711 | BranchProtection { 712 | pattern: "beta", 713 | is_admin_enforced: true, 714 | dismisses_stale_reviews: false, 715 | required_approving_review_count: 0, 716 | required_status_check_contexts: [], 717 | push_allowances: [], 718 | requires_approving_reviews: false, 719 | }, 720 | ), 721 | }, 722 | ], 723 | }, 724 | ), 725 | ] 726 | "#); 727 | } 728 | 729 | #[test] 730 | fn repo_update_branch_protection() { 731 | let mut model = DataModel::default(); 732 | model.create_repo( 733 | RepoData::new("repo1") 734 | .team("team1", RepoPermission::Write) 735 | .branch_protections(vec![ 736 | BranchProtectionBuilder::pr_required("master", &["test"], 1).build(), 737 | ]), 738 | ); 739 | 740 | let gh = model.gh_model(); 741 | let protection = model 742 | .get_repo("repo1") 743 | .branch_protections 744 | .last_mut() 745 | .unwrap(); 746 | match &mut protection.mode { 747 | BranchProtectionMode::PrRequired { 748 | ci_checks, 749 | required_approvals, 750 | } => { 751 | ci_checks.push("Test".to_string()); 752 | *required_approvals = 0; 753 | } 754 | BranchProtectionMode::PrNotRequired => unreachable!(), 755 | } 756 | protection.dismiss_stale_review = true; 757 | 758 | let diff = model.diff_repos(gh); 759 | insta::assert_debug_snapshot!(diff, @r#" 760 | [ 761 | Update( 762 | UpdateRepoDiff { 763 | org: "rust-lang", 764 | name: "repo1", 765 | repo_node_id: "0", 766 | settings_diff: ( 767 | RepoSettings { 768 | description: "", 769 | homepage: None, 770 | archived: false, 771 | auto_merge_enabled: false, 772 | }, 773 | RepoSettings { 774 | description: "", 775 | homepage: None, 776 | archived: false, 777 | auto_merge_enabled: false, 778 | }, 779 | ), 780 | permission_diffs: [], 781 | branch_protection_diffs: [ 782 | BranchProtectionDiff { 783 | pattern: "master", 784 | operation: Update( 785 | "0", 786 | BranchProtection { 787 | pattern: "master", 788 | is_admin_enforced: true, 789 | dismisses_stale_reviews: false, 790 | required_approving_review_count: 1, 791 | required_status_check_contexts: [ 792 | "test", 793 | ], 794 | push_allowances: [], 795 | requires_approving_reviews: true, 796 | }, 797 | BranchProtection { 798 | pattern: "master", 799 | is_admin_enforced: true, 800 | dismisses_stale_reviews: true, 801 | required_approving_review_count: 0, 802 | required_status_check_contexts: [ 803 | "Test", 804 | "test", 805 | ], 806 | push_allowances: [], 807 | requires_approving_reviews: true, 808 | }, 809 | ), 810 | }, 811 | ], 812 | }, 813 | ), 814 | ] 815 | "#); 816 | } 817 | 818 | #[test] 819 | fn repo_remove_branch_protection() { 820 | let mut model = DataModel::default(); 821 | model.create_repo( 822 | RepoData::new("repo1") 823 | .team("team1", RepoPermission::Write) 824 | .branch_protections(vec![ 825 | BranchProtectionBuilder::pr_required("main", &["test"], 1).build(), 826 | BranchProtectionBuilder::pr_required("stable", &["test"], 0).build(), 827 | ]), 828 | ); 829 | 830 | let gh = model.gh_model(); 831 | model.get_repo("repo1").branch_protections.pop().unwrap(); 832 | 833 | let diff = model.diff_repos(gh); 834 | insta::assert_debug_snapshot!(diff, @r#" 835 | [ 836 | Update( 837 | UpdateRepoDiff { 838 | org: "rust-lang", 839 | name: "repo1", 840 | repo_node_id: "0", 841 | settings_diff: ( 842 | RepoSettings { 843 | description: "", 844 | homepage: None, 845 | archived: false, 846 | auto_merge_enabled: false, 847 | }, 848 | RepoSettings { 849 | description: "", 850 | homepage: None, 851 | archived: false, 852 | auto_merge_enabled: false, 853 | }, 854 | ), 855 | permission_diffs: [], 856 | branch_protection_diffs: [ 857 | BranchProtectionDiff { 858 | pattern: "stable", 859 | operation: Delete( 860 | "1", 861 | ), 862 | }, 863 | ], 864 | }, 865 | ), 866 | ] 867 | "#); 868 | } 869 | -------------------------------------------------------------------------------- /src/github/tests/test_utils.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeSet, HashMap, HashSet}; 2 | 3 | use derive_builder::Builder; 4 | use rust_team_data::v1; 5 | use rust_team_data::v1::{ 6 | Bot, BranchProtectionMode, GitHubTeam, MergeBot, Person, RepoPermission, TeamGitHub, TeamKind, 7 | }; 8 | 9 | use crate::github::api::{ 10 | BranchProtection, GithubRead, Repo, RepoTeam, RepoUser, Team, TeamMember, TeamPrivacy, TeamRole, 11 | }; 12 | use crate::github::{ 13 | RepoDiff, SyncGitHub, TeamDiff, api, construct_branch_protection, convert_permission, 14 | }; 15 | 16 | pub const DEFAULT_ORG: &str = "rust-lang"; 17 | 18 | type UserId = u64; 19 | 20 | /// Represents the contents of rust_team_data state. 21 | /// In tests, you should fill the model with repos, teams, people etc., 22 | /// and then call `gh_model` to construct a corresponding GitHubModel. 23 | /// After that, you can modify the data model further, then generate a diff 24 | /// and assert that it has the expected value. 25 | #[derive(Default, Clone)] 26 | pub struct DataModel { 27 | people: Vec, 28 | teams: Vec, 29 | repos: Vec, 30 | } 31 | 32 | impl DataModel { 33 | pub fn create_user(&mut self, name: &str) -> UserId { 34 | let github_id = self.people.len() as UserId; 35 | self.people.push(Person { 36 | name: name.to_string(), 37 | email: Some(format!("{name}@rust.com")), 38 | github_id, 39 | }); 40 | github_id 41 | } 42 | 43 | pub fn create_team(&mut self, team: TeamDataBuilder) { 44 | let team = team.build().expect("Cannot build team"); 45 | self.teams.push(team); 46 | } 47 | 48 | pub fn get_team(&mut self, name: &str) -> &mut TeamData { 49 | self.teams 50 | .iter_mut() 51 | .find(|t| t.name == name) 52 | .expect("Team not found") 53 | } 54 | 55 | pub fn create_repo(&mut self, repo: RepoDataBuilder) { 56 | let repo = repo.build().expect("Cannot build repo"); 57 | self.repos.push(repo); 58 | } 59 | 60 | pub fn get_repo(&mut self, name: &str) -> &mut RepoData { 61 | self.repos 62 | .iter_mut() 63 | .find(|r| r.name == name) 64 | .expect("Repo not found") 65 | } 66 | 67 | /// Creates a GitHub model from the current team data mock. 68 | /// Note that all users should have been created before calling this method, so that 69 | /// GitHub knows about the users' existence. 70 | pub fn gh_model(&self) -> GithubMock { 71 | let users: HashMap = self 72 | .people 73 | .iter() 74 | .map(|user| (user.github_id, user.name.clone())) 75 | .collect(); 76 | 77 | let mut orgs: HashMap = HashMap::default(); 78 | 79 | for team in &self.teams { 80 | for gh_team in &team.gh_teams { 81 | let org = orgs.entry(gh_team.org.clone()).or_default(); 82 | let res = org.team_memberships.insert( 83 | gh_team.name.clone(), 84 | gh_team 85 | .members 86 | .iter() 87 | .map(|member| { 88 | ( 89 | *member, 90 | TeamMember { 91 | username: users.get(member).expect("User not found").clone(), 92 | role: TeamRole::Member, 93 | }, 94 | ) 95 | }) 96 | .collect(), 97 | ); 98 | assert!(res.is_none()); 99 | 100 | org.teams.push(api::Team { 101 | id: Some(org.teams.len() as u64), 102 | name: gh_team.name.clone(), 103 | description: Some("Managed by the rust-lang/team repository.".to_string()), 104 | privacy: TeamPrivacy::Closed, 105 | slug: gh_team.name.clone(), 106 | }); 107 | 108 | org.members.extend(gh_team.members.iter().copied()); 109 | } 110 | } 111 | 112 | for repo in &self.repos { 113 | let org = orgs.entry(repo.org.clone()).or_default(); 114 | org.repos.insert( 115 | repo.name.clone(), 116 | Repo { 117 | node_id: org.repos.len().to_string(), 118 | name: repo.name.clone(), 119 | org: repo.org.clone(), 120 | description: repo.description.clone(), 121 | homepage: repo.homepage.clone(), 122 | archived: false, 123 | allow_auto_merge: None, 124 | }, 125 | ); 126 | let teams = repo 127 | .teams 128 | .clone() 129 | .into_iter() 130 | .map(|t| api::RepoTeam { 131 | name: t.name, 132 | permission: match t.permission { 133 | RepoPermission::Write => api::RepoPermission::Write, 134 | RepoPermission::Admin => api::RepoPermission::Admin, 135 | RepoPermission::Maintain => api::RepoPermission::Maintain, 136 | RepoPermission::Triage => api::RepoPermission::Triage, 137 | }, 138 | }) 139 | .collect(); 140 | let members = repo 141 | .members 142 | .clone() 143 | .into_iter() 144 | .map(|m| api::RepoUser { 145 | name: m.name, 146 | permission: convert_permission(&m.permission), 147 | }) 148 | .collect(); 149 | org.repo_members 150 | .insert(repo.name.clone(), RepoMembers { teams, members }); 151 | 152 | let repo_v1: v1::Repo = repo.clone().into(); 153 | let mut protections = vec![]; 154 | for protection in &repo.branch_protections { 155 | protections.push(( 156 | format!("{}", protections.len()), 157 | construct_branch_protection(&repo_v1, protection), 158 | )); 159 | } 160 | org.branch_protections 161 | .insert(repo.name.clone(), protections); 162 | } 163 | 164 | if orgs.is_empty() { 165 | orgs.insert(DEFAULT_ORG.to_string(), GithubOrg::default()); 166 | } 167 | 168 | GithubMock { users, orgs } 169 | } 170 | 171 | pub fn diff_teams(&self, github: GithubMock) -> Vec { 172 | self.create_sync(github) 173 | .diff_teams() 174 | .expect("Cannot diff teams") 175 | } 176 | 177 | pub fn diff_repos(&self, github: GithubMock) -> Vec { 178 | self.create_sync(github) 179 | .diff_repos() 180 | .expect("Cannot diff repos") 181 | } 182 | 183 | fn create_sync(&self, github: GithubMock) -> SyncGitHub { 184 | let teams = self.teams.iter().cloned().map(|t| t.into()).collect(); 185 | let repos = self.repos.iter().cloned().map(|r| r.into()).collect(); 186 | 187 | SyncGitHub::new(Box::new(github), teams, repos).expect("Cannot create SyncGitHub") 188 | } 189 | } 190 | 191 | #[derive(Clone, Builder)] 192 | #[builder(pattern = "owned")] 193 | pub struct TeamData { 194 | #[builder(default = "TeamKind::Team")] 195 | kind: TeamKind, 196 | name: String, 197 | #[builder(default)] 198 | gh_teams: Vec, 199 | } 200 | 201 | impl TeamData { 202 | pub fn new(name: &str) -> TeamDataBuilder { 203 | TeamDataBuilder::default().name(name.to_string()) 204 | } 205 | 206 | pub fn add_gh_member(&mut self, team: &str, member: UserId) { 207 | self.github_team(team).members.push(member); 208 | } 209 | 210 | pub fn remove_gh_member(&mut self, team: &str, user: UserId) { 211 | self.github_team(team).members.retain(|u| *u != user); 212 | } 213 | 214 | pub fn remove_gh_team(&mut self, name: &str) { 215 | self.gh_teams.retain(|t| t.name != name); 216 | } 217 | 218 | fn github_team(&mut self, name: &str) -> &mut GitHubTeam { 219 | self.gh_teams 220 | .iter_mut() 221 | .find(|t| t.name == name) 222 | .expect("GitHub team not found") 223 | } 224 | } 225 | 226 | impl From for v1::Team { 227 | fn from(value: TeamData) -> Self { 228 | let TeamData { 229 | name, 230 | kind, 231 | gh_teams, 232 | } = value; 233 | v1::Team { 234 | name: name.clone(), 235 | kind, 236 | subteam_of: None, 237 | top_level: None, 238 | members: vec![], 239 | alumni: vec![], 240 | github: (!gh_teams.is_empty()).then_some(TeamGitHub { teams: gh_teams }), 241 | website_data: None, 242 | roles: vec![], 243 | discord: vec![], 244 | } 245 | } 246 | } 247 | 248 | impl TeamDataBuilder { 249 | pub fn gh_team(mut self, org: &str, name: &str, members: &[UserId]) -> Self { 250 | let mut gh_teams = self.gh_teams.unwrap_or_default(); 251 | gh_teams.push(GitHubTeam { 252 | org: org.to_string(), 253 | name: name.to_string(), 254 | members: members.to_vec(), 255 | }); 256 | self.gh_teams = Some(gh_teams); 257 | self 258 | } 259 | } 260 | 261 | #[derive(Clone, Builder)] 262 | #[builder(pattern = "owned")] 263 | pub struct RepoData { 264 | name: String, 265 | #[builder(default = DEFAULT_ORG.to_string())] 266 | org: String, 267 | #[builder(default)] 268 | pub description: String, 269 | #[builder(default)] 270 | pub homepage: Option, 271 | #[builder(default)] 272 | bots: Vec, 273 | #[builder(default)] 274 | pub teams: Vec, 275 | #[builder(default)] 276 | pub members: Vec, 277 | #[builder(default)] 278 | pub archived: bool, 279 | #[builder(default)] 280 | pub allow_auto_merge: bool, 281 | #[builder(default)] 282 | pub branch_protections: Vec, 283 | } 284 | 285 | impl RepoData { 286 | pub fn new(name: &str) -> RepoDataBuilder { 287 | RepoDataBuilder::default().name(name.to_string()) 288 | } 289 | 290 | pub fn add_member(&mut self, name: &str, permission: RepoPermission) { 291 | self.members.push(v1::RepoMember { 292 | name: name.to_string(), 293 | permission, 294 | }); 295 | } 296 | 297 | pub fn add_team(&mut self, name: &str, permission: RepoPermission) { 298 | self.teams.push(v1::RepoTeam { 299 | name: name.to_string(), 300 | permission, 301 | }); 302 | } 303 | } 304 | 305 | impl From for v1::Repo { 306 | fn from(value: RepoData) -> Self { 307 | let RepoData { 308 | name, 309 | org, 310 | description, 311 | homepage, 312 | bots, 313 | teams, 314 | members, 315 | archived, 316 | allow_auto_merge, 317 | branch_protections, 318 | } = value; 319 | Self { 320 | org, 321 | name: name.clone(), 322 | description, 323 | homepage, 324 | bots, 325 | teams: teams.clone(), 326 | members: members.clone(), 327 | branch_protections, 328 | archived, 329 | private: false, 330 | auto_merge_enabled: allow_auto_merge, 331 | } 332 | } 333 | } 334 | 335 | impl RepoDataBuilder { 336 | pub fn team(mut self, name: &str, permission: RepoPermission) -> Self { 337 | let mut teams = self.teams.clone().unwrap_or_default(); 338 | teams.push(v1::RepoTeam { 339 | name: name.to_string(), 340 | permission, 341 | }); 342 | self.teams = Some(teams); 343 | self 344 | } 345 | 346 | pub fn member(mut self, name: &str, permission: RepoPermission) -> Self { 347 | let mut members = self.members.clone().unwrap_or_default(); 348 | members.push(v1::RepoMember { 349 | name: name.to_string(), 350 | permission, 351 | }); 352 | self.members = Some(members); 353 | self 354 | } 355 | } 356 | 357 | #[derive(Clone)] 358 | pub struct BranchProtectionBuilder { 359 | pub pattern: String, 360 | pub dismiss_stale_review: bool, 361 | pub mode: BranchProtectionMode, 362 | pub allowed_merge_teams: Vec, 363 | pub merge_bots: Vec, 364 | } 365 | 366 | impl BranchProtectionBuilder { 367 | pub fn pr_required(pattern: &str, ci_checks: &[&str], required_approvals: u32) -> Self { 368 | Self::create( 369 | pattern, 370 | BranchProtectionMode::PrRequired { 371 | ci_checks: ci_checks.iter().map(|s| s.to_string()).collect(), 372 | required_approvals, 373 | }, 374 | ) 375 | } 376 | 377 | pub fn pr_not_required(pattern: &str) -> Self { 378 | Self::create(pattern, BranchProtectionMode::PrNotRequired) 379 | } 380 | 381 | pub fn build(self) -> v1::BranchProtection { 382 | let BranchProtectionBuilder { 383 | pattern, 384 | dismiss_stale_review, 385 | mode, 386 | allowed_merge_teams, 387 | merge_bots, 388 | } = self; 389 | v1::BranchProtection { 390 | pattern, 391 | dismiss_stale_review, 392 | mode, 393 | allowed_merge_teams, 394 | merge_bots, 395 | } 396 | } 397 | 398 | fn create(pattern: &str, mode: BranchProtectionMode) -> Self { 399 | Self { 400 | pattern: pattern.to_string(), 401 | mode, 402 | dismiss_stale_review: false, 403 | allowed_merge_teams: vec![], 404 | merge_bots: vec![], 405 | } 406 | } 407 | } 408 | 409 | /// Represents the state of GitHub repositories, teams and users. 410 | #[derive(Default)] 411 | pub struct GithubMock { 412 | // user ID -> login 413 | users: HashMap, 414 | // org name -> organization data 415 | orgs: HashMap, 416 | } 417 | 418 | impl GithubMock { 419 | pub fn add_invitation(&mut self, org: &str, repo: &str, user: &str) { 420 | self.get_org_mut(org) 421 | .team_invitations 422 | .entry(repo.to_string()) 423 | .or_default() 424 | .push(user.to_string()); 425 | } 426 | 427 | fn get_org(&self, org: &str) -> &GithubOrg { 428 | self.orgs 429 | .get(org) 430 | .unwrap_or_else(|| panic!("Org {org} not found")) 431 | } 432 | 433 | fn get_org_mut(&mut self, org: &str) -> &mut GithubOrg { 434 | self.orgs 435 | .get_mut(org) 436 | .unwrap_or_else(|| panic!("Org {org} not found")) 437 | } 438 | } 439 | 440 | impl GithubRead for GithubMock { 441 | fn usernames(&self, ids: &[UserId]) -> anyhow::Result> { 442 | Ok(self 443 | .users 444 | .iter() 445 | .filter(|(k, _)| ids.contains(k)) 446 | .map(|(k, v)| (*k, v.clone())) 447 | .collect()) 448 | } 449 | 450 | fn org_owners(&self, org: &str) -> anyhow::Result> { 451 | Ok(self.get_org(org).owners.iter().copied().collect()) 452 | } 453 | 454 | fn org_teams(&self, org: &str) -> anyhow::Result> { 455 | Ok(self 456 | .get_org(org) 457 | .teams 458 | .iter() 459 | .map(|team| (team.name.clone(), team.slug.clone())) 460 | .collect()) 461 | } 462 | 463 | fn team(&self, org: &str, team: &str) -> anyhow::Result> { 464 | Ok(self 465 | .get_org(org) 466 | .teams 467 | .iter() 468 | .find(|t| t.name == team) 469 | .cloned()) 470 | } 471 | 472 | fn team_memberships( 473 | &self, 474 | team: &Team, 475 | org: &str, 476 | ) -> anyhow::Result> { 477 | Ok(self 478 | .get_org(org) 479 | .team_memberships 480 | .get(&team.name) 481 | .cloned() 482 | .unwrap_or_default()) 483 | } 484 | 485 | fn team_membership_invitations( 486 | &self, 487 | org: &str, 488 | team: &str, 489 | ) -> anyhow::Result> { 490 | Ok(self 491 | .get_org(org) 492 | .team_invitations 493 | .get(team) 494 | .cloned() 495 | .unwrap_or_default() 496 | .into_iter() 497 | .collect()) 498 | } 499 | 500 | fn repo(&self, org: &str, repo: &str) -> anyhow::Result> { 501 | Ok(self 502 | .orgs 503 | .get(org) 504 | .and_then(|org| org.repos.get(repo).cloned())) 505 | } 506 | 507 | fn repo_teams(&self, org: &str, repo: &str) -> anyhow::Result> { 508 | Ok(self 509 | .get_org(org) 510 | .repo_members 511 | .get(repo) 512 | .cloned() 513 | .map(|members| members.teams) 514 | .unwrap_or_default()) 515 | } 516 | 517 | fn repo_collaborators(&self, org: &str, repo: &str) -> anyhow::Result> { 518 | Ok(self 519 | .get_org(org) 520 | .repo_members 521 | .get(repo) 522 | .cloned() 523 | .map(|members| members.members) 524 | .unwrap_or_default()) 525 | } 526 | 527 | fn branch_protections( 528 | &self, 529 | org: &str, 530 | repo: &str, 531 | ) -> anyhow::Result> { 532 | let Some(protections) = self.get_org(org).branch_protections.get(repo) else { 533 | return Ok(Default::default()); 534 | }; 535 | let mut result = HashMap::default(); 536 | for (id, protection) in protections { 537 | result.insert(protection.pattern.clone(), (id.clone(), protection.clone())); 538 | } 539 | 540 | Ok(result) 541 | } 542 | } 543 | 544 | #[derive(Default)] 545 | struct GithubOrg { 546 | members: BTreeSet, 547 | owners: BTreeSet, 548 | teams: Vec, 549 | // Team name -> list of invited users 550 | team_invitations: HashMap>, 551 | // Team name -> members 552 | team_memberships: HashMap>, 553 | // Repo name -> repo data 554 | repos: HashMap, 555 | // Repo name -> (teams, members) 556 | repo_members: HashMap, 557 | // Repo name -> Vec<(protection ID, branch protection)> 558 | branch_protections: HashMap>, 559 | } 560 | 561 | #[derive(Clone)] 562 | pub struct RepoMembers { 563 | teams: Vec, 564 | members: Vec, 565 | } 566 | -------------------------------------------------------------------------------- /src/mailgun/api.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use log::info; 3 | use reqwest::{ 4 | Method, 5 | blocking::{Client, RequestBuilder}, 6 | header::{self, HeaderValue}, 7 | }; 8 | use secrecy::{ExposeSecret, SecretString}; 9 | 10 | pub(super) struct Mailgun { 11 | token: SecretString, 12 | client: Client, 13 | dry_run: bool, 14 | } 15 | 16 | impl Mailgun { 17 | pub(super) fn new(token: SecretString, dry_run: bool) -> Self { 18 | Self { 19 | token, 20 | client: Client::new(), 21 | dry_run, 22 | } 23 | } 24 | 25 | pub(super) fn get_routes(&self, skip: Option) -> Result { 26 | let url = if let Some(skip) = skip { 27 | format!("routes?skip={skip}") 28 | } else { 29 | "routes".into() 30 | }; 31 | Ok(self 32 | .request(Method::GET, &url) 33 | .send()? 34 | .error_for_status()? 35 | .json()?) 36 | } 37 | 38 | pub(super) fn create_route( 39 | &self, 40 | priority: i32, 41 | description: &str, 42 | expression: &str, 43 | actions: &[String], 44 | ) -> Result<(), Error> { 45 | if self.dry_run { 46 | return Ok(()); 47 | } 48 | 49 | let priority_str = priority.to_string(); 50 | let mut form = vec![ 51 | ("priority", priority_str.as_str()), 52 | ("description", description), 53 | ("expression", expression), 54 | ]; 55 | for action in actions { 56 | form.push(("action", action.as_str())); 57 | } 58 | 59 | self.request(Method::POST, "routes") 60 | .form(&form) 61 | .send()? 62 | .error_for_status()?; 63 | 64 | Ok(()) 65 | } 66 | 67 | pub(super) fn update_route( 68 | &self, 69 | id: &str, 70 | priority: i32, 71 | actions: &[String], 72 | ) -> Result<(), Error> { 73 | if self.dry_run { 74 | return Ok(()); 75 | } 76 | 77 | let priority_str = priority.to_string(); 78 | let mut form = vec![("priority", priority_str.as_str())]; 79 | for action in actions { 80 | form.push(("action", action.as_str())); 81 | } 82 | 83 | self.request(Method::PUT, &format!("routes/{id}")) 84 | .form(&form) 85 | .send()? 86 | .error_for_status()?; 87 | 88 | Ok(()) 89 | } 90 | 91 | pub(super) fn delete_route(&self, id: &str) -> Result<(), Error> { 92 | info!("deleting route with ID {}", id); 93 | if self.dry_run { 94 | return Ok(()); 95 | } 96 | 97 | self.request(Method::DELETE, &format!("routes/{id}")) 98 | .send()? 99 | .error_for_status()?; 100 | Ok(()) 101 | } 102 | 103 | fn request(&self, method: Method, url: &str) -> RequestBuilder { 104 | let url = if url.starts_with("https://") { 105 | url.into() 106 | } else { 107 | format!("https://api.mailgun.net/v3/{url}") 108 | }; 109 | 110 | self.client 111 | .request(method, url) 112 | .basic_auth("api", Some(&self.token.expose_secret())) 113 | .header( 114 | header::USER_AGENT, 115 | HeaderValue::from_static(crate::USER_AGENT), 116 | ) 117 | } 118 | } 119 | 120 | #[derive(serde::Deserialize)] 121 | pub(super) struct RoutesResponse { 122 | pub(super) items: Vec, 123 | pub(super) total_count: u64, 124 | } 125 | 126 | #[derive(serde::Deserialize)] 127 | pub(super) struct Route { 128 | pub(super) actions: Vec, 129 | pub(super) expression: String, 130 | pub(super) id: String, 131 | pub(super) priority: i32, 132 | pub(super) description: serde_json::Value, 133 | } 134 | -------------------------------------------------------------------------------- /src/mailgun/mod.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | 3 | use std::collections::{HashMap, HashSet}; 4 | use std::str; 5 | 6 | use self::api::Mailgun; 7 | use crate::TeamApi; 8 | use anyhow::{Context, bail}; 9 | use log::info; 10 | use rust_team_data::{email_encryption, v1 as team_data}; 11 | use secrecy::SecretString; 12 | 13 | const DESCRIPTION: &str = "managed by an automatic script on github"; 14 | 15 | // Limit (in bytes) of the size of a Mailgun rule's actions list. 16 | const ACTIONS_SIZE_LIMIT_BYTES: usize = 4000; 17 | 18 | #[derive(Debug, Clone, PartialEq, Eq)] 19 | struct List { 20 | address: String, 21 | members: Vec, 22 | priority: i32, 23 | } 24 | 25 | fn mangle_lists(email_encryption_key: &str, lists: team_data::Lists) -> anyhow::Result> { 26 | let mut result = Vec::new(); 27 | 28 | for (_key, mut list) in lists.lists.into_iter() { 29 | // Handle encrypted list addresses. 30 | list.address = email_encryption::try_decrypt(email_encryption_key, &list.address)?; 31 | 32 | let base_list = List { 33 | address: mangle_address(&list.address)?, 34 | members: Vec::new(), 35 | priority: 0, 36 | }; 37 | 38 | // Mailgun only supports at most 4000 bytes of "actions" for each rule, and some of our 39 | // lists have so many members we're going over that limit. 40 | // 41 | // The official workaround for this, as explained in the docs [1], is to create multiple 42 | // rules, all with the same filter but each with a different set of actions. This snippet 43 | // of code implements that. 44 | // 45 | // Since all the lists have the same address, to differentiate them during the sync this 46 | // also sets the priority of the rule to the partition number. 47 | // 48 | // [1] https://documentation.mailgun.com/en/latest/user_manual.html#routes 49 | let mut current_list = base_list.clone(); 50 | let mut current_actions_len = 0; 51 | let mut partitions_count = 0; 52 | for mut member in list.members { 53 | // Handle encrypted member email addresses. 54 | member = email_encryption::try_decrypt(email_encryption_key, &member)?; 55 | 56 | let action = build_route_action(&member); 57 | if current_actions_len + action.len() > ACTIONS_SIZE_LIMIT_BYTES { 58 | partitions_count += 1; 59 | result.push(current_list); 60 | 61 | current_list = base_list.clone(); 62 | current_list.priority = partitions_count; 63 | current_actions_len = 0; 64 | } 65 | 66 | current_actions_len += action.len(); 67 | current_list.members.push(member); 68 | } 69 | 70 | result.push(current_list); 71 | } 72 | 73 | Ok(result) 74 | } 75 | 76 | fn mangle_address(addr: &str) -> anyhow::Result { 77 | // Escape dots since they have a special meaning in Python regexes 78 | let mangled = addr.replace('.', "\\."); 79 | 80 | // Inject (?:\+.+)? before the '@' in the address to support '+' aliases like 81 | // infra+botname@rust-lang.org 82 | if let Some(at_pos) = mangled.find('@') { 83 | let (user, domain) = mangled.split_at(at_pos); 84 | Ok(format!("^{user}(?:\\+.+)?{domain}$")) 85 | } else { 86 | bail!("the address `{}` doesn't have any '@'", addr); 87 | } 88 | } 89 | 90 | pub(crate) fn run( 91 | token: SecretString, 92 | email_encryption_key: &str, 93 | team_api: &TeamApi, 94 | dry_run: bool, 95 | ) -> anyhow::Result<()> { 96 | let mailgun = Mailgun::new(token, dry_run); 97 | let mailmap = team_api.get_lists()?; 98 | 99 | // Mangle all the mailing lists 100 | let lists = mangle_lists(email_encryption_key, mailmap)?; 101 | 102 | let mut routes = Vec::new(); 103 | let mut response = mailgun.get_routes(None)?; 104 | let mut cur = 0u64; 105 | while !response.items.is_empty() { 106 | cur += response.items.len() as u64; 107 | routes.extend(response.items); 108 | if cur >= response.total_count { 109 | break; 110 | } 111 | response = mailgun.get_routes(Some(cur))?; 112 | } 113 | 114 | let mut addr2list = HashMap::new(); 115 | for list in &lists { 116 | if addr2list 117 | .insert((list.address.clone(), list.priority), list) 118 | .is_some() 119 | { 120 | bail!( 121 | "duplicate address: {} (with priority {})", 122 | list.address, 123 | list.priority 124 | ); 125 | } 126 | } 127 | 128 | for route in routes { 129 | if route.description != DESCRIPTION { 130 | continue; 131 | } 132 | let address = extract(&route.expression, "match_recipient(\"", "\")"); 133 | let key = (address.to_string(), route.priority); 134 | match addr2list.remove(&key) { 135 | Some(new_list) => sync(&mailgun, &route, new_list) 136 | .with_context(|| format!("failed to sync {address}"))?, 137 | None => mailgun 138 | .delete_route(&route.id) 139 | .with_context(|| format!("failed to delete {address}"))?, 140 | } 141 | } 142 | 143 | for (_, list) in addr2list.iter() { 144 | create(&mailgun, list).with_context(|| format!("failed to create {}", list.address))?; 145 | } 146 | 147 | Ok(()) 148 | } 149 | 150 | fn build_route_action(member: &str) -> String { 151 | format!("forward(\"{member}\")") 152 | } 153 | 154 | fn build_route_actions(list: &List) -> impl Iterator + '_ { 155 | list.members.iter().map(|member| build_route_action(member)) 156 | } 157 | 158 | fn create(mailgun: &Mailgun, list: &List) -> anyhow::Result<()> { 159 | info!("creating list {}", list.address); 160 | 161 | let expr = format!("match_recipient(\"{}\")", list.address); 162 | let actions = build_route_actions(list).collect::>(); 163 | mailgun.create_route(list.priority, DESCRIPTION, &expr, &actions)?; 164 | Ok(()) 165 | } 166 | 167 | fn sync(mailgun: &Mailgun, route: &api::Route, list: &List) -> anyhow::Result<()> { 168 | let before = route 169 | .actions 170 | .iter() 171 | .map(|action| extract(action, "forward(\"", "\")")) 172 | .collect::>(); 173 | let after = list.members.iter().map(|s| &s[..]).collect::>(); 174 | if before == after { 175 | return Ok(()); 176 | } 177 | 178 | info!("updating list {}", list.address); 179 | let actions = build_route_actions(list).collect::>(); 180 | mailgun.update_route(&route.id, list.priority, &actions)?; 181 | Ok(()) 182 | } 183 | 184 | fn extract<'a>(s: &'a str, prefix: &str, suffix: &str) -> &'a str { 185 | assert!(s.starts_with(prefix), "`{s}` didn't start with `{prefix}`"); 186 | assert!(s.ends_with(suffix), "`{s}` didn't end with `{suffix}`"); 187 | &s[prefix.len()..s.len() - suffix.len()] 188 | } 189 | 190 | #[cfg(test)] 191 | mod tests { 192 | use super::*; 193 | 194 | #[test] 195 | fn test_build_route_actions() { 196 | let list = List { 197 | address: "list@example.com".into(), 198 | members: vec![ 199 | "foo@example.com".into(), 200 | "bar@example.com".into(), 201 | "baz@example.net".into(), 202 | ], 203 | priority: 0, 204 | }; 205 | 206 | assert_eq!( 207 | vec![ 208 | "forward(\"foo@example.com\")", 209 | "forward(\"bar@example.com\")", 210 | "forward(\"baz@example.net\")", 211 | ], 212 | build_route_actions(&list).collect::>() 213 | ); 214 | } 215 | 216 | #[test] 217 | fn test_mangle_address() { 218 | assert_eq!( 219 | r"^list-name(?:\+.+)?@example\.com$", 220 | mangle_address("list-name@example.com").unwrap() 221 | ); 222 | assert!(mangle_address("list-name.example.com").is_err()); 223 | } 224 | 225 | #[test] 226 | fn test_mangle_lists() { 227 | const ENCRYPTION_KEY: &str = "mGDTk1eIx8P2gTerzKXwvun67d41iUid"; 228 | 229 | let secret_list = email_encryption::encrypt(ENCRYPTION_KEY, "secret-list@example.com") 230 | .expect("failed to encrypt list"); 231 | let secret_member = email_encryption::encrypt(ENCRYPTION_KEY, "secret-member@example.com") 232 | .expect("failed to encrypt member"); 233 | 234 | let original = rust_team_data::v1::Lists { 235 | lists: indexmap::indexmap![ 236 | "small@example.com".to_string() => rust_team_data::v1::List { 237 | address: "small@example.com".into(), 238 | members: vec![ 239 | "foo@example.com".into(), 240 | "bar@example.com".into(), 241 | secret_member.clone(), 242 | ], 243 | }, 244 | secret_list.clone() => rust_team_data::v1::List { 245 | address: secret_list, 246 | members: vec![secret_member, "baz@example.com".into()] 247 | }, 248 | "big@example.com".into() => rust_team_data::v1::List { 249 | address: "big@example.com".into(), 250 | // Generate 300 members automatically to simulate a big list, and test whether the 251 | // partitioning mechanism works. 252 | members: (0..300).map(|i| format!("foo{i:03}@example.com")).collect(), 253 | }, 254 | ], 255 | }; 256 | 257 | let mangled = mangle_lists(ENCRYPTION_KEY, original).unwrap(); 258 | let expected = vec![ 259 | List { 260 | address: mangle_address("small@example.com").unwrap(), 261 | priority: 0, 262 | members: vec![ 263 | "foo@example.com".into(), 264 | "bar@example.com".into(), 265 | "secret-member@example.com".into(), 266 | ], 267 | }, 268 | List { 269 | address: mangle_address("secret-list@example.com").unwrap(), 270 | priority: 0, 271 | members: vec!["secret-member@example.com".into(), "baz@example.com".into()], 272 | }, 273 | // With ACTIONS_SIZE_LIMIT_BYTES = 4000, each list can contain at most 137 users named 274 | // `fooNNN@example.com`. If the limit is changed the numbers will need to be updated. 275 | List { 276 | address: mangle_address("big@example.com").unwrap(), 277 | priority: 0, 278 | members: (0..137) 279 | .map(|i| format!("foo{i:03}@example.com")) 280 | .collect::>(), 281 | }, 282 | List { 283 | address: mangle_address("big@example.com").unwrap(), 284 | priority: 1, 285 | members: (137..274) 286 | .map(|i| format!("foo{i:03}@example.com")) 287 | .collect::>(), 288 | }, 289 | List { 290 | address: mangle_address("big@example.com").unwrap(), 291 | priority: 2, 292 | members: (274..300) 293 | .map(|i| format!("foo{i:03}@example.com")) 294 | .collect::>(), 295 | }, 296 | ]; 297 | assert_eq!(expected, mangled); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod github; 2 | mod mailgun; 3 | mod team_api; 4 | mod utils; 5 | mod zulip; 6 | 7 | use crate::github::{GitHubApiRead, GitHubWrite, HttpClient, create_diff}; 8 | use crate::team_api::TeamApi; 9 | use crate::zulip::SyncZulip; 10 | use anyhow::Context; 11 | use clap::Parser; 12 | use log::{error, info, warn}; 13 | use secrecy::SecretString; 14 | use std::path::PathBuf; 15 | 16 | const AVAILABLE_SERVICES: &[&str] = &["github", "mailgun", "zulip"]; 17 | const USER_AGENT: &str = "rust-lang teams sync (https://github.com/rust-lang/sync-team)"; 18 | 19 | /// Tooling that performs changes on GitHub, MailGun and Zulip. 20 | /// 21 | /// Environment variables: 22 | /// - GITHUB_TOKEN Authentication token with GitHub 23 | /// - MAILGUN_API_TOKEN Authentication token with Mailgun 24 | /// - EMAIL_ENCRYPTION_KEY Key used to decrypt encrypted emails in the team repo 25 | /// - ZULIP_USERNAME Username of the Zulip bot 26 | /// - ZULIP_API_TOKEN Authentication token of the Zulip bot 27 | #[derive(clap::Parser, Debug)] 28 | #[clap(verbatim_doc_comment)] 29 | struct Args { 30 | /// Comma-separated list of available services 31 | #[clap(long, global(true), value_parser = clap::builder::PossibleValuesParser::new( 32 | AVAILABLE_SERVICES 33 | ), value_delimiter = ',')] 34 | services: Vec, 35 | 36 | /// Path to a checkout of `rust-lang/team`. 37 | #[clap(long, global(true), conflicts_with = "team_json")] 38 | team_repo: Option, 39 | 40 | /// Path to a directory with prebuilt JSON data from the `team` repository. 41 | #[clap(long, global(true))] 42 | team_json: Option, 43 | 44 | #[clap(subcommand)] 45 | command: Option, 46 | } 47 | 48 | #[derive(clap::Parser, Debug)] 49 | enum SubCommand { 50 | /// Try to apply changes, but do not send any outgoing API requests. 51 | DryRun, 52 | /// Only print a diff of what would be changed. 53 | PrintPlan, 54 | /// Apply the changes to the specified services. 55 | Apply, 56 | } 57 | 58 | fn app() -> anyhow::Result<()> { 59 | let args = Args::parse(); 60 | 61 | let team_api = if let Some(path) = args.team_repo { 62 | TeamApi::Checkout(path) 63 | } else if let Some(path) = args.team_json { 64 | TeamApi::Prebuilt(path) 65 | } else { 66 | TeamApi::Production 67 | }; 68 | 69 | let mut services = args.services; 70 | if services.is_empty() { 71 | info!("no service to synchronize specified, defaulting to all services"); 72 | services = AVAILABLE_SERVICES 73 | .iter() 74 | .map(|s| (*s).to_string()) 75 | .collect(); 76 | } 77 | 78 | let subcmd = args.command.unwrap_or(SubCommand::DryRun); 79 | let only_print_plan = matches!(subcmd, SubCommand::PrintPlan); 80 | let dry_run = only_print_plan || matches!(subcmd, SubCommand::DryRun); 81 | 82 | if dry_run { 83 | warn!("sync-team is running in dry mode, no changes will be applied."); 84 | } 85 | 86 | for service in services { 87 | info!("synchronizing {}", service); 88 | match service.as_str() { 89 | "github" => { 90 | let client = HttpClient::new()?; 91 | let gh_read = Box::new(GitHubApiRead::from_client(client.clone())?); 92 | let teams = team_api.get_teams()?; 93 | let repos = team_api.get_repos()?; 94 | let diff = create_diff(gh_read, teams, repos)?; 95 | if !diff.is_empty() { 96 | info!("{}", diff); 97 | } 98 | if !only_print_plan { 99 | let gh_write = GitHubWrite::new(client, dry_run)?; 100 | diff.apply(&gh_write)?; 101 | } 102 | } 103 | "mailgun" => { 104 | let token = SecretString::from(get_env("MAILGUN_API_TOKEN")?); 105 | let encryption_key = get_env("EMAIL_ENCRYPTION_KEY")?; 106 | mailgun::run(token, &encryption_key, &team_api, dry_run)?; 107 | } 108 | "zulip" => { 109 | let username = get_env("ZULIP_USERNAME")?; 110 | let token = SecretString::from(get_env("ZULIP_API_TOKEN")?); 111 | let sync = SyncZulip::new(username, token, &team_api, dry_run)?; 112 | let diff = sync.diff_all()?; 113 | if !diff.is_empty() { 114 | info!("{}", diff); 115 | } 116 | if !only_print_plan { 117 | diff.apply(&sync)?; 118 | } 119 | } 120 | _ => panic!("unknown service: {service}"), 121 | } 122 | } 123 | 124 | Ok(()) 125 | } 126 | 127 | fn get_env(key: &str) -> anyhow::Result { 128 | std::env::var(key).with_context(|| format!("failed to get the {key} environment variable")) 129 | } 130 | 131 | fn main() { 132 | init_log(); 133 | if let Err(err) = app() { 134 | // Display shows just the first element of the chain. 135 | error!("failed: {}", err); 136 | for cause in err.chain().skip(1) { 137 | error!("caused by: {}", cause); 138 | } 139 | std::process::exit(1); 140 | } 141 | } 142 | 143 | fn init_log() { 144 | let mut env = env_logger::Builder::new(); 145 | env.filter_module("sync_team", log::LevelFilter::Info); 146 | if let Ok(content) = std::env::var("RUST_LOG") { 147 | env.parse_filters(&content); 148 | } 149 | env.init(); 150 | } 151 | -------------------------------------------------------------------------------- /src/team_api.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::ResponseExt; 2 | use log::{debug, info, trace}; 3 | use std::borrow::Cow; 4 | use std::path::PathBuf; 5 | use std::process::Command; 6 | 7 | /// Determines how do we get access to the ground-truth data from `rust-lang/team`. 8 | pub(crate) enum TeamApi { 9 | /// Access the live data from the published production REST API. 10 | Production, 11 | /// Build the JSON data from a checkout of `rust-lang/team`. 12 | Checkout(PathBuf), 13 | /// Directly access a directory with prebuilt JSON data. 14 | Prebuilt(PathBuf), 15 | } 16 | 17 | impl TeamApi { 18 | pub(crate) fn get_teams(&self) -> anyhow::Result> { 19 | debug!("loading teams list from the Team API"); 20 | Ok(self 21 | .req::("teams.json")? 22 | .teams 23 | .into_iter() 24 | .map(|(_k, v)| v) 25 | .collect()) 26 | } 27 | 28 | pub(crate) fn get_repos(&self) -> anyhow::Result> { 29 | debug!("loading teams list from the Team API"); 30 | Ok(self 31 | .req::("repos.json")? 32 | .repos 33 | .into_iter() 34 | .flat_map(|(_k, v)| v) 35 | .collect()) 36 | } 37 | 38 | pub(crate) fn get_lists(&self) -> anyhow::Result { 39 | debug!("loading email lists list from the Team API"); 40 | self.req::("lists.json") 41 | } 42 | 43 | pub(crate) fn get_zulip_groups(&self) -> anyhow::Result { 44 | debug!("loading GitHub id to Zulip id map from the Team API"); 45 | self.req::("zulip-groups.json") 46 | } 47 | 48 | pub(crate) fn get_zulip_streams(&self) -> anyhow::Result { 49 | debug!("loading Zulip streams from the Team API"); 50 | self.req::("zulip-streams.json") 51 | } 52 | 53 | fn req(&self, url: &str) -> anyhow::Result { 54 | match self { 55 | TeamApi::Production => { 56 | let base = std::env::var("TEAM_DATA_BASE_URL") 57 | .map(Cow::Owned) 58 | .unwrap_or_else(|_| Cow::Borrowed(rust_team_data::v1::BASE_URL)); 59 | let url = format!("{base}/{url}"); 60 | trace!("http request: GET {}", url); 61 | Ok(reqwest::blocking::get(&url)? 62 | .error_for_status()? 63 | .json_annotated()?) 64 | } 65 | TeamApi::Checkout(path) => { 66 | let dest = tempfile::tempdir()?; 67 | info!( 68 | "generating the content of the Team API from {}", 69 | path.display() 70 | ); 71 | let status = Command::new("cargo") 72 | .arg("run") 73 | .arg("--") 74 | .arg("static-api") 75 | .arg(dest.path()) 76 | .env("RUST_LOG", "rust_team=warn") 77 | .current_dir(path) 78 | .status()?; 79 | if status.success() { 80 | info!("contents of the Team API generated successfully"); 81 | let contents = std::fs::read(dest.path().join("v1").join(url))?; 82 | Ok(serde_json::from_slice(&contents)?) 83 | } else { 84 | anyhow::bail!("failed to generate the contents of the Team API"); 85 | } 86 | } 87 | TeamApi::Prebuilt(directory) => { 88 | let contents = std::fs::read(directory.join("v1").join(url))?; 89 | Ok(serde_json::from_slice(&contents)?) 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use reqwest::blocking::Response; 3 | use serde::de::DeserializeOwned; 4 | use std::str::FromStr; 5 | 6 | pub trait ResponseExt { 7 | fn custom_error_for_status(self) -> anyhow::Result; 8 | fn json_annotated(self) -> anyhow::Result; 9 | } 10 | 11 | impl ResponseExt for Response { 12 | fn custom_error_for_status(self) -> anyhow::Result { 13 | match self.error_for_status_ref() { 14 | Ok(_) => Ok(self), 15 | Err(err) => { 16 | let body = self.text().context("failed to read response body")?; 17 | Err(err).context(format!("Body: {body:?}")) 18 | } 19 | } 20 | } 21 | 22 | /// Try to load the response as JSON. If it fails, include the response body 23 | /// as text in the error message, so that it is easier to understand what was 24 | /// the problem. 25 | fn json_annotated(self) -> anyhow::Result { 26 | let text = self.text()?; 27 | 28 | serde_json::from_str::(&text).with_context(|| { 29 | // Try to at least deserialize as generic JSON, to provide a more readable 30 | // visualization of the response body. 31 | let body_content = serde_json::Value::from_str(&text) 32 | .and_then(|v| serde_json::to_string_pretty(&v)) 33 | .unwrap_or(text); 34 | 35 | format!( 36 | "Cannot deserialize type `{}` from the following response body:\n{body_content}", 37 | std::any::type_name::(), 38 | ) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/zulip/api.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Context as _; 4 | use reqwest::blocking::Client; 5 | use secrecy::{ExposeSecret, SecretString}; 6 | use serde::Deserialize; 7 | 8 | const ZULIP_BASE_URL: &str = "https://rust-lang.zulipchat.com/api/v1"; 9 | 10 | /// Access to the Zulip API 11 | #[derive(Clone)] 12 | pub(crate) struct ZulipApi { 13 | client: Client, 14 | username: String, 15 | token: SecretString, 16 | dry_run: bool, 17 | } 18 | 19 | impl ZulipApi { 20 | /// Create a new `ZulipApi` instance 21 | pub(crate) fn new(username: String, token: SecretString, dry_run: bool) -> Self { 22 | Self { 23 | client: Client::new(), 24 | username, 25 | token, 26 | dry_run, 27 | } 28 | } 29 | 30 | /// Creates a Zulip user group with the supplied name, description, and members 31 | /// 32 | /// This is a noop if the user group already exists. 33 | pub(crate) fn create_user_group( 34 | &self, 35 | user_group_name: &str, 36 | description: &str, 37 | member_ids: &[u64], 38 | ) -> anyhow::Result<()> { 39 | log::info!( 40 | "creating Zulip user group '{}' with description '{}' and member ids: {:?}", 41 | user_group_name, 42 | description, 43 | member_ids 44 | ); 45 | if self.dry_run { 46 | return Ok(()); 47 | } 48 | 49 | let member_ids = serialize_as_array(member_ids); 50 | let mut form = HashMap::new(); 51 | form.insert("name", user_group_name); 52 | form.insert("description", description); 53 | form.insert("members", &member_ids); 54 | 55 | let r = self.req(reqwest::Method::POST, "/user_groups/create", Some(form))?; 56 | if r.status() == 400 { 57 | let body = r.json::()?; 58 | let err = || { 59 | anyhow::format_err!( 60 | "got 400 when creating user group {}: {}", 61 | user_group_name, 62 | body 63 | ) 64 | }; 65 | let error = body.get("msg").ok_or_else(err)?.as_str().ok_or_else(err)?; 66 | if error.contains("already exists") { 67 | log::debug!("Zulip user group '{}' already existed", user_group_name); 68 | return Ok(()); 69 | } else { 70 | return Err(err()); 71 | } 72 | } 73 | 74 | r.error_for_status()?; 75 | 76 | Ok(()) 77 | } 78 | 79 | /// Get all user groups of the Rust Zulip instance 80 | pub(crate) fn get_user_groups(&self) -> anyhow::Result> { 81 | let response = self 82 | .req(reqwest::Method::GET, "/user_groups", None)? 83 | .error_for_status()? 84 | .json::()? 85 | .user_groups; 86 | 87 | Ok(response) 88 | } 89 | 90 | /// Get all streams of the Rust Zulip instance 91 | pub(crate) fn get_streams(&self) -> anyhow::Result> { 92 | let mut form = HashMap::new(); 93 | form.insert("include_web_public", "true"); 94 | form.insert("include_all_active", "true"); 95 | 96 | let response = self 97 | .req(reqwest::Method::GET, "/streams", Some(form))? 98 | .error_for_status()? 99 | .json::()? 100 | .streams; 101 | 102 | Ok(response) 103 | } 104 | 105 | /// Get all members a stream in the Rust Zulip instance 106 | pub(crate) fn get_stream_members(&self, stream_id: u64) -> anyhow::Result> { 107 | let response = self 108 | .req( 109 | reqwest::Method::GET, 110 | &format!("/streams/{stream_id}/members"), 111 | None, 112 | )? 113 | .error_for_status()? 114 | .json::()? 115 | .subscribers; 116 | 117 | Ok(response) 118 | } 119 | 120 | /// Get all users of the Rust Zulip instance 121 | pub(crate) fn get_users(&self) -> anyhow::Result> { 122 | let response = self 123 | .req(reqwest::Method::GET, "/users", None)? 124 | .error_for_status()? 125 | .json::()? 126 | .members; 127 | 128 | Ok(response) 129 | } 130 | 131 | /// Is a Zulip stream private? 132 | pub(crate) fn is_stream_private(&self, stream_id: u64) -> anyhow::Result { 133 | let stream = self.get_stream(stream_id).with_context(|| { 134 | format!("Failed to determine if stream with id {stream_id} is private") 135 | })?; 136 | Ok(stream.invite_only) 137 | } 138 | 139 | fn get_stream(&self, stream_id: u64) -> anyhow::Result { 140 | #[derive(Deserialize)] 141 | struct OneZulipStream { 142 | stream: ZulipStream, 143 | } 144 | 145 | Ok(self 146 | .req(reqwest::Method::GET, &format!("/streams/{stream_id}"), None)? 147 | .error_for_status()? 148 | .json::()? 149 | .stream) 150 | } 151 | 152 | pub(crate) fn update_user_group_members( 153 | &self, 154 | user_group_id: u64, 155 | add_ids: &[u64], 156 | remove_ids: &[u64], 157 | ) -> anyhow::Result<()> { 158 | if add_ids.is_empty() && remove_ids.is_empty() { 159 | log::debug!( 160 | "user group {} does not need to have its group members updated", 161 | user_group_id 162 | ); 163 | return Ok(()); 164 | } 165 | 166 | log::info!( 167 | "updating user group {} by adding {:?} and removing {:?}", 168 | user_group_id, 169 | add_ids, 170 | remove_ids 171 | ); 172 | 173 | if self.dry_run { 174 | return Ok(()); 175 | } 176 | 177 | let add_ids = serialize_as_array(add_ids); 178 | let remove_ids = serialize_as_array(remove_ids); 179 | let mut form = HashMap::new(); 180 | form.insert("add", add_ids.as_str()); 181 | form.insert("delete", remove_ids.as_str()); 182 | 183 | let path = format!("/user_groups/{user_group_id}/members"); 184 | let response = self.req(reqwest::Method::POST, &path, Some(form))?; 185 | 186 | if response.status() == 400 { 187 | log::warn!( 188 | "failed to update group membership with a bad request: {}", 189 | response 190 | .text() 191 | .unwrap_or_else(|_| String::from("")) 192 | ); 193 | return Ok(()); 194 | } 195 | 196 | response.error_for_status()?; 197 | Ok(()) 198 | } 199 | 200 | pub(crate) fn update_stream_membership( 201 | &self, 202 | stream_name: &str, 203 | stream_id: u64, 204 | add_ids: &[u64], 205 | remove_ids: &[u64], 206 | ) -> anyhow::Result<()> { 207 | if add_ids.is_empty() && remove_ids.is_empty() { 208 | log::debug!( 209 | "stream {} does not need to have its members updated", 210 | stream_id 211 | ); 212 | return Ok(()); 213 | } 214 | 215 | log::info!( 216 | "updating stream {} by adding {:?} and removing {:?}", 217 | stream_id, 218 | add_ids, 219 | remove_ids 220 | ); 221 | 222 | if self.dry_run { 223 | return Ok(()); 224 | } 225 | 226 | let submit = |method: reqwest::Method, 227 | subscriptions: String, 228 | principals: String| 229 | -> anyhow::Result<()> { 230 | let mut form = HashMap::new(); 231 | form.insert("subscriptions", subscriptions.as_str()); 232 | form.insert("principals", principals.as_str()); 233 | 234 | let response = self.req(method, "/users/me/subscriptions", Some(form.clone()))?; 235 | 236 | if response.status() == 400 { 237 | log::warn!( 238 | "failed to update stream membership with a bad request: {}. Sent form: {form:?}", 239 | response 240 | .text() 241 | .unwrap_or_else(|_| String::from("")) 242 | ); 243 | return Ok(()); 244 | } 245 | response.error_for_status()?; 246 | 247 | Ok(()) 248 | }; 249 | 250 | if !add_ids.is_empty() { 251 | let subscriptions = serde_json::to_string(&serde_json::json!([{ 252 | "name": stream_name, 253 | }]))?; 254 | let add_ids = serialize_as_array(add_ids); 255 | submit(reqwest::Method::POST, subscriptions, add_ids)?; 256 | } 257 | 258 | if !remove_ids.is_empty() { 259 | let subscriptions = serde_json::to_string(&serde_json::json!([{ 260 | "name": stream_name, 261 | }]))?; 262 | let remove_ids = serialize_as_array(remove_ids); 263 | submit(reqwest::Method::DELETE, subscriptions, remove_ids)?; 264 | } 265 | 266 | Ok(()) 267 | } 268 | 269 | /// Perform a request against the Zulip API 270 | fn req( 271 | &self, 272 | method: reqwest::Method, 273 | path: &str, 274 | form: Option>, 275 | ) -> anyhow::Result { 276 | let mut req = self 277 | .client 278 | .request(method, format!("{ZULIP_BASE_URL}{path}")) 279 | .basic_auth(&self.username, Some(&self.token.expose_secret())); 280 | if let Some(form) = form { 281 | req = req.form(&form); 282 | } 283 | 284 | Ok(req.send()?) 285 | } 286 | } 287 | 288 | /// Serialize a slice of numbers as a JSON array 289 | fn serialize_as_array(items: &[u64]) -> String { 290 | serde_json::to_string(&items).expect("cannot serialize JSON array") 291 | } 292 | 293 | /// A collection of Zulip users 294 | #[derive(Deserialize)] 295 | struct ZulipUsers { 296 | members: Vec, 297 | } 298 | 299 | /// A single Zulip user 300 | #[derive(Deserialize)] 301 | pub(crate) struct ZulipUser { 302 | // Note: users may hide their emails 303 | #[serde(rename = "delivery_email")] 304 | pub(crate) email: Option, 305 | pub(crate) user_id: u64, 306 | } 307 | 308 | /// A collection of Zulip user groups 309 | #[derive(Deserialize)] 310 | struct ZulipUserGroups { 311 | user_groups: Vec, 312 | } 313 | 314 | /// A single Zulip user group 315 | #[derive(Deserialize)] 316 | pub(crate) struct ZulipUserGroup { 317 | pub(crate) id: u64, 318 | pub(crate) name: String, 319 | pub(crate) members: Vec, 320 | } 321 | 322 | /// A collection of Zulip streams 323 | #[derive(Deserialize)] 324 | struct ZulipStreams { 325 | streams: Vec, 326 | } 327 | 328 | /// A single Zulip stream 329 | #[derive(Deserialize)] 330 | pub(crate) struct ZulipStream { 331 | pub(crate) stream_id: u64, 332 | pub(crate) name: String, 333 | pub(crate) invite_only: bool, 334 | } 335 | 336 | /// Membership of a Zulip stream 337 | #[derive(Deserialize)] 338 | struct ZulipStreamMembership { 339 | subscribers: Vec, 340 | } 341 | -------------------------------------------------------------------------------- /src/zulip/mod.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | 3 | use crate::team_api::TeamApi; 4 | use anyhow::Context; 5 | use api::{ZulipApi, ZulipStream, ZulipUserGroup}; 6 | use rust_team_data::v1::{ZulipGroupMember, ZulipStreamMember}; 7 | 8 | use secrecy::SecretString; 9 | use std::collections::BTreeMap; 10 | 11 | pub(crate) struct SyncZulip { 12 | zulip_controller: ZulipController, 13 | stream_definitions: BTreeMap>, 14 | user_group_definitions: BTreeMap>, 15 | } 16 | 17 | impl SyncZulip { 18 | pub(crate) fn new( 19 | username: String, 20 | token: SecretString, 21 | team_api: &TeamApi, 22 | dry_run: bool, 23 | ) -> anyhow::Result { 24 | let zulip_api = ZulipApi::new(username, token, dry_run); 25 | let mut stream_definitions = get_stream_definitions(team_api, &zulip_api)?; 26 | let user_group_definitions = get_user_group_definitions(team_api, &zulip_api)?; 27 | let zulip_controller = ZulipController::new(zulip_api)?; 28 | // rust-lang-owner is the user who owns the Zulip token. 29 | // This user needs to be in private streams to be able to 30 | // add/remove members. 31 | // Since this user is not in the team repo, we need to add 32 | // it manually. 33 | add_rust_lang_owner_to_private_streams(&mut stream_definitions, &zulip_controller)?; 34 | Ok(Self { 35 | zulip_controller, 36 | stream_definitions, 37 | user_group_definitions, 38 | }) 39 | } 40 | 41 | pub(crate) fn diff_all(&self) -> anyhow::Result { 42 | let stream_membership_diffs = self 43 | .stream_definitions 44 | .iter() 45 | .filter_map(|(stream_name, member_ids)| { 46 | self.diff_stream_membership(stream_name, member_ids) 47 | .transpose() 48 | }) 49 | .collect::>>()?; 50 | let user_group_diffs = self 51 | .user_group_definitions 52 | .iter() 53 | .filter_map(|(user_group_name, member_ids)| { 54 | self.diff_user_group(user_group_name, member_ids) 55 | .transpose() 56 | }) 57 | .collect::>>()?; 58 | Ok(Diff { 59 | user_group_diffs, 60 | stream_membership_diffs, 61 | }) 62 | } 63 | 64 | fn diff_user_group( 65 | &self, 66 | user_group_name: &str, 67 | member_ids: &[u64], 68 | ) -> anyhow::Result> { 69 | let id = self 70 | .zulip_controller 71 | .user_group_id_from_name(user_group_name); 72 | let user_group_id = match id { 73 | Some(id) => { 74 | log::debug!("'{user_group_name}' user group ({id}) already exists on Zulip"); 75 | id 76 | } 77 | None => { 78 | log::debug!("no '{user_group_name}' user group found on Zulip"); 79 | return Ok(Some(UserGroupDiff::Create(CreateUserGroupDiff { 80 | name: user_group_name.to_owned(), 81 | description: format!("The {user_group_name} team (managed by the Team repo)"), 82 | member_ids: member_ids.to_owned(), 83 | }))); 84 | } 85 | }; 86 | 87 | let existing_members = self 88 | .zulip_controller 89 | .user_group_members_from_name(user_group_name) 90 | .unwrap(); 91 | log::debug!( 92 | "'{user_group_name}' user group ({user_group_id}) has members on Zulip {existing_members:?} and needs to have {member_ids:?}", 93 | ); 94 | let add_ids = member_ids 95 | .iter() 96 | .filter(|i| !existing_members.contains(i)) 97 | .copied() 98 | .collect::>(); 99 | let remove_ids = existing_members 100 | .iter() 101 | .filter(|i| !member_ids.contains(i)) 102 | .copied() 103 | .collect::>(); 104 | if add_ids.is_empty() && remove_ids.is_empty() { 105 | log::debug!( 106 | "'{user_group_name}' user group ({user_group_id}) does not need to be updated" 107 | ); 108 | Ok(None) 109 | } else { 110 | Ok(Some(UserGroupDiff::Update(UpdateUserGroupDiff { 111 | name: user_group_name.to_owned(), 112 | user_group_id, 113 | member_id_additions: add_ids, 114 | member_id_deletions: remove_ids, 115 | }))) 116 | } 117 | } 118 | 119 | fn diff_stream_membership( 120 | &self, 121 | stream_name: &str, 122 | member_ids: &[u64], 123 | ) -> anyhow::Result> { 124 | let stream_id = match self.zulip_controller.stream_id_from_name(stream_name) { 125 | Some(id) => { 126 | log::debug!("'{stream_name}' stream ({id}) found on Zulip"); 127 | id 128 | } 129 | None => { 130 | log::error!("no '{stream_name}' user group found on Zulip"); 131 | return Ok(None); 132 | } 133 | }; 134 | let is_stream_private = self.zulip_controller.is_stream_private(stream_id)?; 135 | 136 | let existing_members = self.zulip_controller.stream_members_from_id(stream_id)?; 137 | log::debug!( 138 | "'{stream_name}' stream ({stream_id}) has members on Zulip {existing_members:?} and needs to have {member_ids:?}", 139 | ); 140 | let add_ids = member_ids 141 | .iter() 142 | .filter(|i| !existing_members.contains(i)) 143 | .copied() 144 | .collect::>(); 145 | let remove_ids = if is_stream_private { 146 | existing_members 147 | .iter() 148 | .filter(|i| !member_ids.contains(i)) 149 | .copied() 150 | .collect::>() 151 | } else { 152 | vec![] 153 | }; 154 | if add_ids.is_empty() && remove_ids.is_empty() { 155 | log::debug!("'{stream_name}' stream ({stream_id}) does not need to be updated"); 156 | Ok(None) 157 | } else { 158 | Ok(Some(StreamMembershipDiff::Update( 159 | UpdateStreamMembershipDiff { 160 | stream_name: stream_name.to_owned(), 161 | stream_id, 162 | member_id_additions: add_ids, 163 | member_id_deletions: remove_ids, 164 | }, 165 | ))) 166 | } 167 | } 168 | } 169 | 170 | fn add_rust_lang_owner_to_private_streams( 171 | stream_definitions: &mut BTreeMap>, 172 | zulip_controller: &ZulipController, 173 | ) -> anyhow::Result<()> { 174 | // Id of the `rust-lang-owner` Zulip user. 175 | let rust_lang_owner_id = 494485; 176 | for (stream_name, members) in stream_definitions { 177 | let stream_id = zulip_controller 178 | .stream_id_from_name(stream_name) 179 | .with_context(|| { 180 | format!( 181 | "Id of stream '{stream_name}' not found. \ 182 | The stream probably doesn't exist and sync-team doesn't support creating it yet. \ 183 | Please create the stream manually and add the rust-lang-owner user to it." 184 | ) 185 | })?; 186 | let is_stream_private = zulip_controller.zulip_api.is_stream_private(stream_id)?; 187 | if is_stream_private { 188 | members.insert(0, rust_lang_owner_id); 189 | } 190 | } 191 | Ok(()) 192 | } 193 | 194 | pub(crate) struct Diff { 195 | user_group_diffs: Vec, 196 | stream_membership_diffs: Vec, 197 | } 198 | 199 | impl Diff { 200 | pub(crate) fn apply(&self, sync: &SyncZulip) -> anyhow::Result<()> { 201 | for user_group_diff in &self.user_group_diffs { 202 | user_group_diff.apply(sync)?; 203 | } 204 | for stream_membership_diff in &self.stream_membership_diffs { 205 | stream_membership_diff.apply(sync)?; 206 | } 207 | Ok(()) 208 | } 209 | 210 | pub(crate) fn is_empty(&self) -> bool { 211 | self.user_group_diffs.is_empty() && self.stream_membership_diffs.is_empty() 212 | } 213 | } 214 | 215 | impl std::fmt::Display for Diff { 216 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 217 | if !&self.user_group_diffs.is_empty() { 218 | writeln!(f, "💻 User Group Diffs:")?; 219 | for team_diff in &self.user_group_diffs { 220 | write!(f, "{team_diff}")?; 221 | } 222 | } 223 | 224 | if !&self.stream_membership_diffs.is_empty() { 225 | writeln!(f, "💻 Stream Membership Diffs:")?; 226 | for stream_membership_diff in &self.stream_membership_diffs { 227 | write!(f, "{stream_membership_diff}")?; 228 | } 229 | } 230 | 231 | Ok(()) 232 | } 233 | } 234 | 235 | enum StreamMembershipDiff { 236 | Update(UpdateStreamMembershipDiff), 237 | } 238 | 239 | impl StreamMembershipDiff { 240 | fn apply(&self, sync: &SyncZulip) -> anyhow::Result<()> { 241 | match self { 242 | StreamMembershipDiff::Update(u) => u.apply(sync), 243 | } 244 | } 245 | } 246 | 247 | impl std::fmt::Display for StreamMembershipDiff { 248 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 249 | match self { 250 | Self::Update(u) => write!(f, "{u}"), 251 | } 252 | } 253 | } 254 | 255 | struct UpdateStreamMembershipDiff { 256 | stream_name: String, 257 | stream_id: u64, 258 | member_id_additions: Vec, 259 | member_id_deletions: Vec, 260 | } 261 | 262 | impl UpdateStreamMembershipDiff { 263 | fn apply(&self, sync: &SyncZulip) -> Result<(), anyhow::Error> { 264 | sync.zulip_controller.zulip_api.update_stream_membership( 265 | &self.stream_name, 266 | self.stream_id, 267 | &self.member_id_additions, 268 | &self.member_id_deletions, 269 | ) 270 | } 271 | } 272 | 273 | impl std::fmt::Display for UpdateStreamMembershipDiff { 274 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 275 | writeln!(f, "📝 Updating stream membership:")?; 276 | writeln!(f, " Name: {}", self.stream_name)?; 277 | writeln!(f, " ID: {}", self.stream_id)?; 278 | writeln!(f, " Members:")?; 279 | for member_id in &self.member_id_additions { 280 | writeln!(f, " ➕ {member_id}")?; 281 | } 282 | for member_id in &self.member_id_deletions { 283 | writeln!(f, " − {member_id}")?; 284 | } 285 | Ok(()) 286 | } 287 | } 288 | 289 | enum UserGroupDiff { 290 | Create(CreateUserGroupDiff), 291 | Update(UpdateUserGroupDiff), 292 | } 293 | 294 | impl UserGroupDiff { 295 | fn apply(&self, sync: &SyncZulip) -> anyhow::Result<()> { 296 | match self { 297 | UserGroupDiff::Create(c) => c.apply(sync), 298 | UserGroupDiff::Update(u) => u.apply(sync), 299 | } 300 | } 301 | } 302 | 303 | impl std::fmt::Display for UserGroupDiff { 304 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 305 | match self { 306 | Self::Create(c) => write!(f, "{c}"), 307 | Self::Update(u) => write!(f, "{u}"), 308 | } 309 | } 310 | } 311 | 312 | struct CreateUserGroupDiff { 313 | name: String, 314 | description: String, 315 | member_ids: Vec, 316 | } 317 | 318 | impl CreateUserGroupDiff { 319 | fn apply(&self, sync: &SyncZulip) -> Result<(), anyhow::Error> { 320 | sync.zulip_controller 321 | .create_user_group(&self.name, &self.description, &self.member_ids) 322 | } 323 | } 324 | 325 | impl std::fmt::Display for CreateUserGroupDiff { 326 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 327 | writeln!(f, "➕ Creating user group:")?; 328 | writeln!(f, " Name: {}", self.name)?; 329 | writeln!(f, " Description: {}", self.description)?; 330 | writeln!(f, " Members:")?; 331 | for member_id in &self.member_ids { 332 | writeln!(f, " {member_id}")?; 333 | } 334 | Ok(()) 335 | } 336 | } 337 | 338 | struct UpdateUserGroupDiff { 339 | name: String, 340 | user_group_id: u64, 341 | member_id_additions: Vec, 342 | member_id_deletions: Vec, 343 | } 344 | 345 | impl UpdateUserGroupDiff { 346 | fn apply(&self, sync: &SyncZulip) -> Result<(), anyhow::Error> { 347 | sync.zulip_controller.zulip_api.update_user_group_members( 348 | self.user_group_id, 349 | &self.member_id_additions, 350 | &self.member_id_deletions, 351 | ) 352 | } 353 | } 354 | 355 | impl std::fmt::Display for UpdateUserGroupDiff { 356 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 357 | writeln!(f, "📝 Updating user group:")?; 358 | writeln!(f, " Name: {}", self.name)?; 359 | writeln!(f, " Members:")?; 360 | for member_id in &self.member_id_additions { 361 | writeln!(f, " ➕ {member_id}")?; 362 | } 363 | for member_id in &self.member_id_deletions { 364 | writeln!(f, " − {member_id}")?; 365 | } 366 | Ok(()) 367 | } 368 | } 369 | 370 | /// Fetches the definitions of the user groups from the Team API 371 | fn get_user_group_definitions( 372 | team_api: &TeamApi, 373 | zulip_api: &ZulipApi, 374 | ) -> anyhow::Result>> { 375 | let email_map = zulip_api 376 | .get_users()? 377 | .into_iter() 378 | .filter_map(|u| u.email.map(|e| (e, u.user_id))) 379 | .collect::>(); 380 | let user_group_definitions = team_api 381 | .get_zulip_groups()? 382 | .groups 383 | .into_iter() 384 | .map(|(name, group)| { 385 | let members = &group.members; 386 | let member_ids = members 387 | .iter() 388 | .filter_map(|member| match member { 389 | ZulipGroupMember::Email(e) => { 390 | let id = email_map.get(e); 391 | if id.is_none() { 392 | log::warn!("no Zulip id found for '{}'", e); 393 | } 394 | id.copied() 395 | } 396 | ZulipGroupMember::Id(id) => Some(*id), 397 | }) 398 | .collect::>(); 399 | (name, member_ids) 400 | }) 401 | .collect(); 402 | Ok(user_group_definitions) 403 | } 404 | 405 | /// Fetches the definitions of the user streams from the Team API 406 | fn get_stream_definitions( 407 | team_api: &TeamApi, 408 | zulip_api: &ZulipApi, 409 | ) -> anyhow::Result>> { 410 | let email_map = zulip_api 411 | .get_users()? 412 | .into_iter() 413 | .filter_map(|u| u.email.map(|e| (e, u.user_id))) 414 | .collect::>(); 415 | let stream_definitions = team_api 416 | .get_zulip_streams()? 417 | .streams 418 | .into_iter() 419 | .map(|(name, stream)| { 420 | let members = &stream.members; 421 | let member_ids = members 422 | .iter() 423 | .filter_map(|member| match member { 424 | ZulipStreamMember::Email(e) => { 425 | let id = email_map.get(e); 426 | if id.is_none() { 427 | log::warn!("no Zulip id found for '{}'", e); 428 | } 429 | id.copied() 430 | } 431 | ZulipStreamMember::Id(id) => Some(*id), 432 | }) 433 | .collect::>(); 434 | (name, member_ids) 435 | }) 436 | .collect(); 437 | Ok(stream_definitions) 438 | } 439 | 440 | /// Interacts with the Zulip API 441 | struct ZulipController { 442 | /// User group name to Zulip user group id 443 | user_group_ids: BTreeMap, 444 | /// Stream name to Zulip stream id 445 | stream_ids: BTreeMap, 446 | /// The Zulip API 447 | zulip_api: ZulipApi, 448 | } 449 | 450 | impl ZulipController { 451 | /// Create a new `ZulipController` 452 | fn new(zulip_api: ZulipApi) -> anyhow::Result { 453 | let streams = zulip_api.get_streams()?; 454 | let user_groups = zulip_api.get_user_groups()?; 455 | 456 | let stream_ids = streams 457 | .into_iter() 458 | .map(|st| (st.name.clone(), st)) 459 | .collect(); 460 | let user_group_ids = user_groups 461 | .into_iter() 462 | .map(|mut ug| { 463 | // sort for better diagnostics 464 | ug.members.sort_unstable(); 465 | (ug.name.clone(), ug) 466 | }) 467 | .collect(); 468 | 469 | Ok(Self { 470 | user_group_ids, 471 | stream_ids, 472 | zulip_api, 473 | }) 474 | } 475 | 476 | /// Get a user group id for the given user group name 477 | fn user_group_id_from_name(&self, user_group_name: &str) -> Option { 478 | self.user_group_ids.get(user_group_name).map(|u| u.id) 479 | } 480 | 481 | /// Get a stream id for the given stream name 482 | fn stream_id_from_name(&self, stream_name: &str) -> Option { 483 | self.stream_ids.get(stream_name).map(|st| st.stream_id) 484 | } 485 | 486 | /// Create a user group with a certain name, description, and members 487 | fn create_user_group( 488 | &self, 489 | user_group_name: &str, 490 | description: &str, 491 | member_ids: &[u64], 492 | ) -> anyhow::Result<()> { 493 | self.zulip_api 494 | .create_user_group(user_group_name, description, member_ids)?; 495 | 496 | Ok(()) 497 | } 498 | 499 | /// Get the members of a user group given its name 500 | fn user_group_members_from_name(&self, user_group_name: &str) -> Option> { 501 | self.user_group_ids 502 | .get(user_group_name) 503 | .map(|u| u.members.to_owned()) 504 | } 505 | 506 | /// Get the members of a stream given its id 507 | fn stream_members_from_id(&self, stream_id: u64) -> anyhow::Result> { 508 | self.zulip_api.get_stream_members(stream_id) 509 | } 510 | 511 | fn is_stream_private(&self, stream_id: u64) -> anyhow::Result { 512 | self.zulip_api.is_stream_private(stream_id) 513 | } 514 | } 515 | --------------------------------------------------------------------------------