├── .dockerignore ├── .env.sample ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── github-graphql ├── Cargo.toml ├── README.md ├── build.rs └── src │ ├── github.graphql │ └── lib.rs ├── parser ├── Cargo.toml └── src │ ├── command.rs │ ├── command │ ├── assign.rs │ ├── close.rs │ ├── concern.rs │ ├── nominate.rs │ ├── note.rs │ ├── ping.rs │ ├── prioritize.rs │ ├── relabel.rs │ ├── second.rs │ ├── shortcut.rs │ └── transfer.rs │ ├── error.rs │ ├── ignore_block.rs │ ├── lib.rs │ ├── mentions.rs │ └── token.rs ├── rustfmt.toml ├── src ├── actions.rs ├── agenda.rs ├── bin │ ├── compiler.rs │ ├── lang.rs │ ├── prioritization-agenda.rs │ ├── project_goals.rs │ └── types.rs ├── changelogs │ ├── mod.rs │ └── rustc.rs ├── config.rs ├── db.rs ├── db │ ├── issue_data.rs │ ├── jobs.rs │ ├── notifications.rs │ ├── review_prefs.rs │ ├── rustc_commits.rs │ └── users.rs ├── github.rs ├── handlers.rs ├── handlers │ ├── assign.rs │ ├── assign │ │ ├── messages.rs │ │ └── tests │ │ │ ├── tests_candidates.rs │ │ │ └── tests_from_diff.rs │ ├── autolabel.rs │ ├── bot_pull_requests.rs │ ├── check_commits.rs │ ├── check_commits │ │ ├── behind_upstream.rs │ │ ├── issue_links.rs │ │ ├── modified_submodule.rs │ │ ├── no_mentions.rs │ │ ├── no_merges.rs │ │ └── non_default_branch.rs │ ├── close.rs │ ├── concern.rs │ ├── docs_update.rs │ ├── github_releases.rs │ ├── issue_links.rs │ ├── jobs.rs │ ├── major_change.rs │ ├── mentions.rs │ ├── merge_conflicts.rs │ ├── milestone_prs.rs │ ├── nominate.rs │ ├── note.rs │ ├── notification.rs │ ├── notify_zulip.rs │ ├── ping.rs │ ├── pr_tracking.rs │ ├── prioritize.rs │ ├── project_goals.rs │ ├── pull_requests_assignment_update.rs │ ├── relabel.rs │ ├── relnotes.rs │ ├── rendered_link.rs │ ├── review_requested.rs │ ├── review_submitted.rs │ ├── rustc_commits.rs │ ├── shortcut.rs │ ├── transfer.rs │ ├── types_planning_updates.rs │ └── validate_config.rs ├── interactions.rs ├── jobs.rs ├── lib.rs ├── main.rs ├── notification_listing.rs ├── payload.rs ├── rfcbot.rs ├── team_data.rs ├── tests │ ├── github.rs │ └── mod.rs ├── triage.rs ├── utils.rs ├── zulip.rs └── zulip │ ├── api.rs │ ├── client.rs │ └── commands.rs ├── templates ├── _issue.tt ├── _issues.tt ├── _issues_fcps.tt ├── _issues_heading.tt ├── _issues_rfcbot.tt ├── _meetings.tt ├── compiler_backlog_bonanza.tt ├── lang_agenda.tt ├── lang_planning_agenda.tt ├── prioritization_agenda.tt ├── triage │ ├── index.html │ └── pulls.html └── types_planning_agenda.tt └── triagebot.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # if `GITHUB_TOKEN` is not set here, the token can also be stored in `~/.gitconfig` 2 | GITHUB_TOKEN=MUST_BE_CONFIGURED 3 | DATABASE_URL=MUST_BE_CONFIGURED 4 | GITHUB_WEBHOOK_SECRET=MUST_BE_CONFIGURED 5 | # for logging, refer to this document: https://rust-lang-nursery.github.io/rust-cookbook/development_tools/debugging/config_log.html 6 | # `RUSTC_LOG` is not required to run the application, but it makes local development easier 7 | # RUST_LOG=MUST_BE_CONFIGURED 8 | 9 | # If you are running a bot on non-rustbot account, 10 | # this allows to configure that username which the bot will respond to. 11 | # For example write blahblahblah here, if you want for this bot to 12 | # respond to @blahblahblah claim. 13 | # TRIAGEBOT_USERNAME=CAN_BE_CONFIGURED 14 | 15 | # Set your own Zulip instance (local testing only) 16 | # ZULIP_URL=https://testinstance.zulichat.com 17 | 18 | # Used for authenticating a bot 19 | # ZULIP_BOT_EMAIL=bot@testinstance.zulipchat.com 20 | # ZULIP_API_TOKEN=yyy 21 | 22 | # Authenticates inbound webhooks from Github 23 | # ZULIP_WEBHOOK_SECRET=xxx 24 | 25 | # Use another endpoint to retrieve teams of the Rust project (useful for local testing) 26 | # default: https://team-api.infra.rust-lang.org/v1 27 | # TEAMS_API_URL=http://localhost:8080 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | merge_group: 5 | 6 | env: 7 | AWS_ACCESS_KEY_ID: AKIA46X5W6CZEAQSMRH7 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | env: 14 | TEST_DB_URL: postgres://postgres:postgres@localhost:5432/postgres 15 | services: 16 | postgres: 17 | image: postgres:14 18 | env: 19 | POSTGRES_USER: postgres 20 | POSTGRES_PASSWORD: postgres 21 | POSTGRES_DB: postgres 22 | ports: 23 | - 5432:5432 24 | steps: 25 | - uses: actions/checkout@v4 26 | - run: rustup toolchain install stable --profile minimal 27 | - uses: Swatinem/rust-cache@v2 28 | - name: Run tests 29 | run: cargo test --workspace --all-targets 30 | - name: Check formatting 31 | run: cargo fmt --all --check 32 | 33 | deploy: 34 | name: Deploy 35 | runs-on: ubuntu-latest 36 | needs: [ test ] 37 | if: github.event_name == 'merge_group' 38 | steps: 39 | - name: Checkout the source code 40 | uses: actions/checkout@v4 41 | 42 | - name: Test and build 43 | run: docker build -t triagebot . 44 | 45 | - name: Deploy to production 46 | uses: rust-lang/simpleinfra/github-actions/upload-docker-image@master 47 | with: 48 | image: triagebot 49 | repository: rust-triagebot 50 | region: us-west-1 51 | redeploy_ecs_cluster: rust-ecs-prod 52 | redeploy_ecs_service: triagebot 53 | aws_access_key_id: "${{ env.AWS_ACCESS_KEY_ID }}" 54 | aws_secret_access_key: "${{ secrets.AWS_SECRET_ACCESS_KEY }}" 55 | 56 | # Summary job for the merge queue. 57 | # ALL THE PREVIOUS JOBS NEED TO BE ADDED TO THE `needs` SECTION OF THIS JOB! 58 | ci: 59 | needs: [ test, deploy ] 60 | # We need to ensure this job does *not* get skipped if its dependencies fail, 61 | # because a skipped job is considered a success by GitHub. So we have to 62 | # overwrite `if:`. We use `!cancelled()` to ensure the job does still not get run 63 | # when the workflow is canceled manually. 64 | if: ${{ !cancelled() }} 65 | runs-on: ubuntu-latest 66 | steps: 67 | # Manually check the status of all dependencies. `if: failure()` does not work. 68 | - name: Conclusion 69 | run: | 70 | # Print the dependent jobs to see them in the CI log 71 | jq -C <<< '${{ toJson(needs) }}' 72 | # Check if all jobs that we depend on (in the needs array) were successful. 73 | jq --exit-status 'all(.result == "success" or .result == "skipped")' <<< '${{ toJson(needs) }}' 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | .env 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "triagebot" 3 | version = "0.1.0" 4 | authors = ["Mark Rousskov "] 5 | edition = "2021" 6 | rust-version = "1.80.0" 7 | 8 | [workspace] 9 | 10 | [dependencies] 11 | serde_json = "1" 12 | dotenvy = "0.15" 13 | reqwest = { version = "0.12", features = ["json", "blocking"] } 14 | regex = "1" 15 | anyhow = "1" 16 | hex = "0.4" 17 | parser = { path = "parser" } 18 | rust_team_data = { git = "https://github.com/rust-lang/team" } 19 | glob = "0.3.0" 20 | toml = "0.8.20" 21 | hyper = { version = "0.14.4", features = ["server", "stream", "http1", "tcp"] } 22 | tokio = { version = "1", features = ["macros", "time", "rt"] } 23 | futures = { version = "0.3", default-features = false, features = ["std"] } 24 | async-trait = "0.1.31" 25 | uuid = { version = "0.8", features = ["v4", "serde"] } 26 | tracing = "0.1" 27 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 28 | url = "2.1.0" 29 | chrono = { version = "0.4.38", features = ["serde"] } 30 | tokio-postgres = { version = "0.7.2", features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-0_8"] } 31 | postgres-native-tls = "0.5.0" 32 | native-tls = "0.2" 33 | x509-cert = { version = "0.2.5", features = ["pem"] } 34 | serde_path_to_error = "0.1.2" 35 | octocrab = { version = "0.44", features = ["stream"] } 36 | comrak = { version = "0.38", default-features = false } 37 | route-recognizer = "0.3.0" 38 | cynic = "3" 39 | itertools = "0.14.0" 40 | tower = { version = "0.5", features = ["util", "limit", "buffer", "load-shed"] } 41 | github-graphql = { path = "github-graphql" } 42 | rand = "0.8.5" 43 | ignore = "0.4.18" 44 | postgres-types = { version = "0.2.4", features = ["derive"] } 45 | cron = { version = "0.15.0" } 46 | bytes = "1.1.0" 47 | clap = { version = "4", features = ["derive"] } 48 | hmac = "0.12.1" 49 | subtle = "2.6.1" 50 | sha2 = "0.10.9" 51 | 52 | [dependencies.serde] 53 | version = "1" 54 | features = ["derive"] 55 | 56 | [dependencies.tera] 57 | version = "1.3.1" 58 | default-features = false 59 | 60 | [dev-dependencies] 61 | bon = "3" 62 | 63 | [profile.release] 64 | debug = 2 65 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This Dockerfile is composed of two steps: the first one builds the release 2 | # binary, and then the binary is copied inside another, empty image. 3 | 4 | ################# 5 | # Build image # 6 | ################# 7 | 8 | FROM ubuntu:22.04 as build 9 | 10 | RUN apt-get update -y && \ 11 | DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 12 | g++ \ 13 | curl \ 14 | ca-certificates \ 15 | libc6-dev \ 16 | make \ 17 | libssl-dev \ 18 | pkg-config \ 19 | git \ 20 | cmake \ 21 | zlib1g-dev 22 | 23 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- \ 24 | --default-toolchain stable --profile minimal -y 25 | 26 | COPY . . 27 | RUN bash -c 'source $HOME/.cargo/env && cargo test --release --all' 28 | RUN bash -c 'source $HOME/.cargo/env && cargo build --release' 29 | 30 | ################## 31 | # Output image # 32 | ################## 33 | 34 | FROM ubuntu:22.04 AS binary 35 | 36 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ 37 | ca-certificates 38 | 39 | RUN mkdir -p /opt/triagebot 40 | 41 | COPY --from=build /target/release/triagebot /usr/local/bin/ 42 | COPY templates /opt/triagebot/templates 43 | WORKDIR /opt/triagebot 44 | ENV PORT=80 45 | CMD triagebot 46 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 The Rust Project Developers 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /github-graphql/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "github-graphql" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | chrono = { version = "0.4", features = ["serde"] } 8 | cynic = { version = "3.2.2", features = ["rkyv"] } 9 | 10 | [build-dependencies] 11 | cynic-codegen = { version = "3.2.2", features = ["rkyv"] } 12 | -------------------------------------------------------------------------------- /github-graphql/README.md: -------------------------------------------------------------------------------- 1 | # How to use GraphQL with Rust 2 | 3 | # GUI Clients (Electron apps) 4 | 5 | Use a client to experiment and build your GraphQL query/mutation. 6 | 7 | https://insomnia.rest/download 8 | 9 | https://docs.usebruno.com 10 | 11 | Once you're happy with the result, save your query in a `.gql` file in this directory. It will serve as 12 | documentation on how to reproduce the Rust boilerplate. 13 | 14 | # Cynic CLI 15 | 16 | Introspect a schema and save it locally: 17 | 18 | ```sh 19 | cynic introspect \ 20 | -H "User-Agent: cynic/3.4.3" \ 21 | -H "Authorization: Bearer [GITHUB_TOKEN]" \ 22 | "https://api.github.com/graphql" \ 23 | -o schemas/github.graphql 24 | ``` 25 | 26 | Execute a GraphQL query/mutation and save locally the Rust boilerplate: 27 | 28 | ``` sh 29 | cynic querygen --schema schemas/github.graphql --query query.gql 30 | ``` 31 | 32 | -------------------------------------------------------------------------------- /github-graphql/build.rs: -------------------------------------------------------------------------------- 1 | pub fn main() { 2 | cynic_codegen::register_schema("github") 3 | .from_sdl_file("src/github.graphql") 4 | .unwrap() 5 | .as_default() 6 | .unwrap(); 7 | } 8 | -------------------------------------------------------------------------------- /parser/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "parser" 3 | version = "0.1.0" 4 | authors = ["Mark Rousskov "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | pulldown-cmark = "0.12.0" 9 | log = "0.4" 10 | regex = "1.6.0" 11 | -------------------------------------------------------------------------------- /parser/src/command/assign.rs: -------------------------------------------------------------------------------- 1 | //! The assignment command parser. 2 | //! 3 | //! This can parse arbitrary input, giving the user to be assigned. 4 | //! 5 | //! The grammar is as follows: 6 | //! 7 | //! ```text 8 | //! Command: `@bot claim`, `@bot release-assignment`, or `@bot assign @user`. 9 | //! ``` 10 | 11 | use crate::error::Error; 12 | use crate::token::{Token, Tokenizer}; 13 | use std::fmt; 14 | 15 | #[derive(PartialEq, Eq, Debug)] 16 | pub enum AssignCommand { 17 | /// Corresponds to `@bot claim`. 18 | Claim, 19 | /// Corresponds to `@bot release-assignment`. 20 | ReleaseAssignment, 21 | /// Corresponds to `@bot assign @user`. 22 | AssignUser { username: String }, 23 | /// Corresponds to `r? [@]user`. 24 | RequestReview { name: String }, 25 | } 26 | 27 | #[derive(PartialEq, Eq, Debug)] 28 | pub enum ParseError { 29 | ExpectedEnd, 30 | MentionUser, 31 | NoUser, 32 | } 33 | 34 | impl std::error::Error for ParseError {} 35 | 36 | impl fmt::Display for ParseError { 37 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 38 | match self { 39 | ParseError::MentionUser => write!(f, "user should start with @"), 40 | ParseError::ExpectedEnd => write!(f, "expected end of command"), 41 | ParseError::NoUser => write!(f, "specify user to assign to"), 42 | } 43 | } 44 | } 45 | 46 | impl AssignCommand { 47 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 48 | let mut toks = input.clone(); 49 | if let Some(Token::Word("claim")) = toks.peek_token()? { 50 | toks.next_token()?; 51 | if let Some(Token::Dot) | Some(Token::EndOfLine) = toks.peek_token()? { 52 | toks.next_token()?; 53 | *input = toks; 54 | return Ok(Some(AssignCommand::Claim)); 55 | } else { 56 | return Err(toks.error(ParseError::ExpectedEnd)); 57 | } 58 | } else if let Some(Token::Word("assign")) = toks.peek_token()? { 59 | toks.next_token()?; 60 | if let Some(Token::Word(user)) = toks.next_token()? { 61 | if user.starts_with('@') && user.len() != 1 { 62 | Ok(Some(AssignCommand::AssignUser { 63 | username: user[1..].to_owned(), 64 | })) 65 | } else { 66 | return Err(toks.error(ParseError::MentionUser)); 67 | } 68 | } else { 69 | return Err(toks.error(ParseError::NoUser)); 70 | } 71 | } else if let Some(Token::Word("release-assignment")) = toks.peek_token()? { 72 | toks.next_token()?; 73 | if let Some(Token::Dot) | Some(Token::EndOfLine) = toks.peek_token()? { 74 | toks.next_token()?; 75 | *input = toks; 76 | return Ok(Some(AssignCommand::ReleaseAssignment)); 77 | } else { 78 | return Err(toks.error(ParseError::ExpectedEnd)); 79 | } 80 | } else { 81 | return Ok(None); 82 | } 83 | } 84 | 85 | /// Parses the input for `r?` command. 86 | pub fn parse_review<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 87 | match input.next_token() { 88 | Ok(Some(Token::Word(name))) => { 89 | let name = name.strip_prefix('@').unwrap_or(name).to_string(); 90 | if name.is_empty() { 91 | return Err(input.error(ParseError::NoUser)); 92 | } 93 | Ok(Some(AssignCommand::RequestReview { name })) 94 | } 95 | _ => Err(input.error(ParseError::NoUser)), 96 | } 97 | } 98 | } 99 | 100 | #[cfg(test)] 101 | mod tests { 102 | use super::*; 103 | 104 | fn parse<'a>(input: &'a str) -> Result, Error<'a>> { 105 | let mut toks = Tokenizer::new(input); 106 | Ok(AssignCommand::parse(&mut toks)?) 107 | } 108 | 109 | #[test] 110 | fn test_1() { 111 | assert_eq!(parse("claim."), Ok(Some(AssignCommand::Claim)),); 112 | } 113 | 114 | #[test] 115 | fn test_2() { 116 | assert_eq!(parse("claim"), Ok(Some(AssignCommand::Claim)),); 117 | } 118 | 119 | #[test] 120 | fn test_3() { 121 | assert_eq!( 122 | parse("assign @user"), 123 | Ok(Some(AssignCommand::AssignUser { 124 | username: "user".to_owned() 125 | })), 126 | ); 127 | } 128 | 129 | #[test] 130 | fn test_4() { 131 | use std::error::Error; 132 | assert_eq!( 133 | parse("assign @") 134 | .unwrap_err() 135 | .source() 136 | .unwrap() 137 | .downcast_ref(), 138 | Some(&ParseError::MentionUser), 139 | ); 140 | } 141 | 142 | fn parse_review<'a>(input: &'a str) -> Result, Error<'a>> { 143 | let mut toks = Tokenizer::new(input); 144 | Ok(AssignCommand::parse_review(&mut toks)?) 145 | } 146 | 147 | #[test] 148 | fn review_names() { 149 | for (input, name) in [ 150 | ("octocat", "octocat"), 151 | ("@octocat", "octocat"), 152 | ("rust-lang/compiler", "rust-lang/compiler"), 153 | ("@rust-lang/cargo", "rust-lang/cargo"), 154 | ("abc xyz", "abc"), 155 | ("@user?", "user"), 156 | ("@user.", "user"), 157 | ("@user!", "user"), 158 | ] { 159 | assert_eq!( 160 | parse_review(input), 161 | Ok(Some(AssignCommand::RequestReview { 162 | name: name.to_string() 163 | })), 164 | "failed on {input}" 165 | ); 166 | } 167 | } 168 | 169 | #[test] 170 | fn review_names_errs() { 171 | use std::error::Error; 172 | for input in ["", "@", "@ user"] { 173 | assert_eq!( 174 | parse_review(input) 175 | .unwrap_err() 176 | .source() 177 | .unwrap() 178 | .downcast_ref(), 179 | Some(&ParseError::NoUser), 180 | "failed on {input}" 181 | ) 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /parser/src/command/close.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::token::{Token, Tokenizer}; 3 | 4 | #[derive(PartialEq, Eq, Debug)] 5 | pub struct CloseCommand; 6 | 7 | impl CloseCommand { 8 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 9 | if let Some(Token::Word("close")) = input.peek_token()? { 10 | Ok(Some(Self)) 11 | } else { 12 | Ok(None) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /parser/src/command/concern.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::token::{Token, Tokenizer}; 3 | use std::fmt; 4 | 5 | #[derive(PartialEq, Eq, Debug)] 6 | pub enum ConcernCommand { 7 | Concern { title: String }, 8 | Resolve { title: String }, 9 | } 10 | 11 | #[derive(PartialEq, Eq, Debug)] 12 | pub enum ParseError { 13 | MissingTitle, 14 | } 15 | 16 | impl std::error::Error for ParseError {} 17 | impl fmt::Display for ParseError { 18 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 19 | match self { 20 | ParseError::MissingTitle => write!(f, "missing required title"), 21 | } 22 | } 23 | } 24 | 25 | impl ConcernCommand { 26 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 27 | let mut toks = input.clone(); 28 | if let Some(Token::Word(action @ ("concern" | "resolve"))) = toks.peek_token()? { 29 | toks.next_token()?; 30 | 31 | let title = toks.take_line()?.trim().to_string(); 32 | 33 | if title.is_empty() { 34 | return Err(toks.error(ParseError::MissingTitle)); 35 | } 36 | 37 | let command = if action == "resolve" { 38 | ConcernCommand::Resolve { title } 39 | } else { 40 | ConcernCommand::Concern { title } 41 | }; 42 | 43 | Ok(Some(command)) 44 | } else { 45 | Ok(None) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /parser/src/command/nominate.rs: -------------------------------------------------------------------------------- 1 | //! The beta nomination command parser. 2 | //! 3 | //! The grammar is as follows: 4 | //! 5 | //! ```text 6 | //! Command: 7 | //! `@bot beta-nominate `. 8 | //! `@bot nominate `. 9 | //! `@bot beta-accept`. 10 | //! `@bot beta-approve`. 11 | //! ``` 12 | //! 13 | //! This constrains to just one team; users should issue the command multiple 14 | //! times if they want to nominate for more than one team. This is to encourage 15 | //! descriptions of what to do targeted at each team, rather than a general 16 | //! summary. 17 | 18 | use crate::error::Error; 19 | use crate::token::{Token, Tokenizer}; 20 | use std::fmt; 21 | 22 | #[derive(PartialEq, Eq, Debug)] 23 | pub struct NominateCommand { 24 | pub team: String, 25 | pub style: Style, 26 | } 27 | 28 | #[derive(Debug, PartialEq, Eq, Copy, Clone)] 29 | pub enum Style { 30 | Beta, 31 | BetaApprove, 32 | Decision, 33 | } 34 | 35 | #[derive(PartialEq, Eq, Debug)] 36 | pub enum ParseError { 37 | ExpectedEnd, 38 | NoTeam, 39 | } 40 | 41 | impl std::error::Error for ParseError {} 42 | 43 | impl fmt::Display for ParseError { 44 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 45 | match self { 46 | ParseError::ExpectedEnd => write!(f, "expected end of command"), 47 | ParseError::NoTeam => write!(f, "no team specified"), 48 | } 49 | } 50 | } 51 | 52 | impl NominateCommand { 53 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 54 | let mut toks = input.clone(); 55 | let style = match toks.peek_token()? { 56 | Some(Token::Word("beta-nominate")) => Style::Beta, 57 | Some(Token::Word("nominate")) => Style::Decision, 58 | Some(Token::Word("beta-accept")) => Style::BetaApprove, 59 | Some(Token::Word("beta-approve")) => Style::BetaApprove, 60 | None | Some(_) => return Ok(None), 61 | }; 62 | toks.next_token()?; 63 | let team = if style != Style::BetaApprove { 64 | if let Some(Token::Word(team)) = toks.next_token()? { 65 | team.to_owned() 66 | } else { 67 | return Err(toks.error(ParseError::NoTeam)); 68 | } 69 | } else { 70 | String::new() 71 | }; 72 | if let Some(Token::Dot) | Some(Token::EndOfLine) = toks.peek_token()? { 73 | toks.next_token()?; 74 | *input = toks; 75 | return Ok(Some(NominateCommand { team, style })); 76 | } else { 77 | return Err(toks.error(ParseError::ExpectedEnd)); 78 | } 79 | } 80 | } 81 | 82 | #[cfg(test)] 83 | fn parse<'a>(input: &'a str) -> Result, Error<'a>> { 84 | let mut toks = Tokenizer::new(input); 85 | Ok(NominateCommand::parse(&mut toks)?) 86 | } 87 | 88 | #[test] 89 | fn test_1() { 90 | assert_eq!( 91 | parse("nominate compiler."), 92 | Ok(Some(NominateCommand { 93 | team: "compiler".into(), 94 | style: Style::Decision, 95 | })) 96 | ); 97 | } 98 | 99 | #[test] 100 | fn test_2() { 101 | assert_eq!( 102 | parse("beta-nominate compiler."), 103 | Ok(Some(NominateCommand { 104 | team: "compiler".into(), 105 | style: Style::Beta, 106 | })) 107 | ); 108 | } 109 | 110 | #[test] 111 | fn test_3() { 112 | use std::error::Error; 113 | assert_eq!( 114 | parse("nominate foo foo") 115 | .unwrap_err() 116 | .source() 117 | .unwrap() 118 | .downcast_ref(), 119 | Some(&ParseError::ExpectedEnd), 120 | ); 121 | } 122 | 123 | #[test] 124 | fn test_4() { 125 | use std::error::Error; 126 | assert_eq!( 127 | parse("nominate") 128 | .unwrap_err() 129 | .source() 130 | .unwrap() 131 | .downcast_ref(), 132 | Some(&ParseError::NoTeam), 133 | ); 134 | } 135 | -------------------------------------------------------------------------------- /parser/src/command/note.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::token::{Token, Tokenizer}; 3 | use std::fmt; 4 | 5 | #[derive(PartialEq, Eq, Debug)] 6 | pub enum NoteCommand { 7 | Summary { title: String }, 8 | Remove { title: String }, 9 | } 10 | 11 | #[derive(PartialEq, Eq, Debug)] 12 | pub enum ParseError { 13 | MissingTitle, 14 | } 15 | impl std::error::Error for ParseError {} 16 | impl fmt::Display for ParseError { 17 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 18 | match self { 19 | ParseError::MissingTitle => write!(f, "missing required summary title"), 20 | } 21 | } 22 | } 23 | 24 | impl NoteCommand { 25 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 26 | let mut toks = input.clone(); 27 | if let Some(Token::Word("note")) = toks.peek_token()? { 28 | toks.next_token()?; 29 | 30 | let remove = if let Some(Token::Word("remove")) = toks.peek_token()? { 31 | toks.next_token()?; 32 | true 33 | } else { 34 | false 35 | }; 36 | 37 | let title = toks.take_line()?.trim(); 38 | 39 | // For backwards compatibility we also trim " at the start and end 40 | let title = title.trim_matches('"'); 41 | 42 | if title.is_empty() { 43 | return Err(toks.error(ParseError::MissingTitle)); 44 | } 45 | 46 | let command = if remove { 47 | NoteCommand::Remove { 48 | title: title.to_string(), 49 | } 50 | } else { 51 | NoteCommand::Summary { 52 | title: title.to_string(), 53 | } 54 | }; 55 | Ok(Some(command)) 56 | } else { 57 | Ok(None) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /parser/src/command/ping.rs: -------------------------------------------------------------------------------- 1 | //! The assignment command parser. 2 | //! 3 | //! This can parse arbitrary input, giving the user to be assigned. 4 | //! 5 | //! The grammar is as follows: 6 | //! 7 | //! ```text 8 | //! Command: `@bot ping `. 9 | //! ``` 10 | 11 | use crate::error::Error; 12 | use crate::token::{Token, Tokenizer}; 13 | use std::fmt; 14 | 15 | #[derive(PartialEq, Eq, Debug)] 16 | pub struct PingCommand { 17 | pub team: String, 18 | } 19 | 20 | #[derive(PartialEq, Eq, Debug)] 21 | pub enum ParseError { 22 | ExpectedEnd, 23 | NoTeam, 24 | } 25 | 26 | impl std::error::Error for ParseError {} 27 | 28 | impl fmt::Display for ParseError { 29 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 30 | match self { 31 | ParseError::ExpectedEnd => write!(f, "expected end of command"), 32 | ParseError::NoTeam => write!(f, "no team specified"), 33 | } 34 | } 35 | } 36 | 37 | impl PingCommand { 38 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 39 | let mut toks = input.clone(); 40 | if let Some(Token::Word("ping")) = toks.peek_token()? { 41 | toks.next_token()?; 42 | let team = if let Some(Token::Word(team)) = toks.next_token()? { 43 | team.to_owned() 44 | } else { 45 | return Err(toks.error(ParseError::NoTeam)); 46 | }; 47 | if let Some(Token::Dot) | Some(Token::EndOfLine) = toks.peek_token()? { 48 | toks.next_token()?; 49 | *input = toks; 50 | return Ok(Some(PingCommand { team })); 51 | } else { 52 | return Err(toks.error(ParseError::ExpectedEnd)); 53 | } 54 | } else { 55 | return Ok(None); 56 | } 57 | } 58 | } 59 | 60 | #[cfg(test)] 61 | fn parse<'a>(input: &'a str) -> Result, Error<'a>> { 62 | let mut toks = Tokenizer::new(input); 63 | Ok(PingCommand::parse(&mut toks)?) 64 | } 65 | 66 | #[test] 67 | fn test_1() { 68 | assert_eq!( 69 | parse("ping LLVM-icebreakers."), 70 | Ok(Some(PingCommand { 71 | team: "LLVM-icebreakers".into() 72 | })) 73 | ); 74 | } 75 | 76 | #[test] 77 | fn test_2() { 78 | use std::error::Error; 79 | assert_eq!( 80 | parse("ping foo foo") 81 | .unwrap_err() 82 | .source() 83 | .unwrap() 84 | .downcast_ref(), 85 | Some(&ParseError::ExpectedEnd), 86 | ); 87 | } 88 | 89 | #[test] 90 | fn test_3() { 91 | use std::error::Error; 92 | assert_eq!( 93 | parse("ping").unwrap_err().source().unwrap().downcast_ref(), 94 | Some(&ParseError::NoTeam), 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /parser/src/command/prioritize.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq, Eq, Debug)] 2 | pub struct PrioritizeCommand; 3 | 4 | use crate::error::Error; 5 | use crate::token::{Token, Tokenizer}; 6 | 7 | impl PrioritizeCommand { 8 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 9 | if let Some(Token::Word("prioritize")) = input.peek_token()? { 10 | Ok(Some(Self)) 11 | } else { 12 | Ok(None) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /parser/src/command/second.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::token::{Token, Tokenizer}; 3 | 4 | #[derive(PartialEq, Eq, Debug)] 5 | pub struct SecondCommand; 6 | 7 | impl SecondCommand { 8 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 9 | if let Some(Token::Word("second")) = input.peek_token()? { 10 | Ok(Some(Self)) 11 | } else if let Some(Token::Word("seconded")) = input.peek_token()? { 12 | Ok(Some(Self)) 13 | } else { 14 | Ok(None) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /parser/src/command/shortcut.rs: -------------------------------------------------------------------------------- 1 | //! The shortcut command parser. 2 | //! 3 | //! This can parse predefined shortcut input, single word commands. 4 | //! 5 | //! The grammar is as follows: 6 | //! 7 | //! ```text 8 | //! Command: `@bot ready`/`@bot review`, or `@bot author`. 9 | //! ``` 10 | 11 | use crate::error::Error; 12 | use crate::token::{Token, Tokenizer}; 13 | use std::collections::HashMap; 14 | use std::fmt; 15 | 16 | #[derive(PartialEq, Eq, Debug, Copy, Clone)] 17 | pub enum ShortcutCommand { 18 | Ready, 19 | Author, 20 | Blocked, 21 | } 22 | 23 | #[derive(PartialEq, Eq, Debug)] 24 | pub enum ParseError {} 25 | 26 | impl std::error::Error for ParseError {} 27 | 28 | impl fmt::Display for ParseError { 29 | fn fmt(&self, _: &mut fmt::Formatter) -> fmt::Result { 30 | match *self {} 31 | } 32 | } 33 | 34 | impl ShortcutCommand { 35 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 36 | let mut shortcuts = HashMap::new(); 37 | shortcuts.insert("ready", ShortcutCommand::Ready); 38 | shortcuts.insert("review", ShortcutCommand::Ready); 39 | shortcuts.insert("reviewer", ShortcutCommand::Ready); 40 | shortcuts.insert("author", ShortcutCommand::Author); 41 | shortcuts.insert("blocked", ShortcutCommand::Blocked); 42 | 43 | let mut toks = input.clone(); 44 | if let Some(Token::Word(word)) = toks.peek_token()? { 45 | if !shortcuts.contains_key(word) { 46 | return Ok(None); 47 | } 48 | toks.next_token()?; 49 | *input = toks; 50 | let command = shortcuts.get(word).unwrap(); 51 | return Ok(Some(*command)); 52 | } 53 | Ok(None) 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | fn parse(input: &str) -> Result, Error<'_>> { 59 | let mut toks = Tokenizer::new(input); 60 | Ok(ShortcutCommand::parse(&mut toks)?) 61 | } 62 | 63 | #[test] 64 | fn test_1() { 65 | assert_eq!(parse("ready."), Ok(Some(ShortcutCommand::Ready))); 66 | } 67 | 68 | #[test] 69 | fn test_2() { 70 | assert_eq!(parse("ready"), Ok(Some(ShortcutCommand::Ready))); 71 | } 72 | 73 | #[test] 74 | fn test_3() { 75 | assert_eq!(parse("author"), Ok(Some(ShortcutCommand::Author)),); 76 | } 77 | 78 | #[test] 79 | fn test_4() { 80 | assert_eq!(parse("ready word"), Ok(Some(ShortcutCommand::Ready))); 81 | } 82 | 83 | #[test] 84 | fn test_5() { 85 | assert_eq!(parse("blocked"), Ok(Some(ShortcutCommand::Blocked))); 86 | } 87 | -------------------------------------------------------------------------------- /parser/src/command/transfer.rs: -------------------------------------------------------------------------------- 1 | //! Parses the `@bot transfer reponame` command. 2 | 3 | use crate::error::Error; 4 | use crate::token::{Token, Tokenizer}; 5 | use std::fmt; 6 | 7 | #[derive(Debug, PartialEq, Eq)] 8 | pub struct TransferCommand(pub String); 9 | 10 | #[derive(Debug)] 11 | pub enum ParseError { 12 | MissingRepo, 13 | } 14 | 15 | impl std::error::Error for ParseError {} 16 | 17 | impl fmt::Display for ParseError { 18 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 19 | match self { 20 | ParseError::MissingRepo => write!(f, "missing repository name"), 21 | } 22 | } 23 | } 24 | 25 | impl TransferCommand { 26 | pub fn parse<'a>(input: &mut Tokenizer<'a>) -> Result, Error<'a>> { 27 | if !matches!(input.peek_token()?, Some(Token::Word("transfer"))) { 28 | return Ok(None); 29 | } 30 | input.next_token()?; 31 | let repo = if let Some(Token::Word(repo)) = input.next_token()? { 32 | repo.to_owned() 33 | } else { 34 | return Err(input.error(ParseError::MissingRepo)); 35 | }; 36 | Ok(Some(TransferCommand(repo))) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /parser/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::error; 2 | use std::fmt; 3 | 4 | #[derive(Debug)] 5 | pub struct Error<'a> { 6 | pub input: &'a str, 7 | pub position: usize, 8 | pub source: Box, 9 | } 10 | 11 | impl<'a> PartialEq for Error<'a> { 12 | fn eq(&self, other: &Self) -> bool { 13 | self.input == other.input && self.position == other.position 14 | } 15 | } 16 | 17 | impl<'a> error::Error for Error<'a> { 18 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 19 | Some(&*self.source) 20 | } 21 | } 22 | 23 | impl<'a> Error<'a> { 24 | pub fn position(&self) -> usize { 25 | self.position 26 | } 27 | } 28 | 29 | impl<'a> fmt::Display for Error<'a> { 30 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 31 | let space = 10; 32 | let end = std::cmp::min(self.input.len(), self.position + space); 33 | write!( 34 | f, 35 | "...'{}' | error: {} at >| '{}'...", 36 | &self.input[self.position.saturating_sub(space)..self.position], 37 | self.source, 38 | &self.input[self.position..end], 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /parser/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod command; 2 | pub mod error; 3 | mod ignore_block; 4 | mod mentions; 5 | mod token; 6 | 7 | pub use ignore_block::replace_all_outside_ignore_blocks; 8 | pub use mentions::get_mentions; 9 | -------------------------------------------------------------------------------- /parser/src/mentions.rs: -------------------------------------------------------------------------------- 1 | /// This provides a list of usernames or teams that were pinged in the text 2 | /// provided. 3 | /// 4 | /// It will appropriately skip mentions just like GitHub, i.e., mentions inside 5 | /// code blocks will be ignored. 6 | /// 7 | /// Note that the `@` is skipped in the final output. 8 | pub fn get_mentions(input: &str) -> Vec<&str> { 9 | let ignore_regions = crate::ignore_block::IgnoreBlocks::new(input); 10 | 11 | let mut mentions = Vec::new(); 12 | for (idx, _) in input.match_indices('@') { 13 | if let Some(previous) = input[..idx].chars().next_back() { 14 | // A github username must stand apart from other text. 15 | // 16 | // Oddly enough, english letters do not work, but letters outside 17 | // ASCII do work as separators; for now just go with this limited 18 | // list. 19 | if let 'a'..='z' | 'A'..='Z' | '0'..='9' = previous { 20 | continue; 21 | } 22 | } 23 | let mut saw_slash = false; 24 | let username_end = input 25 | .get(idx + 1..) 26 | .unwrap_or_default() 27 | .char_indices() 28 | .find(|(_, terminator)| match terminator { 29 | // These are the valid characters inside of a GitHub 30 | // username 31 | 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => false, 32 | '/' if !saw_slash => { 33 | saw_slash = true; 34 | false 35 | } 36 | _ => true, 37 | }) 38 | .map(|(end, _)| idx + 1 + end) 39 | .unwrap_or(input.len()); 40 | let username = input.get(idx + 1..username_end).unwrap_or_default(); 41 | if username.is_empty() { 42 | continue; 43 | } 44 | if ignore_regions 45 | .overlaps_ignore(idx..idx + username.len()) 46 | .is_some() 47 | { 48 | continue; 49 | } 50 | mentions.push(username); 51 | } 52 | mentions 53 | } 54 | 55 | #[test] 56 | fn mentions_in_code_ignored() { 57 | assert_eq!( 58 | get_mentions("@rust-lang/libs `@user`"), 59 | vec!["rust-lang/libs"] 60 | ); 61 | assert_eq!(get_mentions("@user `@user`"), vec!["user"]); 62 | assert_eq!(get_mentions("`@user`"), Vec::<&str>::new()); 63 | } 64 | 65 | #[test] 66 | fn italics() { 67 | assert_eq!(get_mentions("*@rust-lang/libs*"), vec!["rust-lang/libs"]); 68 | } 69 | 70 | #[test] 71 | fn slash() { 72 | assert_eq!( 73 | get_mentions("@rust-lang/libs/@rust-lang/release"), 74 | vec!["rust-lang/libs", "rust-lang/release"] 75 | ); 76 | } 77 | 78 | #[test] 79 | fn no_panic_lone() { 80 | assert_eq!(get_mentions("@ `@`"), Vec::<&str>::new()); 81 | } 82 | 83 | #[test] 84 | fn no_email() { 85 | assert_eq!(get_mentions("user@example.com"), Vec::<&str>::new()); 86 | assert_eq!(get_mentions("user123@example.com"), Vec::<&str>::new()); 87 | } 88 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" 2 | -------------------------------------------------------------------------------- /src/actions.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use std::collections::HashMap; 3 | use std::sync::{Arc, LazyLock}; 4 | 5 | use async_trait::async_trait; 6 | use serde::{Deserialize, Serialize}; 7 | use tera::{Context, Tera}; 8 | 9 | use crate::github::{self, GithubClient, Repository}; 10 | 11 | #[async_trait] 12 | pub trait Action { 13 | async fn call(&self) -> anyhow::Result; 14 | } 15 | 16 | pub struct Step<'a> { 17 | pub name: &'a str, 18 | pub actions: Vec>, 19 | } 20 | 21 | pub struct Query<'a> { 22 | /// Vec of (owner, name) 23 | pub repos: Vec<(&'a str, &'a str)>, 24 | pub queries: Vec>, 25 | } 26 | 27 | #[derive(Copy, Clone)] 28 | pub enum QueryKind { 29 | List, 30 | Count, 31 | } 32 | 33 | pub struct QueryMap<'a> { 34 | pub name: &'a str, 35 | pub kind: QueryKind, 36 | pub query: Arc, 37 | } 38 | 39 | #[derive(Debug, serde::Serialize)] 40 | pub struct IssueDecorator { 41 | pub number: u64, 42 | pub title: String, 43 | pub html_url: String, 44 | pub repo_name: String, 45 | pub labels: String, 46 | pub author: String, 47 | pub assignees: String, 48 | // Human (readable) timestamp 49 | pub updated_at_hts: String, 50 | 51 | pub fcp_details: Option, 52 | pub mcp_details: Option, 53 | } 54 | 55 | #[derive(Serialize, Deserialize, Debug)] 56 | pub struct FCPConcernDetails { 57 | pub name: String, 58 | pub reviewer_login: String, 59 | pub concern_url: String, 60 | } 61 | 62 | #[derive(Serialize, Deserialize, Debug)] 63 | pub struct FCPReviewerDetails { 64 | pub github_login: String, 65 | pub zulip_id: Option, 66 | } 67 | 68 | #[derive(Serialize, Deserialize, Debug)] 69 | pub struct FCPDetails { 70 | pub bot_tracking_comment_html_url: String, 71 | pub bot_tracking_comment_content: String, 72 | pub initiating_comment_html_url: String, 73 | pub initiating_comment_content: String, 74 | pub disposition: String, 75 | pub should_mention: bool, 76 | pub pending_reviewers: Vec, 77 | pub concerns: Vec, 78 | } 79 | 80 | #[derive(Serialize, Deserialize, Debug)] 81 | pub struct MCPDetails { 82 | pub zulip_link: String, 83 | pub concerns: Option>, 84 | } 85 | 86 | pub static TEMPLATES: LazyLock = LazyLock::new(|| match Tera::new("templates/*") { 87 | Ok(t) => t, 88 | Err(e) => { 89 | println!("Parsing error(s): {}", e); 90 | ::std::process::exit(1); 91 | } 92 | }); 93 | 94 | pub fn to_human(d: DateTime) -> String { 95 | let d1 = chrono::Utc::now() - d; 96 | let days = d1.num_days(); 97 | if days > 60 { 98 | format!("{} months ago", days / 30) 99 | } else { 100 | format!("about {} days ago", days) 101 | } 102 | } 103 | 104 | #[async_trait] 105 | impl<'a> Action for Step<'a> { 106 | async fn call(&self) -> anyhow::Result { 107 | let mut gh = GithubClient::new_from_env(); 108 | gh.set_retry_rate_limit(true); 109 | 110 | let mut context = Context::new(); 111 | let mut results = HashMap::new(); 112 | 113 | let mut handles: Vec)>>> = 114 | Vec::new(); 115 | let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(5)); 116 | 117 | for Query { repos, queries } in &self.actions { 118 | for repo in repos { 119 | let repository = Repository { 120 | full_name: format!("{}/{}", repo.0, repo.1), 121 | // These are unused for query. 122 | default_branch: "master".to_string(), 123 | fork: false, 124 | parent: None, 125 | }; 126 | 127 | for QueryMap { name, kind, query } in queries { 128 | let semaphore = semaphore.clone(); 129 | let name = String::from(*name); 130 | let kind = *kind; 131 | let repository = repository.clone(); 132 | let gh = gh.clone(); 133 | let query = query.clone(); 134 | handles.push(tokio::task::spawn(async move { 135 | let _permit = semaphore.acquire().await?; 136 | let fcps_groups = ["proposed_fcp", "in_pre_fcp", "in_fcp"]; 137 | let mcps_groups = [ 138 | "mcp_new_not_seconded", 139 | "mcp_old_not_seconded", 140 | "mcp_accepted", 141 | "in_pre_fcp", 142 | "in_fcp", 143 | ]; 144 | let issues = query 145 | .query( 146 | &repository, 147 | fcps_groups.contains(&name.as_str()), 148 | mcps_groups.contains(&name.as_str()) 149 | && repository.full_name.contains("rust-lang/compiler-team"), 150 | &gh, 151 | ) 152 | .await?; 153 | Ok((name, kind, issues)) 154 | })); 155 | } 156 | } 157 | } 158 | 159 | for handle in handles { 160 | let (name, kind, issues) = handle.await.unwrap()?; 161 | match kind { 162 | QueryKind::List => { 163 | results.entry(name).or_insert(Vec::new()).extend(issues); 164 | } 165 | QueryKind::Count => { 166 | let count = issues.len(); 167 | let result = if let Some(value) = context.get(&name) { 168 | value.as_u64().unwrap() + count as u64 169 | } else { 170 | count as u64 171 | }; 172 | 173 | context.insert(name, &result); 174 | } 175 | } 176 | } 177 | 178 | for (name, issues) in &results { 179 | context.insert(name, issues); 180 | } 181 | 182 | let date = chrono::Utc::now().format("%Y-%m-%d").to_string(); 183 | context.insert("CURRENT_DATE", &date); 184 | 185 | Ok(TEMPLATES 186 | .render(&format!("{}.tt", self.name), &context) 187 | .unwrap()) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/bin/compiler.rs: -------------------------------------------------------------------------------- 1 | use triagebot::agenda; 2 | 3 | #[tokio::main(flavor = "current_thread")] 4 | async fn main() -> anyhow::Result<()> { 5 | dotenvy::dotenv().ok(); 6 | tracing_subscriber::fmt::init(); 7 | 8 | let args: Vec = std::env::args().collect(); 9 | if args.len() == 2 { 10 | match &args[1][..] { 11 | "backlog_bonanza" => { 12 | let agenda = agenda::compiler_backlog_bonanza(); 13 | print!("{}", agenda.call().await?); 14 | return Ok(()); 15 | } 16 | _ => {} 17 | } 18 | } 19 | 20 | eprintln!("Usage: compiler (backlog_bonanza)"); 21 | 22 | Ok(()) 23 | } 24 | -------------------------------------------------------------------------------- /src/bin/lang.rs: -------------------------------------------------------------------------------- 1 | use triagebot::agenda; 2 | 3 | #[tokio::main(flavor = "current_thread")] 4 | async fn main() -> anyhow::Result<()> { 5 | dotenvy::dotenv().ok(); 6 | tracing_subscriber::fmt::init(); 7 | 8 | let args: Vec = std::env::args().collect(); 9 | if args.len() == 2 { 10 | match &args[1][..] { 11 | "agenda" => { 12 | let agenda = agenda::lang(); 13 | print!("{}", agenda.call().await?); 14 | return Ok(()); 15 | } 16 | "planning" => { 17 | let agenda = agenda::lang_planning(); 18 | print!("{}", agenda.call().await?); 19 | return Ok(()); 20 | } 21 | _ => {} 22 | } 23 | } 24 | 25 | eprintln!("Usage: lang (agenda|planning)"); 26 | 27 | Ok(()) 28 | } 29 | -------------------------------------------------------------------------------- /src/bin/prioritization-agenda.rs: -------------------------------------------------------------------------------- 1 | use triagebot::agenda; 2 | 3 | #[tokio::main(flavor = "current_thread")] 4 | async fn main() -> anyhow::Result<()> { 5 | dotenvy::dotenv().ok(); 6 | tracing_subscriber::fmt::init(); 7 | 8 | let agenda = agenda::prioritization(); 9 | 10 | print!("{}", agenda.call().await?); 11 | Ok(()) 12 | } 13 | -------------------------------------------------------------------------------- /src/bin/project_goals.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use triagebot::zulip::client::ZulipClient; 3 | use triagebot::{github::GithubClient, handlers::project_goals}; 4 | 5 | /// A basic example 6 | #[derive(Parser, Debug)] 7 | struct Opt { 8 | /// If specified, no messages are sent. 9 | #[arg(long)] 10 | dry_run: bool, 11 | 12 | /// Goals with an updated within this threshold will not be pinged. 13 | days_threshold: i64, 14 | 15 | /// A string like "on Sep-5" when the update blog post will be written. 16 | next_meeting_date: String, 17 | } 18 | 19 | #[tokio::main(flavor = "current_thread")] 20 | async fn main() -> anyhow::Result<()> { 21 | dotenvy::dotenv().ok(); 22 | tracing_subscriber::fmt::init(); 23 | 24 | let opt = Opt::parse(); 25 | let gh = GithubClient::new_from_env(); 26 | let zulip = ZulipClient::new_from_env(); 27 | project_goals::ping_project_goals_owners( 28 | &gh, 29 | &zulip, 30 | opt.dry_run, 31 | opt.days_threshold, 32 | &opt.next_meeting_date, 33 | ) 34 | .await?; 35 | 36 | Ok(()) 37 | } 38 | -------------------------------------------------------------------------------- /src/bin/types.rs: -------------------------------------------------------------------------------- 1 | use triagebot::agenda; 2 | 3 | #[tokio::main(flavor = "current_thread")] 4 | async fn main() -> anyhow::Result<()> { 5 | dotenvy::dotenv().ok(); 6 | tracing_subscriber::fmt::init(); 7 | 8 | let args: Vec = std::env::args().collect(); 9 | if args.len() == 2 { 10 | match &args[1][..] { 11 | "planning" => { 12 | let agenda = agenda::types_planning(); 13 | print!("{}", agenda.call().await?); 14 | return Ok(()); 15 | } 16 | _ => {} 17 | } 18 | } 19 | 20 | eprintln!("Usage: types (planning)"); 21 | 22 | Ok(()) 23 | } 24 | -------------------------------------------------------------------------------- /src/changelogs/mod.rs: -------------------------------------------------------------------------------- 1 | mod rustc; 2 | 3 | use comrak::{nodes::AstNode, Arena, ComrakOptions, ComrakRenderOptions}; 4 | use std::collections::HashMap; 5 | 6 | #[derive(Copy, Clone, PartialEq, Eq, Debug, serde::Deserialize)] 7 | #[serde(rename_all = "kebab-case")] 8 | pub(crate) enum ChangelogFormat { 9 | Rustc, 10 | } 11 | 12 | pub(crate) struct Changelog { 13 | versions: HashMap, 14 | } 15 | 16 | impl Changelog { 17 | pub(crate) fn parse(format: ChangelogFormat, content: &str) -> anyhow::Result { 18 | match format { 19 | ChangelogFormat::Rustc => rustc::RustcFormat::new(&Arena::new()).parse(content), 20 | } 21 | } 22 | 23 | pub(crate) fn version(&self, version: &str) -> Option<&str> { 24 | self.versions.get(version).map(|s| s.as_str()) 25 | } 26 | } 27 | 28 | fn render_for_github_releases<'a>(document: &'a AstNode<'a>) -> anyhow::Result { 29 | let mut content = Vec::new(); 30 | comrak::format_commonmark( 31 | document, 32 | &ComrakOptions { 33 | render: ComrakRenderOptions { 34 | // Prevent column width line breaks from appearing in the generated release 35 | // notes. GitHub Releases insert
s for every line break in the markdown, 36 | // mangling the output. 37 | width: std::usize::MAX, 38 | 39 | ..ComrakRenderOptions::default() 40 | }, 41 | ..ComrakOptions::default() 42 | }, 43 | &mut content, 44 | )?; 45 | Ok(String::from_utf8(content)?) 46 | } 47 | -------------------------------------------------------------------------------- /src/changelogs/rustc.rs: -------------------------------------------------------------------------------- 1 | use super::Changelog; 2 | use anyhow::Context as _; 3 | use comrak::{ 4 | nodes::{AstNode, NodeHeading, NodeValue}, 5 | Arena, ComrakOptions, 6 | }; 7 | use std::collections::HashMap; 8 | 9 | pub(super) struct RustcFormat<'a> { 10 | arena: &'a Arena>, 11 | current_h1: Option, 12 | result: Changelog, 13 | } 14 | 15 | impl<'a> RustcFormat<'a> { 16 | pub(super) fn new(arena: &'a Arena>) -> Self { 17 | RustcFormat { 18 | arena, 19 | current_h1: None, 20 | result: Changelog { 21 | versions: HashMap::new(), 22 | }, 23 | } 24 | } 25 | 26 | pub(super) fn parse(mut self, content: &str) -> anyhow::Result { 27 | let ast = comrak::parse_document(&self.arena, &content, &ComrakOptions::default()); 28 | 29 | let mut section_ast = Vec::new(); 30 | for child in ast.children() { 31 | let child_data = child.data.borrow(); 32 | 33 | if let NodeValue::Heading(NodeHeading { level: 1, .. }) = child_data.value { 34 | if let Some(h1) = self.current_h1.take() { 35 | self.store_version(h1, section_ast)?; 36 | } 37 | 38 | let Some(h1_child_data) = child.first_child().map(|c| c.data.borrow()) else { 39 | anyhow::bail!("unable to retrieve heading (H1) child from changelog"); 40 | }; 41 | self.current_h1 = Some( 42 | h1_child_data 43 | .value 44 | .text() 45 | .context("unable to get the text of node below the heading H1")? 46 | .to_string(), 47 | ); 48 | section_ast = Vec::new(); 49 | } else { 50 | section_ast.push(child); 51 | } 52 | } 53 | if let Some(h1) = self.current_h1.take() { 54 | self.store_version(h1, section_ast)?; 55 | } 56 | 57 | Ok(self.result) 58 | } 59 | 60 | fn store_version(&mut self, h1: String, body: Vec<&'a AstNode<'a>>) -> anyhow::Result<()> { 61 | // Create a document with only the contents of this section 62 | let document = self.arena.alloc(NodeValue::Document.into()); 63 | for child in &body { 64 | document.append(child); 65 | } 66 | 67 | let content = super::render_for_github_releases(document)?; 68 | 69 | if let Some(version) = h1.split(' ').nth(1) { 70 | self.result.versions.insert(version.to_string(), content); 71 | } else { 72 | println!("skipped version, invalid header: {}", h1); 73 | } 74 | 75 | Ok(()) 76 | } 77 | } 78 | 79 | #[cfg(test)] 80 | mod tests { 81 | use super::*; 82 | 83 | const CONTENT: &str = "\ 84 | Version 1.45.2 (2020-08-03) 85 | ========================== 86 | 87 | * [Fix bindings in tuple struct patterns][74954] 88 | * [Link in another section][69033] 89 | * Very very very very very very very very very very very long line that has some 90 | linebreaks here and there 91 | 92 | [74954]: https://github.com/rust-lang/rust/issues/74954 93 | 94 | Version 1.45.1 (2020-07-30) 95 | ========================== 96 | 97 | * [Fix const propagation with references.][73613] 98 | * [rustfmt accepts rustfmt_skip in cfg_attr again.][73078] 99 | 100 | [73613]: https://github.com/rust-lang/rust/pull/73613 101 | [73078]: https://github.com/rust-lang/rust/issues/73078 102 | 103 | Version 1.44.0 (2020-06-04) 104 | ========================== 105 | 106 | Language 107 | -------- 108 | - [You can now use `async/.await` with `#[no_std]` enabled.][69033] 109 | 110 | **Syntax-only changes** 111 | 112 | - [Expansion-driven outline module parsing][69838] 113 | ```rust 114 | #[cfg(FALSE)] 115 | mod foo { 116 | mod bar { 117 | mod baz; // `foo/bar/baz.rs` doesn't exist, but no error! 118 | } 119 | } 120 | ``` 121 | 122 | These are still rejected semantically, so you will likely receive an error but 123 | these changes can be seen and parsed by macros and conditional compilation. 124 | 125 | Internal Only 126 | ------------- 127 | These changes provide no direct user facing benefits, but represent significant 128 | improvements to the internals and overall performance of rustc and 129 | related tools. 130 | 131 | - [dep_graph Avoid allocating a set on when the number reads are small.][69778] 132 | 133 | [69033]: https://github.com/rust-lang/rust/pull/69033/ 134 | [69838]: https://github.com/rust-lang/rust/pull/69838/ 135 | [69778]: https://github.com/rust-lang/rust/pull/69778/ 136 | "; 137 | 138 | const EXPECTED_1_45_2: &str = "\ 139 | - [Fix bindings in tuple struct patterns](https://github.com/rust-lang/rust/issues/74954) 140 | - [Link in another section](https://github.com/rust-lang/rust/pull/69033/) 141 | - Very very very very very very very very very very very long line that has some linebreaks here and there 142 | "; 143 | 144 | #[test] 145 | fn test_changelog_parsing() -> anyhow::Result<()> { 146 | let arena = Arena::new(); 147 | let parsed = RustcFormat::new(&arena).parse(CONTENT)?; 148 | 149 | // Ensure the right markdown is generated from each version 150 | let version_1_45_2 = parsed.version("1.45.2").expect("missing version 1.45.2"); 151 | assert_eq!(EXPECTED_1_45_2, version_1_45_2); 152 | 153 | let version_1_44_0 = parsed.version("1.44.0").expect("missing version 1.44.0"); 154 | assert!(version_1_44_0.contains("Avoid allocating a set")); 155 | 156 | Ok(()) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/db/issue_data.rs: -------------------------------------------------------------------------------- 1 | //! The `issue_data` table provides a way to track extra metadata about an 2 | //! issue/PR. 3 | //! 4 | //! Each issue has a unique "key" where you can store data under. Typically 5 | //! that key should be the name of the handler. The data can be anything that 6 | //! can be serialized to JSON. 7 | //! 8 | //! Note that this uses crude locking, so try to keep the duration between 9 | //! loading and saving to a minimum. 10 | 11 | use crate::github::Issue; 12 | use anyhow::{Context, Result}; 13 | use serde::{Deserialize, Serialize}; 14 | use tokio_postgres::types::Json; 15 | use tokio_postgres::{Client as DbClient, Transaction}; 16 | 17 | pub struct IssueData<'db, T> 18 | where 19 | T: for<'a> Deserialize<'a> + Serialize + Default + std::fmt::Debug + Sync + PartialEq + Clone, 20 | { 21 | transaction: Transaction<'db>, 22 | repo: String, 23 | issue_number: i32, 24 | key: String, 25 | pub data: T, 26 | initial_data: T, 27 | } 28 | 29 | impl<'db, T> IssueData<'db, T> 30 | where 31 | T: for<'a> Deserialize<'a> + Serialize + Default + std::fmt::Debug + Sync + PartialEq + Clone, 32 | { 33 | pub async fn load( 34 | db: &'db mut DbClient, 35 | issue: &Issue, 36 | key: &str, 37 | ) -> Result> { 38 | let repo = issue.repository().to_string(); 39 | let issue_number = issue.number as i32; 40 | let transaction = db.transaction().await?; 41 | transaction 42 | .execute("LOCK TABLE issue_data", &[]) 43 | .await 44 | .context("locking issue data")?; 45 | let data = transaction 46 | .query_opt( 47 | "SELECT data FROM issue_data WHERE \ 48 | repo = $1 AND issue_number = $2 AND key = $3", 49 | &[&repo, &issue_number, &key], 50 | ) 51 | .await 52 | .context("selecting issue data")? 53 | .map(|row| row.get::>(0).0) 54 | .unwrap_or_default(); 55 | 56 | let initial_data = data.clone(); 57 | 58 | Ok(IssueData { 59 | transaction, 60 | repo, 61 | issue_number, 62 | key: key.to_string(), 63 | data, 64 | initial_data, 65 | }) 66 | } 67 | 68 | pub async fn save(self) -> Result<()> { 69 | // Avoid writing to the DB needlessly. 70 | if self.data != self.initial_data { 71 | self.transaction 72 | .execute( 73 | "INSERT INTO issue_data (repo, issue_number, key, data) \ 74 | VALUES ($1, $2, $3, $4) \ 75 | ON CONFLICT (repo, issue_number, key) DO UPDATE SET data=EXCLUDED.data", 76 | &[&self.repo, &self.issue_number, &self.key, &Json(&self.data)], 77 | ) 78 | .await 79 | .context("inserting issue data")?; 80 | } 81 | self.transaction 82 | .commit() 83 | .await 84 | .context("committing issue data")?; 85 | Ok(()) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/db/jobs.rs: -------------------------------------------------------------------------------- 1 | //! The `jobs` table provides a way to have scheduled jobs 2 | use anyhow::{Context as _, Result}; 3 | use chrono::{DateTime, Utc}; 4 | use cron::Schedule; 5 | use serde::{Deserialize, Serialize}; 6 | use tokio_postgres::Client as DbClient; 7 | use uuid::Uuid; 8 | 9 | pub struct JobSchedule { 10 | pub name: &'static str, 11 | pub schedule: Schedule, 12 | pub metadata: serde_json::Value, 13 | } 14 | 15 | #[derive(Serialize, Deserialize, Debug)] 16 | pub struct Job { 17 | pub id: Uuid, 18 | pub name: String, 19 | pub scheduled_at: DateTime, 20 | pub metadata: serde_json::Value, 21 | pub executed_at: Option>, 22 | pub error_message: Option, 23 | } 24 | 25 | pub async fn insert_job( 26 | db: &DbClient, 27 | name: &str, 28 | scheduled_at: &DateTime, 29 | metadata: &serde_json::Value, 30 | ) -> Result<()> { 31 | tracing::trace!("insert_job(name={})", name); 32 | 33 | db.execute( 34 | "INSERT INTO jobs (name, scheduled_at, metadata) VALUES ($1, $2, $3) 35 | ON CONFLICT (name, scheduled_at) DO UPDATE SET metadata = EXCLUDED.metadata", 36 | &[&name, &scheduled_at, &metadata], 37 | ) 38 | .await 39 | .context("Inserting job")?; 40 | 41 | Ok(()) 42 | } 43 | 44 | pub async fn delete_job(db: &DbClient, id: &Uuid) -> Result<()> { 45 | tracing::trace!("delete_job(id={})", id); 46 | 47 | db.execute("DELETE FROM jobs WHERE id = $1", &[&id]) 48 | .await 49 | .context("Deleting job")?; 50 | 51 | Ok(()) 52 | } 53 | 54 | pub async fn update_job_error_message(db: &DbClient, id: &Uuid, message: &String) -> Result<()> { 55 | tracing::trace!("update_job_error_message(id={})", id); 56 | 57 | db.execute( 58 | "UPDATE jobs SET error_message = $2 WHERE id = $1", 59 | &[&id, &message], 60 | ) 61 | .await 62 | .context("Updating job error message")?; 63 | 64 | Ok(()) 65 | } 66 | 67 | pub async fn update_job_executed_at(db: &DbClient, id: &Uuid) -> Result<()> { 68 | tracing::trace!("update_job_executed_at(id={})", id); 69 | 70 | db.execute("UPDATE jobs SET executed_at = now() WHERE id = $1", &[&id]) 71 | .await 72 | .context("Updating job executed at")?; 73 | 74 | Ok(()) 75 | } 76 | 77 | pub async fn get_job_by_name_and_scheduled_at( 78 | db: &DbClient, 79 | name: &str, 80 | scheduled_at: &DateTime, 81 | ) -> Result { 82 | tracing::trace!( 83 | "get_job_by_name_and_scheduled_at(name={}, scheduled_at={})", 84 | name, 85 | scheduled_at 86 | ); 87 | 88 | let job = db 89 | .query_one( 90 | "SELECT * FROM jobs WHERE name = $1 AND scheduled_at = $2", 91 | &[&name, &scheduled_at], 92 | ) 93 | .await 94 | .context("Select job by name and scheduled at")?; 95 | 96 | deserialize_job(&job) 97 | } 98 | 99 | // Selects all jobs with: 100 | // - scheduled_at in the past 101 | // - error_message is null or executed_at is at least 60 minutes ago (intended to make repeat executions rare enough) 102 | pub async fn get_jobs_to_execute(db: &DbClient) -> Result> { 103 | let jobs = db 104 | .query( 105 | " 106 | SELECT * FROM jobs WHERE scheduled_at <= now() AND (error_message IS NULL OR executed_at <= now() - INTERVAL '60 minutes')", 107 | &[], 108 | ) 109 | .await 110 | .context("Getting jobs data")?; 111 | 112 | let mut data = Vec::with_capacity(jobs.len()); 113 | for job in jobs { 114 | let serialized_job = deserialize_job(&job); 115 | data.push(serialized_job.unwrap()); 116 | } 117 | 118 | Ok(data) 119 | } 120 | 121 | fn deserialize_job(row: &tokio_postgres::row::Row) -> Result { 122 | let id: Uuid = row.try_get(0)?; 123 | let name: String = row.try_get(1)?; 124 | let scheduled_at: DateTime = row.try_get(2)?; 125 | let metadata: serde_json::Value = row.try_get(3)?; 126 | let executed_at: Option> = row.try_get(4)?; 127 | let error_message: Option = row.try_get(5)?; 128 | 129 | Ok(Job { 130 | id, 131 | name, 132 | scheduled_at, 133 | metadata, 134 | executed_at, 135 | error_message, 136 | }) 137 | } 138 | -------------------------------------------------------------------------------- /src/db/rustc_commits.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context as _; 2 | use chrono::{DateTime, FixedOffset}; 3 | use tokio_postgres::Client as DbClient; 4 | 5 | /// A bors merge commit. 6 | #[derive(Debug, serde::Serialize)] 7 | pub struct Commit { 8 | pub sha: String, 9 | pub parent_sha: String, 10 | pub time: DateTime, 11 | pub pr: Option, 12 | } 13 | 14 | pub async fn record_commit(db: &DbClient, commit: Commit) -> anyhow::Result<()> { 15 | tracing::trace!("record_commit(sha={})", commit.sha); 16 | let pr = commit.pr.expect("commit has pr"); 17 | db.execute( 18 | "INSERT INTO rustc_commits (sha, parent_sha, time, pr) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", 19 | &[&commit.sha, &commit.parent_sha, &commit.time, &(pr as i32)], 20 | ) 21 | .await 22 | .context("inserting commit")?; 23 | Ok(()) 24 | } 25 | 26 | pub async fn has_commit(db: &DbClient, sha: &str) -> bool { 27 | !db.query("SELECT 1 FROM rustc_commits WHERE sha = $1", &[&sha]) 28 | .await 29 | .unwrap() 30 | .is_empty() 31 | } 32 | 33 | pub async fn get_missing_commits(db: &DbClient) -> Vec { 34 | let missing = db 35 | .query( 36 | " 37 | SELECT parent_sha 38 | FROM rustc_commits 39 | WHERE parent_sha NOT IN ( 40 | SELECT sha 41 | FROM rustc_commits 42 | )", 43 | &[], 44 | ) 45 | .await 46 | .unwrap(); 47 | missing.into_iter().map(|row| row.get(0)).collect() 48 | } 49 | 50 | pub async fn get_commits_with_artifacts(db: &DbClient) -> anyhow::Result> { 51 | let commits = db 52 | .query( 53 | " 54 | select sha, parent_sha, time, pr 55 | from rustc_commits 56 | where time >= current_date - interval '168 days' 57 | order by time desc;", 58 | &[], 59 | ) 60 | .await 61 | .context("Getting commit data")?; 62 | 63 | let mut data = Vec::with_capacity(commits.len()); 64 | for commit in commits { 65 | let sha: String = commit.get(0); 66 | let parent_sha: String = commit.get(1); 67 | let time: DateTime = commit.get(2); 68 | let pr: Option = commit.get(3); 69 | 70 | data.push(Commit { 71 | sha, 72 | parent_sha, 73 | time, 74 | pr: pr.map(|n| n as u32), 75 | }); 76 | } 77 | 78 | Ok(data) 79 | } 80 | -------------------------------------------------------------------------------- /src/db/users.rs: -------------------------------------------------------------------------------- 1 | use crate::github::User; 2 | use anyhow::Context; 3 | use tokio_postgres::Client as DbClient; 4 | 5 | /// Add a new user. 6 | /// If an user already exists, updates their username. 7 | pub async fn record_username(db: &DbClient, user_id: u64, username: &str) -> anyhow::Result<()> { 8 | db.execute( 9 | r" 10 | INSERT INTO users (user_id, username) VALUES ($1, $2) 11 | ON CONFLICT (user_id) 12 | DO UPDATE SET username = $2", 13 | &[&(user_id as i64), &username], 14 | ) 15 | .await 16 | .context("inserting user id / username")?; 17 | Ok(()) 18 | } 19 | 20 | /// Return a user from the DB. 21 | pub async fn get_user(db: &DbClient, user_id: u64) -> anyhow::Result> { 22 | let row = db 23 | .query_opt( 24 | r" 25 | SELECT username 26 | FROM users 27 | WHERE user_id = $1;", 28 | &[&(user_id as i64)], 29 | ) 30 | .await 31 | .context("cannot load user from DB")?; 32 | Ok(row.map(|row| { 33 | let username: &str = row.get(0); 34 | User { 35 | id: user_id, 36 | login: username.to_string(), 37 | } 38 | })) 39 | } 40 | 41 | #[cfg(test)] 42 | mod tests { 43 | use crate::db::users::{get_user, record_username}; 44 | use crate::tests::run_db_test; 45 | 46 | #[tokio::test] 47 | async fn update_username_on_conflict() { 48 | run_db_test(|ctx| async { 49 | let db = ctx.db_client(); 50 | 51 | record_username(&db, 1, "Foo").await?; 52 | record_username(&db, 1, "Bar").await?; 53 | 54 | assert_eq!(get_user(&db, 1).await?.unwrap().login, "Bar"); 55 | 56 | Ok(ctx) 57 | }) 58 | .await; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/handlers/assign/messages.rs: -------------------------------------------------------------------------------- 1 | //! Assignment messages functions and constants. 2 | //! 3 | //! This module contains the different constants and functions related 4 | //! to assignment messages. 5 | 6 | pub fn new_user_welcome_message(reviewer: &str) -> String { 7 | format!( 8 | "Thanks for the pull request, and welcome! \ 9 | The Rust team is excited to review your changes, and you should hear from {reviewer} \ 10 | some time within the next two weeks." 11 | ) 12 | } 13 | 14 | pub fn contribution_message(contributing_url: &str, bot: &str) -> String { 15 | format!( 16 | "Please see [the contribution \ 17 | instructions]({contributing_url}) for more information. Namely, in order to ensure the \ 18 | minimum review times lag, PR authors and assigned reviewers should ensure that the review \ 19 | label (`S-waiting-on-review` and `S-waiting-on-author`) stays updated, invoking these commands \ 20 | when appropriate: 21 | 22 | - `@{bot} author`: the review is finished, PR author should check the comments and take action accordingly 23 | - `@{bot} review`: the author is ready for a review, this PR will be queued again in the reviewer's queue" 24 | ) 25 | } 26 | 27 | pub fn welcome_with_reviewer(assignee: &str) -> String { 28 | format!("@{assignee} (or someone else)") 29 | } 30 | 31 | pub fn returning_user_welcome_message(assignee: &str, bot: &str) -> String { 32 | format!( 33 | "r? @{assignee} 34 | 35 | {bot} has assigned @{assignee}. 36 | They will have a look at your PR within the next two weeks and either review your PR or \ 37 | reassign to another reviewer. 38 | 39 | Use `r?` to explicitly pick a reviewer" 40 | ) 41 | } 42 | 43 | pub fn returning_user_welcome_message_no_reviewer(pr_author: &str) -> String { 44 | format!("@{pr_author}: no appropriate reviewer found, use `r?` to override") 45 | } 46 | 47 | pub fn reviewer_off_rotation_message(username: &str) -> String { 48 | format!( 49 | r"`{username}` is not available for reviewing at the moment. 50 | 51 | Please choose another assignee." 52 | ) 53 | } 54 | 55 | pub fn reviewer_assigned_before(username: &str) -> String { 56 | format!( 57 | "Requested reviewer @{username} was already assigned before. 58 | 59 | Please choose another assignee by using `r? @reviewer`." 60 | ) 61 | } 62 | 63 | pub const WELCOME_WITHOUT_REVIEWER: &str = "@Mark-Simulacrum (NB. this repo may be misconfigured)"; 64 | 65 | pub const REVIEWER_IS_PR_AUTHOR: &str = "Pull request author cannot be assigned as reviewer. 66 | 67 | 68 | Please choose another assignee."; 69 | 70 | pub const REVIEWER_ALREADY_ASSIGNED: &str = 71 | "Requested reviewer is already assigned to this pull request. 72 | 73 | Please choose another assignee."; 74 | -------------------------------------------------------------------------------- /src/handlers/assign/tests/tests_from_diff.rs: -------------------------------------------------------------------------------- 1 | //! Tests for `find_reviewers_from_diff` 2 | 3 | use super::super::*; 4 | use std::fmt::Write; 5 | 6 | fn test_from_diff(diff: &Vec, config: toml::Table, expected: &[&str]) { 7 | let aconfig: AssignConfig = config.try_into().unwrap(); 8 | assert_eq!( 9 | find_reviewers_from_diff(&aconfig, &*diff).unwrap(), 10 | expected.iter().map(|x| x.to_string()).collect::>() 11 | ); 12 | } 13 | 14 | /// Generates a fake diff that touches the given files. 15 | /// 16 | /// `paths` should be a slice of `(path, added, removed)` tuples where `added` 17 | /// is the number of lines added, and `removed` is the number of lines 18 | /// removed. 19 | fn make_fake_diff(paths: &[(&str, u32, u32)]) -> Vec { 20 | // This isn't a properly structured diff, but it has approximately enough 21 | // information for what is needed for testing. 22 | paths 23 | .iter() 24 | .map(|(path, added, removed)| { 25 | let mut diff = "@@ -0,0 +1 @@ ".to_string(); 26 | for n in 0..*added { 27 | writeln!(diff, "+Added line {n}").unwrap(); 28 | } 29 | for n in 0..*removed { 30 | writeln!(diff, "-Removed line {n}").unwrap(); 31 | } 32 | diff.push('\n'); 33 | FileDiff { 34 | filename: path.to_string(), 35 | patch: diff, 36 | } 37 | }) 38 | .collect() 39 | } 40 | 41 | #[test] 42 | fn no_matching_owners() { 43 | // When none of the owners match the diff. 44 | let config = toml::toml!( 45 | [owners] 46 | "/compiler" = ["compiler"] 47 | "/library" = ["libs"] 48 | ); 49 | let diff = make_fake_diff(&[("foo/bar.rs", 5, 0)]); 50 | test_from_diff(&diff, config, &[]); 51 | } 52 | 53 | #[test] 54 | fn from_diff_submodule() { 55 | let config = toml::toml!( 56 | [owners] 57 | "/src" = ["user1", "user2"] 58 | ); 59 | let diff = vec![FileDiff { 60 | filename: "src/jemalloc".to_string(), 61 | patch: "@@ -1 +1 @@\n\ 62 | -Subproject commit 2dba541881fb8e35246d653bbe2e7c7088777a4a\n\ 63 | +Subproject commit b001609960ca33047e5cbc5a231c1e24b6041d4b\n\ 64 | " 65 | .to_string(), 66 | }]; 67 | test_from_diff(&diff, config, &["user1", "user2"]); 68 | } 69 | 70 | #[test] 71 | fn prefixed_dirs() { 72 | // Test dirs with multiple overlapping prefixes. 73 | let config = toml::toml!( 74 | [owners] 75 | "/compiler" = ["compiler"] 76 | "/compiler/rustc_llvm" = ["llvm"] 77 | "/compiler/rustc_parse" = ["parser"] 78 | "/compiler/rustc_parse/src/parse/lexer" = ["lexer"] 79 | ); 80 | // Base compiler rule should catch anything in compiler/ 81 | let diff = make_fake_diff(&[("compiler/foo", 1, 1)]); 82 | test_from_diff(&diff, config.clone(), &["compiler"]); 83 | 84 | // Anything in rustc_llvm should go to llvm. 85 | let diff = make_fake_diff(&[("compiler/rustc_llvm/foo", 1, 1)]); 86 | test_from_diff(&diff, config.clone(), &["llvm"]); 87 | 88 | // 1 change in rustc_llvm, multiple changes in other directories, the 89 | // other directories win because they have more changes. 90 | let diff = make_fake_diff(&[ 91 | ("compiler/rustc_llvm/foo", 1, 1), 92 | ("compiler/rustc_traits/src/foo.rs", 0, 1), 93 | ("compiler/rustc_macros//src/foo.rs", 2, 3), 94 | ]); 95 | test_from_diff(&diff, config.clone(), &["compiler"]); 96 | 97 | // Change in a deeply nested directory should win over its parent. 98 | let diff = make_fake_diff(&[("compiler/rustc_parse/src/parse/lexer/foo.rs", 1, 1)]); 99 | test_from_diff(&diff, config.clone(), &["lexer"]); 100 | 101 | // Most changes in one component should win over the base compiler. 102 | let diff = make_fake_diff(&[ 103 | ("compiler/rustc_parse/src/foo.rs", 5, 10), 104 | ("compiler/rustc_llvm/src/foo.rs", 1, 1), 105 | ]); 106 | test_from_diff(&diff, config.clone(), &["parser"]); 107 | } 108 | 109 | #[test] 110 | fn deleted_file() { 111 | // Test dirs matching for a deleted file. 112 | let config = toml::toml!( 113 | [owners] 114 | "/compiler" = ["compiler"] 115 | "/compiler/rustc_parse" = ["parser"] 116 | ); 117 | let diff = make_fake_diff(&[("compiler/rustc_parse/src/foo.rs", 0, 10)]); 118 | test_from_diff(&diff, config, &["parser"]); 119 | } 120 | 121 | #[test] 122 | fn empty_file_still_counts() { 123 | let config = toml::toml!( 124 | [owners] 125 | "/compiler" = ["compiler"] 126 | "/compiler/rustc_parse" = ["parser"] 127 | ); 128 | let diff = vec![FileDiff { 129 | filename: "compiler/rustc_parse/src/foo.rs".to_string(), 130 | patch: "new file mode 100644\n\ 131 | index 0000000..e69de29\n" 132 | .to_string(), 133 | }]; 134 | test_from_diff(&diff, config, &["parser"]); 135 | } 136 | 137 | #[test] 138 | fn basic_gitignore_pattern() { 139 | let config = toml::toml!( 140 | [owners] 141 | "*.js" = ["javascript-reviewers"] 142 | "/compiler/rustc_parse" = ["parser"] 143 | ); 144 | let diff = make_fake_diff(&[("src/librustdoc/html/static/js/settings.js", 10, 1)]); 145 | test_from_diff(&diff, config, &["javascript-reviewers"]); 146 | } 147 | 148 | #[test] 149 | fn empty_owners_table() { 150 | let config = toml::toml!([owners]); 151 | let diff = make_fake_diff(&[("src.js", 10, 1)]); 152 | test_from_diff(&diff, config, &[]); 153 | } 154 | -------------------------------------------------------------------------------- /src/handlers/bot_pull_requests.rs: -------------------------------------------------------------------------------- 1 | use crate::github::{IssuesAction, PrState}; 2 | use crate::{github::Event, handlers::Context}; 3 | 4 | pub(crate) async fn handle(ctx: &Context, event: &Event) -> anyhow::Result<()> { 5 | let Event::Issue(event) = event else { 6 | return Ok(()); 7 | }; 8 | // Note that this filters out reopened too, which is what we'd expect when we set the state 9 | // back to opened after closing. 10 | if event.action != IssuesAction::Opened { 11 | return Ok(()); 12 | } 13 | if !event.issue.is_pr() { 14 | return Ok(()); 15 | } 16 | 17 | // avoid acting on our own open events, otherwise we'll infinitely loop 18 | if event.sender.login == ctx.username { 19 | return Ok(()); 20 | } 21 | 22 | // If it's not the github-actions bot, we don't expect this handler to be needed. Skip the 23 | // event. 24 | if event.sender.login != "github-actions[bot]" { 25 | return Ok(()); 26 | } 27 | 28 | ctx.github 29 | .set_pr_state( 30 | event.issue.repository(), 31 | event.issue.number, 32 | PrState::Closed, 33 | ) 34 | .await?; 35 | ctx.github 36 | .set_pr_state(event.issue.repository(), event.issue.number, PrState::Open) 37 | .await?; 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /src/handlers/check_commits/behind_upstream.rs: -------------------------------------------------------------------------------- 1 | use crate::github::{GithubCompare, IssuesEvent}; 2 | use tracing as log; 3 | 4 | /// Default threshold for parent commit age in days to trigger a warning 5 | pub(super) const DEFAULT_DAYS_THRESHOLD: usize = 7; 6 | 7 | /// Check if the PR is based on an old parent commit 8 | pub(super) async fn behind_upstream( 9 | age_threshold: usize, 10 | event: &IssuesEvent, 11 | compare: &GithubCompare, 12 | ) -> Option { 13 | log::debug!("Checking if PR #{} is behind upstream", event.issue.number); 14 | 15 | // Compute the number of days old the merge base commit is 16 | let commit_date = compare.merge_base_commit.commit.author.date; 17 | let now = chrono::Utc::now().with_timezone(&commit_date.timezone()); 18 | let days_old = (now - commit_date).num_days() as usize; 19 | 20 | let upstream_commit_url = &compare.merge_base_commit.html_url; 21 | 22 | // First try the parent commit age check as it's more accurate 23 | if days_old > age_threshold { 24 | log::info!( 25 | "PR #{} has a parent commit that is {} days old", 26 | event.issue.number, 27 | days_old 28 | ); 29 | 30 | Some(format!( 31 | r"This PR is based on an [upstream commit]({upstream_commit_url}) that is {days_old} days old. 32 | 33 | *It's recommended to update your branch according to the [rustc-dev-guide](https://rustc-dev-guide.rust-lang.org/contributing.html#keeping-your-branch-up-to-date).*", 34 | )) 35 | } else { 36 | // Parent commit is not too old, log and do nothing 37 | log::debug!("PR #{} parent commit is not too old", event.issue.number); 38 | None 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/handlers/check_commits/issue_links.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use regex::Regex; 4 | 5 | use crate::{config::IssueLinksConfig, github::GithubCommit}; 6 | 7 | static LINKED_RE: LazyLock = 8 | LazyLock::new(|| Regex::new(r"\B([a-zA-Z-_]+/[a-zA-Z-_]+)?(#[0-9]+)\b").unwrap()); 9 | 10 | const MERGE_IGNORE_LIST: [&str; 3] = ["Rollup merge of ", "Auto merge of ", "Merge pull request "]; 11 | 12 | pub(super) fn issue_links_in_commits( 13 | conf: &IssueLinksConfig, 14 | commits: &[GithubCommit], 15 | ) -> Option { 16 | if !conf.check_commits { 17 | return None; 18 | } 19 | 20 | let issue_links_commits = commits 21 | .into_iter() 22 | .filter(|c| { 23 | !MERGE_IGNORE_LIST 24 | .iter() 25 | .any(|i| c.commit.message.starts_with(i)) 26 | }) 27 | .filter(|c| LINKED_RE.is_match(&c.commit.message)) 28 | .map(|c| format!("- {}\n", c.sha)) 29 | .collect::(); 30 | 31 | if issue_links_commits.is_empty() { 32 | None 33 | } else { 34 | Some(format!( 35 | r"There are issue links (such as `#123`) in the commit messages of the following commits. 36 | *Please move them to the PR description, to avoid spamming the issues with references to the commit, and so this bot can automatically canonicalize them to avoid issues with subtree.* 37 | {issue_links_commits}", 38 | )) 39 | } 40 | } 41 | 42 | #[test] 43 | fn test_mentions_in_commits() { 44 | use super::dummy_commit_from_body; 45 | 46 | let config = IssueLinksConfig { 47 | check_commits: true, 48 | }; 49 | 50 | let mut commits = vec![dummy_commit_from_body( 51 | "d1992a392617dfb10518c3e56446b6c9efae38b0", 52 | "This is simple without issue links!", 53 | )]; 54 | 55 | assert_eq!(issue_links_in_commits(&config, &commits), None); 56 | 57 | commits.push(dummy_commit_from_body( 58 | "86176475acda9c775f844f5ad2470f05aebd4249", 59 | "Rollup merge of #123\n\nWe ignore the issue link for Rollup merge of", 60 | )); 61 | commits.push(dummy_commit_from_body( 62 | "8009423d53d30b56d8cf0fec08f9852329a1a9a4", 63 | "Auto merge of #123\n\nWe ignore the issue link for Auto merge of", 64 | )); 65 | commits.push(dummy_commit_from_body( 66 | "1eeacf822f6c11cd10713ddcb54a72352cacb2c2", 67 | "Merge pull request #2236 from rust-lang/rustc-pull", 68 | )); 69 | 70 | assert_eq!(issue_links_in_commits(&config, &commits), None); 71 | 72 | commits.push(dummy_commit_from_body( 73 | "d7daa17bc97df9377640b0d33cbd0bbeed703c3a", 74 | "This is a body with a issue link #123.", 75 | )); 76 | 77 | assert_eq!( 78 | issue_links_in_commits(&config, &commits), 79 | Some( 80 | r"There are issue links (such as `#123`) in the commit messages of the following commits. 81 | *Please move them to the PR description, to avoid spamming the issues with references to the commit, and so this bot can automatically canonicalize them to avoid issues with subtree.* 82 | - d7daa17bc97df9377640b0d33cbd0bbeed703c3a 83 | ".to_string() 84 | ) 85 | ); 86 | 87 | assert_eq!( 88 | issue_links_in_commits( 89 | &IssueLinksConfig { 90 | check_commits: false, 91 | }, 92 | &commits 93 | ), 94 | None 95 | ); 96 | 97 | commits.push(dummy_commit_from_body( 98 | "891f0916a07c215ae8173f782251422f1fea6acb", 99 | "This is a body with a issue link (rust-lang/rust#123).", 100 | )); 101 | 102 | assert_eq!( 103 | issue_links_in_commits(&config, &commits), 104 | Some( 105 | r"There are issue links (such as `#123`) in the commit messages of the following commits. 106 | *Please move them to the PR description, to avoid spamming the issues with references to the commit, and so this bot can automatically canonicalize them to avoid issues with subtree.* 107 | - d7daa17bc97df9377640b0d33cbd0bbeed703c3a 108 | - 891f0916a07c215ae8173f782251422f1fea6acb 109 | ".to_string() 110 | ) 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/handlers/check_commits/modified_submodule.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use regex::{Regex, RegexBuilder}; 4 | 5 | use crate::github::FileDiff; 6 | 7 | const SUBMODULE_WARNING_MSG: &str = "Some commits in this PR modify **submodules**."; 8 | 9 | static SUBPROJECT_COMMIT_RE: LazyLock = LazyLock::new(|| { 10 | RegexBuilder::new(r"^\+Subproject commit [a-zA-Z0-9]+$") 11 | .multi_line(true) 12 | .build() 13 | .unwrap() 14 | }); 15 | 16 | /// Returns a message if the PR modifies a git submodule. 17 | pub(super) fn modifies_submodule(diff: &[FileDiff]) -> Option { 18 | if diff 19 | .iter() 20 | .any(|fd| SUBPROJECT_COMMIT_RE.is_match(&fd.patch)) 21 | { 22 | Some(SUBMODULE_WARNING_MSG.to_string()) 23 | } else { 24 | None 25 | } 26 | } 27 | 28 | #[test] 29 | fn no_submodule_update() { 30 | let filediff = FileDiff { 31 | filename: "src/lib.rs".to_string(), 32 | patch: "@@ -1 +1 @@\ 33 | -let mut my_var = 5;\ 34 | +let mut my_var = \"tmp\";" 35 | .to_string(), 36 | }; 37 | 38 | assert_eq!(modifies_submodule(&[filediff]), None) 39 | } 40 | 41 | #[test] 42 | fn simple_submodule_update() { 43 | // Taken from https://api.github.com/repos/rust-lang/rust/compare/5af801b687e6e8b860ae970e725c8b9a3820d0ce...d6c4ab81be200855df856468ddedde057958441a 44 | let filediff = FileDiff { 45 | filename: "src/tools/rustc-perf".to_string(), 46 | patch: "@@ -1 +1 @@\n\ 47 | -Subproject commit c0f3b53c8e5de87714d18a5f42998859302ae03a\n\ 48 | +Subproject commit 8158f78f738715c060d230351623a7f7cc01bf97" 49 | .to_string(), 50 | }; 51 | 52 | assert_eq!( 53 | modifies_submodule(&[filediff]), 54 | Some(SUBMODULE_WARNING_MSG.to_string()) 55 | ) 56 | } 57 | 58 | #[test] 59 | fn no_submodule_update_tricky_case() { 60 | let filediff = FileDiff { 61 | filename: "src/tools.sh".to_string(), 62 | patch: "@@ -1 +1 @@\ 63 | -let mut subproject_commit = 5;\ 64 | +let mut subproject_commit = \"+Subproject commit \";" 65 | .to_string(), 66 | }; 67 | 68 | assert_eq!(modifies_submodule(&[filediff]), None) 69 | } 70 | -------------------------------------------------------------------------------- /src/handlers/check_commits/no_mentions.rs: -------------------------------------------------------------------------------- 1 | //! Purpose: When opening a PR, or pushing new changes, check for github mentions 2 | //! in commits and notify the user of our no-mentions in commits policy. 3 | 4 | use crate::{config::NoMentionsConfig, github::GithubCommit}; 5 | 6 | pub(super) fn mentions_in_commits( 7 | _conf: &NoMentionsConfig, 8 | commits: &[GithubCommit], 9 | ) -> Option { 10 | let mentions_commits = commits 11 | .into_iter() 12 | .filter(|c| !parser::get_mentions(&c.commit.message).is_empty()) 13 | .map(|c| format!("- {}\n", c.sha)) 14 | .collect::(); 15 | 16 | if mentions_commits.is_empty() { 17 | None 18 | } else { 19 | Some(format!( 20 | r"There are username mentions (such as `@user`) in the commit messages of the following commits. 21 | *Please remove the mentions to avoid spamming these users.* 22 | {mentions_commits}", 23 | )) 24 | } 25 | } 26 | 27 | #[test] 28 | fn test_mentions_in_commits() { 29 | use super::dummy_commit_from_body; 30 | 31 | let mut commits = vec![dummy_commit_from_body( 32 | "d1992a392617dfb10518c3e56446b6c9efae38b0", 33 | "This is simple without mentions!", 34 | )]; 35 | 36 | assert_eq!(mentions_in_commits(&NoMentionsConfig {}, &commits), None); 37 | 38 | commits.push(dummy_commit_from_body( 39 | "10b96a74c484cae79164cbbcdfcd412109e0e4cf", 40 | r"This is a body with a sign-off and co-author 41 | Signed-off-by: Foo Bar 42 | Co-authored-by: Baz Qux ", 43 | )); 44 | 45 | assert_eq!(mentions_in_commits(&NoMentionsConfig {}, &commits), None); 46 | 47 | commits.push(dummy_commit_from_body( 48 | "d7daa17bc97df9377640b0d33cbd0bbeed703c3a", 49 | "This is a body with a @mention!", 50 | )); 51 | 52 | assert_eq!( 53 | mentions_in_commits(&NoMentionsConfig {}, &commits), 54 | Some( 55 | r"There are username mentions (such as `@user`) in the commit messages of the following commits. 56 | *Please remove the mentions to avoid spamming these users.* 57 | - d7daa17bc97df9377640b0d33cbd0bbeed703c3a 58 | ".to_string() 59 | ) 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/handlers/check_commits/no_merges.rs: -------------------------------------------------------------------------------- 1 | //! Purpose: When opening a PR, or pushing new changes, check for merge commits 2 | //! and notify the user of our no-merge policy. 3 | 4 | use crate::{ 5 | config::NoMergesConfig, 6 | github::{GithubCommit, Repository}, 7 | }; 8 | use std::collections::BTreeSet; 9 | use std::fmt::Write; 10 | 11 | pub(super) fn merges_in_commits( 12 | issue_title: &str, 13 | repository: &Repository, 14 | config: &NoMergesConfig, 15 | commits: &Vec, 16 | ) -> Option<(String, Vec)> { 17 | // Don't trigger if the PR has any of the excluded title segments. 18 | if config 19 | .exclude_titles 20 | .iter() 21 | .any(|s| issue_title.contains(s)) 22 | { 23 | return None; 24 | } 25 | 26 | let mut merge_commits = BTreeSet::new(); 27 | for commit in commits { 28 | if commit.parents.len() > 1 { 29 | merge_commits.insert(&*commit.sha); 30 | } 31 | } 32 | 33 | // No merge commits. 34 | if merge_commits.is_empty() { 35 | return None; 36 | } 37 | 38 | let message = config 39 | .message 40 | .as_deref() 41 | .unwrap_or(&get_default_message( 42 | &repository.full_name, 43 | &repository.default_branch, 44 | merge_commits.into_iter(), 45 | )) 46 | .to_string(); 47 | 48 | Some((message, config.labels.clone())) 49 | } 50 | 51 | fn get_default_message<'a>( 52 | repository_name: &str, 53 | default_branch: &str, 54 | commits: impl IntoIterator, 55 | ) -> String { 56 | let mut message = format!( 57 | "The following commits have merge commits (commits with multiple parents) in your changes. \ 58 | We have a [no merge policy](https://rustc-dev-guide.rust-lang.org/git.html#no-merge-policy) \ 59 | so these commits will need to be removed for this pull request to be merged. 60 | " 61 | ); 62 | 63 | for commit in commits { 64 | writeln!(message, "- {commit}").unwrap(); 65 | } 66 | 67 | writeln!( 68 | message, 69 | " 70 | You can start a rebase with the following commands: 71 | ```shell-session 72 | $ # rebase 73 | $ git pull --rebase https://github.com/{repository_name}.git {default_branch} 74 | $ git push --force-with-lease 75 | ```" 76 | ) 77 | .unwrap(); 78 | 79 | message 80 | } 81 | 82 | #[test] 83 | fn end_to_end() { 84 | use super::dummy_commit_from_body; 85 | use crate::github::Parent; 86 | 87 | let config = NoMergesConfig { 88 | exclude_titles: vec!["Subtree update".to_string()], 89 | labels: vec!["merge-commits".to_string()], 90 | message: None, 91 | }; 92 | 93 | let title = "This a PR!"; 94 | let repository = Repository { 95 | full_name: "rust-lang/rust".to_string(), 96 | default_branch: "master".to_string(), 97 | fork: false, 98 | parent: None, 99 | }; 100 | 101 | let commit_with_merge = || { 102 | let mut commit_with_merge = 103 | dummy_commit_from_body("9cc6dce67c917fe5937e984f58f5003ccbb5c37e", "+ main.rs"); 104 | commit_with_merge.parents = vec![ 105 | Parent { 106 | sha: "4fb1228e72cf76549e275c38c226c1c2326ca991".to_string(), 107 | }, 108 | Parent { 109 | sha: "febd545030008f13541064895ae36e19d929a043".to_string(), 110 | }, 111 | ]; 112 | commit_with_merge 113 | }; 114 | 115 | // contains merges 116 | { 117 | let Some((warning, labels)) = merges_in_commits( 118 | &title, 119 | &repository, 120 | &config, 121 | &vec![ 122 | commit_with_merge(), 123 | dummy_commit_from_body("499bdd2d766f98420c66a80a02b7d3ceba4d06ba", "+ nothing.rs"), 124 | ], 125 | ) else { 126 | unreachable!() 127 | }; 128 | assert_eq!(warning, "The following commits have merge commits (commits with multiple parents) in your changes. We have a [no merge policy](https://rustc-dev-guide.rust-lang.org/git.html#no-merge-policy) so these commits will need to be removed for this pull request to be merged. 129 | - 9cc6dce67c917fe5937e984f58f5003ccbb5c37e 130 | 131 | You can start a rebase with the following commands: 132 | ```shell-session 133 | $ # rebase 134 | $ git pull --rebase https://github.com/rust-lang/rust.git master 135 | $ git push --force-with-lease 136 | ``` 137 | "); 138 | assert_eq!(labels, vec!["merge-commits"]); 139 | } 140 | 141 | // doesn't contains merges 142 | { 143 | let commit = dummy_commit_from_body("67c917fe5937e984f58f5003ccbb5c37e", "+ main.rs"); 144 | 145 | assert_eq!( 146 | merges_in_commits(&title, &repository, &config, &vec![commit]), 147 | None 148 | ); 149 | } 150 | 151 | // contains merges, but excluded by title 152 | { 153 | assert_eq!( 154 | merges_in_commits( 155 | "Subtree update of rustc_custom_codegen", 156 | &repository, 157 | &config, 158 | &vec![commit_with_merge()] 159 | ), 160 | None 161 | ); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/handlers/check_commits/non_default_branch.rs: -------------------------------------------------------------------------------- 1 | use crate::{config::WarnNonDefaultBranchException, github::IssuesEvent}; 2 | 3 | /// Returns a message if the PR is opened against the non-default branch (or the 4 | /// exception branch if it's an exception). 5 | pub(super) fn non_default_branch( 6 | exceptions: &[WarnNonDefaultBranchException], 7 | event: &IssuesEvent, 8 | ) -> Option { 9 | let target_branch = &event.issue.base.as_ref().unwrap().git_ref; 10 | 11 | if let Some(exception) = exceptions 12 | .iter() 13 | .find(|e| event.issue.title.contains(&e.title)) 14 | { 15 | if &exception.branch != target_branch { 16 | return Some(not_default_exception_branch_warn( 17 | &exception.branch, 18 | target_branch, 19 | )); 20 | } 21 | } else if &event.repository.default_branch != target_branch { 22 | return Some(not_default_branch_warn( 23 | &event.repository.default_branch, 24 | target_branch, 25 | )); 26 | } 27 | None 28 | } 29 | 30 | fn not_default_branch_warn(default: &str, target: &str) -> String { 31 | format!( 32 | "Pull requests are usually filed against the {default} branch for this repo, \ 33 | but this one is against {target}. \ 34 | Please double check that you specified the right target!" 35 | ) 36 | } 37 | 38 | fn not_default_exception_branch_warn(default: &str, target: &str) -> String { 39 | format!( 40 | "Pull requests targetting the {default} branch are usually filed against the {default} \ 41 | branch, but this one is against {target}. \ 42 | Please double check that you specified the right target!" 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/handlers/close.rs: -------------------------------------------------------------------------------- 1 | //! Allows to close an issue or a PR 2 | 3 | use crate::{config::CloseConfig, github::Event, handlers::Context, interactions::ErrorComment}; 4 | use parser::command::close::CloseCommand; 5 | 6 | pub(super) async fn handle_command( 7 | ctx: &Context, 8 | _config: &CloseConfig, 9 | event: &Event, 10 | _cmd: CloseCommand, 11 | ) -> anyhow::Result<()> { 12 | let issue = event.issue().unwrap(); 13 | let is_team_member = event 14 | .user() 15 | .is_team_member(&ctx.github) 16 | .await 17 | .unwrap_or(false); 18 | if !is_team_member { 19 | let cmnt = ErrorComment::new(&issue, "Only team members can close issues."); 20 | cmnt.post(&ctx.github).await?; 21 | return Ok(()); 22 | } 23 | issue.close(&ctx.github).await?; 24 | Ok(()) 25 | } 26 | -------------------------------------------------------------------------------- /src/handlers/github_releases.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | changelogs::Changelog, 3 | config::GitHubReleasesConfig, 4 | github::{CreateEvent, CreateKind, Event}, 5 | handlers::Context, 6 | }; 7 | use anyhow::Context as _; 8 | use octocrab::Page; 9 | use std::{collections::HashMap, time::Duration}; 10 | use tracing as log; 11 | 12 | pub(super) async fn handle( 13 | ctx: &Context, 14 | event: &Event, 15 | config: &GitHubReleasesConfig, 16 | ) -> anyhow::Result<()> { 17 | // Only allow commit pushed to the changelog branch or tags being created. 18 | match event { 19 | Event::Push(push) if push.git_ref == format!("refs/heads/{}", config.changelog_branch) => {} 20 | Event::Create(CreateEvent { 21 | ref_type: CreateKind::Tag, 22 | .. 23 | }) => {} 24 | _ => return Ok(()), 25 | } 26 | 27 | log::info!("handling github releases"); 28 | 29 | log::debug!("loading the changelog"); 30 | let content = load_changelog(ctx, event, config).await.with_context(|| { 31 | format!( 32 | "failed to load changelog file {} from repo {} in branch {}", 33 | config.changelog_path, 34 | event.repo().full_name, 35 | config.changelog_branch 36 | ) 37 | })?; 38 | let changelog = Changelog::parse(config.format, &content)?; 39 | 40 | log::debug!("loading the git tags"); 41 | let tags = load_paginated( 42 | ctx, 43 | &format!("/repos/{}/git/matching-refs/tags", event.repo().full_name), 44 | |git_ref: &GitRef| { 45 | git_ref 46 | .name 47 | .strip_prefix("refs/tags/") 48 | .unwrap_or(git_ref.name.as_str()) 49 | .to_string() 50 | }, 51 | ) 52 | .await?; 53 | 54 | log::debug!("loading the existing releases"); 55 | let releases = load_paginated( 56 | ctx, 57 | &format!("/repos/{}/releases", event.repo().full_name), 58 | |release: &Release| release.tag_name.clone(), 59 | ) 60 | .await?; 61 | 62 | for tag in tags.keys() { 63 | if let Some(expected_body) = changelog.version(tag) { 64 | let expected_name = format!("{} {}", config.project_name, tag); 65 | 66 | if let Some(release) = releases.get(tag) { 67 | if release.name != expected_name || release.body != expected_body { 68 | log::info!("updating release {} on {}", tag, event.repo().full_name); 69 | let _: serde_json::Value = ctx 70 | .octocrab 71 | .patch( 72 | &release.url, 73 | Some(&serde_json::json!({ 74 | "name": expected_name, 75 | "body": expected_body, 76 | })), 77 | ) 78 | .await?; 79 | } else { 80 | // Avoid waiting for the delay below. 81 | continue; 82 | } 83 | } else { 84 | log::info!("creating release {} on {}", tag, event.repo().full_name); 85 | let e: octocrab::Result = ctx 86 | .octocrab 87 | .post( 88 | format!("/repos/{}/releases", event.repo().full_name), 89 | Some(&serde_json::json!({ 90 | "tag_name": tag, 91 | "name": expected_name, 92 | "body": expected_body, 93 | })), 94 | ) 95 | .await; 96 | match e { 97 | Ok(v) => log::debug!("created release: {:?}", v), 98 | Err(e) => { 99 | log::error!("Failed to create release: {:?}", e); 100 | 101 | // Don't stop creating future releases just because this 102 | // one failed. 103 | } 104 | } 105 | } 106 | 107 | log::debug!("sleeping for one second to avoid hitting any rate limit"); 108 | tokio::time::sleep(Duration::from_secs(1)).await; 109 | } else { 110 | log::trace!( 111 | "skipping tag {} since it doesn't have a changelog entry", 112 | tag 113 | ); 114 | } 115 | } 116 | 117 | Ok(()) 118 | } 119 | 120 | async fn load_changelog( 121 | ctx: &Context, 122 | event: &Event, 123 | config: &GitHubReleasesConfig, 124 | ) -> anyhow::Result { 125 | let resp = ctx 126 | .github 127 | .raw_file( 128 | &event.repo().full_name, 129 | &config.changelog_branch, 130 | &config.changelog_path, 131 | ) 132 | .await? 133 | .ok_or_else(|| anyhow::Error::msg("missing file"))?; 134 | 135 | Ok(String::from_utf8(resp.to_vec())?) 136 | } 137 | 138 | async fn load_paginated(ctx: &Context, url: &str, key: F) -> anyhow::Result> 139 | where 140 | T: serde::de::DeserializeOwned, 141 | R: Eq + PartialEq + std::hash::Hash, 142 | F: Fn(&T) -> R, 143 | { 144 | let mut current_page: Page = ctx 145 | .octocrab 146 | .get::, _, ()>(url, None) 147 | .await 148 | .with_context(|| format!("failed to load {url}"))?; 149 | 150 | let mut items = current_page 151 | .take_items() 152 | .into_iter() 153 | .map(|val| (key(&val), val)) 154 | .collect::>(); 155 | 156 | while let Some(mut new_page) = ctx 157 | .octocrab 158 | .get_page::(¤t_page.next) 159 | .await 160 | .with_context(|| format!("failed to load next page {:?}", current_page.next))? 161 | { 162 | items.extend( 163 | new_page 164 | .take_items() 165 | .into_iter() 166 | .map(|val| (key(&val), val)), 167 | ); 168 | current_page = new_page; 169 | } 170 | 171 | Ok(items) 172 | } 173 | 174 | #[derive(Debug, serde::Deserialize)] 175 | struct GitRef { 176 | #[serde(rename = "ref")] 177 | name: String, 178 | } 179 | 180 | #[derive(Debug, serde::Deserialize)] 181 | struct Release { 182 | url: String, 183 | tag_name: String, 184 | name: String, 185 | body: String, 186 | } 187 | -------------------------------------------------------------------------------- /src/handlers/issue_links.rs: -------------------------------------------------------------------------------- 1 | //! This handler is used to canonicalize linked GitHub issues into their long form 2 | //! so that when pulling subtree into the main repository we don't accidentaly 3 | //! close issues in the wrong repository. 4 | //! 5 | //! Example: `Fixes #123` (in rust-lang/clippy) would now become `Fixes rust-lang/clippy#123` 6 | 7 | use std::borrow::Cow; 8 | use std::sync::LazyLock; 9 | 10 | use regex::Regex; 11 | 12 | use crate::{ 13 | config::IssueLinksConfig, 14 | github::{IssuesAction, IssuesEvent}, 15 | handlers::Context, 16 | }; 17 | 18 | static LINKED_RE: LazyLock = 19 | LazyLock::new(|| Regex::new(r"\B(?P#[0-9]+)\b").unwrap()); 20 | 21 | pub(super) struct IssueLinksInput {} 22 | 23 | pub(super) async fn parse_input( 24 | _ctx: &Context, 25 | event: &IssuesEvent, 26 | config: Option<&IssueLinksConfig>, 27 | ) -> Result, String> { 28 | if !event.issue.is_pr() { 29 | return Ok(None); 30 | } 31 | 32 | if !matches!( 33 | event.action, 34 | IssuesAction::Opened | IssuesAction::Reopened | IssuesAction::Edited 35 | ) { 36 | return Ok(None); 37 | } 38 | 39 | // Require a `[issue-links]` (or it's alias `[canonicalize-issue-links]`) 40 | // configuration block to enable the handler. 41 | if config.is_none() { 42 | return Ok(None); 43 | }; 44 | 45 | Ok(Some(IssueLinksInput {})) 46 | } 47 | 48 | pub(super) async fn handle_input( 49 | ctx: &Context, 50 | _config: &IssueLinksConfig, 51 | e: &IssuesEvent, 52 | _input: IssueLinksInput, 53 | ) -> anyhow::Result<()> { 54 | let full_repo_name = e.issue.repository().full_repo_name(); 55 | 56 | let new_body = fix_linked_issues(&e.issue.body, full_repo_name.as_str()); 57 | 58 | if e.issue.body != new_body { 59 | e.issue.edit_body(&ctx.github, &new_body).await?; 60 | } 61 | 62 | Ok(()) 63 | } 64 | 65 | fn fix_linked_issues<'a>(body: &'a str, full_repo_name: &str) -> Cow<'a, str> { 66 | let replace_by = format!("{full_repo_name}${{issue}}"); 67 | parser::replace_all_outside_ignore_blocks(&LINKED_RE, body, replace_by) 68 | } 69 | 70 | #[test] 71 | fn fixed_body() { 72 | let full_repo_name = "rust-lang/rust"; 73 | 74 | let body = r#" 75 | This is a PR, which links to #123. 76 | 77 | Fix #123 78 | fixed #456 79 | Fixes #7895 80 | Fixesd #7895 81 | Closes: #987 82 | resolves: #655 83 | Resolves #00000 Closes #888 84 | "#; 85 | 86 | let fixed_body = r#" 87 | This is a PR, which links to rust-lang/rust#123. 88 | 89 | Fix rust-lang/rust#123 90 | fixed rust-lang/rust#456 91 | Fixes rust-lang/rust#7895 92 | Fixesd rust-lang/rust#7895 93 | Closes: rust-lang/rust#987 94 | resolves: rust-lang/rust#655 95 | Resolves rust-lang/rust#00000 Closes rust-lang/rust#888 96 | "#; 97 | 98 | let new_body = fix_linked_issues(body, full_repo_name); 99 | assert_eq!(new_body, fixed_body); 100 | } 101 | 102 | #[test] 103 | fn edge_case_body() { 104 | let full_repo_name = "rust-lang/rust"; 105 | 106 | assert_eq!( 107 | fix_linked_issues("#132 with a end", full_repo_name), 108 | "rust-lang/rust#132 with a end" 109 | ); 110 | assert_eq!( 111 | fix_linked_issues("with a start #132", full_repo_name), 112 | "with a start rust-lang/rust#132" 113 | ); 114 | assert_eq!( 115 | fix_linked_issues("#132", full_repo_name), 116 | "rust-lang/rust#132" 117 | ); 118 | assert_eq!( 119 | fix_linked_issues("(#132)", full_repo_name), 120 | "(rust-lang/rust#132)" 121 | ); 122 | } 123 | 124 | #[test] 125 | fn untouched_body() { 126 | let full_repo_name = "rust-lang/rust"; 127 | 128 | let body = r#" 129 | This is a PR. 130 | 131 | Fix rust-lang#123 132 | Resolves #abgt 133 | Resolves: #abgt 134 | Fixes #157a 135 | Fixes#123 136 | `Fixes #123` 137 | 138 | ``` 139 | Example: Fixes #123 140 | ``` 141 | 142 | 143 | "#; 144 | 145 | let new_body = fix_linked_issues(body, full_repo_name); 146 | assert_eq!(new_body, body); 147 | } 148 | -------------------------------------------------------------------------------- /src/handlers/jobs.rs: -------------------------------------------------------------------------------- 1 | // Function to match the scheduled job function with its corresponding handler. 2 | // In case you want to add a new one, just add a new clause to the match with 3 | // the job name and the corresponding function. 4 | 5 | // Further info could be find in src/jobs.rs 6 | 7 | use super::Context; 8 | 9 | pub async fn handle_job( 10 | ctx: &Context, 11 | name: &str, 12 | metadata: &serde_json::Value, 13 | ) -> anyhow::Result<()> { 14 | match name { 15 | "docs_update" => super::docs_update::handle_job().await, 16 | "rustc_commits" => { 17 | super::rustc_commits::synchronize_commits_inner(ctx, None).await; 18 | Ok(()) 19 | } 20 | _ => default(name, &metadata), 21 | } 22 | } 23 | 24 | fn default(name: &str, metadata: &serde_json::Value) -> anyhow::Result<()> { 25 | tracing::trace!( 26 | "handle_job fell into default case: (name={:?}, metadata={:?})", 27 | name, 28 | metadata 29 | ); 30 | 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /src/handlers/mentions.rs: -------------------------------------------------------------------------------- 1 | //! Purpose: When opening a PR, or pushing new changes, check for any paths 2 | //! that are in the `mentions` config, and add a comment that pings the listed 3 | //! interested people. 4 | 5 | use crate::{ 6 | config::{MentionsConfig, MentionsPathConfig}, 7 | db::issue_data::IssueData, 8 | github::{IssuesAction, IssuesEvent}, 9 | handlers::Context, 10 | }; 11 | use anyhow::Context as _; 12 | use serde::{Deserialize, Serialize}; 13 | use std::fmt::Write; 14 | use std::path::Path; 15 | use tracing as log; 16 | 17 | const MENTIONS_KEY: &str = "mentions"; 18 | 19 | pub(super) struct MentionsInput { 20 | paths: Vec, 21 | } 22 | 23 | #[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] 24 | struct MentionState { 25 | paths: Vec, 26 | } 27 | 28 | pub(super) async fn parse_input( 29 | ctx: &Context, 30 | event: &IssuesEvent, 31 | config: Option<&MentionsConfig>, 32 | ) -> Result, String> { 33 | let config = match config { 34 | Some(config) => config, 35 | None => return Ok(None), 36 | }; 37 | 38 | if !matches!( 39 | event.action, 40 | IssuesAction::Opened | IssuesAction::Synchronize | IssuesAction::ReadyForReview 41 | ) { 42 | return Ok(None); 43 | } 44 | 45 | // Don't ping on rollups or draft PRs. 46 | if event.issue.title.starts_with("Rollup of") 47 | || event.issue.draft 48 | || event.issue.title.contains("[beta] backport") 49 | { 50 | return Ok(None); 51 | } 52 | 53 | if let Some(files) = event 54 | .issue 55 | .diff(&ctx.github) 56 | .await 57 | .map_err(|e| { 58 | log::error!("failed to fetch diff: {:?}", e); 59 | }) 60 | .unwrap_or_default() 61 | { 62 | let file_paths: Vec<_> = files.iter().map(|fd| Path::new(&fd.filename)).collect(); 63 | let to_mention: Vec<_> = config 64 | .paths 65 | .iter() 66 | .filter(|(path, MentionsPathConfig { cc, .. })| { 67 | let path = Path::new(path); 68 | // Only mention matching paths. 69 | let touches_relevant_files = file_paths.iter().any(|p| p.starts_with(path)); 70 | // Don't mention if only the author is in the list. 71 | let pings_non_author = match &cc[..] { 72 | [only_cc] => only_cc.trim_start_matches('@') != &event.issue.user.login, 73 | _ => true, 74 | }; 75 | touches_relevant_files && pings_non_author 76 | }) 77 | .map(|(key, _mention)| key.to_string()) 78 | .collect(); 79 | if !to_mention.is_empty() { 80 | return Ok(Some(MentionsInput { paths: to_mention })); 81 | } 82 | } 83 | Ok(None) 84 | } 85 | 86 | pub(super) async fn handle_input( 87 | ctx: &Context, 88 | config: &MentionsConfig, 89 | event: &IssuesEvent, 90 | input: MentionsInput, 91 | ) -> anyhow::Result<()> { 92 | let mut client = ctx.db.get().await; 93 | let mut state: IssueData<'_, MentionState> = 94 | IssueData::load(&mut client, &event.issue, MENTIONS_KEY).await?; 95 | // Build the message to post to the issue. 96 | let mut result = String::new(); 97 | for to_mention in &input.paths { 98 | if state.data.paths.iter().any(|p| p == to_mention) { 99 | // Avoid duplicate mentions. 100 | continue; 101 | } 102 | let MentionsPathConfig { message, cc } = &config.paths[to_mention]; 103 | if !result.is_empty() { 104 | result.push_str("\n\n"); 105 | } 106 | match message { 107 | Some(m) => result.push_str(m), 108 | None => write!(result, "Some changes occurred in {to_mention}").unwrap(), 109 | } 110 | if !cc.is_empty() { 111 | write!(result, "\n\ncc {}", cc.join(", ")).unwrap(); 112 | } 113 | state.data.paths.push(to_mention.to_string()); 114 | } 115 | if !result.is_empty() { 116 | event 117 | .issue 118 | .post_comment(&ctx.github, &result) 119 | .await 120 | .context("failed to post mentions comment")?; 121 | state.save().await?; 122 | } 123 | Ok(()) 124 | } 125 | -------------------------------------------------------------------------------- /src/handlers/milestone_prs.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | github::{Event, GithubClient, IssuesAction}, 3 | handlers::Context, 4 | }; 5 | use anyhow::Context as _; 6 | use regex::Regex; 7 | use reqwest::StatusCode; 8 | use tracing as log; 9 | 10 | pub(super) async fn handle(ctx: &Context, event: &Event) -> anyhow::Result<()> { 11 | let e = if let Event::Issue(e) = event { 12 | e 13 | } else { 14 | return Ok(()); 15 | }; 16 | 17 | // Only trigger on closed issues 18 | if e.action != IssuesAction::Closed { 19 | return Ok(()); 20 | } 21 | 22 | let repo = e.issue.repository(); 23 | if !(repo.organization == "rust-lang" && repo.repository == "rust") { 24 | return Ok(()); 25 | } 26 | 27 | if !e.issue.merged { 28 | log::trace!( 29 | "Ignoring closing of rust-lang/rust#{}: not merged", 30 | e.issue.number 31 | ); 32 | return Ok(()); 33 | } 34 | 35 | let merge_sha = if let Some(s) = &e.issue.merge_commit_sha { 36 | s 37 | } else { 38 | log::error!( 39 | "rust-lang/rust#{}: no merge_commit_sha in event", 40 | e.issue.number 41 | ); 42 | return Ok(()); 43 | }; 44 | 45 | // Fetch the version from the upstream repository. 46 | let version = if let Some(version) = get_version_standalone(&ctx.github, merge_sha).await? { 47 | version 48 | } else { 49 | log::error!("could not find the version of {:?}", merge_sha); 50 | return Ok(()); 51 | }; 52 | 53 | if !version.starts_with("1.") && version.len() < 8 { 54 | log::error!("Weird version {:?} for {:?}", version, merge_sha); 55 | return Ok(()); 56 | } 57 | 58 | // Associate this merged PR with the version it merged into. 59 | // 60 | // Note that this should work for rollup-merged PRs too. It will *not* 61 | // auto-update when merging a beta-backport, for example, but that seems 62 | // fine; we can manually update without too much trouble in that case, and 63 | // eventually automate it separately. 64 | e.issue.set_milestone(&ctx.github, &version).await?; 65 | 66 | let files = e.issue.diff(&ctx.github).await?; 67 | if let Some(files) = files { 68 | if let Some(cargo) = files.iter().find(|fd| fd.filename == "src/tools/cargo") { 69 | // The webhook timeout of 10 seconds can be too short, so process in 70 | // the background. 71 | let diff = cargo.patch.clone(); 72 | tokio::task::spawn(async move { 73 | let gh = GithubClient::new_from_env(); 74 | if let Err(e) = milestone_cargo(&gh, &version, &diff).await { 75 | log::error!("failed to milestone cargo: {e:?}"); 76 | } 77 | }); 78 | } 79 | } 80 | 81 | Ok(()) 82 | } 83 | 84 | async fn get_version_standalone( 85 | gh: &GithubClient, 86 | merge_sha: &str, 87 | ) -> anyhow::Result> { 88 | let resp = gh 89 | .raw() 90 | .get(&format!( 91 | "https://raw.githubusercontent.com/rust-lang/rust/{}/src/version", 92 | merge_sha 93 | )) 94 | .send() 95 | .await 96 | .with_context(|| format!("retrieving src/version for {}", merge_sha))?; 97 | 98 | match resp.status() { 99 | StatusCode::OK => {} 100 | // Don't treat a 404 as a failure, we'll try another way to retrieve the version. 101 | StatusCode::NOT_FOUND => return Ok(None), 102 | status => anyhow::bail!( 103 | "unexpected status code {} while retrieving src/version for {}", 104 | status, 105 | merge_sha 106 | ), 107 | } 108 | 109 | Ok(Some( 110 | resp.text() 111 | .await 112 | .with_context(|| format!("deserializing src/version for {}", merge_sha))? 113 | .trim() 114 | .to_string(), 115 | )) 116 | } 117 | 118 | /// Milestones all PRs in the cargo repo when the submodule is synced in 119 | /// rust-lang/rust. 120 | async fn milestone_cargo( 121 | gh: &GithubClient, 122 | release_version: &str, 123 | submodule_diff: &str, 124 | ) -> anyhow::Result<()> { 125 | // Determine the start/end range of commits in this submodule update by 126 | // looking at the diff content which indicates the old and new hash. 127 | let subproject_re = Regex::new("Subproject commit ([0-9a-f]+)").unwrap(); 128 | let mut caps = subproject_re.captures_iter(submodule_diff); 129 | let cargo_start_hash = &caps.next().unwrap()[1]; 130 | let cargo_end_hash = &caps.next().unwrap()[1]; 131 | assert!(caps.next().is_none()); 132 | 133 | // Get all of the git commits in the cargo repo. 134 | let cargo_repo = gh.repository("rust-lang/cargo").await?; 135 | log::info!("loading cargo changes {cargo_start_hash}...{cargo_end_hash}"); 136 | let commits = cargo_repo 137 | .github_commits_in_range(gh, cargo_start_hash, cargo_end_hash) 138 | .await?; 139 | 140 | // For each commit, look for a message from bors that indicates which 141 | // PR was merged. 142 | // 143 | // GitHub has a specific API for this at 144 | // /repos/{owner}/{repo}/commits/{commit_sha}/pulls 145 | // , 146 | // but it is a little awkward to use, only works on the default branch, 147 | // and this is a bit simpler/faster. However, it is sensitive to the 148 | // specific messages generated by bors or GitHub merge queue, and won't 149 | // catch things merged beyond them. 150 | let merge_re = 151 | Regex::new(r"(?:Auto merge of|Merge pull request) #([0-9]+)|\(#([0-9]+)\)$").unwrap(); 152 | 153 | let pr_nums = commits 154 | .iter() 155 | .filter(|commit| 156 | // Assumptions: 157 | // * A merge commit always has two parent commits. 158 | // * Cargo's PR never got merged as fast-forward / rebase / squash merge. 159 | commit.parents.len() == 2) 160 | .filter_map(|commit| { 161 | let first = commit.commit.message.lines().next().unwrap_or_default(); 162 | merge_re.captures(first).map(|cap| { 163 | cap.get(1) 164 | .or_else(|| cap.get(2)) 165 | .unwrap() 166 | .as_str() 167 | .parse::() 168 | .expect("digits only") 169 | }) 170 | }); 171 | let milestone = cargo_repo 172 | .get_or_create_milestone(gh, release_version, "closed") 173 | .await?; 174 | for pr_num in pr_nums { 175 | log::info!("setting cargo milestone {milestone:?} for {pr_num}"); 176 | cargo_repo.set_milestone(gh, &milestone, pr_num).await?; 177 | } 178 | 179 | Ok(()) 180 | } 181 | -------------------------------------------------------------------------------- /src/handlers/nominate.rs: -------------------------------------------------------------------------------- 1 | //! Purpose: Allow team members to nominate issues or PRs. 2 | 3 | use crate::{ 4 | config::NominateConfig, 5 | github::{self, Event}, 6 | handlers::Context, 7 | interactions::ErrorComment, 8 | }; 9 | use parser::command::nominate::{NominateCommand, Style}; 10 | 11 | pub(super) async fn handle_command( 12 | ctx: &Context, 13 | config: &NominateConfig, 14 | event: &Event, 15 | cmd: NominateCommand, 16 | ) -> anyhow::Result<()> { 17 | let is_team_member = if let Err(_) | Ok(false) = event.user().is_team_member(&ctx.github).await 18 | { 19 | false 20 | } else { 21 | true 22 | }; 23 | 24 | if !is_team_member { 25 | let cmnt = ErrorComment::new( 26 | &event.issue().unwrap(), 27 | format!( 28 | "Nominating and approving issues and pull requests is restricted to members of\ 29 | the Rust teams." 30 | ), 31 | ); 32 | cmnt.post(&ctx.github).await?; 33 | return Ok(()); 34 | } 35 | 36 | let issue_labels = event.issue().unwrap().labels(); 37 | let mut labels_to_add = vec![]; 38 | if cmd.style == Style::BetaApprove { 39 | if !issue_labels.iter().any(|l| l.name == "beta-nominated") { 40 | let cmnt = ErrorComment::new( 41 | &event.issue().unwrap(), 42 | format!( 43 | "This pull request is not beta-nominated, so it cannot be approved yet.\ 44 | Perhaps try to beta-nominate it by using `@{} beta-nominate `?", 45 | ctx.username, 46 | ), 47 | ); 48 | cmnt.post(&ctx.github).await?; 49 | return Ok(()); 50 | } 51 | 52 | // Add the beta-accepted label, but don't attempt to remove beta-nominated or the team 53 | // label. 54 | labels_to_add.push(github::Label { 55 | name: "beta-accepted".into(), 56 | }); 57 | } else { 58 | if !config.teams.contains_key(&cmd.team) { 59 | let cmnt = ErrorComment::new( 60 | &event.issue().unwrap(), 61 | format!( 62 | "This team (`{}`) cannot be nominated for via this command;\ 63 | it may need to be added to `triagebot.toml` on the default branch.", 64 | cmd.team, 65 | ), 66 | ); 67 | cmnt.post(&ctx.github).await?; 68 | return Ok(()); 69 | } 70 | 71 | let label = config.teams[&cmd.team].clone(); 72 | labels_to_add.push(github::Label { name: label }); 73 | 74 | let style_label = match cmd.style { 75 | Style::Decision => "I-nominated", 76 | Style::Beta => "beta-nominated", 77 | Style::BetaApprove => unreachable!(), 78 | }; 79 | labels_to_add.push(github::Label { 80 | name: style_label.into(), 81 | }); 82 | } 83 | 84 | event 85 | .issue() 86 | .unwrap() 87 | .add_labels(&ctx.github, labels_to_add) 88 | .await?; 89 | 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /src/handlers/note.rs: -------------------------------------------------------------------------------- 1 | //! Allow users to add summary comments in Issues & Pull Requests. 2 | //! 3 | //! Users can make a new summary entry by commenting the following: 4 | //! 5 | //! ```md 6 | //! @rustbot note Summary title 7 | //! ``` 8 | //! 9 | //! If this is the first summary entry, rustbot will amend the original post (the top-level comment) to add a "Notes" section. The section should **not** be edited by hand. 10 | //! 11 | //! ```md 12 | //! 13 | //! 14 | //! ### Summary Notes 15 | //! 16 | //! - [Summary title](link-to-comment) by [username](https://github.com/) 17 | //! 18 | //! *Managed by `@bot`—see [help](https://forge.rust-lang.org/triagebot/note.html) for details* 19 | //! 20 | //! ``` 21 | //! 22 | //! If this is *not* the first summary entry, rustbot will simply append the new entry to the existing notes section: 23 | //! 24 | //! ```md 25 | //! 26 | //! 27 | //! ### Summary Notes 28 | //! 29 | //! - [First note](link-to-comment) by [username](https://github.com/) 30 | //! - [Second note](link-to-comment) by [username](https://github.com/) 31 | //! - [Summary title](link-to-comment) by [username](https://github.com/) 32 | //! 33 | //! 34 | //! ``` 35 | //! 36 | 37 | use crate::{config::NoteConfig, github::Event, handlers::Context, interactions::EditIssueBody}; 38 | use itertools::Itertools; 39 | use parser::command::note::NoteCommand; 40 | use std::fmt::Write; 41 | use std::{cmp::Ordering, collections::HashMap}; 42 | use tracing as log; 43 | 44 | #[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, Clone)] 45 | struct NoteDataEntry { 46 | title: String, 47 | comment_url: String, 48 | author: String, 49 | } 50 | 51 | impl NoteDataEntry { 52 | pub(crate) fn to_markdown(&self) -> String { 53 | format!( 54 | "\n- [{title}]({comment_url}) by [{author}](https://github.com/{author})", 55 | title = self.title, 56 | author = self.author, 57 | comment_url = self.comment_url 58 | ) 59 | } 60 | } 61 | impl Ord for NoteDataEntry { 62 | fn cmp(&self, other: &Self) -> Ordering { 63 | self.comment_url.cmp(&other.comment_url) 64 | } 65 | } 66 | impl PartialOrd for NoteDataEntry { 67 | fn partial_cmp(&self, other: &Self) -> Option { 68 | Some(self.cmp(other)) 69 | } 70 | } 71 | 72 | #[derive(Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default, Clone)] 73 | struct NoteData { 74 | entries_by_url: HashMap, 75 | } 76 | 77 | impl NoteData { 78 | pub(crate) fn get_url_from_title_prefix(&self, title: &str) -> Option { 79 | let tmp = self.entries_by_url.clone(); 80 | tmp.iter().sorted().find_map(|(key, val)| { 81 | if val.title.starts_with(title) { 82 | Some(key.to_owned()) 83 | } else { 84 | None 85 | } 86 | }) 87 | } 88 | 89 | pub(crate) fn remove_by_title(&mut self, title: &str) -> Option { 90 | if let Some(url_to_remove) = self.get_url_from_title_prefix(title) { 91 | if let Some(entry) = self.entries_by_url.remove(&url_to_remove) { 92 | log::debug!("SUCCESSFULLY REMOVED ENTRY: {:#?}", &entry); 93 | Some(entry) 94 | } else { 95 | log::debug!("UNABLE TO REMOVE ENTRY WITH URL: {:?}", &url_to_remove); 96 | None 97 | } 98 | } else { 99 | log::debug!("UNABLE TO REMOVE ENTRY WITH TITLE: {:?}", title); 100 | None 101 | } 102 | } 103 | 104 | pub(crate) fn to_markdown(&self, bot: &str) -> String { 105 | if self.entries_by_url.is_empty() { 106 | return String::new(); 107 | } 108 | 109 | let mut text = String::from("\n### Summary Notes\n"); 110 | for (_, entry) in self.entries_by_url.iter().sorted() { 111 | text.push_str(&entry.to_markdown()); 112 | } 113 | let _ = writeln!(text, "\n\n*Managed by `@{bot}`—see [help](https://forge.rust-lang.org/triagebot/note.html) for details*"); 114 | text 115 | } 116 | } 117 | 118 | pub(super) async fn handle_command( 119 | ctx: &Context, 120 | _config: &NoteConfig, 121 | event: &Event, 122 | cmd: NoteCommand, 123 | ) -> anyhow::Result<()> { 124 | let issue = event.issue().unwrap(); 125 | 126 | let mut client = ctx.db.get().await; 127 | let mut e: EditIssueBody<'_, NoteData> = 128 | EditIssueBody::load(&mut client, &issue, "SUMMARY").await?; 129 | let current = e.data_mut(); 130 | 131 | let comment_url = String::from(event.html_url().unwrap()); 132 | let author = event.user().login.to_owned(); 133 | 134 | match &cmd { 135 | NoteCommand::Summary { title } => { 136 | let title = title.to_owned(); 137 | if let Some(existing_entry) = current.entries_by_url.get_mut(&comment_url) { 138 | existing_entry.title = title; 139 | log::debug!("Updated existing entry: {:#?}", existing_entry); 140 | } else { 141 | let new_entry = NoteDataEntry { 142 | title, 143 | comment_url: comment_url.clone(), 144 | author, 145 | }; 146 | log::debug!("New Note Entry: {:#?}", new_entry); 147 | current.entries_by_url.insert(comment_url, new_entry); 148 | log::debug!("Entries by URL: {:#?}", current.entries_by_url); 149 | } 150 | } 151 | NoteCommand::Remove { title } => { 152 | if let Some(entry) = current.remove_by_title(title) { 153 | log::debug!("SUCCESSFULLY REMOVED ENTRY: {:#?}", entry); 154 | } else { 155 | log::debug!("UNABLE TO REMOVE ENTRY"); 156 | } 157 | } 158 | } 159 | 160 | let new_markdown = current.to_markdown(&ctx.username); 161 | log::debug!("New MD: {:#?}", new_markdown); 162 | 163 | e.apply(&ctx.github, new_markdown).await?; 164 | 165 | Ok(()) 166 | } 167 | -------------------------------------------------------------------------------- /src/handlers/ping.rs: -------------------------------------------------------------------------------- 1 | //! Purpose: Allow any user to ping a pre-selected group of people on GitHub via comments. 2 | //! 3 | //! The set of "teams" which can be pinged is intentionally restricted via configuration. 4 | //! 5 | //! Parsing is done in the `parser::command::ping` module. 6 | 7 | use crate::{ 8 | config::PingConfig, 9 | github::{self, Event}, 10 | handlers::Context, 11 | interactions::ErrorComment, 12 | }; 13 | use parser::command::ping::PingCommand; 14 | 15 | pub(super) async fn handle_command( 16 | ctx: &Context, 17 | config: &PingConfig, 18 | event: &Event, 19 | team_name: PingCommand, 20 | ) -> anyhow::Result<()> { 21 | let is_team_member = if let Err(_) | Ok(false) = event.user().is_team_member(&ctx.github).await 22 | { 23 | false 24 | } else { 25 | true 26 | }; 27 | 28 | if !is_team_member { 29 | let cmnt = ErrorComment::new( 30 | &event.issue().unwrap(), 31 | format!("Only Rust team members can ping teams."), 32 | ); 33 | cmnt.post(&ctx.github).await?; 34 | return Ok(()); 35 | } 36 | 37 | let (gh_team, config) = match config.get_by_name(&team_name.team) { 38 | Some(v) => v, 39 | None => { 40 | let cmnt = ErrorComment::new( 41 | &event.issue().unwrap(), 42 | format!( 43 | "This team (`{}`) cannot be pinged via this command; \ 44 | it may need to be added to `triagebot.toml` on the default branch.", 45 | team_name.team, 46 | ), 47 | ); 48 | cmnt.post(&ctx.github).await?; 49 | return Ok(()); 50 | } 51 | }; 52 | let team = github::get_team(&ctx.github, &gh_team).await?; 53 | let team = match team { 54 | Some(team) => team, 55 | None => { 56 | let cmnt = ErrorComment::new( 57 | &event.issue().unwrap(), 58 | format!( 59 | "This team (`{}`) does not exist in the team repository.", 60 | team_name.team, 61 | ), 62 | ); 63 | cmnt.post(&ctx.github).await?; 64 | return Ok(()); 65 | } 66 | }; 67 | 68 | if let Some(label) = &config.label { 69 | if let Err(err) = event 70 | .issue() 71 | .unwrap() 72 | .add_labels( 73 | &ctx.github, 74 | vec![github::Label { 75 | name: label.clone(), 76 | }], 77 | ) 78 | .await 79 | { 80 | let cmnt = ErrorComment::new( 81 | &event.issue().unwrap(), 82 | format!("Error adding team label (`{}`): {:?}.", label, err), 83 | ); 84 | cmnt.post(&ctx.github).await?; 85 | } 86 | } 87 | 88 | let mut users = Vec::new(); 89 | 90 | if let Some(gh) = team.github { 91 | let repo = event.issue().expect("has issue").repository(); 92 | // Ping all github teams associated with this team repo team that are in this organization. 93 | // We cannot ping across organizations, but this should not matter, as teams should be 94 | // sync'd to the org for which triagebot is configured. 95 | for gh_team in gh.teams.iter().filter(|t| t.org == repo.organization) { 96 | users.push(format!("@{}/{}", gh_team.org, gh_team.name)); 97 | } 98 | } else { 99 | for member in &team.members { 100 | users.push(format!("@{}", member.github)); 101 | } 102 | } 103 | 104 | let ping_msg = if users.is_empty() { 105 | format!("no known users to ping?") 106 | } else { 107 | format!("cc {}", users.join(" ")) 108 | }; 109 | let comment = format!("{}\n\n{}", config.message, ping_msg); 110 | event 111 | .issue() 112 | .expect("issue") 113 | .post_comment(&ctx.github, &comment) 114 | .await?; 115 | 116 | Ok(()) 117 | } 118 | -------------------------------------------------------------------------------- /src/handlers/prioritize.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::PrioritizeConfig, 3 | github::{self, Event}, 4 | handlers::Context, 5 | }; 6 | use parser::command::prioritize::PrioritizeCommand; 7 | 8 | pub(super) async fn handle_command( 9 | ctx: &Context, 10 | config: &PrioritizeConfig, 11 | event: &Event, 12 | _: PrioritizeCommand, 13 | ) -> anyhow::Result<()> { 14 | let mut labels = vec![]; 15 | labels.push(github::Label { 16 | name: config.label.to_owned(), 17 | }); 18 | event 19 | .issue() 20 | .unwrap() 21 | .add_labels(&ctx.github, labels) 22 | .await?; 23 | Ok(()) 24 | } 25 | -------------------------------------------------------------------------------- /src/handlers/pull_requests_assignment_update.rs: -------------------------------------------------------------------------------- 1 | use crate::handlers::pr_tracking::load_workqueue; 2 | use crate::jobs::Job; 3 | use async_trait::async_trait; 4 | 5 | pub struct PullRequestAssignmentUpdate; 6 | 7 | #[async_trait] 8 | impl Job for PullRequestAssignmentUpdate { 9 | fn name(&self) -> &'static str { 10 | "pull_request_assignment_update" 11 | } 12 | 13 | async fn run(&self, ctx: &super::Context, _metadata: &serde_json::Value) -> anyhow::Result<()> { 14 | tracing::trace!("starting pull_request_assignment_update"); 15 | let workqueue = load_workqueue(&ctx.octocrab).await?; 16 | *ctx.workqueue.write().await = workqueue; 17 | tracing::trace!("finished pull_request_assignment_update"); 18 | 19 | Ok(()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/handlers/relnotes.rs: -------------------------------------------------------------------------------- 1 | //! This handler implements collecting release notes from issues and PRs that are tagged with 2 | //! `relnotes`. Any such tagging will open a new issue in rust-lang/rust responsible for tracking 3 | //! the inclusion in releases notes. 4 | //! 5 | //! The new issue will be closed when T-release has added the text proposed (tracked in the issue 6 | //! description) into the final release notes PR. 7 | //! 8 | //! The issue description will be edited manually by teams through the GitHub UI -- in the future, 9 | //! we might add triagebot support for maintaining that text via commands or similar. 10 | //! 11 | //! These issues will also be automatically milestoned when their corresponding PR or issue is. In 12 | //! the absence of a milestone, T-release is responsible for ascertaining which release is 13 | //! associated with the issue. 14 | 15 | use serde::{Deserialize, Serialize}; 16 | 17 | use crate::{ 18 | db::issue_data::IssueData, 19 | github::{Event, IssuesAction}, 20 | handlers::Context, 21 | }; 22 | 23 | const RELNOTES_KEY: &str = "relnotes"; 24 | 25 | #[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] 26 | struct RelnotesState { 27 | relnotes_issue: Option, 28 | } 29 | 30 | const TITLE_PREFIX: &str = "Tracking issue for release notes"; 31 | 32 | pub(super) async fn handle(ctx: &Context, event: &Event) -> anyhow::Result<()> { 33 | let Event::Issue(e) = event else { 34 | return Ok(()); 35 | }; 36 | 37 | let repo = e.issue.repository(); 38 | if !(repo.organization == "rust-lang" && repo.repository == "rust") { 39 | return Ok(()); 40 | } 41 | 42 | if e.issue.title.starts_with(TITLE_PREFIX) { 43 | // Ignore these issues -- they're otherwise potentially self-recursive. 44 | return Ok(()); 45 | } 46 | 47 | let mut client = ctx.db.get().await; 48 | let mut state: IssueData<'_, RelnotesState> = 49 | IssueData::load(&mut client, &e.issue, RELNOTES_KEY).await?; 50 | 51 | if let Some(paired) = state.data.relnotes_issue { 52 | // Already has a paired release notes issue. 53 | 54 | if let IssuesAction::Milestoned = &e.action { 55 | if let Some(milestone) = &e.issue.milestone { 56 | ctx.github 57 | .set_milestone(&e.issue.repository().to_string(), &milestone, paired) 58 | .await?; 59 | } 60 | } 61 | 62 | return Ok(()); 63 | } 64 | 65 | if let IssuesAction::Labeled { label } = &e.action { 66 | let is_fcp_merge = label.name == "finished-final-comment-period" 67 | && e.issue 68 | .labels 69 | .iter() 70 | .any(|label| label.name == "disposition-merge"); 71 | 72 | if label.name == "relnotes" || label.name == "relnotes-perf" || is_fcp_merge { 73 | let title = format!("{TITLE_PREFIX} of #{}: {}", e.issue.number, e.issue.title); 74 | let body = format!( 75 | " 76 | This issue tracks the release notes text for #{pr_number}. 77 | 78 | cc {people} -- original issue/PR authors and assignees for drafting text 79 | 80 | See the forge.rust-lang.org chapter about [release notes](https://forge.rust-lang.org/release/release-notes.html#preparing-release-notes) for an overview of how the release team makes use of these tracking issues. 81 | 82 | ### Release notes text 83 | 84 | This section should be edited to specify the correct category(s) for the change, with succinct description(s) of what changed. Some things worth considering: 85 | - Does this need an additional compat notes section? 86 | - Was this a libs stabilization that should have additional headers to list new APIs under `Stabilized APIs` and `Const Stabilized APIs`? 87 | 88 | 89 | ````markdown 90 | # Language/Compiler/Libraries/Stabilized APIs/Const Stabilized APIs/Rustdoc/Compatibility Notes/Internal Changes/Other 91 | - [{pr_title}]({pr_url}) 92 | ```` 93 | 94 | > [!TIP] 95 | > Use the [previous releases](https://doc.rust-lang.org/nightly/releases.html) for inspiration on how to write the release notes text and which categories to pick. 96 | 97 | ### Release blog section 98 | 99 | If this change is notable enough for inclusion in the blog post then this section should be edited to contain a draft for the blog post. *Otherwise leave it empty.* 100 | 101 | 102 | ````markdown 103 | ```` 104 | 105 | > [!NOTE] 106 | > 107 | > If a blog post section is required the `release-blog-post` label should be added (`@rustbot label +release-blog-post`) to this issue as otherwise it may be missed by the release team. 108 | ", 109 | pr_number = e.issue.number, 110 | people = [&e.issue.user].into_iter().chain(e.issue.assignees.iter()) 111 | .map(|v| format!("@{}", v.login)).collect::>().join(", "), 112 | pr_title = e.issue.title, 113 | pr_url = e.issue.html_url, 114 | ); 115 | let resp = ctx 116 | .github 117 | .new_issue( 118 | &e.issue.repository(), 119 | &title, 120 | &body, 121 | ["relnotes", "relnotes-tracking-issue"] 122 | .into_iter() 123 | .chain(e.issue.labels.iter().map(|l| &*l.name).filter(|l| { 124 | l.starts_with("A-") // A-* (area) 125 | || l.starts_with("F-") // F-* (feature) 126 | || l.starts_with("L-") // L-* (lint) 127 | || l.starts_with("O-") // O-* (OS) 128 | || l.starts_with("T-") // T-* (team) 129 | || l.starts_with("WG-") // WG-* (working group) 130 | })) 131 | .map(ToOwned::to_owned) 132 | .collect::>(), 133 | ) 134 | .await?; 135 | if let Some(milestone) = &e.issue.milestone { 136 | ctx.github 137 | .set_milestone(&e.issue.repository().to_string(), &milestone, resp.number) 138 | .await?; 139 | } 140 | state.data.relnotes_issue = Some(resp.number); 141 | state.save().await?; 142 | } 143 | } 144 | 145 | Ok(()) 146 | } 147 | -------------------------------------------------------------------------------- /src/handlers/rendered_link.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use anyhow::bail; 4 | 5 | use crate::{ 6 | config::RenderedLinkConfig, 7 | github::{Event, IssuesAction, IssuesEvent}, 8 | handlers::Context, 9 | }; 10 | 11 | pub(super) async fn handle( 12 | ctx: &Context, 13 | event: &Event, 14 | config: &RenderedLinkConfig, 15 | ) -> anyhow::Result<()> { 16 | let Event::Issue(e) = event else { 17 | return Ok(()); 18 | }; 19 | 20 | if !e.issue.is_pr() { 21 | return Ok(()); 22 | } 23 | 24 | if let Err(e) = add_rendered_link(&ctx, &e, config).await { 25 | tracing::error!("Error adding rendered link: {:?}", e); 26 | } 27 | 28 | Ok(()) 29 | } 30 | 31 | async fn add_rendered_link( 32 | ctx: &Context, 33 | e: &IssuesEvent, 34 | config: &RenderedLinkConfig, 35 | ) -> anyhow::Result<()> { 36 | if e.action == IssuesAction::Opened 37 | || e.action == IssuesAction::Closed 38 | || e.action == IssuesAction::Reopened 39 | || e.action == IssuesAction::Synchronize 40 | { 41 | let files = e.issue.files(&ctx.github).await?; 42 | 43 | let rendered_link = files 44 | .iter() 45 | .find(|f| { 46 | config 47 | .trigger_files 48 | .iter() 49 | .any(|tf| f.filename.starts_with(tf)) 50 | }) 51 | .and_then(|file| { 52 | let head = e.issue.head.as_ref()?; 53 | let base = e.issue.base.as_ref()?; 54 | 55 | // This URL should be stable while the PR is open, even if the 56 | // user pushes new commits. 57 | // 58 | // It will go away if the user deletes their branch, or if 59 | // they reset it (such as if they created a PR from master). 60 | // That should usually only happen after the PR is closed 61 | // a which point we switch to a SHA-based url. 62 | // 63 | // If the PR is merged we use a URL that points to the actual 64 | // repository, as to be resilient to branch deletion, as well 65 | // be in sync with current "master" branch. 66 | // 67 | // For a PR "octocat:master" <- "Bob:patch-1", we generate, 68 | // - if merged: `https://github.com/octocat/REPO/blob/master/FILEPATH` 69 | // - if open: `https://github.com/Bob/REPO/blob/patch-1/FILEPATH` 70 | // - if closed: `https://github.com/octocat/REPO/blob/SHA/FILEPATH` 71 | Some(format!( 72 | "[Rendered](https://github.com/{}/blob/{}/{})", 73 | if e.issue.merged || e.action == IssuesAction::Closed { 74 | &e.repository.full_name 75 | } else { 76 | &head.repo.as_ref()?.full_name 77 | }, 78 | if e.issue.merged { 79 | &base.git_ref 80 | } else if e.action == IssuesAction::Closed { 81 | &head.sha 82 | } else { 83 | &head.git_ref 84 | }, 85 | file.filename 86 | )) 87 | }); 88 | 89 | let new_body: Cow<'_, str> = if !e.issue.body.contains("[Rendered]") { 90 | if let Some(rendered_link) = rendered_link { 91 | // add rendered link to the end of the body 92 | format!("{}\n\n{rendered_link}", e.issue.body).into() 93 | } else { 94 | // or return the original body since we don't have 95 | // a rendered link to add 96 | e.issue.body.as_str().into() 97 | } 98 | } else if let Some(start_pos) = e.issue.body.find("[Rendered](") { 99 | let Some(end_offset) = &e.issue.body[start_pos..].find(')') else { 100 | bail!("no `)` after `[Rendered]` found") 101 | }; 102 | 103 | // replace the current rendered link with the new one or replace 104 | // it with an empty string if we don't have one 105 | e.issue 106 | .body 107 | .replace( 108 | &e.issue.body[start_pos..=(start_pos + end_offset)], 109 | rendered_link.as_deref().unwrap_or(""), 110 | ) 111 | .into() 112 | } else { 113 | bail!( 114 | "found `[Rendered]` but not it's associated link, can't replace it or remove it, bailing out" 115 | ) 116 | }; 117 | 118 | // avoid an expensive GitHub api call by first checking if we actually 119 | // edited the pull request body 120 | if e.issue.body != new_body { 121 | e.issue.edit_body(&ctx.github, &new_body).await?; 122 | } 123 | } 124 | 125 | Ok(()) 126 | } 127 | -------------------------------------------------------------------------------- /src/handlers/review_requested.rs: -------------------------------------------------------------------------------- 1 | use crate::config::ReviewRequestedConfig; 2 | use crate::github::{IssuesAction, IssuesEvent, Label}; 3 | use crate::handlers::Context; 4 | 5 | pub(crate) struct ReviewRequestedInput {} 6 | 7 | pub(crate) async fn parse_input( 8 | _ctx: &Context, 9 | event: &IssuesEvent, 10 | config: Option<&ReviewRequestedConfig>, 11 | ) -> Result, String> { 12 | // PR author requests a review from one of the assignees 13 | 14 | if config.is_none() { 15 | return Ok(None); 16 | } 17 | 18 | let IssuesAction::ReviewRequested { 19 | requested_reviewer: Some(requested_reviewer), 20 | } = &event.action 21 | else { 22 | return Ok(None); 23 | }; 24 | 25 | if event.sender != event.issue.user { 26 | return Ok(None); 27 | } 28 | 29 | if !event.issue.assignees.contains(requested_reviewer) { 30 | return Ok(None); 31 | } 32 | 33 | Ok(Some(ReviewRequestedInput {})) 34 | } 35 | 36 | pub(crate) async fn handle_input( 37 | ctx: &Context, 38 | config: &ReviewRequestedConfig, 39 | event: &IssuesEvent, 40 | ReviewRequestedInput {}: ReviewRequestedInput, 41 | ) -> anyhow::Result<()> { 42 | event 43 | .issue 44 | .add_labels( 45 | &ctx.github, 46 | config 47 | .add_labels 48 | .iter() 49 | .cloned() 50 | .map(|name| Label { name }) 51 | .collect(), 52 | ) 53 | .await?; 54 | 55 | for label in &config.remove_labels { 56 | event.issue.remove_label(&ctx.github, label).await?; 57 | } 58 | 59 | Ok(()) 60 | } 61 | -------------------------------------------------------------------------------- /src/handlers/review_submitted.rs: -------------------------------------------------------------------------------- 1 | use crate::github::{Issue, IssueCommentAction, IssueCommentEvent, Label, PullRequestReviewState}; 2 | use crate::{config::ReviewSubmittedConfig, github::Event, handlers::Context}; 3 | 4 | pub(crate) async fn handle( 5 | ctx: &Context, 6 | event: &Event, 7 | config: &ReviewSubmittedConfig, 8 | ) -> anyhow::Result<()> { 9 | if let Event::IssueComment( 10 | event @ IssueCommentEvent { 11 | action: IssueCommentAction::Created, 12 | issue: Issue { 13 | pull_request: Some(_), 14 | .. 15 | }, 16 | .. 17 | }, 18 | ) = event 19 | { 20 | if event.comment.pr_review_state != Some(PullRequestReviewState::ChangesRequested) { 21 | return Ok(()); 22 | } 23 | 24 | if event.issue.assignees.contains(&event.comment.user) { 25 | // Remove review labels 26 | for label in &config.review_labels { 27 | event.issue.remove_label(&ctx.github, &label).await?; 28 | } 29 | // Add waiting on author 30 | event 31 | .issue 32 | .add_labels( 33 | &ctx.github, 34 | vec![Label { 35 | name: config.reviewed_label.clone(), 36 | }], 37 | ) 38 | .await?; 39 | } 40 | } 41 | 42 | Ok(()) 43 | } 44 | -------------------------------------------------------------------------------- /src/handlers/rustc_commits.rs: -------------------------------------------------------------------------------- 1 | use crate::db::rustc_commits; 2 | use crate::db::rustc_commits::get_missing_commits; 3 | use crate::jobs::Job; 4 | use crate::{ 5 | github::{self, Event}, 6 | handlers::Context, 7 | }; 8 | use async_trait::async_trait; 9 | use std::collections::VecDeque; 10 | use tracing as log; 11 | 12 | const BORS_GH_ID: u64 = 3372342; 13 | 14 | pub async fn handle(ctx: &Context, event: &Event) -> anyhow::Result<()> { 15 | let body = match event.comment_body() { 16 | Some(v) => v, 17 | // Skip events that don't have comment bodies associated 18 | None => return Ok(()), 19 | }; 20 | 21 | let event = if let Event::IssueComment(e) = event { 22 | if e.action != github::IssueCommentAction::Created { 23 | return Ok(()); 24 | } 25 | 26 | e 27 | } else { 28 | return Ok(()); 29 | }; 30 | 31 | if !body.contains("Test successful") { 32 | return Ok(()); 33 | } 34 | 35 | if event.comment.user.id != BORS_GH_ID { 36 | log::trace!("Ignoring non-bors comment, user: {:?}", event.comment.user); 37 | return Ok(()); 38 | } 39 | 40 | let repo = event.issue.repository(); 41 | if !(repo.organization == "rust-lang" && repo.repository == "rust") { 42 | return Ok(()); 43 | } 44 | 45 | let start = ""); 48 | let (start, end) = if let (Some(start), Some(end)) = (start, end) { 49 | (start, end) 50 | } else { 51 | log::warn!("Unable to extract build completion from comment {:?}", body); 52 | return Ok(()); 53 | }; 54 | 55 | let bors: BorsMessage = match serde_json::from_str(&body[start..end]) { 56 | Ok(bors) => bors, 57 | Err(e) => { 58 | log::error!( 59 | "failed to parse build completion from {:?}: {:?}", 60 | &body[start..end], 61 | e 62 | ); 63 | return Ok(()); 64 | } 65 | }; 66 | 67 | if bors.type_ != "BuildCompleted" { 68 | log::trace!("Not build completion? {:?}", bors); 69 | } 70 | 71 | if bors.base_ref != "master" { 72 | log::trace!("Ignoring bors merge, not on master"); 73 | return Ok(()); 74 | } 75 | 76 | synchronize_commits(ctx, &bors.merge_sha, event.issue.number.try_into().unwrap()).await; 77 | 78 | Ok(()) 79 | } 80 | 81 | /// Fetch commits that are not present in the database. 82 | async fn synchronize_commits(ctx: &Context, sha: &str, pr: u32) { 83 | log::trace!("synchronize_commits for sha={:?}, pr={}", sha, pr); 84 | synchronize_commits_inner(ctx, Some((sha.to_owned(), pr))).await; 85 | } 86 | 87 | pub async fn synchronize_commits_inner(ctx: &Context, starter: Option<(String, u32)>) { 88 | let db = ctx.db.get().await; 89 | 90 | // List of roots to be resolved. Each root and its parents will be recursively resolved 91 | // until an existing commit is found. 92 | let mut to_be_resolved = VecDeque::new(); 93 | if let Some((sha, pr)) = starter { 94 | to_be_resolved.push_back((sha.to_string(), Some(pr))); 95 | } 96 | to_be_resolved.extend( 97 | get_missing_commits(&db) 98 | .await 99 | .into_iter() 100 | .map(|c| (c, None::)), 101 | ); 102 | log::info!("synchronize_commits for {:?}", to_be_resolved); 103 | 104 | let db = ctx.db.get().await; 105 | while let Some((sha, mut pr)) = to_be_resolved.pop_front() { 106 | let mut gc = match ctx.github.rust_commit(&sha).await { 107 | Some(c) => c, 108 | None => { 109 | log::error!("Could not find bors-reported sha: {:?}", sha); 110 | continue; 111 | } 112 | }; 113 | let parent_sha = gc.parents.remove(0).sha; 114 | 115 | if pr.is_none() { 116 | if let Some(tail) = gc.commit.message.strip_prefix("Auto merge of #") { 117 | if let Some(end) = tail.find(' ') { 118 | if let Ok(number) = tail[..end].parse::() { 119 | pr = Some(number); 120 | } 121 | } 122 | } 123 | } 124 | 125 | let pr = match pr.take() { 126 | Some(number) => number, 127 | None => { 128 | log::warn!("Failed to find PR number for commit {}", sha); 129 | continue; 130 | } 131 | }; 132 | 133 | let res = rustc_commits::record_commit( 134 | &db, 135 | rustc_commits::Commit { 136 | sha: gc.sha, 137 | parent_sha: parent_sha.clone(), 138 | time: gc.commit.author.date, 139 | pr: Some(pr), 140 | }, 141 | ) 142 | .await; 143 | match res { 144 | Ok(()) => { 145 | if !rustc_commits::has_commit(&db, &parent_sha).await { 146 | to_be_resolved.push_back((parent_sha, None)) 147 | } 148 | } 149 | Err(e) => log::error!("Failed to record commit {:?}", e), 150 | } 151 | } 152 | } 153 | 154 | pub struct RustcCommitsJob; 155 | 156 | #[async_trait] 157 | impl Job for RustcCommitsJob { 158 | fn name(&self) -> &'static str { 159 | "rustc_commits" 160 | } 161 | 162 | async fn run(&self, ctx: &super::Context, _metadata: &serde_json::Value) -> anyhow::Result<()> { 163 | synchronize_commits_inner(ctx, None).await; 164 | Ok(()) 165 | } 166 | } 167 | 168 | #[derive(Debug, serde::Deserialize)] 169 | struct BorsMessage { 170 | #[serde(rename = "type")] 171 | type_: String, 172 | base_ref: String, 173 | merge_sha: String, 174 | } 175 | -------------------------------------------------------------------------------- /src/handlers/shortcut.rs: -------------------------------------------------------------------------------- 1 | //! Purpose: Allow the use of single words shortcut to do specific actions on GitHub via comments. 2 | //! 3 | //! Parsing is done in the `parser::command::shortcut` module. 4 | 5 | use crate::{ 6 | config::ShortcutConfig, 7 | db::issue_data::IssueData, 8 | github::{Event, Label}, 9 | handlers::Context, 10 | interactions::ErrorComment, 11 | }; 12 | use octocrab::models::AuthorAssociation; 13 | use parser::command::shortcut::ShortcutCommand; 14 | 15 | /// Key for the state in the database 16 | const AUTHOR_REMINDER_KEY: &str = "author-reminder"; 17 | 18 | /// State stored in the database for a PR. 19 | #[derive(Debug, Default, serde::Deserialize, serde::Serialize, Clone, PartialEq)] 20 | struct AuthorReminderState { 21 | /// ID of the reminder comment. 22 | reminder_comment: Option, 23 | } 24 | 25 | pub(super) async fn handle_command( 26 | ctx: &Context, 27 | _config: &ShortcutConfig, 28 | event: &Event, 29 | input: ShortcutCommand, 30 | ) -> anyhow::Result<()> { 31 | let issue = event.issue().unwrap(); 32 | // NOTE: if shortcuts available to issues are created, they need to be allowed here 33 | if !issue.is_pr() { 34 | let msg = format!("The \"{:?}\" shortcut only works on pull requests.", input); 35 | let cmnt = ErrorComment::new(&issue, msg); 36 | cmnt.post(&ctx.github).await?; 37 | return Ok(()); 38 | } 39 | 40 | let issue_labels = issue.labels(); 41 | let waiting_on_review = "S-waiting-on-review"; 42 | let waiting_on_author = "S-waiting-on-author"; 43 | let blocked = "S-blocked"; 44 | let status_labels = [waiting_on_review, waiting_on_author, blocked, "S-inactive"]; 45 | 46 | let add = match input { 47 | ShortcutCommand::Ready => waiting_on_review, 48 | ShortcutCommand::Author => waiting_on_author, 49 | ShortcutCommand::Blocked => blocked, 50 | }; 51 | 52 | if !issue_labels.iter().any(|l| l.name == add) { 53 | for remove in status_labels { 54 | if remove != add { 55 | issue.remove_label(&ctx.github, remove).await?; 56 | } 57 | } 58 | issue 59 | .add_labels( 60 | &ctx.github, 61 | vec![Label { 62 | name: add.to_owned(), 63 | }], 64 | ) 65 | .await?; 66 | } 67 | 68 | // We add a small reminder for the author to use `@bot ready` when ready 69 | // 70 | // Except if the author is a member (or the owner) of the repository, as 71 | // the author should already know about the `ready` command and already 72 | // have the required permissions to update the labels manually anyway. 73 | if matches!(input, ShortcutCommand::Author) 74 | && !matches!( 75 | issue.author_association, 76 | AuthorAssociation::Member | AuthorAssociation::Owner 77 | ) 78 | { 79 | // Get the state of the author reminder for this PR 80 | let mut db = ctx.db.get().await; 81 | let mut state: IssueData<'_, AuthorReminderState> = 82 | IssueData::load(&mut db, &issue, AUTHOR_REMINDER_KEY).await?; 83 | 84 | if state.data.reminder_comment.is_none() { 85 | let comment_body = format!( 86 | "Reminder, once the PR becomes ready for a review, use `@{bot} ready`.", 87 | bot = &ctx.username, 88 | ); 89 | let comment = issue 90 | .post_comment(&ctx.github, comment_body.as_str()) 91 | .await?; 92 | 93 | state.data.reminder_comment = Some(comment.node_id); 94 | state.save().await?; 95 | } 96 | } 97 | 98 | Ok(()) 99 | } 100 | -------------------------------------------------------------------------------- /src/handlers/transfer.rs: -------------------------------------------------------------------------------- 1 | //! Handles the `@rustbot transfer reponame` command to transfer an issue to 2 | //! another repository. 3 | 4 | use crate::{config::TransferConfig, github::Event, handlers::Context}; 5 | use parser::command::transfer::TransferCommand; 6 | 7 | pub(super) async fn handle_command( 8 | ctx: &Context, 9 | _config: &TransferConfig, 10 | event: &Event, 11 | input: TransferCommand, 12 | ) -> anyhow::Result<()> { 13 | let issue = event.issue().unwrap(); 14 | if issue.is_pr() { 15 | issue 16 | .post_comment(&ctx.github, "Only issues can be transferred.") 17 | .await?; 18 | return Ok(()); 19 | } 20 | if !event 21 | .user() 22 | .is_team_member(&ctx.github) 23 | .await 24 | .ok() 25 | .unwrap_or(false) 26 | { 27 | issue 28 | .post_comment( 29 | &ctx.github, 30 | "Only team members may use the `transfer` command.", 31 | ) 32 | .await?; 33 | return Ok(()); 34 | } 35 | 36 | let repo = input.0; 37 | let repo = repo.strip_prefix("rust-lang/").unwrap_or(&repo); 38 | if repo.contains('/') { 39 | issue 40 | .post_comment(&ctx.github, "Cross-organization transfers are not allowed.") 41 | .await?; 42 | return Ok(()); 43 | } 44 | 45 | if let Err(e) = issue.transfer(&ctx.github, "rust-lang", &repo).await { 46 | issue 47 | .post_comment(&ctx.github, &format!("Failed to transfer issue:\n{e:?}")) 48 | .await?; 49 | return Ok(()); 50 | } 51 | 52 | Ok(()) 53 | } 54 | -------------------------------------------------------------------------------- /src/handlers/validate_config.rs: -------------------------------------------------------------------------------- 1 | //! For pull requests that have changed the triagebot.toml, validate that the 2 | //! changes are a valid configuration file. 3 | //! It won't validate anything unless the PR is open and has changed. 4 | 5 | use crate::{ 6 | config::{ValidateConfig, CONFIG_FILE_NAME}, 7 | github::IssuesAction, 8 | handlers::{Context, IssuesEvent}, 9 | }; 10 | use tracing as log; 11 | 12 | pub(super) async fn parse_input( 13 | ctx: &Context, 14 | event: &IssuesEvent, 15 | _config: Option<&ValidateConfig>, 16 | ) -> Result, String> { 17 | if !matches!( 18 | event.action, 19 | IssuesAction::Opened | IssuesAction::Reopened | IssuesAction::Synchronize 20 | ) { 21 | return Ok(None); 22 | } 23 | // All processing needs to be done in parse_input (instead of 24 | // handle_input) because we want this to *always* run. handle_input 25 | // requires the config to exist in triagebot.toml, but we want this to run 26 | // even if it isn't configured. As a consequence, error handling needs to 27 | // be a little more cautious here, since we don't want to relay 28 | // un-actionable errors to the user. 29 | let diff = match event.issue.diff(&ctx.github).await { 30 | Ok(Some(diff)) => diff, 31 | Ok(None) => return Ok(None), 32 | Err(e) => { 33 | log::error!("failed to get diff {e}"); 34 | return Ok(None); 35 | } 36 | }; 37 | if !diff.iter().any(|diff| diff.filename == CONFIG_FILE_NAME) { 38 | return Ok(None); 39 | } 40 | 41 | let Some(pr_source) = &event.issue.head else { 42 | log::error!("expected head commit in {event:?}"); 43 | return Ok(None); 44 | }; 45 | let Some(repo) = &pr_source.repo else { 46 | log::warn!("repo is not available in {event:?}"); 47 | return Ok(None); 48 | }; 49 | let triagebot_content = match ctx 50 | .github 51 | .raw_file(&repo.full_name, &pr_source.sha, CONFIG_FILE_NAME) 52 | .await 53 | { 54 | Ok(Some(c)) => c, 55 | Ok(None) => { 56 | log::error!("{CONFIG_FILE_NAME} modified, but failed to get content"); 57 | return Ok(None); 58 | } 59 | Err(e) => { 60 | log::error!("failed to get {CONFIG_FILE_NAME}: {e}"); 61 | return Ok(None); 62 | } 63 | }; 64 | 65 | let triagebot_content = String::from_utf8_lossy(&*triagebot_content); 66 | if let Err(e) = toml::from_str::(&triagebot_content) { 67 | let position = match e.span() { 68 | // toml sometimes gives bad spans, see https://github.com/toml-rs/toml/issues/589 69 | Some(span) if span != (0..0) => { 70 | let (line, col) = translate_position(&triagebot_content, span.start); 71 | let url = format!( 72 | "https://github.com/{}/blob/{}/{CONFIG_FILE_NAME}#L{line}", 73 | repo.full_name, pr_source.sha 74 | ); 75 | format!(" at position [{line}:{col}]({url})",) 76 | } 77 | Some(_) | None => String::new(), 78 | }; 79 | 80 | return Err(format!( 81 | "Invalid `triagebot.toml`{position}:\n\ 82 | `````\n\ 83 | {e}\n\ 84 | `````", 85 | )); 86 | } 87 | Ok(None) 88 | } 89 | 90 | pub(super) async fn handle_input( 91 | _ctx: &Context, 92 | _config: &ValidateConfig, 93 | _event: &IssuesEvent, 94 | _input: (), 95 | ) -> anyhow::Result<()> { 96 | Ok(()) 97 | } 98 | 99 | /// Helper to translate a toml span to a `(line_no, col_no)` (1-based). 100 | fn translate_position(input: &str, index: usize) -> (usize, usize) { 101 | if input.is_empty() { 102 | return (0, index); 103 | } 104 | 105 | let safe_index = index.min(input.len() - 1); 106 | let column_offset = index - safe_index; 107 | 108 | let nl = input[0..safe_index] 109 | .as_bytes() 110 | .iter() 111 | .rev() 112 | .enumerate() 113 | .find(|(_, b)| **b == b'\n') 114 | .map(|(nl, _)| safe_index - nl - 1); 115 | let line_start = match nl { 116 | Some(nl) => nl + 1, 117 | None => 0, 118 | }; 119 | let line = input[0..line_start] 120 | .as_bytes() 121 | .iter() 122 | .filter(|c| **c == b'\n') 123 | .count(); 124 | let column = input[line_start..=safe_index].chars().count() - 1; 125 | let column = column + column_offset; 126 | 127 | (line + 1, column + 1) 128 | } 129 | -------------------------------------------------------------------------------- /src/interactions.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use serde::{Deserialize, Serialize}; 3 | use tokio_postgres::Client as DbClient; 4 | 5 | use crate::{ 6 | db::issue_data::IssueData, 7 | github::{GithubClient, Issue}, 8 | }; 9 | use std::fmt::Write; 10 | 11 | pub struct ErrorComment<'a> { 12 | issue: &'a Issue, 13 | message: String, 14 | } 15 | 16 | impl<'a> ErrorComment<'a> { 17 | pub fn new(issue: &'a Issue, message: T) -> ErrorComment<'a> 18 | where 19 | T: Into, 20 | { 21 | ErrorComment { 22 | issue, 23 | message: message.into(), 24 | } 25 | } 26 | 27 | pub async fn post(&self, client: &GithubClient) -> anyhow::Result<()> { 28 | let mut body = String::new(); 29 | writeln!(body, "**Error**: {}", self.message)?; 30 | writeln!(body)?; 31 | writeln!( 32 | body, 33 | "Please file an issue on GitHub at [triagebot](https://github.com/rust-lang/triagebot) if there's \ 34 | a problem with this bot, or reach out on [#t-infra](https://rust-lang.zulipchat.com/#narrow/stream/242791-t-infra) on Zulip." 35 | )?; 36 | self.issue.post_comment(client, &body).await?; 37 | Ok(()) 38 | } 39 | } 40 | 41 | pub struct EditIssueBody<'a, T> 42 | where 43 | T: for<'t> Deserialize<'t> + Serialize + Default + std::fmt::Debug + Sync + PartialEq + Clone, 44 | { 45 | issue_data: IssueData<'a, T>, 46 | issue: &'a Issue, 47 | id: &'static str, 48 | } 49 | 50 | static START_BOT: &str = "\n\n"; 51 | static END_BOT: &str = ""; 52 | 53 | fn normalize_body(body: &str) -> String { 54 | str::replace(body, "\r\n", "\n") 55 | } 56 | 57 | impl<'a, T> EditIssueBody<'a, T> 58 | where 59 | T: for<'t> Deserialize<'t> + Serialize + Default + std::fmt::Debug + Sync + PartialEq + Clone, 60 | { 61 | pub async fn load( 62 | db: &'a mut DbClient, 63 | issue: &'a Issue, 64 | id: &'static str, 65 | ) -> Result> { 66 | let issue_data = IssueData::load(db, issue, id).await?; 67 | 68 | let mut edit = EditIssueBody { 69 | issue_data, 70 | issue, 71 | id, 72 | }; 73 | 74 | // Legacy, if we find data inside the issue body for the current 75 | // id, use that instead of the (hopefully) default value given 76 | // by IssueData. 77 | if let Some(d) = edit.current_data_markdown() { 78 | edit.issue_data.data = d; 79 | } 80 | 81 | Ok(edit) 82 | } 83 | 84 | pub fn data_mut(&mut self) -> &mut T { 85 | &mut self.issue_data.data 86 | } 87 | 88 | pub async fn apply(self, client: &GithubClient, text: String) -> anyhow::Result<()> { 89 | let mut current_body = normalize_body(&self.issue.body.clone()); 90 | let start_section = self.start_section(); 91 | let end_section = self.end_section(); 92 | 93 | let bot_section = format!("{}{}{}", start_section, text, end_section); 94 | let empty_bot_section = format!("{}{}", start_section, end_section); 95 | let all_new = format!("\n\n{}{}{}", START_BOT, bot_section, END_BOT); 96 | 97 | // Edit or add the new text the current triagebot section 98 | if current_body.contains(START_BOT) { 99 | if current_body.contains(&start_section) { 100 | let start_idx = current_body.find(&start_section).unwrap(); 101 | let end_idx = current_body.find(&end_section).unwrap(); 102 | current_body.replace_range(start_idx..(end_idx + end_section.len()), &bot_section); 103 | if current_body.contains(&all_new) && bot_section == empty_bot_section { 104 | let start_idx = current_body.find(&all_new).unwrap(); 105 | let end_idx = start_idx + all_new.len(); 106 | current_body.replace_range(start_idx..end_idx, ""); 107 | } 108 | self.issue.edit_body(&client, ¤t_body).await?; 109 | } else { 110 | let end_idx = current_body.find(&END_BOT).unwrap(); 111 | current_body.insert_str(end_idx, &bot_section); 112 | self.issue.edit_body(&client, ¤t_body).await?; 113 | } 114 | } else { 115 | let new_body = format!("{}{}", current_body, all_new); 116 | 117 | self.issue.edit_body(&client, &new_body).await?; 118 | } 119 | 120 | // Save the state in the database 121 | self.issue_data.save().await?; 122 | 123 | Ok(()) 124 | } 125 | 126 | fn start_section(&self) -> String { 127 | format!("\n", self.id) 128 | } 129 | 130 | fn end_section(&self) -> String { 131 | format!("\n\n", self.id) 132 | } 133 | 134 | // Legacy, only used for handling data inside the issue body it-self 135 | 136 | fn current_data_markdown(&self) -> Option { 137 | let all = self.get_current_markdown()?; 138 | let start = self.data_section_start(); 139 | let end = self.data_section_end(); 140 | let start_idx = all.find(&start)?; 141 | let end_idx = all.find(&end)?; 142 | let text = &all[(start_idx + start.len())..end_idx]; 143 | Some(serde_json::from_str(text).unwrap_or_else(|e| { 144 | panic!("deserializing data {:?} failed: {:?}", text, e); 145 | })) 146 | } 147 | 148 | fn get_current_markdown(&self) -> Option { 149 | let self_issue_body = normalize_body(&self.issue.body); 150 | let start_section = self.start_section(); 151 | let end_section = self.end_section(); 152 | if self_issue_body.contains(START_BOT) { 153 | if self_issue_body.contains(&start_section) { 154 | let start_idx = self_issue_body.find(&start_section).unwrap(); 155 | let end_idx = self_issue_body.find(&end_section).unwrap(); 156 | let current = 157 | String::from(&self_issue_body[start_idx..(end_idx + end_section.len())]); 158 | Some(current) 159 | } else { 160 | None 161 | } 162 | } else { 163 | None 164 | } 165 | } 166 | 167 | fn data_section_start(&self) -> String { 168 | format!("\n\n", self.id) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/jobs.rs: -------------------------------------------------------------------------------- 1 | //! # Scheduled Jobs 2 | //! 3 | //! Scheduled jobs essentially come in two flavors: automatically repeating 4 | //! (cron) jobs and one-off jobs. 5 | //! 6 | //! The core trait here is the `Job` trait, which *must* define the name of the 7 | //! job (to be used as an identifier in the database) and the function to run 8 | //! when the job runs. 9 | //! 10 | //! The metadata is a serde_json::Value 11 | //! Please refer to https://docs.rs/serde_json/latest/serde_json/value/fn.from_value.html 12 | //! on how to interpret it as an instance of type T, implementing Serialize/Deserialize. 13 | //! 14 | //! The schedule is a cron::Schedule 15 | //! Please refer to https://docs.rs/cron/latest/cron/struct.Schedule.html for further info 16 | //! 17 | //! ## Example, sending a zulip message once a week 18 | //! 19 | //! To give an example, let's imagine we want to sends a Zulip message every 20 | //! Friday at 11:30am ET into #t-release with a "@T-release meeting!"" content. 21 | //! 22 | //! To begin, let's create a generic zulip message Job: 23 | //! #[derive(Serialize, Deserialize)] 24 | //! struct ZulipMetadata { 25 | //! pub message: String 26 | //! pub channel: String, 27 | //! } 28 | //! struct ZulipMessageJob; 29 | //! impl Job for ZulipMessageJob { ... } 30 | //! 31 | //! (Imagine that this job requires a channel and a message in the metadata.) 32 | //! 33 | //! If we wanted to have a default scheduled message, we could add the following to 34 | //! `default_jobs`: 35 | //! JobSchedule { 36 | //! name: ZulipMessageJob.name(), 37 | //! schedule: Schedule::from_str("0 30 11 * * FRI *").unwrap(), 38 | //! metadata: serde_json::value::to_value(ZulipMetadata { 39 | //! message: "@T-release meeting!".to_string() 40 | //! channel: "T-release".to_string(), 41 | //! }).unwrap(), 42 | //! } 43 | 44 | use std::str::FromStr; 45 | 46 | use async_trait::async_trait; 47 | use cron::Schedule; 48 | 49 | use crate::handlers::pull_requests_assignment_update::PullRequestAssignmentUpdate; 50 | use crate::{ 51 | db::jobs::JobSchedule, 52 | handlers::{docs_update::DocsUpdateJob, rustc_commits::RustcCommitsJob, Context}, 53 | }; 54 | 55 | /// How often new cron-based jobs will be placed in the queue. 56 | /// This is the minimum period *between* a single cron task's executions. 57 | pub const JOB_SCHEDULING_CADENCE_IN_SECS: u64 = 1800; 58 | 59 | /// How often the database is inspected for jobs which need to execute. 60 | /// This is the granularity at which events will occur. 61 | pub const JOB_PROCESSING_CADENCE_IN_SECS: u64 = 60; 62 | 63 | // The default jobs list that are currently scheduled to run 64 | pub fn jobs() -> Vec> { 65 | vec![ 66 | Box::new(DocsUpdateJob), 67 | Box::new(RustcCommitsJob), 68 | Box::new(PullRequestAssignmentUpdate), 69 | ] 70 | } 71 | 72 | // Definition of the schedule repetition for the jobs we want to run. 73 | pub fn default_jobs() -> Vec { 74 | vec![ 75 | JobSchedule { 76 | name: DocsUpdateJob.name(), 77 | // Around 9am Pacific time on every Monday. 78 | schedule: Schedule::from_str("0 00 17 * * Mon *").unwrap(), 79 | metadata: serde_json::Value::Null, 80 | }, 81 | JobSchedule { 82 | name: RustcCommitsJob.name(), 83 | // Every 30 minutes... 84 | schedule: Schedule::from_str("* 0,30 * * * * *").unwrap(), 85 | metadata: serde_json::Value::Null, 86 | }, 87 | JobSchedule { 88 | name: PullRequestAssignmentUpdate.name(), 89 | // Every 30 minutes 90 | schedule: Schedule::from_str("* 0,30 * * * * *").unwrap(), 91 | metadata: serde_json::Value::Null, 92 | }, 93 | ] 94 | } 95 | 96 | #[async_trait] 97 | pub trait Job { 98 | fn name(&self) -> &str; 99 | 100 | async fn run(&self, ctx: &Context, metadata: &serde_json::Value) -> anyhow::Result<()>; 101 | } 102 | 103 | #[test] 104 | fn jobs_defined() { 105 | // This checks that we don't panic (during schedule parsing) and that all names are unique 106 | // Checks we don't panic here, mostly for the schedule parsing. 107 | let all_jobs = jobs(); 108 | let mut all_job_names: Vec<_> = all_jobs.into_iter().map(|j| j.name().to_string()).collect(); 109 | all_job_names.sort(); 110 | let mut unique_all_job_names = all_job_names.clone(); 111 | unique_all_job_names.sort(); 112 | unique_all_job_names.dedup(); 113 | assert_eq!(all_job_names, unique_all_job_names); 114 | 115 | // Also ensure that our default jobs are release jobs 116 | let default_jobs = default_jobs(); 117 | default_jobs 118 | .iter() 119 | .for_each(|j| assert!(all_job_names.contains(&j.name.to_string()))); 120 | } 121 | -------------------------------------------------------------------------------- /src/notification_listing.rs: -------------------------------------------------------------------------------- 1 | use crate::db::notifications::get_notifications; 2 | 3 | pub async fn render(db: &crate::db::PooledClient, user: &str) -> String { 4 | let notifications = match get_notifications(db, user).await { 5 | Ok(n) => n, 6 | Err(e) => { 7 | return format!("{:?}", e.context("getting notifications")); 8 | } 9 | }; 10 | 11 | let mut out = String::new(); 12 | out.push_str(""); 13 | out.push_str(""); 14 | out.push_str(""); 15 | out.push_str("Triagebot Notification Data"); 16 | out.push_str(""); 17 | out.push_str(""); 18 | 19 | out.push_str(&format!("

