Initial commit
This commit is contained in:
		
						commit
						550a8b05c4
					
				|  | @ -0,0 +1 @@ | |||
| /target | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							|  | @ -0,0 +1,19 @@ | |||
| [package] | ||||
| name = "issuerss" | ||||
| version = "0.1.0" | ||||
| authors = ["blank X <theblankx@protonmail.com>"] | ||||
| edition = "2018" | ||||
| 
 | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
| 
 | ||||
| [profile.release] | ||||
| lto = true | ||||
| 
 | ||||
| [dependencies] | ||||
| tokio = { version = "1.4", features = ["rt"] } | ||||
| reqwest = "0.11" | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| chrono = { version = "0.4", default-features = false, features = ["std", "alloc"] } | ||||
| pulldown-cmark = "0.8" | ||||
| quick-xml = "0.22" | ||||
|  | @ -0,0 +1,21 @@ | |||
| MIT License | ||||
| 
 | ||||
| Copyright (c) 2021 blank X | ||||
| 
 | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| of this software and associated documentation files (the "Software"), to deal | ||||
| in the Software without restriction, including without limitation the rights | ||||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||||
| copies of the Software, and to permit persons to whom the Software is | ||||
| furnished to do so, subject to the following conditions: | ||||
| 
 | ||||
| The above copyright notice and this permission notice shall be included in all | ||||
| copies or substantial portions of the Software. | ||||
| 
 | ||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||||
| SOFTWARE. | ||||
|  | @ -0,0 +1,292 @@ | |||
| mod structs; | ||||
| 
 | ||||
| use pulldown_cmark::{Options, Parser}; | ||||
| use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; | ||||
| use quick_xml::Writer; | ||||
| use reqwest::{ | ||||
|     header::{HeaderMap, HeaderValue}, | ||||
|     ClientBuilder, | ||||
| }; | ||||
| use std::env; | ||||
| use std::io::Cursor; | ||||
| use std::iter::Skip; | ||||
| extern crate serde_json; | ||||
| 
 | ||||
