Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[suiop][incidents] add notion integration #19422

Merged
merged 11 commits into from
Oct 4, 2024
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/suiop-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ tracing-subscriber.workspace = true
tracing.workspace = true
once_cell.workspace = true
futures.workspace = true
thiserror.workspace = true
strsim = "0.11.1"


[dev-dependencies]
tempfile.workspace = true
11 changes: 8 additions & 3 deletions crates/suiop-cli/src/cli/incidents/incident.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use serde::{Deserialize, Serialize};

use super::pd::PagerDutyIncident;
use super::pd::Priority;
use crate::cli::slack::{Channel, User};
use super::user::User;
use crate::cli::slack::Channel;

const DATE_FORMAT_IN: &str = "%Y-%m-%dT%H:%M:%SZ";
const DATE_FORMAT_OUT: &str = "%m/%d/%Y %H:%M";
Expand All @@ -22,7 +23,7 @@ pub struct Incident {
pub created_at: Option<String>,
pub resolved_at: Option<String>,
pub html_url: String,
/// The slack users responsible for reporting
/// The users responsible for reporting
#[serde(skip_deserializing)]
pub poc_users: Option<Vec<User>>,
pub priority: Option<Priority>,
Expand Down Expand Up @@ -143,7 +144,11 @@ impl Incident {
|| "".to_string(),
|u| u
.iter()
.map(|u| { format!("<@{}>", u.id) })
.map(|u| {
u.slack_user
.as_ref()
.map_or("".to_owned(), |su| format!("<@{}>", su.id))
})
.collect::<Vec<_>>()
.join(", ")
)
Expand Down
2 changes: 2 additions & 0 deletions crates/suiop-cli/src/cli/incidents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

mod incident;
mod jira;
pub(crate) mod notion;
mod pd;
mod selection;
mod user;

use crate::cli::slack::Slack;
use anyhow::Result;
Expand Down
225 changes: 225 additions & 0 deletions crates/suiop-cli/src/cli/incidents/notion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

use crate::cli::notion::ids::DatabaseId;
use crate::cli::notion::models::search::DatabaseQuery;
use crate::cli::notion::models::{ListResponse, Page};
use crate::cli::notion::NotionApi;
use anyhow::{Context, Result};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::env;
use std::str::FromStr;
use tracing::debug;

use crate::DEBUG_MODE;

use super::incident::Incident;

// incident selection db
pub static INCIDENT_DB_ID: Lazy<DatabaseId> = Lazy::new(|| {
if *DEBUG_MODE {
// incident selection db for testing
DatabaseId::from_str("10e6d9dcb4e980f8ae73c4aa2da176cd").expect("Invalid Database ID")
} else {
// incident selection db for production
DatabaseId::from_str("a8da55dadb524e7db202b4dfd799d9ce").expect("Invalid Database ID")
}
});

// incident selection db names
pub static INCIDENT_DB_NAME: Lazy<String> = Lazy::new(|| {
if *DEBUG_MODE {
"Incident Selection (Debug)".to_owned()
} else {
"Incident Selection".to_owned()
}
});

/// Macro for debugging Notion database properties.
///
/// This macro takes two arguments:
/// - `$notion`: A reference to a Notion instance.
/// - `$prop`: The name of the property to debug.
///
/// It retrieves the specified database, gets the property, and prints debug information
/// based on the property type. Supported property types include:
/// - MultiSelect
/// - People
/// - Date
/// - Title
/// - Checkbox
///
/// For unsupported property types, it prints an "Unexpected property type" message.
///
/// # Panics
///
/// This macro will panic if:
/// - It fails to get the database.
/// - The specified property does not exist in the database.
#[allow(unused_macros)]
macro_rules! debug_prop {
($notion:expr, $prop:expr) => {
let db = $notion
.client
.get_database(INCIDENT_DB_ID.clone())
.await
.expect("Failed to get database");
let prop = db.properties.get($prop).unwrap();
match prop {
PropertyConfiguration::MultiSelect {
multi_select,
id: _,
} => {
println!("multi select property");
println!("{:#?}", multi_select.options);
}
PropertyConfiguration::People { id: _ } => {
println!("people property");
}
PropertyConfiguration::Date { id: _ } => {
println!("date property");
}
PropertyConfiguration::Title { id: _ } => {
println!("title property");
}
PropertyConfiguration::Checkbox { id: _ } => {
println!("checkbox property");
}
_ => {
println!("Unexpected property type {:?}", prop);
}
}
};
}

pub struct Notion {
client: NotionApi,
token: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NotionPerson {
pub object: String,
pub id: String,
pub name: String,
pub avatar_url: Option<String>,
pub person: NotionPersonDetails,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NotionPersonDetails {
pub email: String,
}
impl Notion {
pub fn new() -> Self {
let token = env::var("NOTION_API_TOKEN")
.expect("Please set the NOTION_API_TOKEN environment variable");
let client = NotionApi::new(token.clone()).expect("Failed to create Notion API client");
Self { client, token }
}

/// Get all incidents from the incident selection database
#[allow(dead_code)]
pub async fn get_incident_selection_incidents(&self) -> Result<ListResponse<Page>> {
// Retrieve the db
self.client
.query_database(INCIDENT_DB_ID.clone(), DatabaseQuery::default())
.await
.map_err(|e| anyhow::anyhow!(e))
}

/// Get all people objects from the Notion API
pub async fn get_all_people(&self) -> Result<Vec<NotionPerson>> {
let url = "https://api.notion.com/v1/users";
let client = reqwest::Client::new();

let response = client
.get(url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Notion-Version", "2022-06-28")
.send()
.await
.map_err(|e| anyhow::anyhow!("Failed to send request: {}", e))?;

if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Request failed with status: {}",
response.status()
));
}

response
.json::<serde_json::Value>()
.await
.map(|v| {
serde_json::from_value::<Vec<NotionPerson>>(v["results"].clone())
.expect("deserializing people")
})
.map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))
}

