فهرست منبع

Add mentions.

Eric Huss 2 سال پیش
والد
کامیت
edcbb6c31a
7فایلهای تغییر یافته به همراه191 افزوده شده و 1 حذف شده
  1. 2 0
      Cargo.lock
  2. 1 1
      Cargo.toml
  3. 15 0
      src/config.rs
  4. 10 0
      src/db.rs
  5. 47 0
      src/db/issue_data.rs
  6. 2 0
      src/handlers.rs
  7. 114 0
      src/handlers/mentions.rs

+ 2 - 0
Cargo.lock

@@ -1326,6 +1326,8 @@ dependencies = [
  "chrono",
  "fallible-iterator",
  "postgres-protocol",
+ "serde",
+ "serde_json",
 ]
 
 [[package]]

+ 1 - 1
Cargo.toml

@@ -29,7 +29,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
 url = "2.1.0"
 once_cell = "1"
 chrono = { version = "0.4", features = ["serde"] }
-tokio-postgres = { version = "0.7.2", features = ["with-chrono-0_4"] }
+tokio-postgres = { version = "0.7.2", features = ["with-chrono-0_4", "with-serde_json-1"] }
 postgres-native-tls = "0.5.0"
 native-tls = "0.2"
 serde_path_to_error = "0.1.2"

+ 15 - 0
src/config.rs

@@ -32,6 +32,7 @@ pub(crate) struct Config {
     pub(crate) review_submitted: Option<ReviewSubmittedConfig>,
     pub(crate) shortcut: Option<ShortcutConfig>,
     pub(crate) note: Option<NoteConfig>,
+    pub(crate) mentions: Option<MentionsConfig>,
 }
 
 #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
@@ -84,6 +85,19 @@ pub(crate) struct NoteConfig {
     _empty: (),
 }
 
+#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
+pub(crate) struct MentionsConfig {
+    #[serde(flatten)]
+    pub(crate) paths: HashMap<String, MentionsPathConfig>,
+}
+
+#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
+pub(crate) struct MentionsPathConfig {
+    pub(crate) message: Option<String>,
+    #[serde(default)]
+    pub(crate) reviewers: Vec<String>,
+}
+
 #[derive(PartialEq, Eq, Debug, serde::Deserialize)]
 #[serde(rename_all = "kebab-case")]
 pub(crate) struct RelabelConfig {
@@ -350,6 +364,7 @@ mod tests {
                 notify_zulip: None,
                 github_releases: None,
                 review_submitted: None,
+                mentions: None,
             }
         );
     }

+ 10 - 0
src/db.rs

@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex};
 use tokio::sync::{OwnedSemaphorePermit, Semaphore};
 use tokio_postgres::Client as DbClient;
 
+pub mod issue_data;
 pub mod notifications;
 pub mod rustc_commits;
 
@@ -206,4 +207,13 @@ CREATE TABLE rustc_commits (
 );
 ",
     "ALTER TABLE rustc_commits ADD COLUMN pr INTEGER;",
+    "
+CREATE TABLE issue_data (
+    repo TEXT,
+    issue_number INTEGER,
+    key TEXT,
+    data JSONB,
+    PRIMARY KEY (repo, issue_number, key)
+);
+",
 ];

+ 47 - 0
src/db/issue_data.rs

@@ -0,0 +1,47 @@
+//! The `issue_data` table provides a way to track extra metadata about an
+//! issue/PR.
+//!
+//! Each issue has a unique "key" where you can store data under. Typically
+//! that key should be the name of the handler. The data can be anything that
+//! can be serialized to JSON.
+
+use crate::github::Issue;
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use tokio_postgres::types::Json;
+use tokio_postgres::Client as DbClient;
+
+pub async fn load<T: for<'a> Deserialize<'a>>(
+    db: &DbClient,
+    issue: &Issue,
+    key: &str,
+) -> Result<Option<T>> {
+    let repo = issue.repository().to_string();
+    let data = db
+        .query_opt(
+            "SELECT data FROM issue_data WHERE \
+            repo = $1 AND issue_number = $2 AND key = $3",
+            &[&repo, &(issue.number as i32), &key],
+        )
+        .await
+        .context("selecting issue data")?
+        .map(|row| row.get::<usize, Json<T>>(0).0);
+    Ok(data)
+}
+
+pub async fn save<T: Serialize + std::fmt::Debug + Sync>(
+    db: &DbClient,
+    issue: &Issue,
+    key: &str,
+    data: &T,
+) -> Result<()> {
+    let repo = issue.repository().to_string();
+    db.execute(
+        "INSERT INTO issue_data (repo, issue_number, key, data) VALUES ($1, $2, $3, $4) \
+         ON CONFLICT (repo, issue_number, key) DO UPDATE SET data=EXCLUDED.data",
+        &[&repo, &(issue.number as i32), &key, &Json(data)],
+    )
+    .await
+    .context("inserting issue data")?;
+    Ok(())
+}

