diff options
author | Teddy Wing | 2020-08-01 15:32:17 +0200 |
---|---|---|
committer | Teddy Wing | 2020-08-01 15:32:17 +0200 |
commit | fdc1cc7bc3d237ef4976f9f9d2ffc09b7ba164d5 (patch) | |
tree | 4dc68f07d4a2e7ad8193f2b5f9a90c9fe6792aec /github-suggestion/src | |
parent | b218abebdf8a0aed73bfe6f61ab22e51a0f2f43c (diff) | |
download | git-suggestion-fdc1cc7bc3d237ef4976f9f9d2ffc09b7ba164d5.tar.bz2 |
Move library to a separate crate
Keep the binaries in the root crate, and add a new crate for the
`github-suggestion` library.
I want to add some library code to the CLI programs to extract common
functionality between the different binaries.
Diffstat (limited to 'github-suggestion/src')
-rw-r--r-- | github-suggestion/src/client.rs | 58 | ||||
-rw-r--r-- | github-suggestion/src/lib.rs | 11 | ||||
-rw-r--r-- | github-suggestion/src/suggestion.rs | 346 | ||||
-rw-r--r-- | github-suggestion/src/url.rs | 52 |
4 files changed, 467 insertions, 0 deletions
diff --git a/github-suggestion/src/client.rs b/github-suggestion/src/client.rs new file mode 100644 index 0000000..c6ae1f1 --- /dev/null +++ b/github-suggestion/src/client.rs @@ -0,0 +1,58 @@ +use github_rs::client::{Executor, Github}; +use serde_json::Value; +use thiserror::Error; + +use crate::suggestion::Suggestion; + + +#[derive(Debug, Error)] +pub enum Error { + #[error("GitHub client error: {0}")] + Github(String), + + #[error("Unable to deserialize")] + Deserialize(#[from] serde_json::error::Error), +} + + +pub struct Client<'a> { + client: Github, + owner: &'a str, + repo: &'a str, +} + +impl<'a> Client<'a> { + pub fn new( + token: &str, + owner: &'a str, repo: &'a str, + ) -> Result<Self, Error> { + let client = match Github::new(&token) { + Ok(g) => g, + Err(e) => return Err(Error::Github(e.to_string())), + }; + + Ok(Client { client, owner, repo }) + } + + pub fn fetch(&self, id: &str) -> Result<Suggestion, Error> { + let response = self.client + .get() + .repos() + .owner(self.owner) + .repo(self.repo) + .pulls() + .comments() + .id(id) + .execute::<Value>(); + + match response { + Ok((_, _, Some(json))) => { + let suggestion = serde_json::from_value(json)?; + + Ok(suggestion) + }, + Ok((_, _, None)) => Err(Error::Github("no response".to_owned())), + Err(e) => Err(Error::Github(e.to_string())), + } + } +} diff --git a/github-suggestion/src/lib.rs b/github-suggestion/src/lib.rs new file mode 100644 index 0000000..c082736 --- /dev/null +++ b/github-suggestion/src/lib.rs @@ -0,0 +1,11 @@ +#![warn(rust_2018_idioms)] + + +pub mod client; +pub mod suggestion; + +mod url; + +pub use crate::client::Client; +pub use crate::suggestion::Suggestion; +pub use crate::url::SuggestionUrl; diff --git a/github-suggestion/src/suggestion.rs b/github-suggestion/src/suggestion.rs new file mode 100644 index 0000000..2c92e6a --- /dev/null +++ b/github-suggestion/src/suggestion.rs @@ -0,0 +1,346 @@ +use std::io::{BufRead, BufReader, BufWriter, Write}; +use std::path::Path; + +use git2::{Patch, Repository}; +use regex::Regex; +use serde::Deserialize; +use thiserror::Error; + + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + Git(#[from] git2::Error), + + #[error("{0} is not a blob")] + GitObjectNotBlob(git2::Oid), + + #[error("{message}")] + BufWriter { + source: std::io::IntoInnerError<BufWriter<Vec<u8>>>, + message: String, + }, + + #[error("{message}: {source}")] + Io { + source: std::io::Error, + message: String, + }, + + #[error("{0} is not valid UTF-8")] + InvalidUtf8(String), + + #[error("Regex error: {0}")] + Regex(#[from] regex::Error), +} + +#[derive(Debug, PartialEq)] +enum LineEnding { + Lf, + CrLf, +} + +#[derive(Debug, Deserialize)] +pub struct Suggestion { + #[serde(rename = "diff_hunk")] + diff: String, + + #[serde(rename = "body")] + comment: String, + + #[serde(rename = "original_commit_id")] + commit: String, + + path: String, + + original_start_line: Option<usize>, + + #[serde(rename = "original_line")] + original_end_line: usize, +} + +impl Suggestion { + pub fn diff(&self) -> Result<String, Error> { + let repo = Repository::open(".")?; + + self.diff_with_repo(&repo) + } + + fn diff_with_repo(&self, repo: &Repository) -> Result<String, Error> { + let commit = repo.find_commit(self.commit.parse()?)?; + + let path = Path::new(&self.path); + + let object = commit + .tree()? + .get_path(path)? + .to_object(&repo)?; + + let blob = object.as_blob() + .ok_or_else(|| Error::GitObjectNotBlob(object.id()))?; + + let blob_reader = BufReader::new(blob.content()); + let mut new = BufWriter::new(Vec::new()); + self.apply_to(blob_reader, &mut new)?; + let new_buffer = new.into_inner() + .map_err(|e| Error::BufWriter { + source: e, + message: "unable to read right side of patch".to_owned(), + })?; + + let mut diff = Patch::from_blob_and_buffer( + blob, + Some(&path), + &new_buffer, + Some(&path), + None, + )?; + + Ok( + diff.to_buf()? + .as_str() + .ok_or_else(|| Error::InvalidUtf8("diff".to_owned()))? + .to_owned() + ) + } + + fn suggestion_with_line_ending( + &self, + line_ending: &LineEnding, + ) -> Result<String, Error> { + let re = Regex::new(r"(?s).*(?-s)```\s*suggestion.*\n")?; + let s = re.replace(&self.comment, ""); + let s = s.replace("```", ""); + + // Suggestion blocks use CRLF by default. + if *line_ending == LineEnding::Lf { + return Ok(s.replace('\r', "")); + } + + Ok(s) + } + + pub fn apply(&self) -> Result<(), Error> { + let repo = Repository::open(".")?; + + let diff_text = self.diff_with_repo(&repo)?; + let diff = git2::Diff::from_buffer(diff_text.as_bytes())?; + + repo.apply( + &diff, + git2::ApplyLocation::WorkDir, + None, + )?; + + Ok(()) + } + + fn apply_to<R: BufRead, W: Write>( + &self, + reader: R, + writer: &mut W, + ) -> Result<(), Error> { + let mut line_ending = LineEnding::Lf; + + for (i, line) in reader.lines().enumerate() { + let line_number = i + 1; + + let line = line.map_err(|e| Error::Io { + source: e, + message: "Unable to read line".to_owned(), + })?; + + // Determine which line endings the file uses by looking at the + // first line. + if line_number == 1 && is_line_crlf(&line) { + line_ending = LineEnding::CrLf; + } + + if line_number == self.original_end_line { + write!( + writer, + "{}", + self.suggestion_with_line_ending(&line_ending)?, + ).map_err(|e| Error::Io { + source: e, + message: "Write error".to_owned(), + })?; + } else if self.original_start_line.is_none() + || line_number < self.original_start_line.unwrap() + || line_number > self.original_end_line { + writeln!(writer, "{}", line) + .map_err(|e| Error::Io { + source: e, + message: "Write error".to_owned(), + })?; + } + } + + Ok(()) + } +} + +/// Determine the line ending for `line`. +/// +/// If the second-to-last character on the first line is "\r", assume CRLF. +/// Otherwise, default to LF. +fn is_line_crlf(line: &str) -> bool { + if let Some(c) = line.chars().rev().nth(2) { + if c == '\r' { + return true; + } + } + + false +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn suggestion_diff_with_repo_generates_diff() { + use tempfile::tempdir; + + + let git_root = tempdir().unwrap(); + let repo = Repository::init(git_root.path()).unwrap(); + + let file = r#" + ‘Beware the Jabberwock, my son! + The jaws that bite, the claws that catch! + Beware the Jubjub bird, and shun + The frumious Bandersnatch!’ + + He took his vorpal blade in hand: + Long time the manxome foe he sought-- + So rested he by the Tumtum tree, + And stood awhile in thought. +"#; + + let path = "poems/Jabberwocky.txt"; + + let mut index = repo.index().unwrap(); + index.add_frombuffer( + &git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), + mtime: git2::IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: 0o100644, + uid: 0, + gid: 0, + file_size: file.len() as u32, + id: git2::Oid::zero(), + flags: 0, + flags_extended: 0, + path: path.as_bytes().to_vec(), + }, + file.as_bytes(), + ).unwrap(); + let tree_oid = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_oid).unwrap(); + + let author = git2::Signature::now( + "Oshino Shinobu", + "oshino.shinobu@example.com", + ).unwrap(); + + let commit = repo.commit( + Some("HEAD"), + &author, + &author, + "Sample commit", + &tree, + &[], + ).unwrap(); + + let suggestion = Suggestion { + diff: "".to_owned(), + comment: r#"``` suggestion + He took his vorpal sword in hand: + Long time the manxome foe he sought— +```"#.to_owned(), + commit: commit.to_string(), + path: path.to_owned(), + original_start_line: Some(7), + original_end_line: 8, + }; + + let expected = r#"diff --git a/poems/Jabberwocky.txt b/poems/Jabberwocky.txt +index 89840a2..06acdfc 100644 +--- a/poems/Jabberwocky.txt ++++ b/poems/Jabberwocky.txt +@@ -4,7 +4,7 @@ + Beware the Jubjub bird, and shun + The frumious Bandersnatch!’ + +- He took his vorpal blade in hand: +- Long time the manxome foe he sought-- ++ He took his vorpal sword in hand: ++ Long time the manxome foe he sought— + So rested he by the Tumtum tree, + And stood awhile in thought. +"#; + + assert_eq!( + suggestion.diff_with_repo(&repo).unwrap(), + expected, + ); + } + + #[test] + fn suggestion_apply_to_writes_patch_to_writer() { + use std::io::Cursor; + + + let mut original_buffer = Vec::new(); + let original = r#" + ‘Beware the Jabberwock, my son! + The jaws that bite, the claws that catch! + Beware the Jubjub bird, and shun + The frumious Bandersnatch!’ + + He took his vorpal blade in hand: + Long time the manxome foe he sought-- + So rested he by the Tumtum tree, + And stood awhile in thought. +"#; + + write!(original_buffer, "{}", original).unwrap(); + + let suggestion = Suggestion { + diff: "".to_owned(), + comment: r#"``` suggestion + He took his vorpal sword in hand: + Long time the manxome foe he sought— +```"#.to_owned(), + commit: "".to_owned(), + path: "".to_owned(), + original_start_line: Some(7), + original_end_line: 8, + }; + + let expected = r#" + ‘Beware the Jabberwock, my son! + The jaws that bite, the claws that catch! + Beware the Jubjub bird, and shun + The frumious Bandersnatch!’ + + He took his vorpal sword in hand: + Long time the manxome foe he sought— + So rested he by the Tumtum tree, + And stood awhile in thought. +"#; + + let original_reader = Cursor::new(original_buffer); + let mut actual = Cursor::new(Vec::new()); + suggestion.apply_to(original_reader, &mut actual).unwrap(); + + assert_eq!( + std::str::from_utf8(&actual.into_inner()).unwrap(), + expected, + ); + } +} diff --git a/github-suggestion/src/url.rs b/github-suggestion/src/url.rs new file mode 100644 index 0000000..60a3d0e --- /dev/null +++ b/github-suggestion/src/url.rs @@ -0,0 +1,52 @@ +use std::str::FromStr; + +use thiserror::Error; + +use url; +use url::Url; + + +#[derive(Debug, Error)] +pub enum Error { + #[error("Unable to parse URL")] + Url(#[from] url::ParseError), + + #[error("URL has no path")] + NoPath, + + #[error("URL has no fragment")] + NoFragment, + + #[error("Unable to parse owner or repo")] + NoOwnerRepo, +} + +#[derive(Debug)] +pub struct SuggestionUrl { + pub owner: String, + pub repo: String, + pub comment_id: String, +} + +impl FromStr for SuggestionUrl { + type Err = Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let url = Url::parse(s)?; + let path = url.path_segments() + .ok_or(Error::NoPath)? + .collect::<Vec<_>>(); + + if path.len() < 2 { + return Err(Error::NoOwnerRepo); + } + + Ok(SuggestionUrl { + owner: path[0].to_owned(), + repo: path[1].to_owned(), + comment_id: url.fragment() + .ok_or(Error::NoFragment)? + .replacen("discussion_r", "", 1), + }) + } +} |