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