Skip to content

Commit 0956855

Browse files
Merge pull request #1625 from ehuss/mentions
Add mentions.
2 parents 935be4c + 5919d5d commit 0956855

File tree

7 files changed

+222
-1
lines changed

7 files changed

+222
-1
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
2929
url = "2.1.0"
3030
once_cell = "1"
3131
chrono = { version = "0.4", features = ["serde"] }
32-
tokio-postgres = { version = "0.7.2", features = ["with-chrono-0_4"] }
32+
tokio-postgres = { version = "0.7.2", features = ["with-chrono-0_4", "with-serde_json-1"] }
3333
postgres-native-tls = "0.5.0"
3434
native-tls = "0.2"
3535
serde_path_to_error = "0.1.2"

src/config.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub(crate) struct Config {
3232
pub(crate) review_submitted: Option<ReviewSubmittedConfig>,
3333
pub(crate) shortcut: Option<ShortcutConfig>,
3434
pub(crate) note: Option<NoteConfig>,
35+
pub(crate) mentions: Option<MentionsConfig>,
3536
}
3637

3738
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
@@ -84,6 +85,19 @@ pub(crate) struct NoteConfig {
8485
_empty: (),
8586
}
8687

88+
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
89+
pub(crate) struct MentionsConfig {
90+
#[serde(flatten)]
91+
pub(crate) paths: HashMap<String, MentionsPathConfig>,
92+
}
93+
94+
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
95+
pub(crate) struct MentionsPathConfig {
96+
pub(crate) message: Option<String>,
97+
#[serde(default)]
98+
pub(crate) reviewers: Vec<String>,
99+
}
100+
87101
#[derive(PartialEq, Eq, Debug, serde::Deserialize)]
88102
#[serde(rename_all = "kebab-case")]
89103
pub(crate) struct RelabelConfig {
@@ -350,6 +364,7 @@ mod tests {
350364
notify_zulip: None,
351365
github_releases: None,
352366
review_submitted: None,
367+
mentions: None,
353368
}
354369
);
355370
}

src/db.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex};
55
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
66
use tokio_postgres::Client as DbClient;
77

8+
pub mod issue_data;
89
pub mod notifications;
910
pub mod rustc_commits;
1011

@@ -206,4 +207,13 @@ CREATE TABLE rustc_commits (
206207
);
207208
",
208209
"ALTER TABLE rustc_commits ADD COLUMN pr INTEGER;",
210+
"
211+
CREATE TABLE issue_data (
212+
repo TEXT,
213+
issue_number INTEGER,
214+
key TEXT,
215+
data JSONB,
216+
PRIMARY KEY (repo, issue_number, key)
217+
);
218+
",
209219
];

src/db/issue_data.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//! The `issue_data` table provides a way to track extra metadata about an
2+
//! issue/PR.
3+
//!
4+
//! Each issue has a unique "key" where you can store data under. Typically
5+
//! that key should be the name of the handler. The data can be anything that
6+
//! can be serialized to JSON.
7+
//!
8+
//! Note that this uses crude locking, so try to keep the duration between
9+
//! loading and saving to a minimum.
10+
11+
use crate::github::Issue;
12+
use anyhow::{Context, Result};
13+
use serde::{Deserialize, Serialize};
14+
use tokio_postgres::types::Json;
15+
use tokio_postgres::{Client as DbClient, Transaction};
16+
17+
pub struct IssueData<'db, T>
18+
where
19+
T: for<'a> Deserialize<'a> + Serialize + Default + std::fmt::Debug + Sync,
20+
{
21+
transaction: Transaction<'db>,
22+
repo: String,
23+
issue_number: i32,
24+
key: String,
25+
pub data: T,
26+
}
27+
28+
impl<'db, T> IssueData<'db, T>
29+
where
30+
T: for<'a> Deserialize<'a> + Serialize + Default + std::fmt::Debug + Sync,
31+
{
32+
pub async fn load(
33+
db: &'db mut DbClient,
34+
issue: &Issue,
35+
key: &str,
36+
) -> Result<IssueData<'db, T>> {
37+
let repo = issue.repository().to_string();
38+
let issue_number = issue.number as i32;
39+
let transaction = db.transaction().await?;
40+
transaction
41+
.execute("LOCK TABLE issue_data", &[])
42+
.await
43+
.context("locking issue data")?;
44+
let data = transaction
45+
.query_opt(
46+
"SELECT data FROM issue_data WHERE \
47+
repo = $1 AND issue_number = $2 AND key = $3",
48+
&[&repo, &issue_number, &key],
49+
)
50+
.await
51+
.context("selecting issue data")?
52+
.map(|row| row.get::<usize, Json<T>>(0).0)
53+
.unwrap_or_default();
54+
Ok(IssueData {
55+
transaction,
56+
repo,
57+
issue_number,
58+
key: key.to_string(),
59+
data,
60+
})
61+
}
62+
63+
pub async fn save(self) -> Result<()> {
64+
self.transaction
65+
.execute(
66+
"INSERT INTO issue_data (repo, issue_number, key, data) \
67+
VALUES ($1, $2, $3, $4) \
68+
ON CONFLICT (repo, issue_number, key) DO UPDATE SET data=EXCLUDED.data",
69+
&[&self.repo, &self.issue_number, &self.key, &Json(&self.data)],
70+
)
71+
.await
72+
.context("inserting issue data")?;
73+
self.transaction
74+
.commit()
75+
.await
76+
.context("committing issue data")?;
77+
Ok(())
78+
}
79+
}