/// Get the shape of the incident selection database to understand the data model
#[allow(dead_code)]
pub async fn get_shape(self) -> Result<()> {
let db = self.client.get_database(INCIDENT_DB_ID.clone()).await?;
println!("{:#?}", db.properties);
Ok(())
}

/// Insert a suiop incident into the incident selection database
pub async fn insert_incident(&self, incident: Incident) -> Result<()> {
let url = "https://api.notion.com/v1/pages";
let body = json!({
"parent": { "database_id": INCIDENT_DB_ID.to_string() },
"properties": {
"Name": {
"title": [{
"text": {
"content":format!("{}: {}", incident.number, incident.title)
}
}]
},
"link": {
"url": incident.html_url,
},
"PoC(s)": {
"people": incident.poc_users.unwrap_or_else(|| panic!("no poc users for incident {}", incident.number)).iter().map(|u| {
json!({
"object": "user",
"id": u.notion_user.as_ref().map(|u| u.id.clone()),
})
}).collect::<Vec<_>>(),
},
}
});

let client = reqwest::ClientBuilder::new()
// .default_headers(headers)
.build()
.expect("failed to build reqwest client");
let response = client
.post(url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Content-Type", "application/json")
.header("Notion-Version", "2021-05-13")
.json(&body)
.send()
.await
.context("sending insert db row")?;

if response.status().is_success() {
debug!(
"inserted incident: {:?}",
response.text().await.context("getting response text")?
);
Ok(())
} else {
Err(anyhow::anyhow!(
"Failed to insert incident: {:?}",
response.text().await.context("getting response text")?
))
}
}
}
53 changes: 44 additions & 9 deletions crates/suiop-cli/src/cli/incidents/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ use std::collections::HashMap;
use strsim::normalized_damerau_levenshtein;
use tracing::debug;

