diff --git a/Cargo.lock b/Cargo.lock index ed55ae0c..7680e03b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,6 +102,17 @@ dependencies = [ "syn 1.0.91", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -277,6 +288,21 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "combine" version = "3.8.1" @@ -406,7 +432,7 @@ dependencies = [ "proc-macro2", "quote", "rkyv", - "strsim", + "strsim 0.10.0", "syn 1.0.91", "thiserror", ] @@ -442,7 +468,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn 1.0.91", ] @@ -855,6 +881,15 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.4.1" @@ -2217,7 +2252,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "quote", "syn 1.0.91", @@ -2265,12 +2300,42 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "structopt" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" +dependencies = [ + "clap", + "lazy_static", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" +dependencies = [ + "heck 0.3.3", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.91", +] + [[package]] name = "subtle" version = "2.4.1" @@ -2335,6 +2400,15 @@ dependencies = [ "unic-segment", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.30" @@ -2720,6 +2794,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", + "structopt", "tera", "tokio", "tokio-postgres", @@ -2852,6 +2927,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "unicode-width" version = "0.1.9" @@ -2925,6 +3006,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 7803a91a..fcf96ffa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ ignore = "0.4.18" postgres-types = { version = "0.2.4", features = ["derive"] } cron = { version = "0.12.0" } bytes = "1.1.0" +structopt = "0.3.26" [dependencies.serde] version = "1" diff --git a/src/bin/project_goals.rs b/src/bin/project_goals.rs index e9afa1d3..41eb7bab 100644 --- a/src/bin/project_goals.rs +++ b/src/bin/project_goals.rs @@ -1,24 +1,34 @@ +use structopt::StructOpt; use triagebot::{github::GithubClient, handlers::project_goals}; +/// A basic example +#[derive(StructOpt, Debug)] +struct Opt { + /// If specified, no messages are sent. + #[structopt(long)] + dry_run: bool, + + /// Goals with an updated within this threshold will not be pinged. + days_threshold: i64, + + /// A string like "on Sep-5" when the update blog post will be written. + next_meeting_date: String, +} + #[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { dotenv::dotenv().ok(); tracing_subscriber::fmt::init(); - let mut dry_run = false; - - for arg in std::env::args().skip(1) { - match arg.as_str() { - "--dry-run" => dry_run = true, - _ => { - eprintln!("Usage: project_goals [--dry-run]"); - std::process::exit(1); - } - } - } - + let opt = Opt::from_args(); let gh = GithubClient::new_from_env(); - project_goals::ping_project_goals_owners(&gh, dry_run).await?; + project_goals::ping_project_goals_owners( + &gh, + opt.dry_run, + opt.days_threshold, + &opt.next_meeting_date, + ) + .await?; Ok(()) } diff --git a/src/handlers/project_goals.rs b/src/handlers/project_goals.rs index f4614fd7..55141a35 100644 --- a/src/handlers/project_goals.rs +++ b/src/handlers/project_goals.rs @@ -6,7 +6,7 @@ use crate::jobs::Job; use crate::zulip::to_zulip_id; use anyhow::Context as _; use async_trait::async_trait; -use chrono::Utc; +use chrono::{Datelike, NaiveDate, Utc}; use tracing::{self as log}; use super::Context; @@ -17,7 +17,11 @@ const GOALS_STREAM: u64 = 435869; // #project-goals const C_TRACKING_ISSUE: &str = "C-tracking-issue"; const MESSAGE: &str = r#" -Dear $OWNERS, it's been $DAYS days since the last update to your goal *$GOAL*. Please comment on the github tracking issue goals#$GOALNUM with an update at your earliest convenience. Thanks! <3 +Dear $OWNERS, it's been $DAYS days since the last update to your goal *$GOAL*. + +We will begin drafting the next blog post collecting goal updates $NEXT_UPDATE. + +Please comment on the github tracking issue goals#$GOALNUM before then. Thanks! <3 Here is a suggested template for updates (feel free to drop the items that don't apply): @@ -35,7 +39,7 @@ impl Job for ProjectGoalsUpdateJob { } async fn run(&self, ctx: &super::Context, _metadata: &serde_json::Value) -> anyhow::Result<()> { - ping_project_goals_owners(&ctx.github, false).await + ping_project_goals_owners_automatically(&ctx.github).await } } @@ -51,7 +55,40 @@ pub async fn check_project_goal_acl(_gh: &GithubClient, gh_id: u64) -> anyhow::R Ok(gh_id == GOAL_OWNER_GH_ID) } -pub async fn ping_project_goals_owners(gh: &GithubClient, dry_run: bool) -> anyhow::Result<()> { +async fn ping_project_goals_owners_automatically(gh: &GithubClient) -> anyhow::Result<()> { + // Predicted schedule is to author a blog post on the 3rd week of the month. + // We start pinging when the month starts until we see an update in this month + // or the last 7 days of previous month. + // + // Therefore, we compute: + // * Days since start of this month -- threshold will be this number of days + 7. + // * Date of the 3rd Monday in the month -- this will be the next update (e.g., `on Sep-5`). + let now = Utc::now(); + + // We want to ping people unless they've written an update since the last week of the previous month. + let days_threshold = now.day() + 7; + + // Format the 3rd Monday of the month, e.g. "on Sep-5", for inclusion. + let third_monday = + NaiveDate::from_weekday_of_month_opt(now.year(), now.month(), chrono::Weekday::Mon, 3) + .unwrap() + .format("on %b-%d") + .to_string(); + + ping_project_goals_owners(gh, false, days_threshold as i64, &third_monday).await +} + +/// Sends a ping message to all project goal owners if +/// they have not posted an update in the last `days_threshold` days. +/// +/// `next_update` is a human readable description of when the next update +/// will be drafted (e.g., `"on Sep 5"`). +pub async fn ping_project_goals_owners( + gh: &GithubClient, + dry_run: bool, + days_threshold: i64, + next_update: &str, +) -> anyhow::Result<()> { let goals_repo = gh.repository(&RUST_PROJECT_GOALS_REPO).await?; let tracking_issues_query = github::Query { @@ -78,7 +115,7 @@ pub async fn ping_project_goals_owners(gh: &GithubClient, dry_run: bool) -> anyh days_since_last_comment, comments, ); - if days_since_last_comment < 21 && comments > 1 { + if days_since_last_comment < days_threshold && comments > 1 { continue; } @@ -99,7 +136,8 @@ pub async fn ping_project_goals_owners(gh: &GithubClient, dry_run: bool) -> anyh }, ) .replace("$GOALNUM", &issue.number.to_string()) - .replace("$GOAL", &issue.title); + .replace("$GOAL", &issue.title) + .replace("$NEXT_UPDATE", next_update); let zulip_req = crate::zulip::MessageApiRequest { recipient: crate::zulip::Recipient::Stream { @@ -115,7 +153,9 @@ pub async fn ping_project_goals_owners(gh: &GithubClient, dry_run: bool) -> anyh if !dry_run { zulip_req.send(&gh.raw()).await?; } else { - log::debug!("skipping zulip send because dry run"); + eprintln!(); + eprintln!("-- Dry Run ------------------------------------"); + eprintln!("Would send to {zulip_topic_name}: {}", zulip_req.content); } } diff --git a/src/zulip.rs b/src/zulip.rs index cfed9a73..8e77ab82 100644 --- a/src/zulip.rs +++ b/src/zulip.rs @@ -8,6 +8,7 @@ use crate::handlers::Context; use anyhow::{format_err, Context as _}; use std::env; use std::fmt::Write as _; +use std::str::FromStr; use tracing as log; #[derive(Debug, serde::Deserialize)] @@ -190,8 +191,29 @@ fn handle_command<'a>( .map_err(|e| format_err!("Failed to await at this time: {e:?}")) } Some("ping-goals") => { + let usage_err = |description: &str| Err(format_err!( + "Error: {description}\n\ + \n\ + Usage: triagebot ping-goals D N, where:\n\ + \n\ + * D is the number of days before an update is considered stale\n\ + * N is the date of next update, like \"Sep-5\"\n", + )); + + let Some(threshold) = words.next() else { + return usage_err("expected number of days"); + }; + let threshold = match i64::from_str(threshold) { + Ok(v) => v, + Err(e) => return usage_err(&format!("ill-formed number of days, {e}")), + }; + + let Some(next_update) = words.next() else { + return usage_err("expected date of next update"); + }; + if project_goals::check_project_goal_acl(&ctx.github, gh_id).await? { - ping_project_goals_owners(&ctx.github, false) + ping_project_goals_owners(&ctx.github, false, threshold, &format!("on {next_update}")) .await .map_err(|e| format_err!("Failed to await at this time: {e:?}"))?; return Ok(None);