Преглед изворни кода

Merge pull request #1625 from ehuss/mentions

Add mentions.
Mark Rousskov пре 2 година
родитељ
комит
0956855229
7 измењених фајлова са 222 додато и 1 уклоњено
  1. 2 0
      Cargo.lock
  2. 1 1
      Cargo.toml
  3. 15 0
      src/config.rs
  4. 10 0
      src/db.rs
  5. 79 0
      src/db/issue_data.rs
  6. 2 0
      src/handlers.rs
  7. 113 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)
+);
+",
 ];

+ 79 - 0
src/db/issue_data.rs

@@ -0,0 +1,79 @@
+//! 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.
+//!
+//! Note that this uses crude locking, so try to keep the duration between
+//! loading and saving to a minimum.
+
+use crate::github::Issue;
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use tokio_postgres::types::Json;
+use tokio_postgres::{Client as DbClient, Transaction};
+
+pub struct IssueData<'db, T>
+where
+    T: for<'a> Deserialize<'a> + Serialize + Default + std::fmt::Debug + Sync,
+{
+    transaction: Transaction<'db>,
+    repo: String,
+    issue_number: i32,
+    key: String,
+    pub data: T,
+}
+
+impl<'db, T> IssueData<'db, T>
+where
+    T: for<'a> Deserialize<'a> + Serialize + Default + std::fmt::Debug + Sync,
+{
+    pub async fn load(
+        db: &'db mut DbClient,
+        issue: &Issue,
+        key: &str,
+    ) -> Result<IssueData<'db, T>> {
+        let repo = issue.repository().to_string();
+        let issue_number = issue.number as i32;
+        let transaction = db.transaction().await?;
+        transaction
+            .execute("LOCK TABLE issue_data", &[])
+            .await
+            .context("locking issue data")?;
+        let data = transaction
+            .query_opt(
+                "SELECT data FROM issue_data WHERE \
+                 repo = $1 AND issue_number = $2 AND key = $3",
+                &[&repo, &issue_number, &key],
+            )
+            .await
+            .context("selecting issue data")?
+            .map(|row| row.get::<usize, Json<T>>(0).0)
+            .unwrap_or_default();
+        Ok(IssueData {
+            transaction,
+            repo,
+            issue_number,
+            key: key.to_string(),
+            data,
+        })
+    }
+
+    pub async fn save(self) -> Result<()> {
+        self.transaction
+            .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",
+                &[&self.repo, &self.issue_number, &self.key, &Json(&self.data)],
+            )
+            .await
+            .context("inserting issue data")?;
+        self.transaction
+            .commit()
+            .await
+            .context("committing 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,
 }
 

+ 113 - 0
src/handlers/mentions.rs

@@ -0,0 +1,113 @@
+//! 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::IssueData,
+    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 mut client = ctx.db.get().await;
+    let mut state: IssueData<'_, MentionState> =
+        IssueData::load(&mut client, &event.issue, MENTIONS_KEY).await?;
+    // Build the message to post to the issue.
+    let mut result = String::new();
+    for to_mention in &input.paths {
+        if state.data.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.data.paths.push(to_mention.to_string());
+    }
+    if !result.is_empty() {
+        event
+            .issue
+            .post_comment(&ctx.github, &result)
+            .await
+            .context("failed to post mentions comment")?;
+        state.save().await?;
+    }
+    Ok(())
+}