| pub async fn run(mut args: Skip<env::Args>) { | ||||
|     let repo = args.next().expect("Missing repo"); | ||||
|     let issue_number = args.next().expect("Missing issue number"); | ||||
|     let mut header_map = HeaderMap::new(); | ||||
|     header_map.insert( | ||||
|         "accept", | ||||
|         HeaderValue::from_static("application/vnd.github.v3+json"), | ||||
|     ); | ||||
|     let client = ClientBuilder::new() | ||||
|         .default_headers(header_map) | ||||
|         .user_agent(&format!( | ||||
|             "{}/{}", | ||||
|             env!("CARGO_PKG_NAME"), | ||||
|             env!("CARGO_PKG_VERSION") | ||||
|         )) | ||||
|         .build() | ||||
|         .unwrap(); | ||||
|     let text = client | ||||
|         .get(&format!( | ||||
|             "https://api.github.com/repos/{}/issues/{}", | ||||
|             repo, issue_number | ||||
|         )) | ||||
|         .send() | ||||
|         .await | ||||
|         .unwrap() | ||||
|         .text() | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     let issue: structs::Issue = serde_json::from_str(&text).unwrap(); | ||||
|     let text = client | ||||
|         .get(&issue.events_url) | ||||
|         .send() | ||||
|         .await | ||||
|         .unwrap() | ||||
|         .text() | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     let mut events: Vec<structs::EventType> = serde_json::from_str(&text).unwrap(); | ||||
|     let text = client | ||||
|         .get(&issue.comments_url) | ||||
|         .send() | ||||
|         .await | ||||
|         .unwrap() | ||||
|         .text() | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     let comments: Vec<structs::EventType> = serde_json::from_str(&text).unwrap(); | ||||
|     events.extend(comments); | ||||
|     events.sort_by(|i, j| { | ||||
|         let i = match i { | ||||
|             structs::EventType::Comment(comment) => comment.created_at, | ||||
|             structs::EventType::Event(event) => event.created_at, | ||||
|         }; | ||||
|         let j = match j { | ||||
|             structs::EventType::Comment(comment) => comment.created_at, | ||||
|             structs::EventType::Event(event) => event.created_at, | ||||
|         }; | ||||
|         i.partial_cmp(&j).unwrap() | ||||
|     }); | ||||
| 
 | ||||
|     let mut writer = Writer::new(Cursor::new(Vec::new())); | ||||
|     { | ||||
|         let mut elem = BytesStart::owned(b"rss".to_vec(), 3); | ||||
|         elem.push_attribute(("version", "2.0")); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"channel".to_vec(), 7); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"title".to_vec(), 5); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.title).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"title".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"link".to_vec(), 4); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.html_url).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"link".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"description".to_vec(), 11); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&format!("Comments and events from {}", &issue.title)) | ||||
|             .into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"description".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"item".to_vec(), 4); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"title".to_vec(), 5); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.title).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"title".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"link".to_vec(), 4); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.html_url).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"link".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let mut elem = BytesStart::owned(b"guid".to_vec(), 4); | ||||
|         elem.push_attribute(("isPermaLink", "false")); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.node_id).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"guid".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"author".to_vec(), 6); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.user.login).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"author".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"pubDate".to_vec(), 7); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.created_at.to_rfc2822()).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"pubDate".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         if !issue.body.is_empty() { | ||||
|             let elem = BytesStart::owned(b"description".to_vec(), 11); | ||||
|             writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|             let parser = Parser::new_ext(&issue.body, Options::all()); | ||||
|             let mut html_buf = String::with_capacity(issue.body.len()); | ||||
|             pulldown_cmark::html::push_html(&mut html_buf, parser); | ||||
|             let elem = BytesText::from_plain_str(html_buf.trim()).into_owned(); | ||||
|             writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|             let elem = BytesEnd::owned(b"description".to_vec()); | ||||
|             writer.write_event(Event::End(elem)).unwrap(); | ||||
|         } | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"item".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
|     } | ||||
| 
 | ||||
|     for event in events { | ||||
|         let (title, link, description, author, guid, pub_date) = match event { | ||||
|             structs::EventType::Comment(comment) => { | ||||
|                 let parser = Parser::new_ext(&comment.body, Options::all()); | ||||
|                 let mut html_buf = String::with_capacity(comment.body.len()); | ||||
|                 pulldown_cmark::html::push_html(&mut html_buf, parser); | ||||
|                 let mut title = comment.body.splitn(2, '\n').next().unwrap().to_string(); | ||||
|                 if title.len() > 80 { | ||||
|                     title = format!("{}...", title.split_at(80).0); | ||||
|                 } | ||||
|                 ( | ||||
|                     title, | ||||
|                     Some(comment.html_url), | ||||
|                     html_buf.trim().to_string(), | ||||
|                     comment.user.login, | ||||
|                     comment.node_id, | ||||
|                     comment.created_at.to_rfc2822(), | ||||
|                 ) | ||||
|             } | ||||
|             structs::EventType::Event(event) => { | ||||
|                 let mut text = "Extra fields:\n<pre><code>".to_string(); | ||||
|                 pulldown_cmark::escape::escape_html( | ||||
|                     &mut text, | ||||
|                     &serde_json::to_string_pretty(&event.extra) | ||||
|                         .unwrap_or_else(|_| format!("{:?}", &event.extra)), | ||||
|                 ) | ||||
|                 .unwrap(); | ||||
|                 text.push_str("</code></pre>"); | ||||
|                 ( | ||||
|                     format!("New event: {}", &event.event), | ||||
|                     None, | ||||
|                     text, | ||||
|                     event.actor.login, | ||||
|                     event.node_id, | ||||
|                     event.created_at.to_rfc2822(), | ||||
|                 ) | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"item".to_vec(), 4); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"title".to_vec(), 5); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&title).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"title".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         if let Some(link) = link { | ||||
|             let elem = BytesStart::owned(b"link".to_vec(), 4); | ||||
|             writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|             let elem = BytesText::from_plain_str(&link).into_owned(); | ||||
|             writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|             let elem = BytesEnd::owned(b"link".to_vec()); | ||||
|             writer.write_event(Event::End(elem)).unwrap(); | ||||
|         } | ||||
| 
 | ||||
