Skip to content

Commit

Permalink
Merge pull request #1828 from nikomatsakis/ping-goal-owners
Browse files Browse the repository at this point in the history
ping goal owners
  • Loading branch information
jackh726 authored Jul 28, 2024
2 parents 989e03c + 6fcccda commit 5a4b41c
Show file tree
Hide file tree
Showing 5 changed files with 316 additions and 1 deletion.
24 changes: 24 additions & 0 deletions src/bin/project_goals.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use triagebot::{github::GithubClient, handlers::project_goals};

#[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 gh = GithubClient::new_from_env();
project_goals::ping_project_goals_owners(&gh, dry_run).await?;

Ok(())
}
6 changes: 5 additions & 1 deletion src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ pub struct Issue {
pub number: u64,
#[serde(deserialize_with = "opt_string")]
pub body: String,
created_at: chrono::DateTime<Utc>,
pub created_at: chrono::DateTime<Utc>,
pub updated_at: chrono::DateTime<Utc>,
/// The SHA for a merge commit.
///
Expand Down Expand Up @@ -304,6 +304,10 @@ pub struct Issue {
pub merged: bool,
#[serde(default)]
pub draft: bool,

/// Number of comments
pub comments: Option<i32>,

/// The API URL for discussion comments.
///
/// Example: `https://api.github.com/repos/octocat/Hello-World/issues/1347/comments`
Expand Down
9 changes: 9 additions & 0 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mod notify_zulip;
mod ping;
pub mod pr_tracking;
mod prioritize;
pub mod project_goals;
pub mod pull_requests_assignment_update;
mod relabel;
mod review_requested;
Expand All @@ -66,6 +67,14 @@ pub async fn handle(ctx: &Context, event: &Event) -> Vec<HandlerError> {
handle_command(ctx, event, &config, body, &mut errors).await;
}

if let Err(e) = project_goals::handle(ctx, event).await {
log::error!(
"failed to process event {:?} with `project_goals` handler: {:?}",
event,
e
);
}

if let Err(e) = notification::handle(ctx, event).await {
log::error!(
"failed to process event {:?} with notification handler: {:?}",
Expand Down
265 changes: 265 additions & 0 deletions src/handlers/project_goals.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
use crate::github::{
self, GithubClient, IssueCommentAction, IssueCommentEvent, IssuesAction, IssuesEvent, User,
};
use crate::github::{Event, Issue};
use crate::jobs::Job;
use crate::zulip::to_zulip_id;
use anyhow::Context as _;
use async_trait::async_trait;
use chrono::Utc;
use tracing::{self as log};

use super::Context;

const MAX_ZULIP_TOPIC: usize = 60;
const RUST_PROJECT_GOALS_REPO: &'static str = "rust-lang/rust-project-goals";
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
Here is a suggested template for updates (feel free to drop the items that don't apply):
* **Key developments:** *What has happened since the last time. It's perfectly ok to list "nothing" if that's the truth, we know people get busy.*
* **Blockers:** *List any Rust teams you are waiting on and what you are waiting for.*
* **Help wanted:** *Are there places where you are looking for contribution or feedback from the broader community?*
"#;

pub struct ProjectGoalsUpdateJob;

#[async_trait]
impl Job for ProjectGoalsUpdateJob {
fn name(&self) -> &'static str {
"project_goals_update_job"
}

async fn run(&self, ctx: &super::Context, _metadata: &serde_json::Value) -> anyhow::Result<()> {
ping_project_goals_owners(&ctx.github, false).await
}
}

/// Returns true if the user with the given github id is allowed to ping all group people
/// and do other "project group adminstrative" tasks.
pub async fn check_project_goal_acl(_gh: &GithubClient, gh_id: u64) -> anyhow::Result<bool> {
/// Github ID of the user allowed to ping all group people.
///
/// FIXME: We should create a team for the person/people managing the goals program
/// and check that the zulip person is on it, but I'm too
const GOAL_OWNER_GH_ID: u64 = 155238; // nikomatsakis

Ok(gh_id == GOAL_OWNER_GH_ID)
}

pub async fn ping_project_goals_owners(gh: &GithubClient, dry_run: bool) -> anyhow::Result<()> {
let goals_repo = gh.repository(&RUST_PROJECT_GOALS_REPO).await?;

let tracking_issues_query = github::Query {
filters: vec![("state", "open"), ("is", "issue")],
include_labels: vec!["C-tracking-issue"],
exclude_labels: vec![],
};
let issues = goals_repo
.get_issues(&gh, &tracking_issues_query)
.await
.with_context(|| "Unable to get issues.")?;

for issue in issues {
let comments = issue.comments.unwrap_or(0);

// Find the time of the last comment posted.
let days_since_last_comment = (Utc::now() - issue.updated_at).num_days();

// Start pinging 3 weeks after the last update.
// As a special case, if the last update was within a day of creation, that means no initial update, so ping anyway.
log::debug!(
"issue #{}: days_since_last_comment = {} days, number of comments = {}",
issue.number,
days_since_last_comment,
comments,
);
if days_since_last_comment < 21 && comments > 1 {
continue;
}

let zulip_topic_name = zulip_topic_name(&issue);
let Some(zulip_owners) = zulip_owners(gh, &issue).await? else {
log::debug!("no owners assigned");
continue;
};

let message = MESSAGE
.replace("$OWNERS", &zulip_owners)
.replace(
"$DAYS",
&if comments <= 1 {
"∞".to_string()
} else {
days_since_last_comment.to_string()
},
)
.replace("$GOALNUM", &issue.number.to_string())
.replace("$GOAL", &issue.title);

let zulip_req = crate::zulip::MessageApiRequest {
recipient: crate::zulip::Recipient::Stream {
id: GOALS_STREAM,
topic: &zulip_topic_name,
},
content: &message,
};

log::debug!("zulip_topic_name = {zulip_topic_name:#?}");
log::debug!("message = {message:#?}");

if !dry_run {
zulip_req.send(&gh.raw()).await?;
} else {
log::debug!("skipping zulip send because dry run");
}
}

Ok(())
}

fn zulip_topic_name(issue: &Issue) -> String {
let goal_number = format!("(goals#{})", issue.number);
let mut title = String::new();
for word in issue.title.split_whitespace() {
if title.len() + word.len() + 1 + goal_number.len() >= MAX_ZULIP_TOPIC {
break;
}
title.push_str(word);
title.push(' ');
}
title.push_str(&goal_number);
assert!(title.len() < MAX_ZULIP_TOPIC);
title
}

async fn zulip_owners(gh: &GithubClient, issue: &Issue) -> anyhow::Result<Option<String>> {
use std::fmt::Write;

Ok(match &issue.assignees[..] {
[] => None,
[string0] => Some(owner_string(gh, string0).await?),
[string0, string1] => Some(format!(
"{} and {}",
owner_string(gh, string0).await?,
owner_string(gh, string1).await?
)),
[string0 @ .., string1] => {
let mut out = String::new();
for s in string0 {
write!(out, "{}, ", owner_string(gh, s).await?).unwrap();
}
write!(out, "{}, ", owner_string(gh, string1).await?).unwrap();
Some(out)
}
})
}

async fn owner_string(gh: &GithubClient, assignee: &User) -> anyhow::Result<String> {
if let Some(zulip_id) = to_zulip_id(gh, assignee.id).await? {
Ok(format!("@**|{zulip_id}**"))
} else {
// No zulip-id? Fallback to github user name.
Ok(format!(
"@{login} ([register your zulip-id here to get a real ping!](https://github.com/rust-lang/team/tree/master/people/{login}.toml))",
login = assignee.login,
))
}
}

pub async fn handle(ctx: &Context, event: &Event) -> anyhow::Result<()> {
let gh = &ctx.github;

if event.repo().full_name != RUST_PROJECT_GOALS_REPO {
return Ok(());
}

match event {
// When a new issue is opened that is tagged as a tracking issue,
// automatically create a Zulip topic for it and post a comment to the issue.
Event::Issue(IssuesEvent {
action: IssuesAction::Opened,
issue,
..
}) => {
if issue.labels.iter().any(|l| l.name == C_TRACKING_ISSUE) {
return Ok(());
}
let zulip_topic_name = zulip_topic_name(issue);
let zulip_owners = match zulip_owners(gh, issue).await? {
Some(names) => names,
None => format!("(no owners assigned)"),
};
let title = &issue.title;
let goalnum = issue.number;
let zulip_req = crate::zulip::MessageApiRequest {
recipient: crate::zulip::Recipient::Stream {
id: GOALS_STREAM,
topic: &zulip_topic_name,
},
content: &format!(
r#"New tracking issue goals#{goalnum}.\n* Goal title: {title}\n* Goal owners: {zulip_owners}"#
),
};
zulip_req.send(&gh.raw()).await?;
Ok(())
}

// When a new comment is posted on a tracking issue, post it to Zulip.
Event::IssueComment(IssueCommentEvent {
action,
issue,
comment,
..
}) => {
let number = issue.number;
let action_str = match action {
IssueCommentAction::Created => "posted",
IssueCommentAction::Edited => "edited",
IssueCommentAction::Deleted => "deleted",
};
let zulip_topic_name = zulip_topic_name(issue);
let url = &comment.html_url;
let text = &comment.body;
let zulip_author = owner_string(gh, &comment.user).await?;

let mut ticks = "````".to_string();
while text.contains(&ticks) {
ticks.push('`');
}

match action {
IssueCommentAction::Created | IssueCommentAction::Edited => {
let zulip_req = crate::zulip::MessageApiRequest {
recipient: crate::zulip::Recipient::Stream {
id: GOALS_STREAM,
topic: &zulip_topic_name,
},
content: &format!(
r#"[Comment {action_str}]({url}) on goals#{number} by {zulip_author}:\n\
{ticks}quote\n\
{text}\n\
{ticks}"#
),
};
zulip_req.send(&gh.raw()).await?;
}

IssueCommentAction::Deleted => {
// Do we really care?
}
}

Ok(())
}

_ => {
/* No action for other cases */
Ok(())
}
}
}
13 changes: 13 additions & 0 deletions src/zulip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::db::notifications::add_metadata;
use crate::db::notifications::{self, delete_ping, move_indices, record_ping, Identifier};
use crate::github::{get_id_for_username, GithubClient};
use crate::handlers::docs_update::docs_update;
use crate::handlers::project_goals::{self, ping_project_goals_owners};
use crate::handlers::pull_requests_assignment_update::get_review_prefs;
use crate::handlers::Context;
use anyhow::{format_err, Context as _};
Expand Down Expand Up @@ -188,6 +189,18 @@ fn handle_command<'a>(
.await
.map_err(|e| format_err!("Failed to await at this time: {e:?}"))
}
Some("ping-goals") => {
if project_goals::check_project_goal_acl(&ctx.github, gh_id).await? {
ping_project_goals_owners(&ctx.github, false)
.await
.map_err(|e| format_err!("Failed to await at this time: {e:?}"))?;
return Ok(None);
} else {
return Err(format_err!(
"That command is only permitted for those running the project-goal program.",
));
}
}
Some("docs-update") => return trigger_docs_update(message_data),
_ => {}
}
Expand Down

0 comments on commit 5a4b41c

Please sign in to comment.