src/handlers.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ mod close;
2929
mod github_releases;
3030
mod glacier;
3131
mod major_change;
32+
mod mentions;
3233
mod milestone_prs;
3334
mod nominate;
3435
mod note;
@@ -153,6 +154,7 @@ macro_rules! issue_handlers {
153154
issue_handlers! {
154155
autolabel,
155156
major_change,
157+
mentions,
156158
notify_zulip,
157159
}
158160

src/handlers/mentions.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//! Purpose: When opening a PR, or pushing new changes, check for any paths
2+
//! that are in the `mentions` config, and add a comment that pings the listed
3+
//! interested people.
4+
5+
use crate::{
6+
config::{MentionsConfig, MentionsPathConfig},
7+
db::issue_data::IssueData,
8+
github::{files_changed, IssuesAction, IssuesEvent},
9+
handlers::Context,
10+
};
11+
use anyhow::Context as _;
12+
use serde::{Deserialize, Serialize};
13+
use std::fmt::Write;
14+
use std::path::Path;
15+
use tracing as log;
16+
17+
const MENTIONS_KEY: &str = "mentions";
18+
19+
pub(super) struct MentionsInput {
20+
paths: Vec<String>,
21+
}
22+
23+
#[derive(Debug, Default, Deserialize, Serialize)]
24+
struct MentionState {
25+
paths: Vec<String>,
26+
}
27+
28+
pub(super) async fn parse_input(
29+
ctx: &Context,
30+
event: &IssuesEvent,
31+
config: Option<&MentionsConfig>,
32+
) -> Result<Option<MentionsInput>, String> {
33+
let config = match config {
34+
Some(config) => config,
35+
None => return Ok(None),
36+
};
37+
38+
if !matches!(
39+
event.action,
40+
IssuesAction::Opened | IssuesAction::Synchronize
41+
) {
42+
return Ok(None);
43+
}
44+
45+
if let Some(diff) = event
46+
.issue
47+
.diff(&ctx.github)
48+
.await
49+
.map_err(|e| {
50+
log::error!("failed to fetch diff: {:?}", e);
51+
})
52+
.unwrap_or_default()
53+
{
54+
let files = files_changed(&diff);
55+
let file_paths: Vec<_> = files.iter().map(|p| Path::new(p)).collect();
56+
let to_mention: Vec<_> = config
57+
.paths
58+
.iter()
59+
// Only mention matching paths.
60+
// Don't mention if the author is in the list.
61+
.filter(|(path, MentionsPathConfig { reviewers, .. })| {
62+
let path = Path::new(path);
63+
file_paths.iter().any(|p| p.starts_with(path))
64+
&& !reviewers.iter().any(|r| r == &event.issue.user.login)
65+
})
66+
.map(|(key, _mention)| key.to_string())
67+
.collect();
68+
if !to_mention.is_empty() {
69+
return Ok(Some(MentionsInput { paths: to_mention }));
70+
}
71+
}
72+
Ok(None)
73+
}
74+
75+
pub(super) async fn handle_input(
76+
ctx: &Context,
77+
config: &MentionsConfig,
78+
event: &IssuesEvent,
79+
input: MentionsInput,
80+
) -> anyhow::Result<()> {
81+
let mut client = ctx.db.get().await;
82+
let mut state: IssueData<'_, MentionState> =
83+
IssueData::load(&mut client, &event.issue, MENTIONS_KEY).await?;
84+
// Build the message to post to the issue.
85+
let mut result = String::new();
86+
for to_mention in &input.paths {
87+
if state.data.paths.iter().any(|p| p == to_mention) {
88+
// Avoid duplicate mentions.
89+
continue;
90+
}
91+
let MentionsPathConfig { message, reviewers } = &config.paths[to_mention];
92+
if !result.is_empty() {
93+
result.push_str("\n\n");
94+
}
95+
match message {
96+
Some(m) => result.push_str(m),
97+
None => write!(result, "Some changes occurred in {to_mention}").unwrap(),
98+
}
99+
if !reviewers.is_empty() {
100+
write!(result, "\n\ncc {}", reviewers.join(", ")).unwrap();
101+
}
102+
state.data.paths.push(to_mention.to_string());
103+
}
104+
if !result.is_empty() {
105+
event
106+
.issue
107+
.post_comment(&ctx.github, &result)
108+
.await
109+
.context("failed to post mentions comment")?;
110+
state.save().await?;
111+
}
112+
Ok(())
113+
}

0 commit comments

Comments
 (0)