|         let mut elem = BytesStart::owned(b"guid".to_vec(), 4); | ||||
|         elem.push_attribute(("isPermaLink", "false")); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&guid).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"guid".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"author".to_vec(), 6); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&author).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"author".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"pubDate".to_vec(), 7); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&pub_date).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"pubDate".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"description".to_vec(), 11); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&description).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"description".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"item".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
|     } | ||||
| 
 | ||||
|     let elem = BytesEnd::owned(b"channel".to_vec()); | ||||
|     writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|     let elem = BytesEnd::owned(b"rss".to_vec()); | ||||
|     writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|     println!( | ||||
|         "{}", | ||||
|         String::from_utf8(writer.into_inner().into_inner()).unwrap() | ||||
|     ); | ||||
| } | ||||
|  | @ -0,0 +1,80 @@ | |||
| use chrono::{DateTime, FixedOffset}; | ||||
| use serde::de::{self, Visitor}; | ||||
| use serde::{Deserialize, Deserializer}; | ||||
| use std::collections::HashMap; | ||||
| use std::fmt; | ||||
| use std::marker::PhantomData; | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| pub struct Issue { | ||||
|     pub comments_url: String, | ||||
|     pub events_url: String, | ||||
|     pub html_url: String, | ||||
|     pub node_id: String, | ||||
|     pub title: String, | ||||
|     pub user: User, | ||||
|     pub state: String, | ||||
|     pub body: String, | ||||
|     #[serde(deserialize_with = "deserialize_datetime")] | ||||
|     pub created_at: DateTime<FixedOffset>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| #[serde(untagged)] | ||||
| pub enum EventType { | ||||
|     Comment(Comment), | ||||
|     Event(Event), | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| pub struct Comment { | ||||
|     pub html_url: String, | ||||
|     pub node_id: String, | ||||
|     pub user: User, | ||||
|     #[serde(deserialize_with = "deserialize_datetime")] | ||||
|     pub created_at: DateTime<FixedOffset>, | ||||
|     pub body: String, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| pub struct Event { | ||||
|     pub url: String, | ||||
|     pub id: usize, | ||||
|     pub node_id: String, | ||||
|     pub actor: User, | ||||
|     #[serde(deserialize_with = "deserialize_datetime")] | ||||
|     pub created_at: DateTime<FixedOffset>, | ||||
|     pub event: String, | ||||
|     #[serde(flatten)] | ||||
|     pub extra: HashMap<String, serde_json::Value>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| pub struct User { | ||||
|     pub login: String, | ||||
| } | ||||
| 
 | ||||
| fn deserialize_datetime<'de, D>(deserializer: D) -> Result<DateTime<FixedOffset>, D::Error> | ||||
| where | ||||
|     D: Deserializer<'de>, | ||||
| { | ||||
|     struct DeserializeDateTime<T>(PhantomData<fn() -> T>); | ||||
| 
 | ||||
|     impl<'de> Visitor<'de> for DeserializeDateTime<DateTime<FixedOffset>> { | ||||
|         type Value = DateTime<FixedOffset>; | ||||
| 
 | ||||
|         fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { | ||||
|             formatter.write_str("RFC3339 datetime string") | ||||
|         } | ||||
| 
 | ||||
|         fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> | ||||
|         where | ||||
|             E: de::Error, | ||||
|         { | ||||
|             //          https://brokenco.de/2020/08/03/serde-deserialize-with-string.html
 | ||||
|             DateTime::parse_from_rfc3339(value).map_err(serde::de::Error::custom) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     deserializer.deserialize_any(DeserializeDateTime(PhantomData)) | ||||
| } | ||||
|  | @ -0,0 +1,256 @@ | |||
| mod structs; | ||||
| 
 | ||||
| use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event}; | ||||
| use quick_xml::{Reader, Writer}; | ||||
| use reqwest::{Client, Url}; | ||||
| use std::borrow::Cow; | ||||
| use std::env; | ||||
| use std::io::Cursor; | ||||
| use std::iter::Skip; | ||||
| extern crate serde_json; | ||||
| 
 | ||||
| pub async fn run(mut args: Skip<env::Args>) { | ||||
|     let repo = args.next().expect("Missing repo"); | ||||
|     let issue_number = args.next().expect("Missing issue number"); | ||||
|     let query = match args.next().expect("Missing type").as_str() { | ||||
|         "issue" => structs::ISSUE_QUERY, | ||||
|         "mr" => structs::MR_QUERY, | ||||
|         _ => panic!("Unknown type"), | ||||
|     }; | ||||
|     let client = Client::new(); | ||||
|     let text = client | ||||
|         .post("https://gitlab.com/api/graphql") | ||||
|         .body( | ||||
|             serde_json::json!({"query": query, "variables": {"repo": &repo, "id": &issue_number}}) | ||||
|                 .to_string(), | ||||
|         ) | ||||
|         .header("Content-Type", "application/json") | ||||
|         .send() | ||||
|         .await | ||||
|         .unwrap() | ||||
|         .text() | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     let issue: structs::Data = serde_json::from_str(&text).unwrap(); | ||||
|     let issue = match issue.data.project { | ||||
|         structs::IssueOrMR::Issue(i) => i, | ||||
|         structs::IssueOrMR::MergeRequest(i) => i, | ||||
|     }; | ||||
| 
 | ||||
|     let mut writer = Writer::new(Cursor::new(Vec::new())); | ||||
|     { | ||||
|         let mut elem = BytesStart::owned(b"rss".to_vec(), 3); | ||||
|         elem.push_attribute(("version", "2.0")); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"channel".to_vec(), 7); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"title".to_vec(), 5); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.title).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"title".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"link".to_vec(), 4); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.web_url).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"link".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"description".to_vec(), 11); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&format!("Comments and events from {}", &issue.title)) | ||||
|             .into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"description".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"item".to_vec(), 4); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"title".to_vec(), 5); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.title).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"title".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"link".to_vec(), 4); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.web_url).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"link".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let mut elem = BytesStart::owned(b"guid".to_vec(), 4); | ||||
|         elem.push_attribute(("isPermaLink", "false")); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.id).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"guid".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"author".to_vec(), 6); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.author.username).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"author".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"pubDate".to_vec(), 7); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&issue.created_at.to_rfc2822()).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"pubDate".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         if !issue.description_html.is_empty() { | ||||
|             let elem = BytesStart::owned(b"description".to_vec(), 11); | ||||
|             writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|             let elem = BytesText::from_plain_str(&fix_description( | ||||
|                 &issue.description_html, | ||||
|                 &issue.web_url, | ||||
|             )) | ||||
|             .into_owned(); | ||||
|             writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|             let elem = BytesEnd::owned(b"description".to_vec()); | ||||
|             writer.write_event(Event::End(elem)).unwrap(); | ||||
|         } | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"item".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
|     } | ||||
| 
 | ||||
