Add notifications for target maintainers (#10)

This adds a feature where maintainers can add themselves to an array in the source to receive notifications whenever the target fails.

The way this works is via GitHub, I created a new repository https://github.com/Noratrieb/does-it-build-notifications where an app will create issues that ping the respective users. This is the easiest option for me and also fits nicely into people's workflows on GitHub.

Whenever a target fails, an issue is created for it. This issue is kept open (with a new comment every day that it continues to fail), and then closed when the target builds again.
This commit is contained in:
nora 2025-11-10 20:52:15 +01:00 committed by GitHub
parent 112420d224
commit e4c69f17be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 784 additions and 13 deletions

View file

@ -17,6 +17,7 @@ use tracing::{debug, error, info};
use crate::{
db::{BuildMode, Db, FullBuildInfo, Status},
nightlies::Nightlies,
notification::GitHubClient,
};
struct CustomBuildFlags {
@ -52,7 +53,7 @@ impl Display for Toolchain {
}
}
pub async fn background_builder(db: Db) -> Result<()> {
pub async fn background_builder(db: Db, github_client: GitHubClient) -> Result<()> {
if concurrent_jobs() == 0 {
info!("Suspending background thread since DOES_IT_BUILD_PARALLEL_JOBS=0");
loop {
@ -61,7 +62,7 @@ pub async fn background_builder(db: Db) -> Result<()> {
}
loop {
if let Err(err) = background_builder_inner(&db).await {
if let Err(err) = background_builder_inner(&db, &github_client).await {
error!(
?err,
"error in background builder, waiting for an hour before retrying: {err}"
@ -71,7 +72,7 @@ pub async fn background_builder(db: Db) -> Result<()> {
}
}
async fn background_builder_inner(db: &Db) -> Result<()> {
async fn background_builder_inner(db: &Db, github_client: &GitHubClient) -> Result<()> {
let nightlies = Nightlies::fetch().await.wrap_err("fetching nightlies")?;
let already_finished = db
.finished_nightlies()
@ -82,7 +83,7 @@ async fn background_builder_inner(db: &Db) -> Result<()> {
match next {
Some((nightly, mode)) => {
info!(%nightly, %mode, "Building next nightly");
let result = build_every_target_for_toolchain(db, &nightly, mode)
let result = build_every_target_for_toolchain(db, &nightly, mode, &github_client)
.await
.wrap_err_with(|| format!("building targets for toolchain {nightly}"));
if let Err(err) = result {
@ -178,6 +179,7 @@ pub async fn build_every_target_for_toolchain(
db: &Db,
nightly: &str,
mode: BuildMode,
github_client: &GitHubClient,
) -> Result<()> {
if db.is_nightly_finished(nightly, mode).await? {
debug!("Nightly is already finished, not trying again");
@ -194,7 +196,7 @@ pub async fn build_every_target_for_toolchain(
let results = futures::stream::iter(
targets
.iter()
.map(|target| build_single_target(db, nightly, target, mode)),
.map(|target| build_single_target(db, nightly, target, mode, github_client)),
)
.buffer_unordered(concurrent_jobs())
.collect::<Vec<Result<()>>>()
@ -204,7 +206,7 @@ pub async fn build_every_target_for_toolchain(
}
for target in targets {
build_single_target(db, nightly, &target, mode)
build_single_target(db, nightly, &target, mode, github_client)
.await
.wrap_err_with(|| format!("building target {target} for toolchain {toolchain}"))?;
}
@ -217,8 +219,14 @@ pub async fn build_every_target_for_toolchain(
Ok(())
}
#[tracing::instrument(skip(db))]
async fn build_single_target(db: &Db, nightly: &str, target: &str, mode: BuildMode) -> Result<()> {
#[tracing::instrument(skip(db, github_client))]
async fn build_single_target(
db: &Db,
nightly: &str,
target: &str,
mode: BuildMode,
github_client: &GitHubClient,
) -> Result<()> {
let existing = db
.build_status_full(nightly, target, mode)
.await
@ -238,7 +246,7 @@ async fn build_single_target(db: &Db, nightly: &str, target: &str, mode: BuildMo
.await
.wrap_err("running build")?;
db.insert(FullBuildInfo {
let full_build_info = FullBuildInfo {
nightly: nightly.into(),
target: target.into(),
status: result.status,
@ -255,8 +263,14 @@ async fn build_single_target(db: &Db, nightly: &str, target: &str, mode: BuildMo
),
does_it_build_version: Some(crate::VERSION_SHORT.into()),
build_duration_ms: Some(start_time.elapsed().as_millis().try_into().unwrap()),
})
.await?;
};
let result = crate::notification::notify_build(github_client, db, &full_build_info).await;
if let Err(err) = result {
error!(?err, "Failed to send build notification");
}
db.insert(full_build_info).await?;
Ok(())
}

View file

@ -92,6 +92,22 @@ impl BuildStats {
}
}
#[derive(Debug, PartialEq, Clone, Copy, sqlx::Type, Serialize, Deserialize)]
#[sqlx(rename_all = "snake_case")]
#[serde(rename_all = "lowercase")]
pub enum NotificationStatus {
Open,
Closed,
}
#[derive(Debug, sqlx::FromRow)]
pub struct NotificationIssue {
pub issue_number: i64,
pub status: NotificationStatus,
pub first_failed_nightly: String,
pub target: String,
}
impl Db {
pub async fn open(path: &str) -> Result<Self> {
let db_opts = SqliteConnectOptions::from_str(path)
@ -281,4 +297,43 @@ impl Db {
.wrap_err("inserting finished broken nightly")?;
Ok(())
}
pub async fn find_existing_notification(
&self,
target: &str,
) -> Result<Option<NotificationIssue>> {
sqlx::query_as::<_, NotificationIssue>(
"SELECT * FROM notification_issues WHERE status = 'open' AND target = ?",
)
.bind(target)
.fetch_optional(&self.conn)
.await
.wrap_err("finding existing notification")
}
pub async fn insert_notification(&self, notification: NotificationIssue) -> Result<()> {
sqlx::query(
"INSERT INTO notification_issues\
(issue_number, status, first_failed_nightly, target)\
VALUES (?, ?, ?, ?)",
)
.bind(notification.issue_number)
.bind(notification.status)
.bind(notification.first_failed_nightly)
.bind(notification.target)
.execute(&self.conn)
.await
.wrap_err("inserting new notification")?;
Ok(())
}
pub async fn finish_notification(&self, issue_number: i64) -> Result<()> {
sqlx::query("UPDATE notification_issues SET status = ? WHERE issue_number = ?")
.bind(NotificationStatus::Closed)
.bind(issue_number)
.execute(&self.conn)
.await
.wrap_err("marking notification as closed")?;
Ok(())
}
}

39
src/github.rs Normal file
View file

@ -0,0 +1,39 @@
use color_eyre::{eyre::Context, Result};
use octocrab::issues;
pub struct GitHubClient {
pub send_pings: bool,
owner: String,
repo: String,
pub client: octocrab::Octocrab,
}
impl GitHubClient {
pub async fn new(
send_pings: bool,
client: octocrab::Octocrab,
owner: String,
repo: String,
) -> Result<Self> {
let installation = client
.apps()
.get_repository_installation(&owner, &repo)
.await
.wrap_err_with(|| format!("getting installation for {owner}/{repo}"))?;
let client = client
.installation(installation.id)
.wrap_err("getting client for installation")?;
Ok(Self {
send_pings,
owner,
repo,
client,
})
}
pub fn issues(&self) -> issues::IssueHandler<'_> {
self.client.issues(&self.owner, &self.repo)
}
}

View file

@ -1,6 +1,7 @@
mod build;
mod db;
mod nightlies;
mod notification;
mod web;
use color_eyre::{eyre::WrapErr, Result};
@ -12,6 +13,10 @@ const VERSION_SHORT: &str = env!("GIT_COMMIT_SHORT");
#[tokio::main]
async fn main() -> Result<()> {
main_inner().await
}
async fn main_inner() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new("info")))
.init();
@ -22,7 +27,33 @@ async fn main() -> Result<()> {
.await
.wrap_err("running migrations")?;
let builder = build::background_builder(db.clone());
let send_pings = std::env::var("GITHUB_SEND_PINGS")
.map(|_| true)
.unwrap_or(false);
let github_owner = std::env::var("GITHUB_OWNER").wrap_err("missing GITHUB_OWNER env var")?;
let github_repo = std::env::var("GITHUB_REPO").wrap_err("missing GITHUB_REPO env var")?;
let app_id = std::env::var("GITHUB_APP_ID")
.wrap_err("missing GITHUB_APP_ID env var")?
.parse::<u64>()
.wrap_err("invalid GITHUB_APP_ID")?;
let key = std::env::var("GITHUB_APP_PRIVATE_KEY")
.wrap_err("missing GITHUB_APP_PRIVATE_KEY env var")?;
let key = jsonwebtoken::EncodingKey::from_rsa_pem(key.as_bytes()).unwrap();
let github_client = octocrab::Octocrab::builder()
.app(app_id.into(), key)
.build()
.wrap_err("failed to create client")?;
let github_client = notification::GitHubClient::new(
send_pings,
github_client,
github_owner.clone(),
github_repo.clone(),
)
.await?;
let builder = build::background_builder(db.clone(), github_client);
let server = web::webserver(db);
tokio::select! {

232
src/notification.rs Normal file
View file

@ -0,0 +1,232 @@
use color_eyre::eyre::{Context, Result};
use octocrab::models::issues::IssueStateReason;
use octocrab::models::IssueState;
use tracing::info;
use crate::db::{Db, FullBuildInfo, NotificationIssue, NotificationStatus, Status};
pub const TABLE_FILE: &str = file!();
pub const TABLE_LINE: u32 = line!() + 1;
const TARGET_NOTIFICATIONS: &[(&str, &[&str])] = &[("armv7-sony-vita-newlibeabihf", &["pheki"])];
pub fn notification_pr_url() -> String {
format!("https://github.com/Noratrieb/does-it-build/blob/main/{TABLE_FILE}#L{TABLE_LINE}")
}
pub fn maintainers_for_target(target: &str) -> Option<&'static [&'static str]> {
TARGET_NOTIFICATIONS
.iter()
.find(|(target_name, _)| *target_name == target)
.map(|(_, maintainers)| *maintainers)
}
pub struct GitHubClient {
pub send_pings: bool,
owner: String,
repo: String,
pub client: octocrab::Octocrab,
}
impl GitHubClient {
pub async fn new(
send_pings: bool,
client: octocrab::Octocrab,
owner: String,
repo: String,
) -> Result<Self> {
let installation = client
.apps()
.get_repository_installation(&owner, &repo)
.await
.wrap_err_with(|| format!("getting installation for {owner}/{repo}"))?;
let client = client
.installation(installation.id)
.wrap_err("getting client for installation")?;
Ok(Self {
send_pings,
owner,
repo,
client,
})
}
pub fn issues(&self) -> octocrab::issues::IssueHandler<'_> {
self.client.issues(&self.owner, &self.repo)
}
}
pub async fn notify_build(
github_client: &GitHubClient,
db: &Db,
build_info: &FullBuildInfo,
) -> Result<()> {
match build_info.status {
Status::Error => notify_build_failure(github_client, db, build_info).await,
Status::Pass => notify_build_pass(github_client, db, build_info).await,
}
}
pub async fn notify_build_failure(
github_client: &GitHubClient,
db: &Db,
build_info: &FullBuildInfo,
) -> Result<()> {
let FullBuildInfo {
target,
nightly,
stderr,
..
} = build_info;
let Some(notify_usernames) = maintainers_for_target(target) else {
return Ok(());
};
info!("Creating issue for target {target}, notifying {notify_usernames:?}");
let issue = db.find_existing_notification(target).await?;
let url = format!(
"https://does-it-build.noratrieb.dev/build?nightly={nightly}&target={target}&mode=std"
);
if let Some(issue) = issue {
// An existing issue, send a comment.
github_client
.issues()
.create_comment(
issue.issue_number as u64,
format!(
"💥 The target {target} still fails to build on the nightly {nightly}!
<{url}>
<details><summary>full logs</summary>
```
{stderr}
```
</details>
"
),
)
.await
.wrap_err("creating update comment")?;
return Ok(());
}
// Ensure the labels exist.
let label = github_client.issues().get_label(target).await;
match label {
Ok(_) => {}
Err(octocrab::Error::GitHub { source, .. }) if source.status_code.as_u16() == 404 => {
github_client
.issues()
.create_label(target, "d73a4a", format!("Target: {target}"))
.await
.wrap_err("creating label")?;
}
Err(err) => return Err(err).wrap_err("failed to fetch label label"),
}
let pings = notify_usernames
.iter()
.map(|name| {
if github_client.send_pings {
format!("@{name}")
} else {
format!("@\\{name}")
}
})
.collect::<Vec<_>>()
.join(" ");
let issue = github_client
.issues()
.create(format!("{target} fails to build on {nightly}"))
.labels(Some(vec![target.to_owned()]))
.body(format!(
"💥 The target {target} fails to build on the nightly {nightly}!
<{url}>
<details>
<summary>full logs</summary>
```
{stderr}
```
</details>
{pings}
This issue will be closed automatically when this target works again!"
))
.send()
.await
.wrap_err("failed to create issue")?;
db.insert_notification(NotificationIssue {
first_failed_nightly: nightly.into(),
issue_number: issue.number as i64,
status: NotificationStatus::Open,
target: target.into(),
})
.await
.wrap_err("inserting issue into DB")?;
Ok(())
}
pub async fn notify_build_pass(
github_client: &GitHubClient,
db: &Db,
build_info: &FullBuildInfo,
) -> Result<()> {
let FullBuildInfo {
target, nightly, ..
} = build_info;
let issue = db.find_existing_notification(target).await?;
if let Some(issue) = issue {
info!(
"Closing issue {} for {target}, since {nightly} builds again",
issue.issue_number
);
let url = format!(
"https://does-it-build.noratrieb.dev/build?nightly={nightly}&target={target}&mode=std"
);
// An existing issue, send a comment.
github_client
.issues()
.create_comment(
issue.issue_number as u64,
format!("✅ The target {target} successfully builds on nightly {nightly}, \
thanks for playing this round of Tier 3 rustc target breakage fixing! See y'all next time :3!\n\n<{url}>"),
)
.await
.wrap_err("creating update comment")?;
github_client
.issues()
.update(issue.issue_number as u64)
.state(IssueState::Closed)
.state_reason(IssueStateReason::Completed)
.send()
.await
.wrap_err("closing issue")?;
db.finish_notification(issue.issue_number as i64).await?;
}
Ok(())
}

View file

@ -11,7 +11,10 @@ use color_eyre::{eyre::Context, Result};
use serde::Deserialize;
use tracing::{error, info};
use crate::db::{BuildInfo, BuildMode, BuildStats, Db, Status};
use crate::{
db::{BuildInfo, BuildMode, BuildStats, Db, Status},
notification,
};
#[derive(Clone)]
pub struct AppState {
@ -138,6 +141,8 @@ async fn web_target(State(state): State<AppState>, Query(query): Query<TargetQue
version: &'static str,
builds: Vec<(String, Option<BuildInfo>, Option<BuildInfo>)>,
showing_failures: bool,
notification_pr_url: String,
maintainers: Option<&'static [&'static str]>,
}
let filter_failures = query.failures.unwrap_or(false);
@ -173,12 +178,16 @@ async fn web_target(State(state): State<AppState>, Query(query): Query<TargetQue
.collect::<Vec<_>>();
builds.sort_by_cached_key(|build| Reverse(build.0.clone()));
let maintainers = notification::maintainers_for_target(&query.target);
let page = TargetPage {
status,
target: query.target,
version: crate::VERSION,
builds,
showing_failures: filter_failures,
notification_pr_url: notification::notification_pr_url(),
maintainers,
};
Html(page.render().unwrap()).into_response()