diff options
author | Alyssa Ross <hi@alyssa.is> | 2021-02-14 11:57:45 +0000 |
---|---|---|
committer | Alyssa Ross <hi@alyssa.is> | 2021-02-17 15:16:29 +0000 |
commit | 6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4 (patch) | |
tree | 5955257a31295586dd2203137736693ae01068d9 /src | |
download | pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.gz pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.bz2 pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.lz pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.xz pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.tar.zst pr-tracker-6f9ccf054b8af59243e50b24d2c8b36e22ab3ac4.zip |
Initial commit
Diffstat (limited to 'src')
-rw-r--r-- | src/branches.rs | 62 | ||||
-rw-r--r-- | src/github.rs | 139 | ||||
-rw-r--r-- | src/main.rs | 211 | ||||
-rw-r--r-- | src/merge_commit.graphql | 15 | ||||
-rw-r--r-- | src/nixpkgs.rs | 127 | ||||
-rw-r--r-- | src/systemd.rs | 49 | ||||
-rw-r--r-- | src/tree.rs | 91 |
7 files changed, 694 insertions, 0 deletions
diff --git a/src/branches.rs b/src/branches.rs new file mode 100644 index 0000000..0e1e04f --- /dev/null +++ b/src/branches.rs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception +// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is> + +use std::borrow::Cow; +use std::collections::BTreeMap; + +use once_cell::sync::Lazy; +use regex::{Regex, RegexSet}; + +const NEXT_BRANCH_TABLE: [(&str, &str); 8] = [ + (r"\Astaging(-[\d.]+)?\z", "staging-next$1"), + (r"\Astaging-next\z", "master"), + (r"\Amaster\z", "nixpkgs-unstable"), + (r"\Amaster\z", "nixos-unstable-small"), + (r"\Anixos-(.*)-small\z", "nixos-$1"), + (r"\Arelease-([\d.]+)\z", "nixpkgs-$1-darwin"), + (r"\Arelease-([\d.]+)\z", "nixos-$1-small"), + (r"\Astaging-next-([\d.]*)\z", "release-$1"), +]; + +static BRANCH_NEXTS: Lazy<BTreeMap<&str, Vec<&str>>> = Lazy::new(|| { + NEXT_BRANCH_TABLE + .iter() + .fold(BTreeMap::new(), |mut map, (pattern, next)| { + map.entry(pattern).or_insert_with(Vec::new).push(next); + map + }) +}); + +static BRANCH_NEXTS_BY_INDEX: Lazy<Vec<&Vec<&str>>> = Lazy::new(|| BRANCH_NEXTS.values().collect()); + +static BRANCH_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| { + BRANCH_NEXTS + .keys() + .copied() + .map(Regex::new) + .map(Result::unwrap) + .collect() +}); + +static BRANCH_REGEXES: Lazy<RegexSet> = Lazy::new(|| RegexSet::new(BRANCH_NEXTS.keys()).unwrap()); + +pub fn next_branches(branch: &str) -> Vec<Cow<str>> { + BRANCH_REGEXES + .matches(branch) + .iter() + .flat_map(|index| { + let regex = BRANCH_PATTERNS.get(index).unwrap(); + BRANCH_NEXTS_BY_INDEX + .get(index) + .unwrap() + .iter() + .map(move |next| regex.replace(branch, *next)) + }) + .collect() +} + +#[test] +fn test_next_branches() { + let res = next_branches("release-20.09"); + assert_eq!(res, vec!["nixpkgs-20.09-darwin", "nixos-20.09-small"]) +} diff --git a/src/github.rs b/src/github.rs new file mode 100644 index 0000000..dc7c8e5 --- /dev/null +++ b/src/github.rs @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception +// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is> + +use std::ffi::OsStr; +use std::fmt::{self, Display, Formatter}; +use std::os::unix::ffi::OsStrExt; + +use graphql_client::GraphQLQuery; +use serde::Deserialize; +use surf::http::headers::HeaderValue; +use surf::StatusCode; + +type GitObjectID = String; + +#[derive(Debug)] +pub enum Error { + NotFound, + Serialization(serde_json::Error), + Request(surf::Error), + Response(StatusCode), + Deserialization(http_types::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + use Error::*; + match self { + NotFound => write!(f, "Not found"), + Serialization(e) => write!(f, "Serialization error: {}", e), + Request(e) => write!(f, "Request error: {}", e), + Response(s) => write!(f, "Unexpected response status: {}", s), + Deserialization(e) => write!(f, "Deserialization error: {}", e), + } + } +} + +impl std::error::Error for Error {} + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "vendor/github_schema.graphql", + query_path = "src/merge_commit.graphql", + response_derives = "Debug" +)] +struct MergeCommitQuery; + +#[derive(Debug, Deserialize)] +struct GitHubGraphQLResponse<D> { + data: D, +} + +#[derive(Debug)] +pub enum PullRequestStatus { + Open, + Closed, + Merged { + /// This field is optional because GitHub doesn't provide us with this information + /// for PRs merged before around March 2016. + merge_commit_oid: Option<String>, + }, +} + +#[derive(Debug)] +pub struct MergeInfo { + pub branch: String, + pub status: PullRequestStatus, +} + +pub struct GitHub<'a> { + token: &'a OsStr, + user_agent: &'a OsStr, +} + +impl<'a> GitHub<'a> { + pub fn new(token: &'a OsStr, user_agent: &'a OsStr) -> Self { + Self { token, user_agent } + } + + fn authorization_header(&self) -> Result<HeaderValue, surf::Error> { + let mut value = b"bearer ".to_vec(); + value.extend_from_slice(self.token.as_bytes()); + Ok(HeaderValue::from_bytes(value)?) + } + + pub async fn merge_info_for_nixpkgs_pr(&self, pr: i64) -> Result<MergeInfo, Error> { + let query = MergeCommitQuery::build_query(merge_commit_query::Variables { + owner: "NixOS".to_string(), + repo: "nixpkgs".to_string(), + number: pr, + }); + + let response = surf::post("https://api.github.com/graphql") + .header("Accept", "application/vnd.github.merge-info-preview+json") + .header( + "User-Agent", + HeaderValue::from_bytes(self.user_agent.as_bytes().to_vec()) + .map_err(Error::Request)?, + ) + .header( + "Authorization", + self.authorization_header().map_err(Error::Request)?, + ) + .body(serde_json::to_vec(&query).map_err(Error::Serialization)?) + .send() + .await + .map_err(Error::Request)?; + + let status = response.status(); + if status == StatusCode::NotFound || status == StatusCode::Gone { + return Err(Error::NotFound); + } else if !status.is_success() { + return Err(Error::Response(status)); + } + + let data: GitHubGraphQLResponse<merge_commit_query::ResponseData> = dbg!(response) + .body_json() + .await + .map_err(Error::Deserialization)?; + + let pr = data + .data + .repository + .and_then(|repo| repo.pull_request) + .ok_or(Error::NotFound)?; + + Ok(MergeInfo { + branch: pr.base_ref_name, + status: if pr.merged { + PullRequestStatus::Merged { + merge_commit_oid: pr.merge_commit.map(|commit| commit.oid), + } + } else if pr.closed { + PullRequestStatus::Closed + } else { + PullRequestStatus::Open + }, + }) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..22023a8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception +// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is> + +mod branches; +mod github; +mod nixpkgs; +mod systemd; +mod tree; + +use std::ffi::OsString; +use std::path::PathBuf; + +use askama::Template; +use async_std::io; +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; +use futures_util::future::join_all; +use http_types::mime; +use once_cell::sync::Lazy; +use serde::Deserialize; +use structopt::StructOpt; +use tide::{Request, Response}; + +use github::{GitHub, PullRequestStatus}; +use nixpkgs::Nixpkgs; +use systemd::{is_socket_inet, is_socket_unix, listen_fds}; +use tree::Tree; + +#[derive(StructOpt, Debug)] +struct Config { + #[structopt(long, parse(from_os_str))] + path: PathBuf, + + #[structopt(long, parse(from_os_str))] + remote: PathBuf, + + #[structopt(long, parse(from_os_str))] + user_agent: OsString, + + #[structopt(long)] + source_url: String, + + #[structopt(long, default_value = "/")] + mount: String, +} + +static CONFIG: Lazy<Config> = Lazy::new(Config::from_args); + +static GITHUB_TOKEN: Lazy<OsString> = Lazy::new(|| { + use std::io::{stdin, BufRead, BufReader}; + use std::os::unix::prelude::*; + + 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) +}); + +#[derive(Debug, Default, Template)] +#[template(path = "page.html")] +struct PageTemplate { + error: Option<String>, + pr_number: Option<String>, + closed: bool, + tree: Option<Tree>, + source_url: String, +} + +#[derive(Debug, Deserialize)] +struct Query { + pr: Option<String>, +} + +async fn track_pr(pr_number: Option<String>, status: &mut u16, page: &mut PageTemplate) { + let pr_number = match pr_number { + Some(pr_number) => pr_number, + None => return, + }; + + 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 merge_info = match github.merge_info_for_nixpkgs_pr(pr_number_i64).await { + 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); + + if matches!(merge_info.status, PullRequestStatus::Closed) { + page.closed = true; + return; + } + + let nixpkgs = Nixpkgs::new(&CONFIG.path, &CONFIG.remote); + let tree = Tree::make(merge_info.branch.to_string(), &merge_info.status, &nixpkgs).await; + + if let github::PullRequestStatus::Merged { + merge_commit_oid, .. + } = merge_info.status + { + 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 handle_request<S>(request: Request<S>) -> http_types::Result<Response> { + let mut status = 200; + let mut page = PageTemplate { + source_url: CONFIG.source_url.clone(), + ..Default::default() + }; + + let pr_number = request.query::<Query>()?.pr; + + track_pr(pr_number, &mut status, &mut page).await; + + 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 { + Ok(v) => return v, + 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); + + 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(); + + for fd in (3..).into_iter().take(fd_count as usize) { + 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); + } +} diff --git a/src/merge_commit.graphql b/src/merge_commit.graphql new file mode 100644 index 0000000..5df583a --- /dev/null +++ b/src/merge_commit.graphql @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception +# SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is> + +query MergeCommitQuery($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + baseRefName + mergeCommit { + oid + } + merged + closed + } + } +} diff --git a/src/nixpkgs.rs b/src/nixpkgs.rs new file mode 100644 index 0000000..7e622c2 --- /dev/null +++ b/src/nixpkgs.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception +// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is> + +use std::collections::BTreeSet; +use std::ffi::OsStr; +use std::ffi::OsString; +use std::fmt::{self, Display, Formatter}; +use std::os::unix::prelude::*; +use std::path::{Path, PathBuf}; +use std::process::ExitStatus; + +use async_std::io; +use async_std::process::{Command, Stdio}; + +#[derive(Debug)] +pub enum Error { + Io(io::Error), + ExitFailure(ExitStatus), +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + use Error::*; + match self { + Io(e) => write!(f, "git: {}", e), + ExitFailure(e) => match e.code() { + Some(code) => write!(f, "git exited {}", code), + None => write!(f, "git killed by signal {}", e.signal().unwrap()), + }, + } + } +} + +impl std::error::Error for Error {} + +type Result<T, E = Error> = std::result::Result<T, E>; + +fn check_status(status: ExitStatus) -> Result<()> { + if status.success() { + Ok(()) + } else { + Err(Error::ExitFailure(status)) + } +} + +pub struct Nixpkgs<'a> { + path: &'a Path, + remote_name: &'a Path, +} + +impl<'a> Nixpkgs<'a> { + pub fn new(path: &'a Path, remote_name: &'a Path) -> Self { + Self { path, remote_name } + } + + fn git_command(&self, subcommand: impl AsRef<OsStr>) -> Command { + let mut command = Command::new("git"); + command.arg("-C"); + command.arg(&self.path); + command.arg(subcommand); + command + } + + async fn git_branch_contains(&self, commit: &str) -> Result<Vec<u8>> { + let output = self + .git_command("branch") + .args(&["-r", "--format=%(refname)", "--contains"]) + .arg(commit) + .stderr(Stdio::inherit()) + .output() + .await + .map_err(Error::Io)?; + + check_status(output.status)?; + + Ok(output.stdout) + } + + async fn git_fetch_nixpkgs(&self) -> Result<()> { + // TODO: add refspecs + self.git_command("fetch") + .arg(&self.remote_name) + .status() + .await + .map_err(Error::Io) + .and_then(check_status) + } + + pub async fn branches_containing_commit( + &self, + commit: &str, + out: &mut BTreeSet<OsString>, + ) -> Result<()> { + let output = match self.git_branch_contains(commit).await { + Err(Error::ExitFailure(status)) if status.code().is_some() => { + eprintln!("pr-tracker: git branch --contains failed; updating branches"); + + if let Err(e) = self.git_fetch_nixpkgs().await { + eprintln!("pr-tracker: fetching nixpkgs: {}", e); + // Carry on, because it might have fetched what we + // need before dying. + } + + self.git_branch_contains(commit).await? + } + + Ok(output) => output, + Err(e) => return Err(e), + }; + + let mut prefix = PathBuf::from("refs/remotes/"); + prefix.push(&self.remote_name); + + for branch_name in output + .split(|byte| *byte == b'\n') + .filter(|b| !b.is_empty()) + .map(OsStr::from_bytes) + .map(Path::new) + .filter_map(|r| r.strip_prefix(&prefix).ok()) + .map(Into::into) + { + out.insert(branch_name); + } + + Ok(()) + } +} diff --git a/src/systemd.rs b/src/systemd.rs new file mode 100644 index 0000000..7bd2142 --- /dev/null +++ b/src/systemd.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception +// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is> + +use std::io; +use std::os::raw::{c_char, c_int, c_uint}; +use std::os::unix::prelude::*; +use std::ptr::null; + +extern "C" { + fn sd_listen_fds(unset_environment: c_int) -> c_int; + fn sd_is_socket_inet( + fd: c_int, + family: c_int, + type_: c_int, + listening: c_int, + port: u16, + ) -> c_int; + fn sd_is_socket_unix( + fd: c_int, + type_: c_int, + listening: c_int, + path: *const c_char, + length: usize, + ) -> c_int; +} + +pub fn listen_fds(unset_environment: bool) -> io::Result<c_uint> { + let r = unsafe { sd_listen_fds(if unset_environment { 1 } else { 0 }) }; + if r < 0 { + return Err(io::Error::from_raw_os_error(-r)); + } + Ok(r as c_uint) +} + +pub fn is_socket_inet(fd: RawFd) -> io::Result<bool> { + let r = unsafe { sd_is_socket_inet(fd, 0, 0, -1, 0) }; + if r < 0 { + return Err(io::Error::from_raw_os_error(-r)); + } + Ok(r != 0) +} + +pub fn is_socket_unix(fd: RawFd) -> io::Result<bool> { + let r = unsafe { sd_is_socket_unix(fd, 0, -1, null(), 0) }; + if r < 0 { + return Err(io::Error::from_raw_os_error(-r)); + } + Ok(r != 0) +} diff --git a/src/tree.rs b/src/tree.rs new file mode 100644 index 0000000..f569dba --- /dev/null +++ b/src/tree.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later WITH GPL-3.0-linking-exception +// SPDX-FileCopyrightText: 2021 Alyssa Ross <hi@alyssa.is> + +use std::collections::BTreeSet; +use std::ffi::{OsStr, OsString}; + +use askama::Template; + +use crate::branches::next_branches; +use crate::github; +use crate::nixpkgs::Nixpkgs; + +#[derive(Debug, Template)] +#[template(path = "tree.html")] +pub struct Tree { + branch_name: String, + accepted: Option<bool>, + children: Vec<Tree>, +} + +impl Tree { + fn generate(branch: String, found_branches: &mut BTreeSet<OsString>) -> Tree { + found_branches.insert((&branch).into()); + + let nexts = next_branches(&branch) + .into_iter() + .map(|b| Self::generate(b.to_string(), found_branches)) + .collect(); + + Tree { + accepted: None, + branch_name: branch, + children: nexts, + } + } + + fn fill_accepted(&mut self, branches: &BTreeSet<OsString>, missing_means_absent: bool) { + self.accepted = match branches.contains(OsStr::new(&self.branch_name)) { + true => Some(true), + false if missing_means_absent => Some(false), + false => None, + }; + + for child in self.children.iter_mut() { + child.fill_accepted(branches, missing_means_absent); + } + } + + pub async fn make(base_branch: String, merge_status: &github::PullRequestStatus, nixpkgs: &Nixpkgs<'_>) -> Tree { + let mut missing_means_absent = true; + let mut branches = BTreeSet::new(); + + let mut tree = Self::generate(base_branch.clone(), &mut branches); + + if let github::PullRequestStatus::Merged { + merge_commit_oid, .. + } = merge_status + { + if let Some(merge_commit) = merge_commit_oid { + let mut containing_commits = BTreeSet::new(); + + if let Err(e) = + nixpkgs.branches_containing_commit(&merge_commit, &mut containing_commits) + .await + { + eprintln!("pr-tracker: branches_containing_commit: {}", e); + missing_means_absent = false; + } + + branches = branches + .intersection(&containing_commits) + .cloned() + .collect(); + } else { + branches.clear(); + missing_means_absent = false; + } + + // Even if something goes wrong with our local Git repo, + // or GitHub didn't tell us the merge commit, we know that + // the base branch of the PR must contain the commit, + // because GitHub told us it was merged into it. + branches.insert(base_branch.into()); + } else { + branches.clear(); + } + + tree.fill_accepted(&branches, missing_means_absent); + tree + } +} |