diff --git a/src/handlers.rs b/src/handlers.rs index 34beb9b1..1283ba38 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -29,7 +29,7 @@ impl fmt::Display for HandlerError { mod assign; mod autolabel; -mod backport; +pub mod backport; mod bot_pull_requests; mod check_commits; mod close; diff --git a/src/handlers/backport.rs b/src/handlers/backport.rs index 51a2bd4d..ae04e40f 100644 --- a/src/handlers/backport.rs +++ b/src/handlers/backport.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use crate::config::BackportConfig; use crate::github::{IssuesAction, IssuesEvent, Label}; use crate::handlers::Context; +use crate::utils::contains_any; use anyhow::Context as AnyhowContext; use futures::future::join_all; use regex::Regex; @@ -204,10 +205,6 @@ pub(super) async fn handle_input( Ok(()) } -fn contains_any(haystack: &[&str], needles: &[&str]) -> bool { - needles.iter().any(|needle| haystack.contains(needle)) -} - #[cfg(test)] mod tests { use crate::handlers::backport::CLOSES_ISSUE_REGEXP; diff --git a/src/utils.rs b/src/utils.rs index fb0c5430..deddcbea 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -36,3 +36,7 @@ where AppError(err.into()) } } + +pub fn contains_any(haystack: &[&str], needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} diff --git a/src/zulip.rs b/src/zulip.rs index 23a47721..8c1a59e9 100644 --- a/src/zulip.rs +++ b/src/zulip.rs @@ -13,7 +13,7 @@ use crate::handlers::docs_update::docs_update; use crate::handlers::pr_tracking::get_assigned_prs; use crate::handlers::project_goals::{self, ping_project_goals_owners}; use crate::interactions::ErrorComment; -use crate::utils::pluralize; +use crate::utils::{contains_any, pluralize}; use crate::zulip::api::{MessageApiResponse, Recipient}; use crate::zulip::client::ZulipClient; use crate::zulip::commands::{ @@ -24,12 +24,29 @@ use axum::Json; use axum::extract::State; use axum::extract::rejection::JsonRejection; use axum::response::IntoResponse; +use commands::BackportArgs; +use octocrab::Octocrab; use rust_team_data::v1::{TeamKind, TeamMember}; use std::cmp::Reverse; use std::fmt::Write as _; use std::sync::Arc; use subtle::ConstantTimeEq; -use tracing as log; +use tracing::log; + +const BACKPORT_APPROVED: &str = " +{args.channel} backport {args.verb} as per compiler team [on Zulip]({zulip_link}). A backport PR will be authored by the release team at the end of the current development cycle. Backport labels handled by them. + +@rustbot label +{args.channel}-accepted +"; +const BACKPORT_DECLINED: &str = " +{args.channel} backport {args.verb} as per compiler team [on Zulip]({zulip_link}). + +@rustbot label -{args.channel}-nominated +"; + +const BACKPORT_CHANNELS: [&str; 2] = ["beta", "stable"]; +const BACKPORT_VERBS_APPROVE: [&str; 4] = ["accept", "accepted", "approve", "approved"]; +const BACKPORT_VERBS_DECLINE: [&str; 2] = ["decline", "declined"]; #[derive(Debug, serde::Deserialize)] pub struct Request { @@ -302,10 +319,77 @@ async fn handle_command<'a>( .map_err(|e| format_err!("Failed to await at this time: {e:?}")), StreamCommand::PingGoals(args) => ping_goals_cmd(ctx, gh_id, message_data, &args).await, StreamCommand::DocsUpdate => trigger_docs_update(message_data, &ctx.zulip), + StreamCommand::Backport(args) => { + accept_decline_backport(message_data, &ctx.octocrab, &ctx.zulip, &args).await + } } } } +// TODO: shorter variant of this command (f.e. `backport accept` or even `accept`) that infers everything from the Message payload +async fn accept_decline_backport( + message_data: &Message, + octo_client: &Octocrab, + zulip_client: &ZulipClient, + args_data: &BackportArgs, +) -> anyhow::Result> { + let message = message_data.clone(); + let args = args_data.clone(); + let stream_id = message.stream_id.unwrap(); + let subject = message.subject.unwrap(); + let verb = args.verb.to_lowercase(); + let octo_client = octo_client.clone(); + + // Repository owner and name are hardcoded + // This command is only used in this repository + let repo_owner = "rust-lang"; + let repo_name = "rust"; + + // validate command parameters + if !contains_any(&[args.channel.to_lowercase().as_str()], &BACKPORT_CHANNELS) { + return Err(anyhow::anyhow!( + "Parser error: unknown channel (allowed: {BACKPORT_CHANNELS:?})." + )); + } + + let message_body = if contains_any(&[verb.as_str()], &BACKPORT_VERBS_APPROVE) { + BACKPORT_APPROVED + } else if contains_any(&[verb.as_str()], &BACKPORT_VERBS_DECLINE) { + BACKPORT_DECLINED + } else { + return Err(anyhow::anyhow!( + "Parser error: unknown verb (allowed: {BACKPORT_VERBS_APPROVE:?} or {BACKPORT_VERBS_DECLINE:?})" + )); + }; + + // TODO: factor out the Zulip "URL encoder" to make it practical to use + let zulip_send_req = crate::zulip::MessageApiRequest { + recipient: Recipient::Stream { + id: stream_id, + topic: &subject, + }, + content: "", + }; + let zulip_link = zulip_send_req.url(zulip_client); + + let message_body = message_body + .replace("{args.channel}", args.channel.as_str()) + .replace("{args.verb}", verb.as_str()) + .replace("{zulip_link}", zulip_link.as_str()); + + tokio::spawn(async move { + let res = octo_client + .issues(repo_owner, repo_name) + .create_comment(args.pr_num, &message_body) + .await + .context("unable to post comment on #{args.pr_num}"); + if res.is_err() { + tracing::error!("failed to post comment: {0:?}", res.err()); + } + }); + Ok(Some("".to_string())) +} + async fn ping_goals_cmd( ctx: Arc, gh_id: u64, diff --git a/src/zulip/commands.rs b/src/zulip/commands.rs index 46e54306..bc8e92d4 100644 --- a/src/zulip/commands.rs +++ b/src/zulip/commands.rs @@ -1,5 +1,6 @@ use crate::db::notifications::Identifier; use crate::db::review_prefs::RotationMode; +use crate::github::PullRequestNumber; use clap::{ColorChoice, Parser}; use std::num::NonZeroU32; use std::str::FromStr; @@ -161,8 +162,10 @@ pub enum StreamCommand { Read, /// Ping project goal owners. PingGoals(PingGoalsArgs), - /// Update docs + /// Update docs. DocsUpdate, + /// Accept or decline a backport. + Backport(BackportArgs), } #[derive(clap::Parser, Debug, PartialEq, Clone)] @@ -173,6 +176,16 @@ pub struct PingGoalsArgs { pub next_update: String, } +#[derive(clap::Parser, Debug, PartialEq, Clone)] +pub struct BackportArgs { + /// Release channel this backport is pointing to. Allowed: "beta" or "stable". + pub channel: String, + /// Accept or decline this backport? Allowed: "accept", "accepted", "approve", "approved", "decline", "declined". + pub verb: String, + /// PR to be backported + pub pr_num: PullRequestNumber, +} + /// Helper function to parse CLI arguments without any colored help or error output. pub fn parse_cli<'a, T: Parser, I: Iterator>(input: I) -> anyhow::Result { fn allow_title_case(sub: clap::Command) -> clap::Command { @@ -292,6 +305,26 @@ mod tests { assert_eq!(parse_stream(&["await"]), StreamCommand::EndTopic); } + #[test] + fn backports_command() { + assert_eq!( + parse_stream(&["backport", "beta", "accept", "123456"]), + StreamCommand::Backport(BackportArgs { + channel: "beta".to_string(), + verb: "accept".to_string(), + pr_num: 123456 + }) + ); + assert_eq!( + parse_stream(&["backport", "stable", "decline", "123456"]), + StreamCommand::Backport(BackportArgs { + channel: "stable".to_string(), + verb: "decline".to_string(), + pr_num: 123456 + }) + ); + } + fn parse_chat(input: &[&str]) -> ChatCommand { parse_cli::(input.into_iter().copied()).unwrap() }