pr-tracker/src/main.rs

352 lines
11 KiB
Rust
Raw Normal View History

2021-02-14 12:57:45 +01:00
// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception
// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is>
// SPDX-FileCopyrightText: 2021 Sumner Evans <me@sumnerevans.com>
2021-02-14 12:57:45 +01:00
mod branches;
mod github;
2024-07-15 22:09:52 +02:00
mod mail;
2021-02-14 12:57:45 +01:00
mod nixpkgs;
mod systemd;
mod tree;
use std::collections::HashSet;
2024-07-18 01:03:05 +02:00
use std::fs::{remove_dir_all, remove_file, File};
use std::io::BufReader;
2021-02-14 12:57:45 +01:00
use std::path::PathBuf;
use std::{ffi::OsString, fs::read_dir};
2021-02-14 12:57:45 +01:00
use askama::Template;
use async_std::io::{self};
2021-02-14 12:57:45 +01:00
use async_std::net::TcpListener;
use async_std::os::unix::io::FromRawFd;
use async_std::os::unix::net::UnixListener;
use async_std::pin::Pin;
use async_std::prelude::*;
use async_std::process::exit;
2024-07-17 23:09:38 +02:00
use clap::Parser;
2021-02-14 12:57:45 +01:00
use futures_util::future::join_all;
use http_types::mime;
use once_cell::sync::Lazy;
use regex::Regex;
2021-02-14 12:57:45 +01:00
use serde::Deserialize;
use serde_json::json;
2021-02-14 12:57:45 +01:00
use tide::{Request, Response};
use github::{GitHub, PullRequestStatus};
2024-07-15 22:09:52 +02:00
use mail::send_notification;
2021-02-14 12:57:45 +01:00
use nixpkgs::Nixpkgs;
use systemd::{is_socket_inet, is_socket_unix, listen_fds};
use tree::Tree;
2024-07-17 23:09:38 +02:00
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
2024-07-18 01:03:05 +02:00
pub struct Config {
2024-07-17 23:09:38 +02:00
#[arg(long)]
2021-02-14 12:57:45 +01:00
path: PathBuf,
2024-07-17 23:09:38 +02:00
#[arg(long)]
2021-02-14 12:57:45 +01:00
remote: PathBuf,
2024-07-17 23:09:38 +02:00
#[arg(long)]
2021-02-14 12:57:45 +01:00
user_agent: OsString,
2024-07-17 23:09:38 +02:00
#[arg(long, default_value = "/")]
2021-02-14 12:57:45 +01:00
mount: String,
2024-07-17 23:09:38 +02:00
#[arg(long, default_value = "data")]
data_folder: String,
2024-07-17 23:09:38 +02:00
#[arg(long)]
email_white_list: Option<PathBuf>,
2024-07-18 01:03:05 +02:00
#[arg(long)]
url: String,
2021-02-14 12:57:45 +01:00
}
2024-07-18 01:03:05 +02:00
pub static CONFIG: Lazy<Config> = Lazy::new(Config::parse);
2021-02-14 12:57:45 +01:00
2024-07-17 23:29:51 +02:00
static WHITE_LIST: Lazy<HashSet<String>> = Lazy::new(|| {
use std::io::{BufRead, BufReader};
match &CONFIG.email_white_list {
Some(str) => {
let reader = BufReader::new(File::open(str).unwrap());
reader.lines().map(|line| line.unwrap()).collect()
}
_ => HashSet::new(),
}
});
2021-02-14 12:57:45 +01:00
static GITHUB_TOKEN: Lazy<OsString> = Lazy::new(|| {
2024-07-14 20:45:22 +02:00
use std::env;
2021-02-14 12:57:45 +01:00
use std::io::{stdin, BufRead, BufReader};
use std::os::unix::prelude::*;
2024-07-14 20:45:22 +02:00
match env::var_os("PR_TRACKER_GITHUB_TOKEN") {
Some(token) => token,
None => {
let mut bytes = Vec::with_capacity(41);
if let Err(e) = BufReader::new(stdin()).read_until(b'\n', &mut bytes) {
eprintln!("pr-tracker: read: {}", e);
exit(74)
}
if bytes.last() == Some(&b'\n') {
bytes.pop();
}
OsString::from_vec(bytes)
}
2021-02-14 12:57:45 +01:00
}
});
#[derive(Debug, Default, Template)]
#[template(path = "page.html")]
struct PageTemplate {
error: Option<String>,
pr_number: Option<String>,
email: Option<String>,
pr_title: Option<String>,
2021-02-14 12:57:45 +01:00
closed: bool,
subscribed: bool,
2021-02-14 12:57:45 +01:00
tree: Option<Tree>,
}
#[derive(Debug, Deserialize)]
struct Query {
pr: Option<String>,
email: Option<String>,
2021-02-14 12:57:45 +01:00
}
async fn track_pr(pr_number: String, status: &mut u16, page: &mut PageTemplate) {
2021-02-14 12:57:45 +01:00
let pr_number_i64 = match pr_number.parse() {
Ok(n) => n,
Err(_) => {
*status = 400;
page.error = Some(format!("Invalid PR number: {}", pr_number));
return;
}
};
let github = GitHub::new(&GITHUB_TOKEN, &CONFIG.user_agent);
let pr_info = match github.pr_info_for_nixpkgs_pr(pr_number_i64).await {
2021-02-14 12:57:45 +01:00
Err(github::Error::NotFound) => {
*status = 404;
page.error = Some(format!("No such nixpkgs PR #{}.", pr_number_i64));
return;
}
Err(e) => {
*status = 500;
page.error = Some(e.to_string());
return;
}
Ok(info) => info,
};
page.pr_number = Some(pr_number);
page.pr_title = Some(pr_info.title);
2021-02-14 12:57:45 +01:00
if matches!(pr_info.status, PullRequestStatus::Closed) {
2021-02-14 12:57:45 +01:00
page.closed = true;
return;
}
let nixpkgs = Nixpkgs::new(&CONFIG.path, &CONFIG.remote);
let tree = Tree::make(pr_info.branch.to_string(), &pr_info.status, &nixpkgs).await;
2021-02-14 12:57:45 +01:00
if let github::PullRequestStatus::Merged {
merge_commit_oid, ..
} = pr_info.status
2021-02-14 12:57:45 +01:00
{
if merge_commit_oid.is_none() {
page.error = Some("For older PRs, GitHub doesn't tell us the merge commit, so we're unable to track this PR past being merged.".to_string());
}
}
page.tree = Some(tree);
}
async fn update_subscribers<S>(_request: Request<S>) -> http_types::Result<Response> {
let mut status = 200;
let mut page = PageTemplate {
..Default::default()
};
let re_pull = Regex::new(r"^[0-9]*$")?;
let re_mail = Regex::new(
r#"^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$"#,
)?;
for f in read_dir(CONFIG.data_folder.clone())? {
let dir_path = f?.path();
let dir_name = dir_path.file_name().and_then(|x| x.to_str()).unwrap();
if dir_path.is_dir() && re_pull.is_match(dir_name) {
track_pr(dir_name.to_string(), &mut status, &mut page).await;
println!("Pruning pr number {dir_name}");
if let Some(ref tree) = page.tree {
let mut v = Vec::new();
let remaining = tree.collect_branches(&mut v);
let current: HashSet<String> = v.into_iter().collect();
println!("the pr is merged in: {:#?}", current);
for f in read_dir(dir_path.clone())? {
let file_path = f?.path();
2024-07-15 22:09:52 +02:00
let file_name = file_path
.file_name()
.and_then(|x| x.to_str())
.unwrap()
.to_owned();
if file_path.is_file() && re_mail.is_match(&file_name) {
println!("{} has received notifications for:", file_name);
let file = File::open(file_path)?;
let reader = BufReader::new(file);
let val: HashSet<String> = serde_json::from_reader(reader)?;
println!("{:#?}", val);
let to_do = &current - &val;
2024-07-19 21:57:47 +02:00
println!("They will be notified for: {:#?}", to_do);
if !to_do.is_empty() {
send_notification(
&file_name,
&to_do,
page.pr_number.as_ref().unwrap(),
page.pr_title.as_ref().unwrap(),
!remaining,
)?;
}
}
}
if !remaining {
println!("Removing {}", dir_name);
remove_dir_all(dir_path)?;
}
}
}
}
Ok(Response::builder(200)
.content_type(mime::HTML)
.body("Sucess")
.build())
}
2021-02-14 12:57:45 +01:00
2024-07-18 01:03:05 +02:00
async fn unsubscribe<S>(request: Request<S>) -> http_types::Result<Response> {
let pr_number = request.query::<Query>()?.pr;
let email = request.query::<Query>()?.email;
if let Some(email) = email {
for f in read_dir(CONFIG.data_folder.clone())? {
let dir_path = f?.path();
let dir_name = dir_path.file_name().and_then(|x| x.to_str()).unwrap();
if dir_path.is_dir()
&& (pr_number.is_none() || pr_number.as_ref().is_some_and(|x| x == dir_name))
{
match remove_file(dir_path.join(&email)) {
Ok(_) => {}
Err(_) => {}
}
}
}
}
Ok(Response::builder(200)
.content_type(mime::HTML)
.body("")
.build())
}
2021-02-14 12:57:45 +01:00
async fn handle_request<S>(request: Request<S>) -> http_types::Result<Response> {
let mut status = 200;
let mut page = PageTemplate {
..Default::default()
};
let pr_number = request.query::<Query>()?.pr;
let email = request.query::<Query>()?.email;
page.email = email.clone();
2021-02-14 12:57:45 +01:00
match pr_number.clone() {
Some(pr_number) => track_pr(pr_number, &mut status, &mut page).await,
None => {}
};
match email {
Some(email) => {
if let Some(ref tree) = page.tree {
let mut v = Vec::new();
let remaining = tree.collect_branches(&mut v);
if !remaining {
page.error = Some("There are no branches remaining to be tracked".to_string())
2024-07-17 23:29:51 +02:00
} else if !WHITE_LIST.is_empty() && !WHITE_LIST.contains(&email) {
page.error = Some("You are not part of the white list.".to_string())
} else {
page.subscribed = true;
let folder = format!("{}/{}", CONFIG.data_folder, pr_number.unwrap());
std::fs::create_dir_all(folder.clone())?;
std::fs::write(format!("{folder}/{email}"), json!(v).to_string())?;
}
}
}
None => {}
}
2021-02-14 12:57:45 +01:00
Ok(Response::builder(status)
.content_type(mime::HTML)
.body(page.render()?)
.build())
}
#[async_std::main]
async fn main() {
fn handle_error<T, E>(result: Result<T, E>, code: i32, message: impl AsRef<str>) -> T
where
E: std::error::Error,
{
match result {
2022-02-16 14:47:27 +01:00
Ok(v) => v,
2021-02-14 12:57:45 +01:00
Err(e) => {
eprintln!("pr-tracker: {}: {}", message.as_ref(), e);
exit(code);
}
}
}
// Make sure arguments are parsed before starting server.
let _ = *CONFIG;
let _ = *GITHUB_TOKEN;
let mut server = tide::new();
let mut root = server.at(&CONFIG.mount);
root.at("/").get(handle_request);
root.at("update").get(update_subscribers);
2024-07-18 01:03:05 +02:00
root.at("unsubscribe").get(unsubscribe);
2021-02-14 12:57:45 +01:00
let fd_count = handle_error(listen_fds(true), 71, "sd_listen_fds");
if fd_count == 0 {
eprintln!("pr-tracker: No listen file descriptors given");
exit(64);
}
let mut listeners: Vec<Pin<Box<dyn Future<Output = _>>>> = Vec::new();
2023-10-28 13:20:49 +02:00
for fd in (3..).take(fd_count as usize) {
2021-02-14 12:57:45 +01:00
let s = server.clone();
if handle_error(is_socket_inet(fd), 74, "sd_is_socket_inet") {
listeners.push(Box::pin(s.listen(unsafe { TcpListener::from_raw_fd(fd) })));
} else if handle_error(is_socket_unix(fd), 74, "sd_is_socket_unix") {
listeners.push(Box::pin(s.listen(unsafe { UnixListener::from_raw_fd(fd) })));
} else {
eprintln!("pr-tracker: file descriptor {} is not a socket", fd);
exit(64);
}
}
let errors: Vec<_> = join_all(listeners)
.await
.into_iter()
.filter_map(io::Result::err)
.collect();
for error in errors.iter() {
eprintln!("pr-tracker: listen: {}", error);
}
if !errors.is_empty() {
exit(74);
}
}