mirror of
https://github.com/Lumither/nocturne.git
synced 2026-10-17 10:26:21 +00:00
feat(backend) update (unfinished)
This commit is contained in:
20 files changed
+131
-317
No files matched your search
@@ -9,7 +9,8 @@ fn main() {
|
||||
.peel_to_commit()
|
||||
.expect("failed to get HEAD commit");
|
||||
let hash = commit.id().to_string();
|
||||
let utc_date = Utc::now().date_naive().format("%Y%m%d").to_string();
|
||||
let build_id = format!("{}@{}", utc_date, &hash[..8]);
|
||||
let utc_timestamp = Utc::now().date_naive().format("%Y%m%d").to_string();
|
||||
let pkg_version = env!("CARGO_PKG_VERSION");
|
||||
let build_id = format!("{}:{}@{}", pkg_version, &hash[..8], utc_timestamp);
|
||||
println!("cargo:rustc-env=BUILD_ID={}", build_id);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
CREATE TABLE tag
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(20) UNIQUE NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE category
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(20) UNIQUE NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE post
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
identifier VARCHAR(50) UNIQUE NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
subtitle VARCHAR(255) NOT NULL,
|
||||
published_date DATE NOT NULL,
|
||||
last_update DATE,
|
||||
content TEXT NOT NULL,
|
||||
|
||||
category SERIAL REFERENCES category (id) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE post_tag
|
||||
(
|
||||
post SERIAL REFERENCES post (id),
|
||||
tag SERIAL REFERENCES tag (id)
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
mod database;
|
||||
pub mod logger;
|
||||
mod misc;
|
||||
mod schema;
|
||||
|
||||
use std::{env, panic};
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
use sqlx::{query, Pool, Postgres};
|
||||
use tracing::info;
|
||||
//
|
||||
// const POST_BASE_MODEL: &str = r##"
|
||||
// CREATE TABLE IF NOT EXISTS Post
|
||||
// (
|
||||
// post_id UUID PRIMARY KEY,
|
||||
// title VARCHAR(255) NOT NULL,
|
||||
// category VARCHAR(255) NOT NULL,
|
||||
// filename TEXT UNIQUE NOT NULL,
|
||||
// summary TEXT,
|
||||
// sub_title TEXT,
|
||||
// content TEXT NOT NULL,
|
||||
// last_update TIMESTAMPTZ,
|
||||
// first_update TIMESTAMPTZ NOT NULL
|
||||
// );
|
||||
// "##;
|
||||
//
|
||||
// // base model, will add more cols at runtime
|
||||
// const POST_META_MODEL: &str = r##"
|
||||
// CREATE TABLE IF NOT EXISTS Meta
|
||||
// (
|
||||
// post_id UUID PRIMARY KEY
|
||||
// );
|
||||
// "##;
|
||||
//
|
||||
// const POST_HASH_MODEL: &str = r##"
|
||||
// CREATE TABLE IF NOT EXISTS Hash
|
||||
// (
|
||||
// post_id UUID PRIMARY KEY,
|
||||
// hash VARCHAR(44)
|
||||
// );
|
||||
// "##;
|
||||
//
|
||||
// const TAG_MODEL: &str = r##"
|
||||
// CREATE TABLE IF NOT EXISTS Tag
|
||||
// (
|
||||
// post_id UUID NOT NULL,
|
||||
// tag VARCHAR(255) NOT NULL
|
||||
// );
|
||||
// "##;
|
||||
|
||||
pub async fn init(db_connection: &Pool<Postgres>) {
|
||||
// query(POST_BASE_MODEL).execute(db_connection).await.unwrap();
|
||||
// query(POST_META_MODEL).execute(db_connection).await.unwrap();
|
||||
// query(TAG_MODEL).execute(db_connection).await.unwrap();
|
||||
// query(POST_HASH_MODEL).execute(db_connection).await.unwrap();
|
||||
info!("database schema initialized");
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
#[allow(dead_code)]
|
||||
mod constants;
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod scheduler;
|
||||
|
||||
mod init;
|
||||
mod modules;
|
||||
mod scheduler;
|
||||
mod utils;
|
||||
|
||||
use crate::init::{logger, start};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
use crate::constants::blog::PAGE_SIZE;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{query, PgPool, Row};
|
||||
use tracing::error;
|
||||
|
||||
pub async fn get_page_count(
|
||||
State(db_connection): State<PgPool>,
|
||||
) -> Result<Json<Value>, Json<Value>> {
|
||||
match query("select cast(ceil(count(*) / $1) as integer) as res from post")
|
||||
.bind(PAGE_SIZE as f32)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
let res: i32 = res.get("res");
|
||||
Ok(Json::from(json!({"res": res})))
|
||||
}
|
||||
Err(e) => {
|
||||
// todo: rename
|
||||
error!("Failed to get blog page count: {}", e.to_string());
|
||||
Err(Json::from(json!({"error": e.to_string()})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::constants::blog::PAGE_SIZE;
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{query, PgPool, Row};
|
||||
use tracing::error;
|
||||
|
||||
const GET_PAGE_COUNT_QUERY: &str = include_str!("post_page_count.sql");
|
||||
|
||||
pub async fn handler(State(db_connection): State<PgPool>) -> Result<Json<Value>, Json<Value>> {
|
||||
match query(GET_PAGE_COUNT_QUERY)
|
||||
.bind(PAGE_SIZE as i64)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(res) => {
|
||||
let res: i32 = res.get("count");
|
||||
Ok(Json::from(json!({"res": res})))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("failed to get blog page count: {}", e.to_string());
|
||||
Err(Json::from(json!({"error": e.to_string()})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
SELECT cast(ceil(count(*) / cast($1 AS FLOAT)) AS INTEGER) AS count
|
||||
FROM posts;
|
||||
@@ -1,153 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{query, Error, PgPool, Row};
|
||||
use std::str::FromStr;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_post(
|
||||
State(db_connection): State<PgPool>,
|
||||
Path(post_id): Path<String>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let post_id = match Uuid::from_str(post_id.as_str()) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to parse UUID <{}>: {}",
|
||||
post_id.as_str(),
|
||||
e.to_string()
|
||||
);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json::from(json!(("error", e.to_string()))),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let post = match query(
|
||||
r#"
|
||||
SELECT jsonb_strip_nulls(to_jsonb(t.*)) - 'post_id' AS post
|
||||
FROM post t
|
||||
WHERE post_id = $1;
|
||||
"#,
|
||||
)
|
||||
.bind(post_id)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value.get::<Value, _>("post"),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Database error on Reading `base post information` from table `post`: <{}>: {}",
|
||||
post_id.to_string(),
|
||||
e.to_string()
|
||||
);
|
||||
return match e {
|
||||
Error::RowNotFound => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
)),
|
||||
_ => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let tags: Vec<_> = match query("SELECT tag FROM Tag WHERE post_id = $1;")
|
||||
.bind(post_id)
|
||||
.fetch_all(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(tags) => tags.iter().map(|tag| tag.get::<String, _>("tag")).collect(),
|
||||
Err(e) => {
|
||||
error!("Database query error :{}", e.to_string());
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let meta = match query(
|
||||
r#"
|
||||
SELECT jsonb_strip_nulls(to_jsonb(t.*)) - 'post_id' AS meta
|
||||
FROM meta t
|
||||
WHERE post_id = $1;
|
||||
"#,
|
||||
)
|
||||
.bind(post_id)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value.get::<Value, _>("meta"),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Database error on Reading `{}` from table `{}`: <{}>: {}",
|
||||
"post meta data",
|
||||
"meta",
|
||||
post_id.to_string(),
|
||||
e.to_string()
|
||||
);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let adjacent = match query(r"
|
||||
WITH target_post AS (SELECT t.date FROM meta t WHERE post_id = $1),
|
||||
prev AS (SELECT post_id, t.title, t.sub_title, t.header_img
|
||||
FROM meta t
|
||||
WHERE t.date < (SELECT date FROM target_post)
|
||||
ORDER BY t.date DESC
|
||||
LIMIT 1),
|
||||
next AS (SELECT post_id, t.title, t.sub_title, t.header_img
|
||||
FROM meta t
|
||||
WHERE t.date > (SELECT date FROM target_post)
|
||||
ORDER BY t.date
|
||||
LIMIT 1)
|
||||
SELECT json_strip_nulls(json_build_object(
|
||||
'prev',
|
||||
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
|
||||
FROM prev),
|
||||
'next',
|
||||
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
|
||||
FROM next)
|
||||
)) AS result;
|
||||
"
|
||||
)
|
||||
.bind(post_id)
|
||||
.fetch_one(&db_connection)
|
||||
.await
|
||||
{
|
||||
Ok(res) => {res.get::<Value, _>("result")}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Database error on Reading `{}` from table `{}`: <{}>: {}",
|
||||
"adjoint posts data",
|
||||
"meta",
|
||||
post_id.to_string(),
|
||||
e.to_string()
|
||||
);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(json!({"error": e.to_string()})),
|
||||
));
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
let mut res = post.as_object().unwrap().to_owned();
|
||||
res.insert("tags".to_string(), tags.into());
|
||||
res.insert("meta".to_string(), meta);
|
||||
res.insert("adj".to_string(), adjacent);
|
||||
|
||||
Ok(Json::from(json!(res)))
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,30 @@
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
|
||||
const GET_POST_BY_I64_ID: &str = include_str!("get_post_by_i64_id.sql");
|
||||
const GET_POST_BY_STR_ID: &str = include_str!("get_post_by_str_id.sql");
|
||||
|
||||
pub async fn handler(
|
||||
State(db_connection): State<PgPool>,
|
||||
Path(identifier): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let post = if let Ok(id) = identifier.parse::<i64>() {
|
||||
select_post_with_i64_id(&db_connection, id).await
|
||||
} else {
|
||||
select_post_with_str_id(&db_connection, &identifier).await
|
||||
};
|
||||
Json::from(json!({"res": "ok"})).into_response()
|
||||
}
|
||||
|
||||
async fn select_post_with_i64_id(db: &PgPool, id: i64) {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn select_post_with_str_id(db: &PgPool, id: &str) {
|
||||
todo!()
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod get_page_count;
|
||||
pub mod get_post;
|
||||
pub mod get_post_list;
|
||||
// pub mod get_post_list;
|
||||
@@ -1 +1 @@
|
||||
pub mod check_update;
|
||||
// pub mod check_update;
|
||||
@@ -5,13 +5,14 @@ use crate::{
|
||||
modules::{
|
||||
blog::{
|
||||
api::{
|
||||
get_page_count::get_page_count, get_post::get_post, get_post_list::get_post_list,
|
||||
get_page_count,
|
||||
get_post,
|
||||
// get_post_list::get_post_list,
|
||||
},
|
||||
cron::check_update,
|
||||
// cron::check_update,
|
||||
},
|
||||
Module,
|
||||
},
|
||||
scheduler::tasks::basic::BasicTask,
|
||||
scheduler::tasks::CronTask,
|
||||
};
|
||||
|
||||
@@ -27,11 +28,12 @@ pub struct Blog {
|
||||
impl Module for Blog {
|
||||
fn get_server_router(&self) -> Router {
|
||||
let db_handler = self.db_handler.clone();
|
||||
// todo: remove new in get func?
|
||||
// todo: move resource init to new func
|
||||
// todo: remove debug comment
|
||||
Router::new()
|
||||
.route("/get_post_list", get(get_post_list))
|
||||
.route("/get_page_count", get(get_page_count))
|
||||
.route("/get_post/:post_id", get(get_post))
|
||||
// .route("/get_post_list", get(get_post_list))
|
||||
.route("/get_page_count", get(get_page_count::handler))
|
||||
.route("/get_post/{identifier}", get(get_post::handler))
|
||||
.with_state(db_handler)
|
||||
}
|
||||
|
||||
@@ -40,11 +42,11 @@ impl Module for Blog {
|
||||
}
|
||||
|
||||
fn get_cron_tasks(&self) -> Vec<Box<dyn CronTask>> {
|
||||
let db_handler = self.db_handler.clone();
|
||||
// let db_handler = self.db_handler.clone();
|
||||
vec![
|
||||
BasicTask::new(move || check_update::task(&db_handler), "0/15 * * * * *")
|
||||
.unwrap()
|
||||
.to_task(),
|
||||
// BasicTask::new(move || check_update::task(&db_handler), "0/15 * * * * *")
|
||||
// .unwrap()
|
||||
// .to_task(),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
CREATE TABLE tags
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(20) UNIQUE NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE categories
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(20) UNIQUE NOT NULL,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE posts
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
identifier VARCHAR(50) UNIQUE NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
subtitle VARCHAR(255) NOT NULL,
|
||||
published_date DATE NOT NULL,
|
||||
last_update DATE,
|
||||
content TEXT NOT NULL,
|
||||
|
||||
category SERIAL REFERENCES categories (id) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE post_tag
|
||||
(
|
||||
post SERIAL REFERENCES posts (id),
|
||||
tag SERIAL REFERENCES tags (id)
|
||||
);
|
||||
|
||||
CREATE TABLE metadata
|
||||
(
|
||||
pid SERIAL REFERENCES posts (id) NOT NULL,
|
||||
meta_key VARCHAR(50) NOT NULL,
|
||||
meta_value TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT meta_pk PRIMARY KEY (pid, meta_key)
|
||||
);
|
||||
@@ -16,6 +16,7 @@ pub struct FileDelta {
|
||||
pub status: Delta,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn do_fetch<'a>(
|
||||
repo: &'a Repository,
|
||||
refs: &[&str],
|
||||
@@ -23,8 +24,6 @@ fn do_fetch<'a>(
|
||||
) -> Result<AnnotatedCommit<'a>, git2::Error> {
|
||||
let mut fetch_option = FetchOptions::new();
|
||||
|
||||
// Always fetch all tags.
|
||||
// Perform a download and also update tips
|
||||
fetch_option.download_tags(AutotagOption::All);
|
||||
remote.fetch(refs, Some(&mut fetch_option), None)?;
|
||||
|
||||
@@ -32,6 +31,7 @@ fn do_fetch<'a>(
|
||||
repo.reference_to_annotated_commit(&fetch_head)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn fast_forward(
|
||||
repo: &Repository,
|
||||
lb: &mut Reference,
|
||||
@@ -45,16 +45,11 @@ fn fast_forward(
|
||||
// println!("{}", msg);
|
||||
lb.set_target(rc.id(), &msg)?;
|
||||
repo.set_head(&name)?;
|
||||
repo.checkout_head(Some(
|
||||
build::CheckoutBuilder::default()
|
||||
// For some reason, the force is required to make the working directory actually get updated
|
||||
// I suspect we should be adding some logic to handle dirty working directory states,
|
||||
// but this is just an example so maybe not.
|
||||
.force(),
|
||||
))?;
|
||||
repo.checkout_head(Some(build::CheckoutBuilder::default().force()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn normal_merge(
|
||||
repo: &Repository,
|
||||
local: &AnnotatedCommit,
|
||||
@@ -73,13 +68,12 @@ fn normal_merge(
|
||||
return Ok(());
|
||||
}
|
||||
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
|
||||
// now create the merge commit
|
||||
|
||||
let msg = format!("merge({}) -> {}", remote.id(), local.id());
|
||||
let sig = repo.signature()?;
|
||||
let local_commit = repo.find_commit(local.id())?;
|
||||
let remote_commit = repo.find_commit(remote.id())?;
|
||||
|
||||
// Do our merge commit and set the current branch head to that commit.
|
||||
let _merge_commit = repo.commit(
|
||||
Some("HEAD"),
|
||||
&sig,
|
||||
@@ -88,34 +82,26 @@ fn normal_merge(
|
||||
&result_tree,
|
||||
&[&local_commit, &remote_commit],
|
||||
)?;
|
||||
// Set working tree to match head.
|
||||
repo.checkout_head(None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn do_merge<'a>(
|
||||
repo: &'a Repository,
|
||||
remote_branch: &str,
|
||||
fetch_commit: AnnotatedCommit<'a>,
|
||||
) -> Result<Vec<FileDelta>, git2::Error> {
|
||||
// 1. do a merge analysis
|
||||
let analysis = repo.merge_analysis(&[&fetch_commit])?;
|
||||
|
||||
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
|
||||
|
||||
// 2. Do the appropriate merge
|
||||
if analysis.0.is_fast_forward() {
|
||||
// println!("Doing a fast-forward");
|
||||
// do a fast-forward
|
||||
let ref_name = format!("refs/heads/{}", remote_branch);
|
||||
match repo.find_reference(&ref_name) {
|
||||
Ok(mut r) => {
|
||||
fast_forward(repo, &mut r, &fetch_commit)?;
|
||||
}
|
||||
Err(_) => {
|
||||
// The branch doesn't exist, so set the reference to the
|
||||
// commit directly. Usually this is because you are pulling
|
||||
// into an empty repository.
|
||||
repo.reference(
|
||||
&ref_name,
|
||||
fetch_commit.id(),
|
||||
@@ -132,17 +118,12 @@ fn do_merge<'a>(
|
||||
}
|
||||
};
|
||||
} else if analysis.0.is_normal() {
|
||||
// do a normal merge
|
||||
normal_merge(repo, &head_commit, &fetch_commit)?;
|
||||
}
|
||||
// else {
|
||||
// // already up-to-date
|
||||
// println!("Nothing to do...");
|
||||
// }
|
||||
|
||||
calculate_diff(repo, &head_commit, &fetch_commit)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn calculate_diff(
|
||||
repo: &Repository,
|
||||
local: &AnnotatedCommit,
|
||||
@@ -156,23 +137,12 @@ fn calculate_diff(
|
||||
|
||||
if let Err(e) = diff.foreach(
|
||||
&mut |d, _| {
|
||||
// let delta = d.status();
|
||||
//
|
||||
// let n_path = d.new_file().path();
|
||||
// let o_path = d.old_file().path();
|
||||
//
|
||||
// if let Some(path) = n_path {
|
||||
// updated_files.insert(path.to_owned());
|
||||
// }
|
||||
// if let Some(path) = o_path {
|
||||
// updated_files.insert(path.to_owned());
|
||||
// }
|
||||
updated_files.push(FileDelta {
|
||||
old_path: d.old_file().path().map(PathBuf::from),
|
||||
new_path: d.new_file().path().map(PathBuf::from),
|
||||
status: d.status(),
|
||||
});
|
||||
true // Continue
|
||||
true
|
||||
},
|
||||
None,
|
||||
None,
|
||||
@@ -184,6 +154,7 @@ fn calculate_diff(
|
||||
Ok(updated_files.into_iter().collect())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn sync(
|
||||
remote_name: &str,
|
||||
remote_branch: &str,
|
||||
|
||||
Reference in new issue
Block a user