+ 2 - 0
src/handlers.rs

@@ -29,6 +29,7 @@ mod close;
 mod github_releases;
 mod glacier;
 mod major_change;
+mod mentions;
 mod milestone_prs;
 mod nominate;
 mod note;
@@ -153,6 +154,7 @@ macro_rules! issue_handlers {
 issue_handlers! {
     autolabel,
     major_change,
+    mentions,
     notify_zulip,
 }
 

+ 114 - 0
src/handlers/mentions.rs

@@ -0,0 +1,114 @@
+//! Purpose: When opening a PR, or pushing new changes, check for any paths
+//! that are in the `mentions` config, and add a comment that pings the listed
+//! interested people.
+
+use crate::{
+    config::{MentionsConfig, MentionsPathConfig},
+    db::issue_data,
+    github::{files_changed, IssuesAction, IssuesEvent},
+    handlers::Context,
+};
+use anyhow::Context as _;
+use serde::{Deserialize, Serialize};
+use std::fmt::Write;
+use std::path::Path;
+use tracing as log;
+
+const MENTIONS_KEY: &str = "mentions";
+
+pub(super) struct MentionsInput {
+    paths: Vec<String>,
+}
+
+#[derive(Debug, Default, Deserialize, Serialize)]
+struct MentionState {
+    paths: Vec<String>,
+}
+
+pub(super) async fn parse_input(
+    ctx: &Context,
+    event: &IssuesEvent,
+    config: Option<&MentionsConfig>,
+) -> Result<Option<MentionsInput>, String> {
+    let config = match config {
+        Some(config) => config,
+        None => return Ok(None),
+    };
+
+    if !matches!(
+        event.action,
+        IssuesAction::Opened | IssuesAction::Synchronize
+    ) {
+        return Ok(None);
+    }
+
+    if let Some(diff) = event
+        .issue
+        .diff(&ctx.github)
+        .await
+        .map_err(|e| {
+            log::error!("failed to fetch diff: {:?}", e);
+        })
+        .unwrap_or_default()
+    {
+        let files = files_changed(&diff);
+        let file_paths: Vec<_> = files.iter().map(|p| Path::new(p)).collect();
+        let to_mention: Vec<_> = config
+            .paths
+            .iter()
+            // Only mention matching paths.
+            // Don't mention if the author is in the list.
+            .filter(|(path, MentionsPathConfig { reviewers, .. })| {
+                let path = Path::new(path);
+                file_paths.iter().any(|p| p.starts_with(path))
+                    && !reviewers.iter().any(|r| r == &event.issue.user.login)
+            })
+            .map(|(key, _mention)| key.to_string())
+            .collect();
+        if !to_mention.is_empty() {
+            return Ok(Some(MentionsInput { paths: to_mention }));
+        }
+    }
+    Ok(None)
+}
+
+pub(super) async fn handle_input(
+    ctx: &Context,
+    config: &MentionsConfig,
+    event: &IssuesEvent,
+    input: MentionsInput,
+) -> anyhow::Result<()> {
+    let client = ctx.db.get().await;
+    let mut state: MentionState = issue_data::load(&client, &event.issue, MENTIONS_KEY)
+        .await?
+        .unwrap_or_default();
+    // Build the message to post to the issue.
+    let mut result = String::new();
+    for to_mention in &input.paths {
+        if state.paths.iter().any(|p| p == to_mention) {
+            // Avoid duplicate mentions.
+            continue;
+        }
+        let MentionsPathConfig { message, reviewers } = &config.paths[to_mention];
+        if !result.is_empty() {
+            result.push_str("\n\n");
+        }
+        match message {
+            Some(m) => result.push_str(m),
+            None => write!(result, "Some changes occurred in {to_mention}").unwrap(),
+        }
+        if !reviewers.is_empty() {
+            write!(result, "\n\ncc {}", reviewers.join(",")).unwrap();
+        }
+        state.paths.push(to_mention.to_string());
+    }
+    if !result.is_empty() {
+        event
+            .issue
+            .post_comment(&ctx.github, &result)
+            .await
+            .context("failed to post mentions comment")?;
+        issue_data::save(&client, &event.issue, MENTIONS_KEY, &state).await?;
+    }
+    Ok(())
+}