Pending notifications for {}

", user)); 20 | 21 | if notifications.is_empty() { 22 | out.push_str("

You have no pending notifications! :)

"); 23 | } else { 24 | out.push_str("
    "); 25 | for notification in notifications { 26 | out.push_str("
  1. "); 27 | out.push_str(&format!( 28 | "{}", 29 | notification.origin_url, 30 | notification 31 | .short_description 32 | .as_ref() 33 | .unwrap_or(¬ification.origin_url) 34 | .replace('&', "&") 35 | .replace('<', "<") 36 | .replace('>', ">") 37 | .replace('"', """) 38 | .replace('\'', "'"), 39 | )); 40 | if let Some(metadata) = ¬ification.metadata { 41 | out.push_str(&format!( 42 | "
    • {}
    ", 43 | metadata 44 | .replace('&', "&") 45 | .replace('<', "<") 46 | .replace('>', ">") 47 | .replace('"', """) 48 | .replace('\'', "'"), 49 | )); 50 | } 51 | out.push_str("
  2. "); 52 | } 53 | out.push_str("
"); 54 | 55 | out.push_str( 56 | "

You can acknowledge a notification by sending ack <idx> \ 57 | to @triagebot on Zulip, or you can acknowledge \ 58 | all notifications by sending ack all. Read about the other notification commands \ 59 | here.

" 60 | ); 61 | } 62 | 63 | out.push_str(""); 64 | out.push_str(""); 65 | 66 | out 67 | } 68 | -------------------------------------------------------------------------------- /src/payload.rs: -------------------------------------------------------------------------------- 1 | use hmac::{Hmac, Mac}; 2 | use sha2::Sha256; 3 | use std::fmt; 4 | 5 | #[derive(Debug)] 6 | pub struct SignedPayloadError; 7 | 8 | impl fmt::Display for SignedPayloadError { 9 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 10 | write!(f, "failed to validate payload") 11 | } 12 | } 13 | 14 | impl std::error::Error for SignedPayloadError {} 15 | 16 | pub fn assert_signed(signature: &str, payload: &[u8]) -> Result<(), SignedPayloadError> { 17 | let signature = signature 18 | .strip_prefix("sha256=") 19 | .ok_or(SignedPayloadError)?; 20 | let signature = match hex::decode(&signature) { 21 | Ok(e) => e, 22 | Err(e) => { 23 | tracing::trace!("hex decode failed for {:?}: {:?}", signature, e); 24 | return Err(SignedPayloadError); 25 | } 26 | }; 27 | 28 | let mut mac = Hmac::::new_from_slice( 29 | std::env::var("GITHUB_WEBHOOK_SECRET") 30 | .expect("Missing GITHUB_WEBHOOK_SECRET") 31 | .as_bytes(), 32 | ) 33 | .unwrap(); 34 | mac.update(&payload); 35 | mac.verify_slice(&signature).map_err(|_| SignedPayloadError) 36 | } 37 | -------------------------------------------------------------------------------- /src/rfcbot.rs: -------------------------------------------------------------------------------- 1 | use reqwest::Url; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | 5 | #[derive(Serialize, Deserialize, Debug, Clone)] 6 | pub struct FCP { 7 | pub id: u32, 8 | pub fk_issue: u32, 9 | pub fk_initiator: u32, 10 | pub fk_initiating_comment: u64, 11 | pub disposition: Option, 12 | pub fk_bot_tracking_comment: u64, 13 | pub fcp_start: Option, 14 | pub fcp_closed: bool, 15 | } 16 | #[derive(Serialize, Deserialize, Debug, Clone)] 17 | pub struct Reviewer { 18 | pub id: u64, 19 | pub login: String, 20 | } 21 | #[derive(Serialize, Deserialize, Debug, Clone)] 22 | pub struct Review { 23 | pub reviewer: Reviewer, 24 | pub approved: bool, 25 | } 26 | #[derive(Serialize, Deserialize, Debug, Clone)] 27 | pub struct Concern { 28 | pub name: String, 29 | pub comment: StatusComment, 30 | pub reviewer: Reviewer, 31 | } 32 | #[derive(Serialize, Deserialize, Debug, Clone)] 33 | pub struct FCPIssue { 34 | pub id: u32, 35 | pub number: u32, 36 | pub fk_milestone: Option, 37 | pub fk_user: u32, 38 | pub fk_assignee: Option, 39 | pub open: bool, 40 | pub is_pull_request: bool, 41 | pub title: String, 42 | pub body: String, 43 | pub locked: bool, 44 | pub closed_at: Option, 45 | pub created_at: Option, 46 | pub updated_at: Option, 47 | pub labels: Vec, 48 | pub repository: String, 49 | } 50 | 51 | #[derive(Serialize, Deserialize, Debug, Clone)] 52 | pub struct StatusComment { 53 | pub id: u64, 54 | pub fk_issue: u32, 55 | pub fk_user: u32, 56 | pub body: String, 57 | pub created_at: String, 58 | pub updated_at: Option, 59 | pub repository: String, 60 | } 61 | 62 | #[derive(Serialize, Deserialize, Debug, Clone)] 63 | pub struct FullFCP { 64 | pub fcp: FCP, 65 | pub reviews: Vec, 66 | pub concerns: Vec, 67 | pub issue: FCPIssue, 68 | pub status_comment: StatusComment, 69 | } 70 | 71 | pub async fn get_all_fcps() -> anyhow::Result> { 72 | let url = Url::parse(&"https://rfcbot.rs/api/all")?; 73 | let res = reqwest::get(url).await?.json::>().await?; 74 | let mut map: HashMap = HashMap::new(); 75 | for full_fcp in res.into_iter() { 76 | map.insert( 77 | format!( 78 | "{}:{}:{}", 79 | full_fcp.issue.repository.clone(), 80 | full_fcp.issue.number.clone(), 81 | full_fcp.issue.title.clone(), 82 | ), 83 | full_fcp, 84 | ); 85 | } 86 | 87 | Ok(map) 88 | } 89 | -------------------------------------------------------------------------------- /src/team_data.rs: -------------------------------------------------------------------------------- 1 | use crate::github::GithubClient; 2 | use anyhow::Context as _; 3 | use rust_team_data::v1::{People, Teams, ZulipMapping, BASE_URL}; 4 | use serde::de::DeserializeOwned; 5 | 6 | async fn by_url(client: &GithubClient, path: &str) -> anyhow::Result { 7 | let base = std::env::var("TEAMS_API_URL").unwrap_or(BASE_URL.to_string()); 8 | let url = format!("{}{}", base, path); 9 | for _ in 0i32..3 { 10 | let map: Result = client.json(client.raw().get(&url)).await; 11 | match map { 12 | Ok(v) => return Ok(v), 13 | Err(e) => { 14 | if e.downcast_ref::() 15 | .map_or(false, |e| e.is_timeout()) 16 | { 17 | continue; 18 | } else { 19 | return Err(e); 20 | } 21 | } 22 | } 23 | } 24 | 25 | Err(anyhow::anyhow!("Failed to retrieve {} in 3 requests", url)) 26 | } 27 | 28 | pub async fn zulip_map(client: &GithubClient) -> anyhow::Result { 29 | by_url(client, "/zulip-map.json") 30 | .await 31 | .context("team-api: zulip-map.json") 32 | } 33 | 34 | pub async fn teams(client: &GithubClient) -> anyhow::Result { 35 | by_url(client, "/teams.json") 36 | .await 37 | .context("team-api: teams.json") 38 | } 39 | 40 | pub async fn people(client: &GithubClient) -> anyhow::Result { 41 | by_url(client, "/people.json") 42 | .await 43 | .context("team-api: people.json") 44 | } 45 | -------------------------------------------------------------------------------- /src/tests/github.rs: -------------------------------------------------------------------------------- 1 | use crate::github::{Issue, IssueState, Label, PullRequestDetails, User}; 2 | use bon::builder; 3 | use chrono::Utc; 4 | 5 | pub fn default_test_user() -> User { 6 | User { 7 | login: "triagebot-tester".to_string(), 8 | id: 1, 9 | } 10 | } 11 | 12 | pub fn user(login: &str, id: u64) -> User { 13 | User { 14 | login: login.to_string(), 15 | id, 16 | } 17 | } 18 | 19 | #[builder] 20 | pub fn issue( 21 | state: Option, 22 | number: Option, 23 | author: Option, 24 | body: Option<&str>, 25 | assignees: Option>, 26 | pr: Option, 27 | org: Option<&str>, 28 | repo: Option<&str>, 29 | labels: Option>, 30 | ) -> Issue { 31 | let number = number.unwrap_or(1); 32 | let state = state.unwrap_or(IssueState::Open); 33 | let author = author.unwrap_or(default_test_user()); 34 | let body = body.unwrap_or("").to_string(); 35 | let assignees = assignees.unwrap_or_default(); 36 | let pull_request = if pr.unwrap_or(false) { 37 | Some(PullRequestDetails::new()) 38 | } else { 39 | None 40 | }; 41 | let org = org.unwrap_or("rust-lang"); 42 | let repo = repo.unwrap_or("rust"); 43 | let labels = labels 44 | .unwrap_or_default() 45 | .into_iter() 46 | .map(|l| Label { 47 | name: l.to_string(), 48 | }) 49 | .collect(); 50 | 51 | Issue { 52 | number, 53 | body, 54 | created_at: Utc::now(), 55 | updated_at: Utc::now(), 56 | merge_commit_sha: None, 57 | title: format!("Issue #{number}"), 58 | html_url: format!("https://github.com/{org}/{repo}/pull/{number}"), 59 | user: author, 60 | labels, 61 | assignees, 62 | pull_request, 63 | merged: false, 64 | draft: false, 65 | comments: None, 66 | // The repository is parsed from comments_url, so this field is important 67 | comments_url: format!("https://api.github.com/repos/{org}/{repo}/issues/{number}/comments"), 68 | repository: Default::default(), 69 | base: None, 70 | head: None, 71 | state, 72 | milestone: None, 73 | mergeable: None, 74 | author_association: octocrab::models::AuthorAssociation::None, 75 | } 76 | } 77 | 78 | #[builder] 79 | pub fn pull_request( 80 | state: Option, 81 | number: Option, 82 | author: Option, 83 | body: Option<&str>, 84 | assignees: Option>, 85 | labels: Option>, 86 | ) -> Issue { 87 | issue() 88 | .maybe_state(state) 89 | .maybe_number(number) 90 | .maybe_author(author) 91 | .maybe_body(body) 92 | .maybe_labels(labels) 93 | .maybe_assignees(assignees) 94 | .pr(true) 95 | .call() 96 | } 97 | -------------------------------------------------------------------------------- /src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::db; 2 | use crate::db::users::record_username; 3 | use crate::db::{make_client, ClientPool, PooledClient}; 4 | use crate::github::GithubClient; 5 | use crate::handlers::Context; 6 | use crate::zulip::client::ZulipClient; 7 | use octocrab::Octocrab; 8 | use std::future::Future; 9 | use std::sync::Arc; 10 | use tokio::sync::RwLock; 11 | use tokio_postgres::config::Host; 12 | use tokio_postgres::Config; 13 | 14 | pub(crate) mod github; 15 | 16 | /// Represents a connection to a Postgres database that can be 17 | /// used in integration tests to test logic that interacts with 18 | /// a database. 19 | pub(crate) struct TestContext { 20 | ctx: Context, 21 | db_name: String, 22 | original_db_url: String, 23 | // Pre-cached client to avoid creating unnecessary connections in tests 24 | client: PooledClient, 25 | } 26 | 27 | impl TestContext { 28 | async fn new(db_url: &str) -> Self { 29 | let config: Config = db_url.parse().expect("Cannot parse connection string"); 30 | 31 | // Create a new database that will be used for this specific test 32 | let client = make_client(&db_url) 33 | .await 34 | .expect("Cannot connect to database"); 35 | let db_name = format!("db{}", uuid::Uuid::new_v4().to_string().replace("-", "")); 36 | client 37 | .execute(&format!("CREATE DATABASE {db_name}"), &[]) 38 | .await 39 | .expect("Cannot create database"); 40 | drop(client); 41 | 42 | // We need to connect to the database against, because Postgres doesn't allow 43 | // changing the active database mid-connection. 44 | // There does not seem to be a way to turn the config back into a connection 45 | // string, so construct it manually. 46 | let test_db_url = format!( 47 | "postgresql://{}:{}@{}:{}/{}", 48 | config.get_user().unwrap(), 49 | String::from_utf8(config.get_password().unwrap().to_vec()).unwrap(), 50 | match &config.get_hosts()[0] { 51 | Host::Tcp(host) => host, 52 | Host::Unix(_) => 53 | panic!("Unix sockets in Postgres connection string are not supported"), 54 | }, 55 | &config.get_ports()[0], 56 | db_name 57 | ); 58 | let pool = ClientPool::new(test_db_url.clone()); 59 | let mut client = pool.get().await; 60 | db::run_migrations(&mut client) 61 | .await 62 | .expect("Cannot run database migrations"); 63 | 64 | let octocrab = Octocrab::builder().build().unwrap(); 65 | let github = GithubClient::new( 66 | "gh-test-fake-token".to_string(), 67 | "https://api.github.com".to_string(), 68 | "https://api.github.com/graphql".to_string(), 69 | "https://raw.githubusercontent.com".to_string(), 70 | ); 71 | let zulip = ZulipClient::new( 72 | "https://rust-fake.zulipchat.com".to_string(), 73 | "test-bot@zulipchat.com".to_string(), 74 | ); 75 | let ctx = Context { 76 | github, 77 | zulip, 78 | db: pool, 79 | username: "triagebot-test".to_string(), 80 | octocrab, 81 | workqueue: Arc::new(RwLock::new(Default::default())), 82 | }; 83 | 84 | Self { 85 | db_name, 86 | original_db_url: db_url.to_string(), 87 | ctx, 88 | client, 89 | } 90 | } 91 | 92 | /// Returns a fake handler context. 93 | /// We currently do not mock outgoing nor incoming GitHub API calls, 94 | /// so the API endpoints will not be actually working. 95 | pub(crate) fn handler_ctx(&self) -> &Context { 96 | &self.ctx 97 | } 98 | 99 | pub(crate) fn db_client(&self) -> &PooledClient { 100 | &self.client 101 | } 102 | 103 | pub(crate) fn db_client_mut(&mut self) -> &mut PooledClient { 104 | &mut self.client 105 | } 106 | 107 | pub(crate) async fn add_user(&self, name: &str, id: u64) { 108 | record_username(self.db_client(), id, name) 109 | .await 110 | .expect("Cannot create user"); 111 | } 112 | 113 | async fn finish(self) { 114 | // Cleanup the test database 115 | // First, we need to stop using the database 116 | drop(self.client); 117 | drop(self.ctx); 118 | 119 | // Then we need to connect to the default database and drop our test DB 120 | let client = make_client(&self.original_db_url) 121 | .await 122 | .expect("Cannot connect to database"); 123 | client 124 | .execute(&format!("DROP DATABASE {}", self.db_name), &[]) 125 | .await 126 | .unwrap(); 127 | } 128 | } 129 | 130 | pub(crate) async fn run_db_test(f: F) 131 | where 132 | F: FnOnce(TestContext) -> Fut, 133 | Fut: Future>, 134 | Ctx: Into, 135 | { 136 | if let Ok(db_url) = std::env::var("TEST_DB_URL") { 137 | let ctx = TestContext::new(&db_url).await; 138 | let ctx: TestContext = f(ctx).await.expect("Test failed").into(); 139 | ctx.finish().await; 140 | } else { 141 | eprintln!("Skipping test because TEST_DB_URL was not passed"); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/triage.rs: -------------------------------------------------------------------------------- 1 | use crate::handlers::Context; 2 | use chrono::{Duration, Utc}; 3 | use hyper::{Body, Response, StatusCode}; 4 | use serde::Serialize; 5 | use serde_json::value::{to_value, Value}; 6 | use std::sync::Arc; 7 | use url::Url; 8 | 9 | const YELLOW_DAYS: i64 = 7; 10 | const RED_DAYS: i64 = 14; 11 | 12 | pub fn index() -> Result, hyper::Error> { 13 | Ok(Response::builder() 14 | .header("Content-Type", "text/html") 15 | .status(StatusCode::OK) 16 | .body(Body::from(include_str!("../templates/triage/index.html"))) 17 | .unwrap()) 18 | } 19 | 20 | pub async fn pulls( 21 | ctx: Arc, 22 | owner: &str, 23 | repo: &str, 24 | ) -> Result, hyper::Error> { 25 | let octocrab = &ctx.octocrab; 26 | let res = octocrab 27 | .pulls(owner, repo) 28 | .list() 29 | .sort(octocrab::params::pulls::Sort::Updated) 30 | .direction(octocrab::params::Direction::Ascending) 31 | .per_page(100) 32 | .send() 33 | .await; 34 | let mut page = match res { 35 | Ok(page) => page, 36 | Err(_) => { 37 | return Ok(Response::builder() 38 | .status(StatusCode::NOT_FOUND) 39 | .body(Body::from("The repository is not found.")) 40 | .unwrap()); 41 | } 42 | }; 43 | let mut base_pulls = page.take_items(); 44 | let mut next_page = page.next; 45 | while let Some(mut page) = octocrab 46 | .get_page::(&next_page) 47 | .await 48 | .unwrap() 49 | { 50 | base_pulls.extend(page.take_items()); 51 | next_page = page.next; 52 | } 53 | 54 | let mut pulls: Vec = Vec::new(); 55 | for base_pull in base_pulls.into_iter() { 56 | let assignee = base_pull.assignee.map_or("".to_string(), |v| v.login); 57 | let updated_at = base_pull 58 | .updated_at 59 | .map_or("".to_string(), |v| v.format("%Y-%m-%d").to_string()); 60 | 61 | let yellow_line = Utc::now() - Duration::days(YELLOW_DAYS); 62 | let red_line = Utc::now() - Duration::days(RED_DAYS); 63 | let need_triage = match base_pull.updated_at { 64 | Some(updated_at) if updated_at <= red_line => "red".to_string(), 65 | Some(updated_at) if updated_at <= yellow_line => "yellow".to_string(), 66 | _ => "green".to_string(), 67 | }; 68 | let days_from_last_updated_at = if let Some(updated_at) = base_pull.updated_at { 69 | (Utc::now() - updated_at).num_days() 70 | } else { 71 | (Utc::now() - base_pull.created_at.unwrap()).num_days() 72 | }; 73 | 74 | let labels = base_pull.labels.map_or("".to_string(), |labels| { 75 | labels 76 | .iter() 77 | .map(|label| label.name.clone()) 78 | .collect::>() 79 | .join(", ") 80 | }); 81 | let wait_for_author = labels.contains("S-waiting-on-author"); 82 | let wait_for_review = labels.contains("S-waiting-on-review"); 83 | let html_url = base_pull.html_url.unwrap(); 84 | let number = base_pull.number; 85 | let title = base_pull.title.unwrap(); 86 | let author = base_pull.user.unwrap().login; 87 | 88 | let pull = PullRequest { 89 | html_url, 90 | number, 91 | title, 92 | assignee, 93 | updated_at, 94 | need_triage, 95 | labels, 96 | author, 97 | wait_for_author, 98 | wait_for_review, 99 | days_from_last_updated_at, 100 | }; 101 | pulls.push(to_value(pull).unwrap()); 102 | } 103 | 104 | let mut context = tera::Context::new(); 105 | context.insert("pulls", &pulls); 106 | context.insert("owner", &owner); 107 | context.insert("repo", &repo); 108 | 109 | let tera = tera::Tera::new("templates/triage/**/*").unwrap(); 110 | let body = Body::from(tera.render("pulls.html", &context).unwrap()); 111 | 112 | Ok(Response::builder() 113 | .header("Content-Type", "text/html") 114 | .status(StatusCode::OK) 115 | .body(body) 116 | .unwrap()) 117 | } 118 | 119 | #[derive(Serialize)] 120 | struct PullRequest { 121 | pub html_url: Url, 122 | pub number: u64, 123 | pub title: String, 124 | pub assignee: String, 125 | pub updated_at: String, 126 | pub need_triage: String, 127 | pub labels: String, 128 | pub author: String, 129 | pub wait_for_author: bool, 130 | pub wait_for_review: bool, 131 | pub days_from_last_updated_at: i64, 132 | } 133 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | /// Pluralize (add an 's' sufix) to `text` based on `count`. 4 | pub fn pluralize(text: &str, count: usize) -> Cow { 5 | if count == 1 { 6 | text.into() 7 | } else { 8 | format!("{}s", text).into() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/zulip/api.rs: -------------------------------------------------------------------------------- 1 | use crate::zulip::client::ZulipClient; 2 | use std::collections::HashMap; 3 | 4 | /// A collection of Zulip users, as returned from '/users' 5 | #[derive(serde::Deserialize)] 6 | pub(crate) struct ZulipUsers { 7 | pub(crate) members: Vec, 8 | } 9 | 10 | #[derive(Clone, serde::Deserialize, Debug, PartialEq, Eq)] 11 | pub(crate) struct ProfileValue { 12 | pub(crate) value: String, 13 | } 14 | 15 | /// A single Zulip user 16 | #[derive(Clone, serde::Deserialize, Debug, PartialEq, Eq)] 17 | pub(crate) struct ZulipUser { 18 | pub(crate) user_id: u64, 19 | #[serde(rename = "full_name")] 20 | pub(crate) name: String, 21 | pub(crate) email: String, 22 | #[serde(default)] 23 | pub(crate) profile_data: HashMap, 24 | } 25 | 26 | impl ZulipUser { 27 | // The custom profile field ID for GitHub profiles on the Rust Zulip 28 | // is 3873. This is likely not portable across different Zulip instance, 29 | // but we assume that triagebot will only be used on this Zulip instance anyway. 30 | pub(crate) fn get_github_username(&self) -> Option<&str> { 31 | self.profile_data.get("3873").map(|v| v.value.as_str()) 32 | } 33 | } 34 | 35 | #[derive(Debug, serde::Deserialize)] 36 | pub(crate) struct MessageApiResponse { 37 | #[serde(rename = "id")] 38 | pub(crate) message_id: u64, 39 | } 40 | 41 | #[derive(Copy, Clone, serde::Serialize)] 42 | #[serde(tag = "type")] 43 | #[serde(rename_all = "snake_case")] 44 | pub(crate) enum Recipient<'a> { 45 | Stream { 46 | #[serde(rename = "to")] 47 | id: u64, 48 | topic: &'a str, 49 | }, 50 | Private { 51 | #[serde(skip)] 52 | id: u64, 53 | #[serde(rename = "to")] 54 | email: &'a str, 55 | }, 56 | } 57 | 58 | impl Recipient<'_> { 59 | pub fn narrow(&self) -> String { 60 | use std::fmt::Write; 61 | 62 | match self { 63 | Recipient::Stream { id, topic } => { 64 | // See 65 | // https://github.com/zulip/zulip/blob/46247623fc279/zerver/lib/url_encoding.py#L9 66 | // ALWAYS_SAFE without `.` from 67 | // https://github.com/python/cpython/blob/113e2b0a07c/Lib/urllib/parse.py#L772-L775 68 | // 69 | // ALWAYS_SAFE doesn't contain `.` because Zulip actually encodes them to be able 70 | // to use `.` instead of `%` in the encoded strings 71 | const ALWAYS_SAFE: &str = 72 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-~"; 73 | 74 | let mut encoded_topic = String::new(); 75 | for ch in topic.bytes() { 76 | if !(ALWAYS_SAFE.contains(ch as char)) { 77 | write!(encoded_topic, ".{:02X}", ch).unwrap(); 78 | } else { 79 | encoded_topic.push(ch as char); 80 | } 81 | } 82 | format!("stream/{}-xxx/topic/{}", id, encoded_topic) 83 | } 84 | Recipient::Private { id, .. } => format!("pm-with/{}-xxx", id), 85 | } 86 | } 87 | 88 | pub fn url(&self, zulip: &ZulipClient) -> String { 89 | format!("{}/#narrow/{}", zulip.instance_url(), self.narrow()) 90 | } 91 | } 92 | 93 | #[cfg(test)] 94 | fn check_encode(topic: &str, expected: &str) { 95 | const PREFIX: &str = "stream/0-xxx/topic/"; 96 | let computed = Recipient::Stream { id: 0, topic }.narrow(); 97 | assert_eq!(&computed[..PREFIX.len()], PREFIX); 98 | assert_eq!(&computed[PREFIX.len()..], expected); 99 | } 100 | 101 | #[test] 102 | fn test_encode() { 103 | check_encode("some text with spaces", "some.20text.20with.20spaces"); 104 | check_encode( 105 | " !\"#$%&'()*+,-./", 106 | ".20.21.22.23.24.25.26.27.28.29.2A.2B.2C-.2E.2F", 107 | ); 108 | check_encode("0123456789:;<=>?", "0123456789.3A.3B.3C.3D.3E.3F"); 109 | check_encode( 110 | "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_", 111 | ".40ABCDEFGHIJKLMNOPQRSTUVWXYZ.5B.5C.5D.5E_", 112 | ); 113 | check_encode( 114 | "`abcdefghijklmnopqrstuvwxyz{|}~", 115 | ".60abcdefghijklmnopqrstuvwxyz.7B.7C.7D~.7F", 116 | ); 117 | check_encode("áé…", ".C3.A1.C3.A9.E2.80.A6"); 118 | } 119 | -------------------------------------------------------------------------------- /src/zulip/client.rs: -------------------------------------------------------------------------------- 1 | use crate::zulip::api::{MessageApiResponse, ZulipUser, ZulipUsers}; 2 | use crate::zulip::Recipient; 3 | use anyhow::Context; 4 | use reqwest::{Client, Method, RequestBuilder, Response}; 5 | use serde::de::DeserializeOwned; 6 | use std::env; 7 | use std::sync::OnceLock; 8 | 9 | #[derive(Clone)] 10 | pub struct ZulipClient { 11 | client: Client, 12 | instance_url: String, 13 | bot_email: String, 14 | // The token is loaded lazily, to avoid requiring the API token if Zulip APIs are not 15 | // actually accessed. 16 | bot_api_token: OnceLock, 17 | } 18 | 19 | impl ZulipClient { 20 | pub fn new_from_env() -> Self { 21 | let instance_url = 22 | env::var("ZULIP_URL").unwrap_or("https://rust-lang.zulipchat.com".into()); 23 | let bot_email = 24 | env::var("ZULIP_BOT_EMAIL").unwrap_or("triage-rust-lang-bot@zulipchat.com".into()); 25 | Self::new(instance_url, bot_email) 26 | } 27 | 28 | pub fn new(instance_url: String, bot_email: String) -> Self { 29 | let client = Client::new(); 30 | Self { 31 | client, 32 | instance_url, 33 | bot_email, 34 | bot_api_token: OnceLock::new(), 35 | } 36 | } 37 | 38 | pub fn instance_url(&self) -> &str { 39 | &self.instance_url 40 | } 41 | 42 | // Taken from https://github.com/kobzol/team/blob/0f68ffc8b0d438d88ef4573deb54446d57e1eae6/src/api/zulip.rs#L45 43 | pub(crate) async fn get_zulip_users(&self) -> anyhow::Result> { 44 | let resp = self 45 | .make_request(Method::GET, "users?include_custom_profile_fields=true") 46 | .send() 47 | .await?; 48 | deserialize_response::(resp) 49 | .await 50 | .map(|users| users.members) 51 | } 52 | 53 | pub(crate) async fn send_message<'a>( 54 | &self, 55 | recipient: Recipient<'a>, 56 | content: &'a str, 57 | ) -> anyhow::Result { 58 | #[derive(serde::Serialize)] 59 | struct SerializedApi<'a> { 60 | #[serde(rename = "type")] 61 | type_: &'static str, 62 | to: String, 63 | #[serde(skip_serializing_if = "Option::is_none")] 64 | topic: Option<&'a str>, 65 | content: &'a str, 66 | } 67 | 68 | let response = self 69 | .make_request(Method::POST, "messages") 70 | .form(&SerializedApi { 71 | type_: match recipient { 72 | Recipient::Stream { .. } => "stream", 73 | Recipient::Private { .. } => "private", 74 | }, 75 | to: match recipient { 76 | Recipient::Stream { id, .. } => id.to_string(), 77 | Recipient::Private { email, .. } => email.to_string(), 78 | }, 79 | topic: match recipient { 80 | Recipient::Stream { topic, .. } => Some(topic), 81 | Recipient::Private { .. } => None, 82 | }, 83 | content, 84 | }) 85 | .send() 86 | .await 87 | .context("fail sending Zulip message")?; 88 | 89 | deserialize_response::(response).await 90 | } 91 | 92 | pub(crate) async fn update_message<'a>( 93 | &self, 94 | message_id: u64, 95 | topic: Option<&'a str>, 96 | propagate_mode: Option<&'a str>, 97 | content: Option<&'a str>, 98 | ) -> anyhow::Result<()> { 99 | #[derive(serde::Serialize)] 100 | struct SerializedApi<'a> { 101 | #[serde(skip_serializing_if = "Option::is_none")] 102 | topic: Option<&'a str>, 103 | #[serde(skip_serializing_if = "Option::is_none")] 104 | propagate_mode: Option<&'a str>, 105 | #[serde(skip_serializing_if = "Option::is_none")] 106 | content: Option<&'a str>, 107 | } 108 | 109 | let resp = self 110 | .make_request(Method::PATCH, &format!("messages/{message_id}")) 111 | .form(&SerializedApi { 112 | topic, 113 | propagate_mode, 114 | content, 115 | }) 116 | .send() 117 | .await 118 | .context("failed to send Zulip API Update Message")?; 119 | 120 | let status = resp.status(); 121 | 122 | if !status.is_success() { 123 | let body = resp 124 | .text() 125 | .await 126 | .context("fail receiving Zulip API response (when updating the message)")?; 127 | 128 | anyhow::bail!(body) 129 | } 130 | 131 | Ok(()) 132 | } 133 | 134 | pub(crate) async fn add_reaction( 135 | &self, 136 | message_id: u64, 137 | emoji_name: &str, 138 | ) -> anyhow::Result<()> { 139 | #[derive(serde::Serialize)] 140 | struct AddReaction<'a> { 141 | message_id: u64, 142 | emoji_name: &'a str, 143 | } 144 | 145 | let resp = self 146 | .make_request(Method::POST, &format!("messages/{message_id}/reactions")) 147 | .form(&AddReaction { 148 | message_id, 149 | emoji_name, 150 | }) 151 | .send() 152 | .await 153 | .context("failed to add reaction to a Zulip message")?; 154 | 155 | let status = resp.status(); 156 | 157 | if !status.is_success() { 158 | let body = resp 159 | .text() 160 | .await 161 | .context("fail receiving Zulip API response (when adding a reaction)")?; 162 | 163 | anyhow::bail!(body) 164 | } 165 | 166 | Ok(()) 167 | } 168 | 169 | fn make_request(&self, method: Method, url: &str) -> RequestBuilder { 170 | let api_token = self.get_api_token(); 171 | self.client 172 | .request(method, &format!("{}/api/v1/{url}", self.instance_url)) 173 | .basic_auth(&self.bot_email, Some(api_token)) 174 | } 175 | 176 | fn get_api_token(&self) -> &str { 177 | self.bot_api_token 178 | .get_or_init(|| env::var("ZULIP_API_TOKEN").expect("ZULIP_API_TOKEN is missing")) 179 | .as_ref() 180 | } 181 | } 182 | 183 | async fn deserialize_response(response: Response) -> anyhow::Result 184 | where 185 | T: DeserializeOwned, 186 | { 187 | let status = response.status(); 188 | 189 | if !status.is_success() { 190 | let body = response.text().await.context("Zulip API request failed")?; 191 | Err(anyhow::anyhow!(body)) 192 | } else { 193 | Ok(response.json::().await.with_context(|| { 194 | anyhow::anyhow!( 195 | "Failed to deserialize value of type {}", 196 | std::any::type_name::() 197 | ) 198 | })?) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /templates/_issue.tt: -------------------------------------------------------------------------------- 1 | {% macro render(issue, with_age="", backport_branch="") %}"{{issue.title}}" [{{issue.repo_name}}#{{issue.number}}]({{issue.html_url}}){% if issue.mcp_details.zulip_link %} ([Zulip]({{issue.mcp_details.zulip_link}})){% endif %}{% if with_age %} (last review activity: {{issue.updated_at_hts}}){%- endif -%} 2 | {%- if backport_branch != "" %} 3 | - Authored by {{ issue.author }} 4 | {%- endif -%} 5 | {% if issue.mcp_details.concerns %}{%- for concern in issue.mcp_details.concerns %} 6 | - concern: [{{- concern.0 -}}]({{- concern.1 -}}) 7 | {%- endfor -%}{%- endif -%} 8 | {%- if backport_branch %} 9 | 18 | {%- endif %}{% endmacro %} 19 | -------------------------------------------------------------------------------- /templates/_issues.tt: -------------------------------------------------------------------------------- 1 | {% import "_issue.tt" as issue %} 2 | 3 | {% macro render(issues, indent="", backport_branch="", with_age=false, empty="No issues at this time.") %} 4 | {%- for issue in issues %} 5 | {{indent}}- {{ backport_branch }}{{issue::render(issue=issue, with_age=with_age, backport_branch=backport_branch)}}{% else %} 6 | {{indent}}- {{empty}}{% endfor -%} 7 | {% endmacro %} 8 | -------------------------------------------------------------------------------- /templates/_issues_fcps.tt: -------------------------------------------------------------------------------- 1 | {% import "_issue.tt" as issue %} 2 | 3 | {% macro render(issues, heading="###", empty="No issues this time.") %} 4 | {%- for issue in issues %} 5 | {{heading}} "{{issue.title}}" {{issue.repo_name}}#{{issue.number}} 6 | 7 | - **Link:** {{issue.html_url}} 8 | - [**Tracking Comment**]({{issue.fcp_details.bot_tracking_comment_html_url}}): {{issue.fcp_details.bot_tracking_comment_content}} 9 | - [**Initiating Comment**]({{issue.fcp_details.initiating_comment_html_url}}): {{issue.fcp_details.initiating_comment_content}} 10 | 11 | {%else%} 12 | 13 | None. 14 | 15 | {%endfor%} 16 | {% endmacro %} 17 | -------------------------------------------------------------------------------- /templates/_issues_heading.tt: -------------------------------------------------------------------------------- 1 | {% import "_issue.tt" as issue %} 2 | 3 | {% macro render(issues, heading="###", empty="No issues this time.", split_url=true) %} 4 | {%- for issue in issues %} 5 | {% if split_url %} 6 | {{heading}} "{{issue.title}}" {{issue.repo_name}}#{{issue.number}} 7 | 8 | **Link:** {{issue.html_url}} 9 | {% else %} 10 | {{heading}} [{{issue.title}}]({{issue.html_url}}) 11 | {% endif %} 12 | {%else%} 13 | 14 | None. 15 | 16 | {%endfor%} 17 | {% endmacro %} 18 | -------------------------------------------------------------------------------- /templates/_issues_rfcbot.tt: -------------------------------------------------------------------------------- 1 | {% import "_issue.tt" as issue %} 2 | 3 | {% macro render(issues, indent="", empty="None.") %} 4 | {%- for issue in issues %} 5 | {%- if issue.fcp_details is object %} 6 | {{indent}}- {{issue.fcp_details.disposition}}: [{{issue.title}} ({{issue.repo_name}}#{{issue.number}})]({{issue.fcp_details.bot_tracking_comment_html_url}}) 7 | {{indent}}{{indent}}-{% for reviewer in issue.fcp_details.pending_reviewers %} @{% if issue.fcp_details.should_mention %}{% else %}_{% endif %}**|{{reviewer.zulip_id}}**{%else%} no pending checkboxes{% endfor %} 8 | {{indent}}{{indent}}-{% if issue.fcp_details.concerns|length > 0 %} concerns:{% endif %}{% for concern in issue.fcp_details.concerns %} [{{concern.name}} (by {{concern.reviewer_login}})]({{concern.concern_url}}){%else%} no pending concerns{% endfor -%} 9 | {% else %} 10 | {{indent}}- {{issue::render(issue=issue)}} 11 | {%- endif -%} 12 | {% else %} 13 | {{indent}}- {{empty}}{%endfor-%} 14 | {% endmacro %} 15 | -------------------------------------------------------------------------------- /templates/_meetings.tt: -------------------------------------------------------------------------------- 1 | {% macro render(meetings, empty="No other meetings scheduled.") %} 2 | {%- for mtg in meetings %} 3 | - [{{ mtg.summary }}]({{ mtg.html_link }}) at {% else %} 4 | - {{empty}}{% endfor -%} 5 | {% endmacro %} 6 | -------------------------------------------------------------------------------- /templates/compiler_backlog_bonanza.tt: -------------------------------------------------------------------------------- 1 | {% import "_issues_heading.tt" as issues_heading %} 2 | {% import "_issues.tt" as issues %} 3 | --- 4 | title: T-compiler backlog bonanza 5 | tags: backlog-bonanza 6 | --- 7 | 8 | # T-compiler backlog bonanza 9 | 10 | ## Tracking issues 11 | 12 | {{-issues_heading::render(issues=tracking_issues)}} 13 | -------------------------------------------------------------------------------- /templates/lang_agenda.tt: -------------------------------------------------------------------------------- 1 | {% import "_issues_heading.tt" as issues_heading %} 2 | {% import "_issues_fcps.tt" as issues_fcps %} 3 | {% import "_issues.tt" as issues %} 4 | --- 5 | title: Triage meeting {{CURRENT_DATE}} 6 | tags: triage-meeting 7 | --- 8 | 9 | # T-lang meeting agenda 10 | 11 | * Meeting date: {{CURRENT_DATE}} 12 | 13 | ## Attendance 14 | 15 | * Team members: 16 | * Others: 17 | 18 | ## Meeting roles 19 | 20 | * Action item scribe: 21 | * Note-taker: 22 | 23 | ## Scheduled meetings 24 | 25 | {{-issues::render(issues=scheduled_meetings, indent="", empty="No pending proposals this time.")}} 26 | 27 | Edit the schedule here: https://github.com/orgs/rust-lang/projects/31/views/7. 28 | 29 | ## Announcements or custom items 30 | 31 | (Meeting attendees, feel free to add items here!) 32 | 33 | ## Action item review 34 | 35 | * [Action items list](https://hackmd.io/gstfhtXYTHa3Jv-P_2RK7A) 36 | 37 | ## Pending lang team project proposals 38 | 39 | {{-issues_heading::render(issues=pending_project_proposals)}} 40 | 41 | ## PRs on the lang-team repo 42 | 43 | {{-issues_heading::render(issues=pending_lang_team_prs)}} 44 | 45 | ## RFCs waiting to be merged 46 | 47 | {{-issues_heading::render(issues=rfcs_waiting_to_be_merged)}} 48 | 49 | ## `S-waiting-on-team` 50 | 51 | {{-issues_heading::render(issues=waiting_on_lang_team)}} 52 | 53 | ## Proposed FCPs 54 | 55 | **Check your boxes!** 56 | 57 | {{-issues_fcps::render(issues=proposed_fcp)}} 58 | 59 | ## Active FCPs 60 | 61 | {{-issues_heading::render(issues=in_fcp)}} 62 | 63 | ## P-critical issues 64 | 65 | {{-issues_heading::render(issues=p_critical)}} 66 | 67 | ## Nominated RFCs, PRs and issues discussed this meeting 68 | 69 | (none yet, move things from the section below as they are discussed) 70 | 71 | ## Nominated RFCs, PRs and issues NOT discussed this meeting 72 | 73 | 74 | 75 | {{-issues_heading::render(issues=nominated)}} 76 | -------------------------------------------------------------------------------- /templates/lang_planning_agenda.tt: -------------------------------------------------------------------------------- 1 | {% import "_issues_heading.tt" as issues_heading %} 2 | {% import "_issues.tt" as issues %} 3 | --- 4 | title: Planning meeting {{CURRENT_DATE}} 5 | tags: planning-meeting 6 | --- 7 | 8 | # T-lang planning meeting agenda 9 | 10 | * Meeting date: {{CURRENT_DATE}} 11 | 12 | ## Attendance 13 | 14 | * Team members: 15 | * Others: 16 | 17 | ## Meeting roles 18 | 19 | * Action item scribe: 20 | * Note-taker: 21 | 22 | ## Proposed meetings 23 | 24 | {{-issues::render(issues=proposed_meetings, indent="", empty="None.")}} 25 | 26 | Please update these in https://github.com/orgs/rust-lang/projects/31/views/7. 27 | 28 | ## Active initiatives 29 | 30 | {{-issues_heading::render(issues=active_initiatives)}} 31 | 32 | ## Pending proposals on the lang-team repo 33 | 34 | {{-issues_heading::render(issues=pending_project_proposals)}} 35 | 36 | ## Pending PRs on the lang-team repo 37 | 38 | {{-issues_heading::render(issues=pending_lang_team_prs)}} 39 | -------------------------------------------------------------------------------- /templates/triage/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Triage dashboard 6 | 18 | 19 | 20 |
21 | 22 |

Triage dashboards

23 | 24 |

Repositories

25 | 26 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /templates/triage/pulls.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 41 | 89 | 90 | 91 | 92 |

Triage dashboard - {{ owner }}/{{ repo }}

93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | {% for pull in pulls %} 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | {% endfor %} 122 | 123 |
#Need triageWait forTitleAuthorAssigneeLabelsUpdated at
{{ pull.number }}{{ pull.days_from_last_updated_at }} {% if pull.days_from_last_updated_at > 1 %}days{% else %}day{% endif %}{% if pull.wait_for_author %}{{ pull.author }}{% elif pull.wait_for_review %}{{ pull.assignee }}{% endif %}{{ pull.title }}{{ pull.author }}{{ pull.assignee }}{{ pull.labels }}{{ pull.updated_at }}
124 |
125 |

From the last updated at

126 |
    127 |
  • 14 days or more
  • 128 |
  • 7 days or more
  • 129 |
  • less than 7days
  • 130 |
131 |
132 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /templates/types_planning_agenda.tt: -------------------------------------------------------------------------------- 1 | {% import "_issues_heading.tt" as issues_heading %} 2 | {% import "_issues.tt" as issues %} 3 | --- 4 | title: {{CURRENT_DATE}} Planning meeting 5 | tags: weekly-meeting, T-types 6 | date: {{CURRENT_DATE}} 7 | --- 8 | 9 | # T-types planning meeting agenda 10 | 11 | ## Updates 12 | 13 | {{-issues_heading::render(issues=roadmap_tracking_issues,split_url=false)}} 14 | 15 | ## Nominated issues 16 | 17 | {{-issues_heading::render(issues=nominated_issues,split_url=false)}} 18 | 19 | ## Types FCPs 20 | 21 | {{-issues_heading::render(issues=types_fcps,split_url=false)}} 22 | 23 | ## Major change proposals 24 | 25 | {{-issues_heading::render(issues=major_changes,split_url=false)}} 26 | 27 | ## Deep dive planning 28 | 29 | {{-issues_heading::render(issues=deep_dive_proposals,split_url=false)}} 30 | -------------------------------------------------------------------------------- /triagebot.toml: -------------------------------------------------------------------------------- 1 | [relabel] 2 | allow-unauthenticated = ["bug", "invalid", "question", "enhancement"] 3 | 4 | [assign] 5 | 6 | [note] 7 | 8 | [concern] 9 | labels = ["has-concerns"] 10 | 11 | [ping.wg-meta] 12 | message = """\ 13 | This is just testing some functionality, please ignore this ping. 14 | """ 15 | label = "help wanted" 16 | --------------------------------------------------------------------------------