use crate::cli::incidents::notion::{Notion, INCIDENT_DB_ID, INCIDENT_DB_NAME};
use crate::cli::incidents::user::User;
use crate::cli::lib::utils::day_of_week;
use crate::cli::slack::{Channel, Slack, User};
use crate::cli::slack::{Channel, Slack};
use crate::DEBUG_MODE;

use super::incident::Incident;

fn request_pocs(slack: &Slack) -> Result<Vec<User>> {
fn request_pocs(users: Vec<User>) -> Result<Vec<User>> {
MultiSelect::new(
"Please select the users who are POCs for this incident",
slack.users.clone(),
users,
)
.with_default(&[])
.prompt()
Expand All @@ -30,7 +32,6 @@ fn filter_incidents_for_review(incidents: Vec<Incident>, min_priority: &str) ->
.trim_start_matches("P")
.parse::<u8>()
.expect("Parsing priority");
println!("min_priority_u: {}", min_priority_u);
incidents
.into_iter()
// filter on priority <= min_priority and any slack channel association
Expand All @@ -46,6 +47,24 @@ fn filter_incidents_for_review(incidents: Vec<Incident>, min_priority: &str) ->

pub async fn review_recent_incidents(incidents: Vec<Incident>) -> Result<()> {
let slack = Slack::new().await;
let notion = Notion::new();
let combined_users = notion
.get_all_people()
.await?
.into_iter()
.map(|nu| {
let slack_user = slack.users.iter().find(|su| {
su.profile
.as_ref()
.unwrap()
.email
.as_ref()
.unwrap_or(&"".to_owned())
== &nu.person.email
});
User::new(slack_user.cloned(), Some(nu)).expect("Failed to convert user from Notion")
})
.collect::<Vec<_>>();
let filtered_incidents = filter_incidents_for_review(incidents, "P2");
let mut group_map = group_by_similar_title(filtered_incidents, 0.9);
let mut to_review = vec![];
Expand Down Expand Up @@ -74,7 +93,7 @@ pub async fn review_recent_incidents(incidents: Vec<Incident>) -> Result<()> {
.prompt()
.expect("Unexpected response");
if ans {
let poc_users = request_pocs(&slack)?;
let poc_users = request_pocs(combined_users.clone())?;
incident_group
.iter_mut()
.for_each(|i| i.poc_users = Some(poc_users.clone()));
Expand All @@ -90,7 +109,7 @@ pub async fn review_recent_incidents(incidents: Vec<Incident>) -> Result<()> {
.prompt()
.expect("Unexpected response");
if ans {
let poc_users = request_pocs(&slack)?;
let poc_users = request_pocs(combined_users.clone())?;
incident.poc_users = Some(poc_users.clone());
to_review.push(incident.clone());
} else {
Expand Down Expand Up @@ -142,17 +161,33 @@ Please comment in the thread to request an adjustment to the list.",
} else {
"incident-postmortems"
};
let ans = Confirm::new(&format!(
let send_message = Confirm::new(&format!(
"Send this message to the #{} channel?",
slack_channel
))
.with_default(false)
.prompt()
.expect("Unexpected response");
if ans {
if send_message {
slack.send_message(slack_channel, &message).await?;
debug!("Message sent to #{}", slack_channel);
}
#[allow(clippy::unnecessary_to_owned)]
let insert_into_db = Confirm::new(&format!(
"Insert {} incidents into {:?} Notion database ({:?}) for review?",
to_review.len(),
INCIDENT_DB_NAME.to_string(),
INCIDENT_DB_ID.to_string()
))
.with_default(false)
.prompt()
.expect("Unexpected response");
if insert_into_db {
for incident in to_review.iter() {
debug!("Inserting incident into Notion: {}", incident.number);
notion.insert_incident(incident.clone()).await?;
}
}
// post to https://slack.com/api/chat.postMessage with message
Ok(())
}

Expand Down
Loading
Loading