|     for note in issue.notes.nodes { | ||||
|         let mut title = note.body.splitn(2, '\n').next().unwrap().to_string(); | ||||
|         if title.len() > 80 { | ||||
|             title = format!("{}...", title.split_at(80).0); | ||||
|         } | ||||
|         let pub_date = note.created_at.to_rfc2822(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"item".to_vec(), 4); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"title".to_vec(), 5); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&title).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"title".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"link".to_vec(), 4); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(¬e.url).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"link".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let mut elem = BytesStart::owned(b"guid".to_vec(), 4); | ||||
|         elem.push_attribute(("isPermaLink", "false")); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(¬e.id).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"guid".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"author".to_vec(), 6); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(¬e.author.username).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"author".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"pubDate".to_vec(), 7); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesText::from_plain_str(&pub_date).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"pubDate".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesStart::owned(b"description".to_vec(), 11); | ||||
|         writer.write_event(Event::Start(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = | ||||
|             BytesText::from_plain_str(&fix_description(¬e.body_html, ¬e.url)).into_owned(); | ||||
|         writer.write_event(Event::Text(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"description".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|         let elem = BytesEnd::owned(b"item".to_vec()); | ||||
|         writer.write_event(Event::End(elem)).unwrap(); | ||||
|     } | ||||
| 
 | ||||
|     let elem = BytesEnd::owned(b"channel".to_vec()); | ||||
|     writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|     let elem = BytesEnd::owned(b"rss".to_vec()); | ||||
|     writer.write_event(Event::End(elem)).unwrap(); | ||||
| 
 | ||||
|     println!( | ||||
|         "{}", | ||||
|         String::from_utf8(writer.into_inner().into_inner()).unwrap() | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| fn fix_description(text: &str, html_url: &str) -> String { | ||||
|     let mut reader = Reader::from_str(text); | ||||
|     reader.check_end_names(false); | ||||
|     let mut writer = Writer::new(Cursor::new(Vec::new())); | ||||
|     let mut buf = Vec::new(); | ||||
|     loop { | ||||
|         match reader.read_event(&mut buf) { | ||||
|             Ok(Event::Start(ref e)) if e.name() == b"a" => { | ||||
|                 let mut elem = BytesStart::owned(b"a".to_vec(), 1); | ||||
|                 for mut attribute in e.attributes().filter_map(|i| i.ok()) { | ||||
|                     if attribute.key == b"href" { | ||||
|                         if let Ok(unescaped) = attribute.unescape_and_decode_value(&reader) { | ||||
|                             if let Ok(url) = Url::parse(html_url).unwrap().join(&unescaped) { | ||||
|                                 attribute.value = Cow::Owned(url.as_str().as_bytes().to_vec()); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                     elem.push_attribute((attribute.key, &attribute.value[..])); | ||||
|                 } | ||||
|                 writer.write_event(Event::Start(elem)) | ||||
|             } | ||||
|             Ok(Event::Eof) => break, | ||||
|             Ok(e) => writer.write_event(e), | ||||
|             Err(e) => panic!("Error at position {}: {:?}", reader.buffer_position(), e), | ||||
|         } | ||||
|         .unwrap(); | ||||
|         buf.clear(); | ||||
|     } | ||||
|     String::from_utf8(writer.into_inner().into_inner()).unwrap() | ||||
| } | ||||
|  | @ -0,0 +1,136 @@ | |||
| use chrono::{DateTime, FixedOffset}; | ||||
| use serde::de::{self, Visitor}; | ||||
| use serde::{Deserialize, Deserializer}; | ||||
| use std::fmt; | ||||
| use std::marker::PhantomData; | ||||
| 
 | ||||
| pub const ISSUE_QUERY: &str = r#"query ($repo: ID!, $id: String) {
 | ||||
|     project (fullPath: $repo) { | ||||
|         issue (iid: $id) { | ||||
|             id | ||||
|             descriptionHtml | ||||
|             author { | ||||
|                 username | ||||
|             } | ||||
|             title | ||||
|             createdAt | ||||
|             webUrl | ||||
|             notes { | ||||
|                 nodes { | ||||
|                     id | ||||
|                     author { | ||||
|                         username | ||||
|                     } | ||||
|                     body | ||||
|                     bodyHtml | ||||
|                     createdAt | ||||
|                     url | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| }"#;
 | ||||
| 
 | ||||
| pub const MR_QUERY: &str = r#"query ($repo: ID!, $id: String!) {
 | ||||
|     project (fullPath: $repo) { | ||||
|         mergeRequest (iid: $id) { | ||||
|             id | ||||
|             descriptionHtml | ||||
|             author { | ||||
|                 username | ||||
|             } | ||||
|             title | ||||
|             createdAt | ||||
|             webUrl | ||||
|             notes { | ||||
|                 nodes { | ||||
|                     id | ||||
|                     author { | ||||
|                         username | ||||
|                     } | ||||
|                     body | ||||
|                     bodyHtml | ||||
|                     createdAt | ||||
|                     url | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| }"#;
 | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| pub struct Data { | ||||
|     pub data: Project, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| pub struct Project { | ||||
|     pub project: IssueOrMR, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub enum IssueOrMR { | ||||
|     Issue(IssueInfo), | ||||
|     MergeRequest(IssueInfo), | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub struct IssueInfo { | ||||
|     pub id: String, | ||||
|     pub description_html: String, | ||||
|     pub author: Author, | ||||
|     pub title: String, | ||||
|     #[serde(deserialize_with = "deserialize_datetime")] | ||||
|     pub created_at: DateTime<FixedOffset>, | ||||
|     pub web_url: String, | ||||
|     pub notes: NoteConnection, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| pub struct Author { | ||||
|     pub username: String, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| pub struct NoteConnection { | ||||
|     pub nodes: Vec<Note>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, Debug)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub struct Note { | ||||
|     pub id: String, | ||||
|     pub author: Author, | ||||
|     pub body: String, | ||||
|     pub body_html: String, | ||||
|     #[serde(deserialize_with = "deserialize_datetime")] | ||||
|     pub created_at: DateTime<FixedOffset>, | ||||
|     pub url: String, | ||||
| } | ||||
| 
 | ||||
| fn deserialize_datetime<'de, D>(deserializer: D) -> Result<DateTime<FixedOffset>, D::Error> | ||||
| where | ||||
|     D: Deserializer<'de>, | ||||
| { | ||||
|     struct DeserializeDateTime<T>(PhantomData<fn() -> T>); | ||||
| 
 | ||||
|     impl<'de> Visitor<'de> for DeserializeDateTime<DateTime<FixedOffset>> { | ||||
|         type Value = DateTime<FixedOffset>; | ||||
| 
 | ||||
|         fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { | ||||
|             formatter.write_str("RFC3339 datetime string") | ||||
|         } | ||||
| 
 | ||||
|         fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> | ||||
|         where | ||||
|             E: de::Error, | ||||
|         { | ||||
|             //          https://brokenco.de/2020/08/03/serde-deserialize-with-string.html
 | ||||
|             DateTime::parse_from_rfc3339(value).map_err(serde::de::Error::custom) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     deserializer.deserialize_any(DeserializeDateTime(PhantomData)) | ||||
| } | ||||
|  | @ -0,0 +1,18 @@ | |||
| mod github; | ||||
| mod gitlab; | ||||
| 
 | ||||
| use std::env; | ||||
| extern crate tokio; | ||||
| 
 | ||||
| fn main() { | ||||
|     let mut args = env::args().skip(1); | ||||
|     let rt = tokio::runtime::Builder::new_current_thread() | ||||
|         .enable_all() | ||||
|         .build() | ||||
|         .unwrap(); | ||||
|     match args.next().expect("Missing service").as_str() { | ||||
|         "github" => rt.block_on(github::run(args)), | ||||
|         "gitlab" => rt.block_on(gitlab::run(args)), | ||||
|         _ => panic!("Unknown service"), | ||||
|     }; | ||||
| } | ||||
		Loading…
	
		Reference in New Issue