From 90a1e2d16aacb40a485d79ffa4094df3f05df544 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 21:48:21 +0800 Subject: [PATCH 01/27] Revert "Handle missing chats properly" This reverts commit 91a7aeb6aba16cd71f8b38d745a828260c784c25. --- src/main.rs | 197 ++++++++++++++++++++++++++-------------------------- 1 file changed, 98 insertions(+), 99 deletions(-) diff --git a/src/main.rs b/src/main.rs index 52a7e66..88bd362 100644 --- a/src/main.rs +++ b/src/main.rs @@ -323,148 +323,147 @@ async fn schedule(bot: Bot) { log::info!("update is going to be triggered at '{datetime}', sleep '{dur:?}'"); sleep(dur).await; log::info!("perform update '{datetime}'"); - update(bot.clone()).await; + if let Err(e) = update(bot.clone()).await { + log::error!("teloxide error in update: {e}"); + } log::info!("finished update '{datetime}'"); } } -async fn update(bot: Bot) { +async fn update(bot: Bot) -> Result<(), teloxide::RequestError> { let chats = match repo::paths::chats() { - Ok(cs) => cs, Err(e) => { log::error!("failed to get chats: {e}"); - return; - } - }; - - for chat in chats { - if let Err(e) = update_chat(bot.clone(), chat).await { - log::error!("teloxide error in update for chat {chat}: {e}"); - } - } -} - -async fn update_chat(bot: Bot, chat: ChatId) -> Result<(), teloxide::RequestError> { - let repos = match repo::paths::repos(chat) { - Err(e) => { - log::error!("failed to get repos for chat {chat}: {e}"); return Ok(()); } - Ok(rs) => rs, + Ok(cs) => cs, }; - for repo in repos { - log::info!("update ({chat}, {repo})"); - let task = repo::tasks::Task { - chat, - repo: repo.to_owned(), - }; - - let resources = match ResourcesMap::get(&task).await { - Ok(r) => r, + for chat in chats { + let repos = match repo::paths::repos(chat) { Err(e) => { - log::warn!("failed to open resources of ({chat}, {repo}), skip: {e}"); + log::error!("failed to get repos for chat {chat}: {e}"); continue; } + Ok(rs) => rs, }; + for repo in repos { + log::info!("update ({chat}, {repo})"); - if let Err(e) = repo::fetch(resources.clone()).await { - log::warn!("failed to fetch ({chat}, {repo}), skip: {e}"); - continue; - } + let task = repo::tasks::Task { + chat, + repo: repo.to_owned(), + }; - // check pull requests of the repo - // check before commit - let pull_requests = { - let settings = resources.settings.read().await; - settings.pull_requests.clone() - }; - for (pr, settings) in pull_requests { - let result = match repo::pr_check(resources.clone(), pr).await { + let resources = match ResourcesMap::get(&task).await { + Ok(r) => r, Err(e) => { - log::warn!("failed to check pr ({chat}, {repo}, {pr}): {e}"); + log::warn!("failed to open resources of ({chat}, {repo}), skip: {e}"); continue; } - Ok(r) => r, }; - log::info!("finished pr check ({chat}, {repo}, {pr})"); - if let Some(commit) = result { - let message = pr_merged_message(&repo, pr, &settings, &commit); - bot.send_message(chat, message) - .parse_mode(ParseMode::MarkdownV2) - .await?; + + if let Err(e) = repo::fetch(resources.clone()).await { + log::warn!("failed to fetch ({chat}, {repo}), skip: {e}"); + continue; } - } - // check branches of the repo - let branches = { - let settings = resources.settings.read().await; - settings.branches.clone() - }; - for (branch, settings) in branches { - let result = match repo::branch_check(resources.clone(), &branch).await { - Err(e) => { - log::warn!("failed to check branch ({chat}, {repo}, {branch}): {e}"); - continue; - } - Ok(r) => r, + // check pull requests of the repo + // check before commit + let pull_requests = { + let settings = resources.settings.read().await; + settings.pull_requests.clone() }; - log::info!("finished branch check ({chat}, {repo}, {branch})"); - if result.new != result.old { - let message = branch_check_message(&repo, &branch, &settings, &result); - let markup = subscribe_button_markup("b", &repo, &branch); - let mut send = bot - .send_message(chat, message) - .parse_mode(ParseMode::MarkdownV2); - match markup { - Ok(m) => { - send = send.reply_markup(m); - } + for (pr, settings) in pull_requests { + let result = match repo::pr_check(resources.clone(), pr).await { Err(e) => { - log::error!("failed to create markup for ({chat}, {repo}, {branch}): {e}"); + log::warn!("failed to check pr ({chat}, {repo}, {pr}): {e}"); + continue; } + Ok(r) => r, + }; + log::info!("finished pr check ({chat}, {repo}, {pr})"); + if let Some(commit) = result { + let message = pr_merged_message(&repo, pr, &settings, &commit); + bot.send_message(chat, message) + .parse_mode(ParseMode::MarkdownV2) + .await?; } - send.await?; } - } - // check commits of the repo - let commits = { - let settings = resources.settings.read().await; - settings.commits.clone() - }; - for (commit, settings) in commits { - let result = match repo::commit_check(resources.clone(), &commit).await { - Err(e) => { - log::warn!("failed to check commit ({chat}, {repo}, {commit}): {e}",); - continue; - } - Ok(r) => r, + // check branches of the repo + let branches = { + let settings = resources.settings.read().await; + settings.branches.clone() }; - log::info!("finished commit check ({chat}, {repo}, {commit})"); - if !result.new.is_empty() { - let message = commit_check_message(&repo, &commit, &settings, &result); - let mut send = bot - .send_message(chat, message) - .parse_mode(ParseMode::MarkdownV2) - .disable_link_preview(true); - if result.removed_by_condition.is_none() { - let markup = subscribe_button_markup("c", &repo, &commit); + for (branch, settings) in branches { + let result = match repo::branch_check(resources.clone(), &branch).await { + Err(e) => { + log::warn!("failed to check branch ({chat}, {repo}, {branch}): {e}"); + continue; + } + Ok(r) => r, + }; + log::info!("finished branch check ({chat}, {repo}, {branch})"); + if result.new != result.old { + let message = branch_check_message(&repo, &branch, &settings, &result); + let markup = subscribe_button_markup("b", &repo, &branch); + let mut send = bot + .send_message(chat, message) + .parse_mode(ParseMode::MarkdownV2); match markup { Ok(m) => { send = send.reply_markup(m); } Err(e) => { log::error!( - "failed to create markup for ({chat}, {repo}, {commit}): {e}" + "failed to create markup for ({chat}, {repo}, {branch}): {e}" ); } } + send.await?; + } + } + + // check commits of the repo + let commits = { + let settings = resources.settings.read().await; + settings.commits.clone() + }; + for (commit, settings) in commits { + let result = match repo::commit_check(resources.clone(), &commit).await { + Err(e) => { + log::warn!("failed to check commit ({chat}, {repo}, {commit}): {e}",); + continue; + } + Ok(r) => r, + }; + log::info!("finished commit check ({chat}, {repo}, {commit})"); + if !result.new.is_empty() { + let message = commit_check_message(&repo, &commit, &settings, &result); + let mut send = bot + .send_message(chat, message) + .parse_mode(ParseMode::MarkdownV2) + .disable_link_preview(true); + if result.removed_by_condition.is_none() { + let markup = subscribe_button_markup("c", &repo, &commit); + match markup { + Ok(m) => { + send = send.reply_markup(m); + } + Err(e) => { + log::error!( + "failed to create markup for ({chat}, {repo}, {commit}): {e}" + ); + } + } + } + send.await?; } - send.await?; } } } + Ok(()) } From 95ef6b03ccb80145e60431c08326cd159707e0a0 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 00:10:20 +0800 Subject: [PATCH 02/27] Refactor repository management --- src/cache.rs | 53 -- src/chat/mod.rs | 2 + src/{repo => chat}/results.rs | 20 +- src/chat/settings.rs | 98 +++ src/command.rs | 2 + src/condition/in_branch.rs | 21 +- src/{condition.rs => condition/mod.rs} | 3 +- src/error.rs | 6 + src/main.rs | 991 ++++--------------------- src/message.rs | 207 ++++++ src/options.rs | 2 + src/repo.rs | 640 ---------------- src/repo/cache.rs | 118 +++ src/repo/mod.rs | 320 ++++++++ src/repo/paths.rs | 104 +-- src/repo/resources.rs | 66 ++ src/repo/settings.rs | 92 +-- src/repo/tasks.rs | 209 ------ src/resources.rs | 92 +++ src/utils.rs | 43 ++ 20 files changed, 1166 insertions(+), 1923 deletions(-) delete mode 100644 src/cache.rs create mode 100644 src/chat/mod.rs rename src/{repo => chat}/results.rs (56%) create mode 100644 src/chat/settings.rs rename src/{condition.rs => condition/mod.rs} (92%) create mode 100644 src/message.rs delete mode 100644 src/repo.rs create mode 100644 src/repo/cache.rs create mode 100644 src/repo/mod.rs create mode 100644 src/repo/resources.rs delete mode 100644 src/repo/tasks.rs create mode 100644 src/resources.rs diff --git a/src/cache.rs b/src/cache.rs deleted file mode 100644 index a3a0664..0000000 --- a/src/cache.rs +++ /dev/null @@ -1,53 +0,0 @@ -use rusqlite::{Connection, params}; - -use crate::error::Error; - -pub fn initialize(cache: &Connection) -> Result<(), Error> { - cache.execute( - "CREATE TABLE commits_cache ( - target_commit TEXT NOT NULL, - this_commit TEXT NOT NULL, - is_parent INTEGER NOT NULL - )", - [], - )?; - cache.execute( - "CREATE UNIQUE INDEX idx_commit_pair - ON commits_cache (target_commit, this_commit)", - [], - )?; - - Ok(()) -} - -pub fn query(cache: &Connection, target: &str, commit: &str) -> Result, Error> { - let mut stmt = cache.prepare_cached( - "SELECT is_parent FROM commits_cache WHERE target_commit = ?1 AND this_commit = ?2", - )?; - log::trace!("query cache: ({target}, {commit})"); - let query_result: Vec = stmt - .query_map(params!(target, commit), |row| row.get(0))? - .collect::>()?; - match query_result.len() { - 0 => Ok(None), - 1 => Ok(Some(query_result[0])), - _ => panic!("internal cache format error"), - } -} - -pub fn store(cache: &Connection, target: &str, commit: &str, hit: bool) -> Result<(), Error> { - let mut stmt = cache.prepare_cached( - "INSERT INTO commits_cache (target_commit, this_commit, is_parent) VALUES (?1, ?2, ?3)", - )?; - log::trace!("insert new cache: ({target}, {commit}, {hit})"); - let inserted = stmt.execute(params!(target, commit, hit))?; - assert_eq!(inserted, 1); - Ok(()) -} - -pub fn remove(cache: &Connection, target: &str) -> Result<(), Error> { - let mut stmt = cache.prepare_cached("DELETE FROM commits_cache WHERE target_commit = ?1")?; - log::trace!("delete cache for target commit: {target}"); - stmt.execute(params!(target))?; - Ok(()) -} diff --git a/src/chat/mod.rs b/src/chat/mod.rs new file mode 100644 index 0000000..def87d8 --- /dev/null +++ b/src/chat/mod.rs @@ -0,0 +1,2 @@ +pub mod results; +pub mod settings; diff --git a/src/repo/results.rs b/src/chat/results.rs similarity index 56% rename from src/repo/results.rs rename to src/chat/results.rs index 5bd6017..0018ad2 100644 --- a/src/repo/results.rs +++ b/src/chat/results.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; #[derive(Default, Debug, Clone, Serialize, Deserialize)] -pub struct Results { +pub struct ChatRepoResults { pub commits: BTreeMap, pub branches: BTreeMap, } @@ -17,3 +17,21 @@ pub struct CommitResults { pub struct BranchResults { pub commit: Option, } + +#[derive(Debug)] +pub struct CommitCheckResult { + pub all: BTreeSet, + pub new: BTreeSet, + pub removed_by_condition: Option, +} + +#[derive(Debug)] +pub struct BranchCheckResult { + pub old: Option, + pub new: Option, +} + +#[derive(Debug)] +pub struct ConditionCheckResult { + pub removed: Vec, +} diff --git a/src/chat/settings.rs b/src/chat/settings.rs new file mode 100644 index 0000000..b2dea2e --- /dev/null +++ b/src/chat/settings.rs @@ -0,0 +1,98 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use teloxide::utils::markdown; +use url::Url; + +use crate::{condition::GeneralCondition, github::GitHubInfo}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ChatRepoSettings { + pub branch_regex: String, + #[serde(default)] + pub github_info: Option, + #[serde(default)] + pub pull_requests: BTreeMap, + #[serde(default)] + pub commits: BTreeMap, + #[serde(default)] + pub branches: BTreeMap, + #[serde(default)] + pub conditions: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitSettings { + pub url: Option, + #[serde(flatten)] + pub notify: NotifySettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequestSettings { + pub url: Url, + #[serde(flatten)] + pub notify: NotifySettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BranchSettings { + #[serde(flatten)] + pub notify: NotifySettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NotifySettings { + #[serde(default)] + pub comment: String, + #[serde(default)] + pub subscribers: BTreeSet, +} + +impl NotifySettings { + pub fn notify_markdown(&self) -> String { + let mut result = String::new(); + let comment = self.comment.trim(); + if !comment.is_empty() { + result.push_str("*comment*:\n"); + result.push_str(&markdown::escape(self.comment.trim())); + } + if !self.subscribers.is_empty() { + if !result.is_empty() { + result.push_str("\n\n"); + } + result.push_str("*subscribers*: "); + result.push_str( + &self + .subscribers + .iter() + .map(Subscriber::markdown) + .collect::>() + .join(" "), + ); + } + result + } + + pub fn description_markdown(&self) -> String { + markdown::escape(self.comment.trim().lines().next().unwrap_or_default()) + } +} + +#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Serialize, Deserialize)] +pub enum Subscriber { + Telegram { username: String }, +} + +impl Subscriber { + fn markdown(&self) -> String { + match self { + Subscriber::Telegram { username } => format!("@{}", markdown::escape(username)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConditionSettings { + pub condition: GeneralCondition, +} diff --git a/src/command.rs b/src/command.rs index af64e74..6c5d204 100644 --- a/src/command.rs +++ b/src/command.rs @@ -14,6 +14,8 @@ use std::{ffi::OsString, iter}; no_binary_name = true, )] pub enum Notifier { + #[command(about = "return current chat id")] + ChatId, #[command(about = "add a repository")] RepoAdd { name: String, url: String }, #[command(about = "edit settings of a repository")] diff --git a/src/condition/in_branch.rs b/src/condition/in_branch.rs index 00d59bd..5140f94 100644 --- a/src/condition/in_branch.rs +++ b/src/condition/in_branch.rs @@ -1,23 +1,32 @@ +use regex::Regex; use serde::{Deserialize, Serialize}; +use crate::chat::results::CommitResults; use crate::condition::Condition; use crate::error::Error; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InBranchCondition { - pub branch: String, + pub branch_regex: String, } impl Condition for InBranchCondition { - fn meet(&self, result: &crate::repo::results::CommitResults) -> bool { - result.branches.contains(&self.branch) + fn meet(&self, result: &CommitResults) -> bool { + let regex = self.regex().unwrap(); + result.branches.iter().any(|b| regex.is_match(b)) } } impl InBranchCondition { pub fn parse(s: &str) -> Result { - Ok(InBranchCondition { - branch: s.to_string(), - }) + let result = InBranchCondition { + branch_regex: s.to_string(), + }; + let _ = result.regex()?; + Ok(result) + } + + pub fn regex(&self) -> Result { + Ok(Regex::new(&format!("^{}$", self.branch_regex))?) } } diff --git a/src/condition.rs b/src/condition/mod.rs similarity index 92% rename from src/condition.rs rename to src/condition/mod.rs index 8e22bbb..0987f51 100644 --- a/src/condition.rs +++ b/src/condition/mod.rs @@ -2,8 +2,7 @@ pub mod in_branch; use serde::{Deserialize, Serialize}; -use crate::error::Error; -use crate::repo::results::CommitResults; +use crate::{chat::results::CommitResults, error::Error}; use self::in_branch::InBranchCondition; diff --git a/src/error.rs b/src/error.rs index 875119f..773f75b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,8 @@ use crate::github::GitHubInfo; #[derive(Error, Debug)] pub enum Error { + #[error("unknown resource: {0}")] + UnknownResource(String), #[error("unclosed quote")] UnclosedQuote, #[error("bad escape")] @@ -56,6 +58,8 @@ pub enum Error { UnknownPullRequest(u64), #[error("unknown branch: '{0}'")] UnknownBranch(String), + #[error("unknown branch in cache: '{0}'")] + UnknownBranchInCache(String), #[error("unknown repository: '{0}'")] UnknownRepository(String), #[error("commit already exists: '{0}'")] @@ -108,6 +112,8 @@ pub enum Error { NoRepoHaveGitHubInfo(GitHubInfo), #[error("unsupported pr url: {0}")] UnsupportedPrUrl(String), + #[error("not in an admin chat")] + NotAdminChat, } impl Error { diff --git a/src/main.rs b/src/main.rs index 88bd362..ba93b41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,53 +1,36 @@ -mod cache; +mod chat; mod command; mod condition; mod error; mod github; +mod message; mod options; mod repo; +mod resources; mod utils; -use std::collections::BTreeSet; use std::env; -use std::fmt; use std::str::FromStr; -use std::sync::Arc; -use std::sync::LazyLock; use chrono::Utc; -use condition::GeneralCondition; use cron::Schedule; use error::Error; use github::GitHubInfo; use regex::Regex; -use repo::BranchCheckResult; -use repo::settings::BranchSettings; -use repo::settings::CommitSettings; -use repo::settings::ConditionSettings; -use repo::settings::NotifySettings; -use repo::settings::PullRequestSettings; -use repo::settings::Subscriber; -use repo::tasks::Task; use serde::Deserialize; use serde::Serialize; use teloxide::dispatching::dialogue::GetChatId; use teloxide::payloads; use teloxide::prelude::*; -use teloxide::sugar::request::RequestLinkPreviewExt; use teloxide::types::InlineKeyboardButton; use teloxide::types::InlineKeyboardButtonKind; use teloxide::types::InlineKeyboardMarkup; -use teloxide::types::ParseMode; use teloxide::update_listeners; use teloxide::utils::command::BotCommands; -use teloxide::utils::markdown; use tokio::time::sleep; use url::Url; -use utils::reply_to_msg; -use crate::repo::tasks::Resources; -use crate::repo::tasks::ResourcesMap; -use crate::utils::push_empty_line; +use crate::utils::reply_to_msg; #[derive(BotCommands, Clone, Debug)] #[command(rename_rule = "lowercase", description = "Supported commands:")] @@ -56,6 +39,41 @@ enum BCommand { Notifier(String), } +#[tokio::main] +async fn main() { + run().await; +} + +async fn run() { + pretty_env_logger::init(); + + options::initialize(); + log::info!("config = {:?}", options::get()); + + octocrab_initialize(); + + let bot = Bot::from_env(); + let command_handler = teloxide::filter_command::().endpoint(answer); + let message_handler = Update::filter_message().branch(command_handler); + let callback_handler = Update::filter_callback_query().endpoint(handle_callback_query); + let handler = dptree::entry() + .branch(message_handler) + .branch(callback_handler); + let mut dispatcher = Dispatcher::builder(bot.clone(), handler) + .enable_ctrlc_handler() + .build(); + + let update_listener = update_listeners::polling_default(bot.clone()).await; + tokio::select! { + _ = schedule(bot.clone()) => { }, + _ = dispatcher.dispatch_with_listener( + update_listener, + LoggingErrorHandler::with_custom_text("An error from the update listener"), + ) => { }, + } + log::info!("exit"); +} + async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { log::debug!("message: {msg:?}"); log::trace!("bot command: {bc:?}"); @@ -65,6 +83,7 @@ async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { log::debug!("command: {command:?}"); let (bot, msg) = (bot.clone(), msg.clone()); match command { + command::Notifier::ChatId => return_chat_id(bot, msg).await, command::Notifier::RepoAdd { name, url } => repo_add(bot, msg, name, url).await, command::Notifier::RepoEdit { name, @@ -143,6 +162,9 @@ async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { } } +#[derive(Serialize, Deserialize, Clone, Debug)] +struct SubscribeTerm(String, String, String, usize); + async fn handle_callback_query(bot: Bot, query: CallbackQuery) -> ResponseResult<()> { let result = handle_callback_query_command_result(&bot, &query).await; let (message, alert) = match result { @@ -161,68 +183,69 @@ async fn handle_callback_query_command_result( _bot: &Bot, query: &CallbackQuery, ) -> Result { - log::debug!("query = {query:?}"); - let (chat_id, username) = get_chat_id_and_username_from_query(query)?; - let subscriber = Subscriber::Telegram { username }; - let _msg = query - .message - .as_ref() - .ok_or(Error::SubscribeCallbackNoMsgId)?; - let data = query.data.as_ref().ok_or(Error::SubscribeCallbackNoData)?; - let SubscribeTerm(kind, repo, id, subscribe) = - serde_json::from_str(data).map_err(Error::Serde)?; - let unsubscribe = subscribe == 0; - match kind.as_str() { - "b" => { - let resources = resources_helper_chat(chat_id, &repo).await?; - { - let mut settings = resources.settings.write().await; - let subscribers = &mut settings - .branches - .get_mut(&id) - .ok_or_else(|| Error::UnknownBranch(id.clone()))? - .notify - .subscribers; - modify_subscriber_set(subscribers, subscriber, unsubscribe)?; - } - resources.save_settings().await?; - } - "c" => { - let resources = resources_helper_chat(chat_id, &repo).await?; - { - let mut settings = resources.settings.write().await; - let subscribers = &mut settings - .commits - .get_mut(&id) - .ok_or_else(|| Error::UnknownCommit(id.clone()))? - .notify - .subscribers; - modify_subscriber_set(subscribers, subscriber, unsubscribe)?; - } - resources.save_settings().await?; - } - "p" => { - let pr_id: u64 = id.parse().map_err(Error::ParseInt)?; - let resources = resources_helper_chat(chat_id, &repo).await?; - { - let mut settings = resources.settings.write().await; - let subscribers = &mut settings - .pull_requests - .get_mut(&pr_id) - .ok_or_else(|| Error::UnknownPullRequest(pr_id))? - .notify - .subscribers; - modify_subscriber_set(subscribers, subscriber, unsubscribe)?; - } - resources.save_settings().await?; - } - _ => Err(Error::SubscribeCallbackDataInvalidKind(kind))?, - } - if unsubscribe { - Ok(format!("unsubscribed from {repo}/{id}")) - } else { - Ok(format!("subscribed to {repo}/{id}")) - } + todo!() + // log::debug!("query = {query:?}"); + // let (chat_id, username) = get_chat_id_and_username_from_query(query)?; + // let subscriber = Subscriber::Telegram { username }; + // let _msg = query + // .message + // .as_ref() + // .ok_or(Error::SubscribeCallbackNoMsgId)?; + // let data = query.data.as_ref().ok_or(Error::SubscribeCallbackNoData)?; + // let SubscribeTerm(kind, repo, id, subscribe) = + // serde_json::from_str(data).map_err(Error::Serde)?; + // let unsubscribe = subscribe == 0; + // match kind.as_str() { + // "b" => { + // let resources = resources_helper_chat(chat_id, &repo).await?; + // { + // let mut settings = resources.settings.write().await; + // let subscribers = &mut settings + // .branches + // .get_mut(&id) + // .ok_or_else(|| Error::UnknownBranch(id.clone()))? + // .notify + // .subscribers; + // modify_subscriber_set(subscribers, subscriber, unsubscribe)?; + // } + // resources.save_settings().await?; + // } + // "c" => { + // let resources = resources_helper_chat(chat_id, &repo).await?; + // { + // let mut settings = resources.settings.write().await; + // let subscribers = &mut settings + // .commits + // .get_mut(&id) + // .ok_or_else(|| Error::UnknownCommit(id.clone()))? + // .notify + // .subscribers; + // modify_subscriber_set(subscribers, subscriber, unsubscribe)?; + // } + // resources.save_settings().await?; + // } + // "p" => { + // let pr_id: u64 = id.parse().map_err(Error::ParseInt)?; + // let resources = resources_helper_chat(chat_id, &repo).await?; + // { + // let mut settings = resources.settings.write().await; + // let subscribers = &mut settings + // .pull_requests + // .get_mut(&pr_id) + // .ok_or_else(|| Error::UnknownPullRequest(pr_id))? + // .notify + // .subscribers; + // modify_subscriber_set(subscribers, subscriber, unsubscribe)?; + // } + // resources.save_settings().await?; + // } + // _ => Err(Error::SubscribeCallbackDataInvalidKind(kind))?, + // } + // if unsubscribe { + // Ok(format!("unsubscribed from {repo}/{id}")) + // } else { + // Ok(format!("subscribed to {repo}/{id}")) + // } } fn get_chat_id_and_username_from_query(query: &CallbackQuery) -> Result<(ChatId, String), Error> { @@ -251,49 +274,6 @@ impl From for CommandError { } } -#[derive(Serialize, Deserialize, Clone, Debug)] -struct SubscribeTerm(String, String, String, usize); - -#[tokio::main] -async fn main() { - run().await; -} - -async fn run() { - pretty_env_logger::init(); - - options::initialize(); - log::info!("config = {:?}", options::get()); - - octocrab_initialize(); - - let bot = Bot::from_env(); - let command_handler = teloxide::filter_command::().endpoint(answer); - let message_handler = Update::filter_message().branch(command_handler); - let callback_handler = Update::filter_callback_query().endpoint(handle_callback_query); - let handler = dptree::entry() - .branch(message_handler) - .branch(callback_handler); - let mut dispatcher = Dispatcher::builder(bot.clone(), handler) - .enable_ctrlc_handler() - .build(); - - let update_listener = update_listeners::polling_default(bot.clone()).await; - tokio::select! { - _ = schedule(bot.clone()) => { }, - _ = dispatcher.dispatch_with_listener( - update_listener, - LoggingErrorHandler::with_custom_text("An error from the update listener"), - ) => { }, - } - - log::info!("cleaning up resources"); - if let Err(e) = ResourcesMap::clear().await { - log::error!("failed to clear resources map: {e}"); - } - log::info!("exit"); -} - fn octocrab_initialize() { let builder = octocrab::Octocrab::builder(); let with_token = match env::var("GITHUB_TOKEN") { @@ -323,238 +303,50 @@ async fn schedule(bot: Bot) { log::info!("update is going to be triggered at '{datetime}', sleep '{dur:?}'"); sleep(dur).await; log::info!("perform update '{datetime}'"); - if let Err(e) = update(bot.clone()).await { + if let Err(e) = update_and_report_error(bot.clone()).await { log::error!("teloxide error in update: {e}"); } log::info!("finished update '{datetime}'"); } } -async fn update(bot: Bot) -> Result<(), teloxide::RequestError> { - let chats = match repo::paths::chats() { - Err(e) => { - log::error!("failed to get chats: {e}"); - return Ok(()); - } - Ok(cs) => cs, - }; - - for chat in chats { - let repos = match repo::paths::repos(chat) { - Err(e) => { - log::error!("failed to get repos for chat {chat}: {e}"); - continue; - } - Ok(rs) => rs, - }; - for repo in repos { - log::info!("update ({chat}, {repo})"); - - let task = repo::tasks::Task { - chat, - repo: repo.to_owned(), - }; - - let resources = match ResourcesMap::get(&task).await { - Ok(r) => r, - Err(e) => { - log::warn!("failed to open resources of ({chat}, {repo}), skip: {e}"); - continue; - } - }; - - if let Err(e) = repo::fetch(resources.clone()).await { - log::warn!("failed to fetch ({chat}, {repo}), skip: {e}"); - continue; - } - - // check pull requests of the repo - // check before commit - let pull_requests = { - let settings = resources.settings.read().await; - settings.pull_requests.clone() - }; - for (pr, settings) in pull_requests { - let result = match repo::pr_check(resources.clone(), pr).await { - Err(e) => { - log::warn!("failed to check pr ({chat}, {repo}, {pr}): {e}"); - continue; - } - Ok(r) => r, - }; - log::info!("finished pr check ({chat}, {repo}, {pr})"); - if let Some(commit) = result { - let message = pr_merged_message(&repo, pr, &settings, &commit); - bot.send_message(chat, message) - .parse_mode(ParseMode::MarkdownV2) - .await?; - } - } - - // check branches of the repo - let branches = { - let settings = resources.settings.read().await; - settings.branches.clone() - }; - for (branch, settings) in branches { - let result = match repo::branch_check(resources.clone(), &branch).await { - Err(e) => { - log::warn!("failed to check branch ({chat}, {repo}, {branch}): {e}"); - continue; - } - Ok(r) => r, - }; - log::info!("finished branch check ({chat}, {repo}, {branch})"); - if result.new != result.old { - let message = branch_check_message(&repo, &branch, &settings, &result); - let markup = subscribe_button_markup("b", &repo, &branch); - let mut send = bot - .send_message(chat, message) - .parse_mode(ParseMode::MarkdownV2); - match markup { - Ok(m) => { - send = send.reply_markup(m); - } - Err(e) => { - log::error!( - "failed to create markup for ({chat}, {repo}, {branch}): {e}" - ); - } - } - send.await?; - } - } - - // check commits of the repo - let commits = { - let settings = resources.settings.read().await; - settings.commits.clone() - }; - for (commit, settings) in commits { - let result = match repo::commit_check(resources.clone(), &commit).await { - Err(e) => { - log::warn!("failed to check commit ({chat}, {repo}, {commit}): {e}",); - continue; - } - Ok(r) => r, - }; - log::info!("finished commit check ({chat}, {repo}, {commit})"); - if !result.new.is_empty() { - let message = commit_check_message(&repo, &commit, &settings, &result); - let mut send = bot - .send_message(chat, message) - .parse_mode(ParseMode::MarkdownV2) - .disable_link_preview(true); - if result.removed_by_condition.is_none() { - let markup = subscribe_button_markup("c", &repo, &commit); - match markup { - Ok(m) => { - send = send.reply_markup(m); - } - Err(e) => { - log::error!( - "failed to create markup for ({chat}, {repo}, {commit}): {e}" - ); - } - } - } - send.await?; - } - } +async fn update_and_report_error(bot: Bot) -> Result<(), teloxide::RequestError> { + match update(bot.clone()).await { + Ok(r) => Ok(r), + Err(CommandError::Normal(e)) => { + log::error!("update error: {e}"); + let options = options::get(); + bot.send_message(ChatId(options.admin_chat_id), format!("update error: {e}")) + .await?; + Ok(()) } + Err(CommandError::Teloxide(e)) => Err(e), } +} +async fn update(bot: Bot) -> Result<(), CommandError> { + log::info!("updating repositories..."); + let repos = repo::list().await?; + for repo in repos { + let resources = repo::resources::RESOURCES_MAP.get(&repo).await?; + repo::fetch_and_update_cache(&resources).await?; + } Ok(()) } async fn list(bot: Bot, msg: Message) -> Result<(), CommandError> { - let chat = msg.chat.id; - let mut result = String::new(); - - let repos = repo::paths::repos(chat)?; - for repo in repos { - result.push('*'); - result.push_str(&markdown::escape(&repo)); - result.push_str("*\n"); - - let task = Task { - chat, - repo: repo.clone(), - }; - let resources = ResourcesMap::get(&task).await?; - let settings = { - let locked = resources.settings.read().await; - locked.clone() - }; - - result.push_str(" *commits*:\n"); - let commits = &settings.commits; - if commits.is_empty() { - result.push_str(" \\(nothing\\)\n"); - } - for (commit, settings) in commits { - result.push_str(&format!( - " \\- `{}`\n {}\n", - markdown::escape(commit), - settings.notify.description_markdown() - )); - } - result.push_str(" *pull requests*:\n"); - let pull_requests = &settings.pull_requests; - if pull_requests.is_empty() { - result.push_str(" \\(nothing\\)\n"); - } - for (pr, settings) in pull_requests { - result.push_str(&format!( - " \\- `{pr}`\n {}\n", - markdown::escape(settings.url.as_str()) - )); - } - result.push_str(" *branches*:\n"); - let branches = &settings.branches; - if branches.is_empty() { - result.push_str(" \\(nothing\\)\n"); - } - for branch in branches.keys() { - result.push_str(&format!(" \\- `{}`\n", markdown::escape(branch))); - } - result.push_str(" *conditions*:\n"); - let conditions = &settings.conditions; - if conditions.is_empty() { - result.push_str(" \\(nothing\\)\n"); - } - for condition in conditions.keys() { - result.push_str(&format!(" \\- `{}`\n", markdown::escape(condition))); - } - - result.push('\n'); - } - if result.is_empty() { - result.push_str("(nothing)\n"); - } - reply_to_msg(&bot, &msg, result) - .parse_mode(ParseMode::MarkdownV2) - .await?; + todo!() +} +async fn return_chat_id(bot: Bot, msg: Message) -> Result<(), CommandError> { + reply_to_msg(&bot, &msg, format!("{}", msg.chat.id)).await?; Ok(()) } async fn repo_add(bot: Bot, msg: Message, name: String, url: String) -> Result<(), CommandError> { - let chat = msg.chat.id; - if repo::exists(chat, &name)? { - return Err(Error::RepoExists(name).into()); - } - reply_to_msg(&bot, &msg, format!("start cloning into '{name}'")).await?; - - let task = Task { - chat, - repo: name.clone(), - }; - let path = task.paths()?; - repo::create(&name, path.repo, &url).await?; - - let resources = ResourcesMap::get(&task).await?; - + ensure_admin_chat(&msg)?; + let _output = repo::create(&name, &url).await?; + let resources = repo::resources::RESOURCES_MAP.get(&name).await?; let github_info = Url::parse(&url) .ok() .and_then(|u| GitHubInfo::parse_from_url(u).ok()); @@ -564,14 +356,13 @@ async fn repo_add(bot: Bot, msg: Message, name: String, url: String) -> Result<( locked.clone() }; resources.save_settings().await?; - reply_to_msg( &bot, &msg, format!("repository '{name}' added, settings:\n{settings:#?}"), ) .await?; - Ok(()) + todo!() } async fn repo_edit( @@ -582,12 +373,13 @@ async fn repo_edit( github_info: Option, clear_github_info: bool, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &name).await?; + ensure_admin_chat(&msg)?; + let resources = repo::resources::RESOURCES_MAP.get(&name).await?; let new_settings = { let mut locked = resources.settings.write().await; if let Some(r) = branch_regex { // ensure regex is valid - let _: Regex = Regex::new(&r).map_err(Error::from)?; + let _: Regex = Regex::new(&format!("^{r}$")).map_err(Error::from)?; locked.branch_regex = r; } if let Some(info) = github_info { @@ -609,11 +401,8 @@ async fn repo_edit( } async fn repo_remove(bot: Bot, msg: Message, name: String) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &name).await?; - if !repo::exists(resources.task.chat, &name)? { - return Err(Error::UnknownRepository(name).into()); - } - repo::remove(resources).await?; + ensure_admin_chat(&msg)?; + repo::remove(&name).await?; reply_to_msg(&bot, &msg, format!("repository '{name}' removed")).await?; Ok(()) } @@ -626,25 +415,7 @@ async fn commit_add( comment: String, url: Option, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - let subscribers = subscriber_from_msg(&msg).into_iter().collect(); - let settings = CommitSettings { - url, - notify: NotifySettings { - comment, - subscribers, - }, - }; - match repo::commit_add(resources, &hash, settings).await { - Ok(()) => { - commit_check(bot, msg, repo, hash).await?; - } - Err(Error::CommitExists(_)) => { - commit_subscribe(bot.clone(), msg.clone(), repo.clone(), hash.clone(), false).await?; - } - Err(e) => return Err(e.into()), - } - Ok(()) + todo!() } async fn commit_remove( @@ -653,10 +424,7 @@ async fn commit_remove( repo: String, hash: String, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - repo::commit_remove(resources, &hash).await?; - reply_to_msg(&bot, &msg, format!("commit {hash} removed")).await?; - Ok(()) + todo!() } async fn commit_check( @@ -665,36 +433,7 @@ async fn commit_check( repo: String, hash: String, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - repo::fetch(resources.clone()).await?; - let commit_settings = { - let settings = resources.settings.read().await; - settings - .commits - .get(&hash) - .ok_or_else(|| Error::UnknownCommit(hash.clone()))? - .clone() - }; - let result = repo::commit_check(resources, &hash).await?; - let reply = commit_check_message(&repo, &hash, &commit_settings, &result); - let mut send = reply_to_msg(&bot, &msg, reply) - .parse_mode(ParseMode::MarkdownV2) - .disable_link_preview(true); - if result.removed_by_condition.is_none() { - match subscribe_button_markup("c", &repo, &hash) { - Ok(m) => { - send = send.reply_markup(m); - } - Err(e) => { - log::error!( - "failed to create markup for ({chat}, {repo}, {hash}): {e}", - chat = msg.chat.id - ); - } - } - } - send.await?; - Ok(()) + todo!() } async fn commit_subscribe( @@ -704,21 +443,7 @@ async fn commit_subscribe( hash: String, unsubscribe: bool, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; - { - let mut settings = resources.settings.write().await; - let subscribers = &mut settings - .commits - .get_mut(&hash) - .ok_or_else(|| Error::UnknownCommit(hash.clone()))? - .notify - .subscribers; - modify_subscriber_set(subscribers, subscriber, unsubscribe)?; - } - resources.save_settings().await?; - reply_to_msg(&bot, &msg, "done").await?; - Ok(()) + todo!() } async fn pr_add( @@ -728,37 +453,7 @@ async fn pr_add( pr_id: Option, optional_comment: Option, ) -> Result<(), CommandError> { - let chat = msg.chat.id; - let (repo, pr_id) = resolve_pr_repo_or_url(chat, repo_or_url, pr_id).await?; - let resources = resources_helper(&msg, &repo).await?; - let github_info = { - let settings = resources.settings.read().await; - settings - .github_info - .clone() - .ok_or(Error::NoGitHubInfo(repo.clone()))? - }; - let url_str = format!("https://github.com/{github_info}/pull/{pr_id}"); - let url = Url::parse(&url_str).map_err(Error::UrlParse)?; - let subscribers = subscriber_from_msg(&msg).into_iter().collect(); - let comment = optional_comment.unwrap_or_default(); - let settings = PullRequestSettings { - url, - notify: NotifySettings { - comment, - subscribers, - }, - }; - match repo::pr_add(resources, pr_id, settings).await { - Ok(()) => { - pr_check(bot, msg, repo, pr_id).await?; - } - Err(Error::PullRequestExists(_)) => { - pr_subscribe(bot.clone(), msg.clone(), repo.clone(), pr_id, false).await?; - } - Err(e) => return Err(e.into()), - }; - Ok(()) + todo!() } async fn resolve_pr_repo_or_url( @@ -766,85 +461,15 @@ async fn resolve_pr_repo_or_url( repo_or_url: String, pr_id: Option, ) -> Result<(String, u64), Error> { - static GITHUB_URL_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r#"https://github\.com/([^/]+)/([^/]+)/pull/(\d+)"#).unwrap()); - match pr_id { - Some(id) => Ok((repo_or_url, id)), - None => { - if let Some(captures) = GITHUB_URL_REGEX.captures(&repo_or_url) { - let owner = &captures[1]; - let repo = &captures[2]; - let github_info = GitHubInfo::new(owner.to_string(), repo.to_string()); - let pr: u64 = captures[3].parse().map_err(Error::ParseInt)?; - let repos = repo::paths::repos(chat)?; - let mut repos_found = Vec::new(); - for repo in repos { - let task = repo::tasks::Task { - chat, - repo: repo.to_owned(), - }; - let resources = ResourcesMap::get(&task).await?; - let repo_github_info = &resources.settings.read().await.github_info; - if repo_github_info.as_ref() == Some(&github_info) { - repos_found.push(repo); - } - } - if repos_found.is_empty() { - return Err(Error::NoRepoHaveGitHubInfo(github_info)); - } else if repos_found.len() != 1 { - return Err(Error::MultipleReposHaveSameGitHubInfo(repos_found)); - } else { - let repo = repos_found.pop().unwrap(); - return Ok((repo, pr)); - } - } - Err(Error::UnsupportedPrUrl(repo_or_url)) - } - } + todo!() } async fn pr_check(bot: Bot, msg: Message, repo: String, pr_id: u64) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - match repo::pr_check(resources, pr_id).await { - Ok(Some(commit)) => { - reply_to_msg( - &bot, - &msg, - format!("pr {pr_id} has been merged\ncommit `{commit}` added"), - ) - .parse_mode(ParseMode::MarkdownV2) - .await?; - commit_check(bot, msg, repo, commit).await?; - } - Ok(None) => { - let mut send = reply_to_msg(&bot, &msg, format!("pr {pr_id} has not been merged yet")) - .parse_mode(ParseMode::MarkdownV2); - match subscribe_button_markup("p", &repo, &pr_id.to_string()) { - Ok(m) => { - send = send.reply_markup(m); - } - Err(e) => { - log::error!( - "failed to create markup for ({chat}, {repo}, {pr_id}): {e}", - chat = msg.chat.id - ); - } - } - send.await?; - } - Err(Error::CommitExists(commit)) => { - commit_subscribe(bot, msg, repo, commit, false).await?; - } - Err(e) => return Err(e.into()), - } - Ok(()) + todo!() } async fn pr_remove(bot: Bot, msg: Message, repo: String, pr_id: u64) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - repo::pr_remove(resources, pr_id).await?; - reply_to_msg(&bot, &msg, format!("pr {pr_id} removed")).await?; - Ok(()) + todo!() } async fn pr_subscribe( @@ -854,21 +479,7 @@ async fn pr_subscribe( pr_id: u64, unsubscribe: bool, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; - { - let mut settings = resources.settings.write().await; - let subscribers = &mut settings - .pull_requests - .get_mut(&pr_id) - .ok_or_else(|| Error::UnknownPullRequest(pr_id))? - .notify - .subscribers; - modify_subscriber_set(subscribers, subscriber, unsubscribe)?; - } - resources.save_settings().await?; - reply_to_msg(&bot, &msg, "done").await?; - Ok(()) + todo!() } async fn branch_add( @@ -877,27 +488,7 @@ async fn branch_add( repo: String, branch: String, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - let settings = BranchSettings { - notify: Default::default(), - }; - match repo::branch_add(resources, &branch, settings).await { - Ok(()) => { - branch_check(bot, msg, repo, branch).await?; - } - Err(Error::BranchExists(_)) => { - branch_subscribe( - bot.clone(), - msg.clone(), - repo.clone(), - branch.clone(), - false, - ) - .await?; - } - Err(e) => return Err(e.into()), - } - Ok(()) + todo!() } async fn branch_remove( @@ -906,10 +497,7 @@ async fn branch_remove( repo: String, branch: String, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - repo::branch_remove(resources, &branch).await?; - reply_to_msg(&bot, &msg, format!("branch {branch} removed")).await?; - Ok(()) + todo!() } async fn branch_check( @@ -918,34 +506,7 @@ async fn branch_check( repo: String, branch: String, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - repo::fetch(resources.clone()).await?; - let branch_settings = { - let settings = resources.settings.read().await; - settings - .branches - .get(&branch) - .ok_or_else(|| Error::UnknownBranch(branch.clone()))? - .clone() - }; - let result = repo::branch_check(resources, &branch).await?; - let reply = branch_check_message(&repo, &branch, &branch_settings, &result); - - let mut send = reply_to_msg(&bot, &msg, reply).parse_mode(ParseMode::MarkdownV2); - match subscribe_button_markup("b", &repo, &branch) { - Ok(m) => { - send = send.reply_markup(m); - } - Err(e) => { - log::error!( - "failed to create markup for ({chat}, {repo}, {branch}): {e}", - chat = msg.chat.id - ); - } - } - send.await?; - - Ok(()) + todo!() } async fn branch_subscribe( @@ -955,21 +516,7 @@ async fn branch_subscribe( branch: String, unsubscribe: bool, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; - { - let mut settings = resources.settings.write().await; - let subscribers = &mut settings - .branches - .get_mut(&branch) - .ok_or_else(|| Error::UnknownBranch(branch.clone()))? - .notify - .subscribers; - modify_subscriber_set(subscribers, subscriber, unsubscribe)?; - } - resources.save_settings().await?; - reply_to_msg(&bot, &msg, "done").await?; - Ok(()) + todo!() } async fn condition_add( @@ -980,13 +527,7 @@ async fn condition_add( kind: condition::Kind, expr: String, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - let settings = ConditionSettings { - condition: GeneralCondition::parse(kind, &expr)?, - }; - repo::condition_add(resources, &identifier, settings).await?; - reply_to_msg(&bot, &msg, format!("condition {identifier} added")).await?; - condition_trigger(bot, msg, repo, identifier).await + todo!() } async fn condition_remove( @@ -995,10 +536,7 @@ async fn condition_remove( repo: String, identifier: String, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - repo::condition_remove(resources, &identifier).await?; - reply_to_msg(&bot, &msg, format!("condition {identifier} removed")).await?; - Ok(()) + todo!() } async fn condition_trigger( @@ -1007,227 +545,16 @@ async fn condition_trigger( repo: String, identifier: String, ) -> Result<(), CommandError> { - let resources = resources_helper(&msg, &repo).await?; - let result = repo::condition_trigger(resources, &identifier).await?; - reply_to_msg( - &bot, - &msg, - condition_check_message(&repo, &identifier, &result), - ) - .parse_mode(ParseMode::MarkdownV2) - .await?; - Ok(()) -} - -async fn resources_helper(msg: &Message, repo: &str) -> Result, Error> { - let task = Task { - chat: msg.chat.id, - repo: repo.to_string(), - }; - ResourcesMap::get(&task).await -} - -async fn resources_helper_chat(chat: ChatId, repo: &str) -> Result, Error> { - let task = Task { - chat, - repo: repo.to_string(), - }; - ResourcesMap::get(&task).await -} - -fn commit_check_message( - repo: &str, - commit: &str, - settings: &CommitSettings, - result: &repo::CommitCheckResult, -) -> String { - format!( - "{summary} -{details}", - summary = commit_check_message_summary(repo, settings, result), - details = markdown::expandable_blockquote(&commit_check_message_detail( - repo, commit, settings, result - )), - ) -} - -fn commit_check_message_summary( - repo: &str, - settings: &CommitSettings, - result: &repo::CommitCheckResult, -) -> String { - format!( - "\\[{repo}\\] {comment} \\+{new}", - repo = markdown::escape(repo), - comment = markdown::escape(&settings.notify.comment), - new = markdown_list_compat(result.new.iter()), - ) -} - -fn commit_check_message_detail( - repo: &str, - commit: &str, - settings: &CommitSettings, - result: &repo::CommitCheckResult, -) -> String { - let auto_remove_msg = match &result.removed_by_condition { - None => String::new(), - Some(condition) => format!( - "\n*auto removed* by condition: `{}`", - markdown::escape(condition) - ), - }; - format!( - "{repo}/`{commit}`{url}{notify} - -*new* branches containing this commit: -{new} - -*all* branches containing this commit: -{all} -{auto_remove_msg} -", - repo = markdown::escape(repo), - commit = markdown::escape(commit), - url = settings - .url - .as_ref() - .map(|u| format!("\n{}", markdown::escape(u.as_str()))) - .unwrap_or_default(), - notify = push_empty_line(&settings.notify.notify_markdown()), - new = markdown_list(result.new.iter()), - all = markdown_list(result.all.iter()) - ) -} - -fn pr_merged_message( - repo: &str, - pr: u64, - settings: &PullRequestSettings, - commit: &String, -) -> String { - format!( - "{repo}/{pr} - merged as `{commit}`{notify} -", - notify = push_empty_line(&settings.notify.notify_markdown()), - ) -} - -fn branch_check_message( - repo: &str, - branch: &str, - settings: &BranchSettings, - result: &BranchCheckResult, -) -> String { - let status = if result.old == result.new { - format!( - "{} -\\(not changed\\)", - markdown_optional_commit(result.new.as_deref()) - ) - } else { - format!( - "{old} \u{2192} -{new}", - old = markdown_optional_commit(result.old.as_deref()), - new = markdown_optional_commit(result.new.as_deref()), - ) - }; - format!( - "{repo}/`{branch}` -{status}{notify} -", - repo = markdown::escape(repo), - branch = markdown::escape(branch), - notify = push_empty_line(&settings.notify.notify_markdown()), - ) -} - -fn condition_check_message( - repo: &str, - identifier: &str, - result: &repo::ConditionCheckResult, -) -> String { - format!( - "{repo}/`{identifier}` - -branches removed by this condition: -{removed} -", - repo = markdown::escape(repo), - identifier = markdown::escape(identifier), - removed = markdown_list(result.removed.iter()), - ) -} - -fn markdown_optional_commit(commit: Option<&str>) -> String { - match &commit { - None => "\\(nothing\\)".to_owned(), - Some(c) => markdown::code_inline(&markdown::escape(c)), - } -} - -fn markdown_list(items: Iter) -> String -where - Iter: Iterator, - T: fmt::Display, -{ - let mut result = String::new(); - for item in items { - result.push_str(&format!("\\- `{}`\n", markdown::escape(&item.to_string()))); - } - if result.is_empty() { - "\u{2205}".to_owned() // the empty set symbol - } else { - assert_eq!(result.pop(), Some('\n')); - result - } -} - -fn markdown_list_compat(items: Iter) -> String -where - Iter: Iterator, - T: fmt::Display, -{ - let mut result = String::new(); - for item in items { - result.push_str(&format!("`{}` ", markdown::escape(&item.to_string()))); - } - if result.is_empty() { - "\u{2205}".to_owned() // the empty set symbol - } else { - assert_eq!(result.pop(), Some(' ')); - result - } -} - -fn subscriber_from_msg(msg: &Message) -> Option { - match &msg.from { - None => None, - Some(u) => u.username.as_ref().map(|name| Subscriber::Telegram { - username: name.to_string(), - }), - } + todo!() } -fn modify_subscriber_set( - set: &mut BTreeSet, - subscriber: Subscriber, - unsubscribe: bool, -) -> Result<(), Error> { - if unsubscribe { - if !set.contains(&subscriber) { - return Err(Error::NotSubscribed); - } - set.remove(&subscriber); +fn ensure_admin_chat(msg: &Message) -> Result<(), CommandError> { + let options = options::get(); + if msg.chat_id().map(|id| id.0) == Some(options.admin_chat_id) { + Ok(()) } else { - if set.contains(&subscriber) { - return Err(Error::AlreadySubscribed); - } - set.insert(subscriber); + Err(Error::NotAdminChat.into()) } - Ok(()) } fn subscribe_button_markup( diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..d99e5b4 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,207 @@ +use std::{collections::BTreeSet, fmt}; + +use teloxide::{types::Message, utils::markdown}; + +use crate::{ + chat::{ + results::{BranchCheckResult, CommitCheckResult, ConditionCheckResult}, + settings::{BranchSettings, CommitSettings, PullRequestSettings, Subscriber}, + }, + error::Error, + utils::push_empty_line, +}; + +pub fn commit_check_message( + repo: &str, + commit: &str, + settings: &CommitSettings, + result: &CommitCheckResult, +) -> String { + format!( + "{summary} +{details}", + summary = commit_check_message_summary(repo, settings, result), + details = markdown::expandable_blockquote(&commit_check_message_detail( + repo, commit, settings, result + )), + ) +} + +pub fn commit_check_message_summary( + repo: &str, + settings: &CommitSettings, + result: &CommitCheckResult, +) -> String { + format!( + "\\[{repo}\\] {comment} \\+{new}", + repo = markdown::escape(repo), + comment = markdown::escape(&settings.notify.comment), + new = markdown_list_compat(result.new.iter()), + ) +} + +pub fn commit_check_message_detail( + repo: &str, + commit: &str, + settings: &CommitSettings, + result: &CommitCheckResult, +) -> String { + let auto_remove_msg = match &result.removed_by_condition { + None => String::new(), + Some(condition) => format!( + "\n*auto removed* by condition: `{}`", + markdown::escape(condition) + ), + }; + format!( + "{repo}/`{commit}`{url}{notify} + +*new* branches containing this commit: +{new} + +*all* branches containing this commit: +{all} +{auto_remove_msg} +", + repo = markdown::escape(repo), + commit = markdown::escape(commit), + url = settings + .url + .as_ref() + .map(|u| format!("\n{}", markdown::escape(u.as_str()))) + .unwrap_or_default(), + notify = push_empty_line(&settings.notify.notify_markdown()), + new = markdown_list(result.new.iter()), + all = markdown_list(result.all.iter()) + ) +} + +pub fn pr_merged_message( + repo: &str, + pr: u64, + settings: &PullRequestSettings, + commit: &String, +) -> String { + format!( + "{repo}/{pr} + merged as `{commit}`{notify} +", + notify = push_empty_line(&settings.notify.notify_markdown()), + ) +} + +pub fn branch_check_message( + repo: &str, + branch: &str, + settings: &BranchSettings, + result: &BranchCheckResult, +) -> String { + let status = if result.old == result.new { + format!( + "{} +\\(not changed\\)", + markdown_optional_commit(result.new.as_deref()) + ) + } else { + format!( + "{old} \u{2192} +{new}", + old = markdown_optional_commit(result.old.as_deref()), + new = markdown_optional_commit(result.new.as_deref()), + ) + }; + format!( + "{repo}/`{branch}` +{status}{notify} +", + repo = markdown::escape(repo), + branch = markdown::escape(branch), + notify = push_empty_line(&settings.notify.notify_markdown()), + ) +} + +pub fn condition_check_message( + repo: &str, + identifier: &str, + result: &ConditionCheckResult, +) -> String { + format!( + "{repo}/`{identifier}` + +branches removed by this condition: +{removed} +", + repo = markdown::escape(repo), + identifier = markdown::escape(identifier), + removed = markdown_list(result.removed.iter()), + ) +} + +pub fn markdown_optional_commit(commit: Option<&str>) -> String { + match &commit { + None => "\\(nothing\\)".to_owned(), + Some(c) => markdown::code_inline(&markdown::escape(c)), + } +} + +pub fn markdown_list(items: Iter) -> String +where + Iter: Iterator, + T: fmt::Display, +{ + let mut result = String::new(); + for item in items { + result.push_str(&format!("\\- `{}`\n", markdown::escape(&item.to_string()))); + } + if result.is_empty() { + "\u{2205}".to_owned() // the empty set symbol + } else { + assert_eq!(result.pop(), Some('\n')); + result + } +} + +pub fn markdown_list_compat(items: Iter) -> String +where + Iter: Iterator, + T: fmt::Display, +{ + let mut result = String::new(); + for item in items { + result.push_str(&format!("`{}` ", markdown::escape(&item.to_string()))); + } + if result.is_empty() { + "\u{2205}".to_owned() // the empty set symbol + } else { + assert_eq!(result.pop(), Some(' ')); + result + } +} + +pub fn subscriber_from_msg(msg: &Message) -> Option { + match &msg.from { + None => None, + Some(u) => u.username.as_ref().map(|name| Subscriber::Telegram { + username: name.to_string(), + }), + } +} + +pub fn modify_subscriber_set( + set: &mut BTreeSet, + subscriber: Subscriber, + unsubscribe: bool, +) -> Result<(), Error> { + if unsubscribe { + if !set.contains(&subscriber) { + return Err(Error::NotSubscribed); + } + set.remove(&subscriber); + } else { + if set.contains(&subscriber) { + return Err(Error::AlreadySubscribed); + } + set.insert(subscriber); + } + Ok(()) +} diff --git a/src/options.rs b/src/options.rs index 6612d0a..74d823d 100644 --- a/src/options.rs +++ b/src/options.rs @@ -8,6 +8,8 @@ pub struct Options { pub working_dir: PathBuf, #[arg(short, long)] pub cron: String, + #[arg(short, long)] + pub admin_chat_id: i64, } pub static OPTIONS: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); diff --git a/src/repo.rs b/src/repo.rs deleted file mode 100644 index edcac25..0000000 --- a/src/repo.rs +++ /dev/null @@ -1,640 +0,0 @@ -pub mod paths; -pub mod results; -pub mod settings; -pub mod tasks; - -use git2::{BranchType, Commit, Oid, Repository}; -use regex::Regex; -use rusqlite::Connection; -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::PathBuf; -use std::process::{Command, Output}; -use std::sync::Arc; -use teloxide::types::ChatId; -use tokio::sync::Mutex; -use tokio::task; - -use crate::condition::Condition; -use crate::error::Error; -use crate::github::GitHubInfo; -use crate::repo::results::CommitResults; -use crate::repo::tasks::ResourcesMap; -use crate::utils::push_empty_line; -use crate::{cache, github}; - -use self::results::BranchResults; -use self::settings::{ - BranchSettings, CommitSettings, ConditionSettings, NotifySettings, PullRequestSettings, -}; -use self::tasks::Resources; - -static ORIGIN_RE: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(|| Regex::new("^origin/(.*)$").unwrap()); - -#[derive(Debug)] -pub struct CommitCheckResult { - pub all: BTreeSet, - pub new: BTreeSet, - pub removed_by_condition: Option, -} - -#[derive(Debug)] -pub struct BranchCheckResult { - pub old: Option, - pub new: Option, -} - -#[derive(Debug)] -pub struct ConditionCheckResult { - pub removed: Vec, -} - -pub async fn create(name: &str, path: PathBuf, url: &str) -> Result { - log::info!("try clone '{url}' into {path:?}"); - - let output = { - let url = url.to_owned(); - let path = path.clone(); - task::spawn_blocking(move || { - Command::new("git") - .arg("clone") - .arg(url) - .arg(path) - // blobless clone - .arg("--filter=tree:0") - .output() - }) - .await?? - }; - if !output.status.success() { - return Err(Error::GitClone { - url: url.to_owned(), - name: name.to_owned(), - output, - }); - } - - let _repo = Repository::open(&path)?; - log::info!("cloned git repository {path:?}"); - - Ok(output) -} - -pub async fn fetch(resources: Arc) -> Result { - let paths = &resources.paths; - let repo_path = &paths.repo; - log::info!("fetch {repo_path:?}"); - - let output = { - let path = repo_path.clone(); - task::spawn_blocking(move || { - Command::new("git") - .arg("fetch") - .arg("--all") - .current_dir(path) - .output() - }) - .await?? - }; - if !output.status.success() { - return Err(Error::GitFetch { - name: resources.task.repo.to_owned(), - output, - }); - } - - Ok(output) -} - -pub fn exists(chat: ChatId, name: &str) -> Result { - let path = paths::get(chat, name)?.repo; - Ok(path.is_dir()) -} - -pub async fn remove(resources: Arc) -> Result<(), Error> { - let paths = resources.paths.clone(); - let task = resources.task.clone(); - - drop(resources); // drop resources in hand - ResourcesMap::remove(&task, || { - log::info!("try remove repository outer directory: {:?}", paths.outer); - fs::remove_dir_all(&paths.outer)?; - log::info!("repository outer directory removed: {:?}", &paths.outer); - Ok(()) - }) - .await?; - - Ok(()) -} - -pub async fn commit_add( - resources: Arc, - commit: &str, - settings: CommitSettings, -) -> Result<(), Error> { - let _guard = resources.commit_lock(commit.to_string()).await; - { - let mut locked = resources.settings.write().await; - if locked.commits.contains_key(commit) { - return Err(Error::CommitExists(commit.to_owned())); - } - locked.commits.insert(commit.to_owned(), settings); - } - resources.save_settings().await -} - -pub async fn commit_remove(resources: Arc, commit: &str) -> Result<(), Error> { - let _guard = resources.commit_lock(commit.to_string()).await; - { - let mut settings = resources.settings.write().await; - if !settings.commits.contains_key(commit) { - return Err(Error::UnknownCommit(commit.to_owned())); - } - settings.commits.remove(commit); - } - resources.save_settings().await?; - - { - let mut results = resources.results.write().await; - results.commits.remove(commit); - } - resources.save_results().await?; - - { - let cache = resources.cache().await?; - let commit = commit.to_owned(); - cache - .interact(move |conn| cache::remove(conn, &commit)) - .await - .map_err(|e| Error::DBInteract(Mutex::new(e)))??; - } - Ok(()) -} - -pub async fn commit_check( - resources: Arc, - target_commit: &str, -) -> Result { - let result = { - let _guard = resources.commit_lock(target_commit.to_string()).await; - - /* 2 phase */ - - /* phase 1: commit check */ - - // get settings - let settings = { - let s = resources.settings.read().await; - s.clone() - }; - - // get old results - let mut old_results = { - let r = resources.results.read().await; - r.clone() - }; - let old_commit_results = old_results - .commits - .remove(target_commit) - .unwrap_or_default(); - let branches_hit_old = old_commit_results.branches; - - let branches_hit = { - let cache = resources.cache().await?; - let target_commit = target_commit.to_owned(); - let resources = resources.clone(); - cache - .interact(move |conn| -> Result<_, Error> { - let repo = resources.repo.blocking_lock(); - let branches = repo.branches(Some(git2::BranchType::Remote))?; - let branch_regex = Regex::new(&settings.branch_regex)?; - - let mut branches_hit = BTreeSet::new(); - for branch_iter_res in branches { - let (branch, _) = branch_iter_res?; - // clean up name - let branch_name = match branch.name()?.and_then(branch_name_map_filter) { - None => continue, - Some(n) => n, - }; - // skip if not match - if !branch_regex.is_match(branch_name) { - continue; - } - let root = branch.get().peel_to_commit()?; - let str_root = format!("{}", root.id()); - - // build the cache - update_from_root(conn, &target_commit, &repo, root)?; - - // query result from cache - let hit_in_branch = cache::query(conn, &target_commit, &str_root)? - .expect("update_from_root should build cache"); - if hit_in_branch { - branches_hit.insert(branch_name.to_owned()); - } - } - Ok(branches_hit) - }) - .await - .map_err(|e| Error::DBInteract(Mutex::new(e)))?? - }; - - let commit_results = CommitResults { - branches: branches_hit.clone(), - }; - log::info!( - "finished updating for commit {} on {}", - target_commit, - resources.task.repo - ); - - // insert and save new results - { - let mut results = resources.results.write().await; - results - .commits - .insert(target_commit.to_owned(), commit_results.clone()); - } - resources.save_results().await?; - - // construct final check results - let branches_hit_diff = branches_hit - .difference(&branches_hit_old) - .cloned() - .collect(); - - /* phase 2: condition check */ - let mut removed_by_condition = None; - for (identifier, c) in settings.conditions.iter() { - if c.condition.meet(&commit_results) && removed_by_condition.is_none() { - removed_by_condition = Some(identifier.clone()); - } - } - - CommitCheckResult { - all: branches_hit, - new: branches_hit_diff, - removed_by_condition, - } - }; // release the commit lock - - if result.removed_by_condition.is_some() { - commit_remove(resources.clone(), target_commit).await?; - } - Ok(result) -} - -fn branch_name_map_filter(name: &str) -> Option<&str> { - if name == "origin/HEAD" { - return None; - } - - let captures = match ORIGIN_RE.captures(name) { - Some(cap) => cap, - None => return Some(name), - }; - - Some(captures.get(1).unwrap().as_str()) -} - -fn update_from_root<'r>( - cache: &Connection, - target: &str, - repo: &'r Repository, - root: Commit<'r>, -) -> Result<(), Error> { - // phase 1: find commits with out cache - let todo = { - let mut t = BTreeSet::new(); - let mut visited = BTreeSet::new(); - let mut stack = vec![root.clone()]; - while let Some(commit) = stack.pop() { - if !visited.insert(commit.id()) { - continue; - } - if visited.len() % 1000 == 0 { - log::info!("checking phase 1, visited {} commits", visited.len()); - } - let str_commit = format!("{}", commit.id()); - if cache::query(cache, target, &str_commit)?.is_none() { - t.insert(commit.id()); - for parent in commit.parents() { - stack.push(parent); - } - } - } - t - }; - - // phase 2: build indegree mapping - let root_id = root.id(); - let mut indegrees: BTreeMap = BTreeMap::new(); - for oid in todo.iter() { - if !indegrees.contains_key(oid) { - indegrees.insert(*oid, 0); - } - let commit = repo.find_commit(*oid)?; - for parent in commit.parents() { - let pid = parent.id(); - if todo.contains(&pid) { - let n = indegrees.get(&pid).cloned().unwrap_or(0); - indegrees.insert(pid, n + 1); - } - } - } - - // phase 3: sort commits - let mut sorted = vec![]; - - if !indegrees.is_empty() { - assert!(indegrees.contains_key(&root_id)); - indegrees.remove(&root_id); - let mut next = vec![root]; - while let Some(commit) = next.pop() { - sorted.push(commit.clone()); - - for parent in commit.parents() { - let pid = parent.id(); - if indegrees.contains_key(&pid) { - let new_count = indegrees[&pid] - 1; - if new_count == 0 { - indegrees.remove(&pid); - next.push(parent); - } else { - indegrees.insert(pid, new_count); - } - } - } - } - } - assert!(indegrees.is_empty()); - - // phase 4: build caches - let mut in_memory_cache: BTreeMap = BTreeMap::new(); - while let Some(commit) = sorted.pop() { - if !sorted.is_empty() && sorted.len() % 1000 == 0 { - log::info!("checking phase 4, remaining {} commits", sorted.len()); - } - - let oid = commit.id(); - let str_commit = format!("{oid}"); - - let mut hit = false; - if str_commit == target { - hit = true; - } else { - for parent in commit.parents() { - let pid = parent.id(); - hit |= if in_memory_cache.contains_key(&pid) { - in_memory_cache[&pid] - } else { - let str_parent = format!("{pid}"); - match cache::query(cache, target, &str_parent)? { - None => unreachable!(), - Some(b) => b, - } - } - } - } - - in_memory_cache.insert(oid, hit); - } - - // unchecked: no nested transaction - let tx = cache.unchecked_transaction()?; - for (oid, hit) in in_memory_cache { - let str_commit = format!("{oid}"); - // wrap store operations in transaction to improve performance - cache::store(&tx, target, &str_commit, hit)?; - } - tx.commit()?; - - Ok(()) -} - -pub async fn branch_add( - resources: Arc, - branch: &str, - settings: BranchSettings, -) -> Result<(), Error> { - let _guard = resources.branch_lock(branch.to_string()).await; - { - let mut locked = resources.settings.write().await; - if locked.branches.contains_key(branch) { - return Err(Error::BranchExists(branch.to_owned())); - } - locked.branches.insert(branch.to_owned(), settings); - } - resources.save_settings().await -} - -pub async fn branch_remove(resources: Arc, branch: &str) -> Result<(), Error> { - let _guard = resources.branch_lock(branch.to_string()).await; - { - let mut locked = resources.settings.write().await; - if !locked.branches.contains_key(branch) { - return Err(Error::UnknownBranch(branch.to_owned())); - } - locked.branches.remove(branch); - } - resources.save_settings().await?; - - { - let mut locked = resources.results.write().await; - locked.branches.remove(branch); - } - resources.save_results().await -} - -pub async fn branch_check( - resources: Arc, - branch_name: &str, -) -> Result { - let _guard = resources.branch_lock(branch_name.to_string()).await; - let result = { - let old_result = { - let results = resources.results.read().await; - match results.branches.get(branch_name) { - Some(r) => r.clone(), - None => Default::default(), - } - }; - - // get the new commit (optional) - let commit = { - let repo = resources.repo.lock().await; - let remote_branch_name = format!("origin/{branch_name}"); - - match repo.find_branch(&remote_branch_name, BranchType::Remote) { - Ok(branch) => { - let commit: String = branch.into_reference().peel_to_commit()?.id().to_string(); - Some(commit) - } - Err(_error) => { - log::warn!( - "branch {} not found in ({}, {})", - branch_name, - resources.task.chat, - resources.task.repo, - ); - None - } - } - }; - - { - let mut results = resources.results.write().await; - results.branches.insert( - branch_name.to_owned(), - BranchResults { - commit: commit.clone(), - }, - ); - } - resources.save_results().await?; - - BranchCheckResult { - old: old_result.commit, - new: commit, - } - }; - Ok(result) -} - -pub async fn condition_add( - resources: Arc, - identifier: &str, - settings: ConditionSettings, -) -> Result<(), Error> { - { - let mut locked = resources.settings.write().await; - if locked.conditions.contains_key(identifier) { - return Err(Error::ConditionExists(identifier.to_owned())); - } - locked.conditions.insert(identifier.to_owned(), settings); - } - resources.save_settings().await -} - -pub async fn condition_remove(resources: Arc, identifier: &str) -> Result<(), Error> { - { - let mut locked = resources.settings.write().await; - if !locked.conditions.contains_key(identifier) { - return Err(Error::UnknownCondition(identifier.to_owned())); - } - locked.conditions.remove(identifier); - } - resources.save_settings().await -} - -pub async fn condition_trigger( - resources: Arc, - identifier: &str, -) -> Result { - let mut remove_list = Vec::new(); - { - let cond = { - let settings = resources.settings.read().await; - match settings.conditions.get(identifier) { - Some(s) => s.condition.clone(), - None => return Err(Error::UnknownCondition(identifier.to_owned())), - } - }; - let commits = { - let results = resources.results.read().await; - results.commits.clone() - }; - for (commit, result) in commits.iter() { - if cond.meet(result) { - remove_list.push(commit.clone()); - } - } - for r in remove_list.iter() { - commit_remove(resources.clone(), r).await?; - } - } - Ok(ConditionCheckResult { - removed: remove_list, - }) -} - -pub async fn pr_add( - resources: Arc, - id: u64, - settings: PullRequestSettings, -) -> Result<(), Error> { - { - let mut locked = resources.settings.write().await; - if locked.pull_requests.contains_key(&id) { - return Err(Error::PullRequestExists(id)); - } - locked.pull_requests.insert(id, settings); - } - resources.save_settings().await -} - -pub async fn pr_remove(resources: Arc, id: u64) -> Result<(), Error> { - { - let mut locked = resources.settings.write().await; - if !locked.pull_requests.contains_key(&id) { - return Err(Error::UnknownPullRequest(id)); - } - locked.pull_requests.remove(&id); - } - resources.save_settings().await -} - -pub async fn pr_check(resources: Arc, id: u64) -> Result, Error> { - let github_info = { - let locked = resources.settings.read().await; - locked - .github_info - .clone() - .ok_or(Error::NoGitHubInfo(resources.task.repo.clone()))? - }; - log::info!("checking pr {github_info}#{id}"); - if github::is_merged(&github_info, id).await? { - let settings = { - let mut locked = resources.settings.write().await; - locked - .pull_requests - .remove(&id) - .ok_or(Error::UnknownPullRequest(id))? - }; - resources.save_settings().await?; - let commit = merged_pr_to_commit(resources, github_info, id, settings).await?; - Ok(Some(commit)) - } else { - Ok(None) - } -} - -pub async fn merged_pr_to_commit( - resources: Arc, - github_info: GitHubInfo, - pr_id: u64, - settings: PullRequestSettings, -) -> Result { - let pr = github::get_pr(&github_info, pr_id).await?; - let commit = pr - .merge_commit_sha - .ok_or(Error::NoMergeCommit { github_info, pr_id })?; - let comment = format!( - "{title}{comment}", - title = pr.title.as_deref().unwrap_or("untitled"), - comment = push_empty_line(&settings.notify.comment), - ); - let commit_settings = CommitSettings { - url: Some(settings.url), - notify: NotifySettings { - comment, - subscribers: settings.notify.subscribers, - }, - }; - - commit_add(resources, &commit, commit_settings) - .await - .map(|()| commit) -} diff --git a/src/repo/cache.rs b/src/repo/cache.rs new file mode 100644 index 0000000..6fcd8f9 --- /dev/null +++ b/src/repo/cache.rs @@ -0,0 +1,118 @@ +use std::collections::BTreeSet; + +use rusqlite::{Connection, params}; + +use crate::error::Error; + +pub fn initialize(cache: &Connection) -> Result<(), Error> { + cache.execute( + "CREATE TABLE commits_cache ( + branch TEXT NOT NULL, + commit_hash TEXT NOT NULL + )", + [], + )?; + cache.execute( + "CREATE TABLE branches ( + branch TEXT NOT NULL PRIMARY KEY, + current_commit TEXT NOT NULL + )", + [], + )?; + + Ok(()) +} + +pub fn branches(cache: &Connection) -> Result, Error> { + let mut stmt = cache.prepare_cached("SELECT branch FROM branches;")?; + let query_result: BTreeSet = stmt + .query_map([], |row| row.get(0))? + .collect::>()?; + Ok(query_result) +} + +pub fn remove_branch(cache: &Connection, branch: &str) -> Result<(), Error> { + log::trace!("delete branch \"{branch}\" from cache"); + let mut stmt1 = cache.prepare_cached("DELETE FROM branches WHERE branch = ?1")?; + stmt1.execute(params!(branch))?; + let mut stmt2 = cache.prepare_cached("DELETE FROM commits_cache WHERE branch = ?1")?; + stmt2.execute(params!(branch))?; + Ok(()) +} + +pub fn query_branch(cache: &Connection, branch: &str) -> Result { + let mut stmt = + cache.prepare_cached("SELECT current_commit FROM branches WHERE branch = ?1;")?; + log::trace!("query branch: {branch}"); + let query_result: Vec = stmt + .query_map(params!(branch), |row| row.get(0))? + .collect::>()?; + if query_result.len() != 1 { + Err(Error::UnknownBranch(branch.to_string())) + } else { + Ok(query_result[0].clone()) + } +} + +pub fn store_branch(cache: &Connection, branch: &str, commit: &str) -> Result<(), Error> { + let mut stmt = + cache.prepare_cached("INSERT INTO branches (branch, current_commit) VALUES (?1, ?2)")?; + log::trace!("insert new branch record: ({branch}, {commit})"); + let inserted = stmt.execute(params!(branch, commit))?; + assert_eq!(inserted, 1); + Ok(()) +} + +pub fn update_branch(cache: &Connection, branch: &str, commit: &str) -> Result<(), Error> { + let mut stmt = + cache.prepare_cached("UPDATE branches SET current_commit = ?2 WHERE branch = ?1")?; + log::trace!("update branch record: ({branch}, {commit})"); + stmt.execute(params!(branch, commit))?; + Ok(()) +} + +pub fn query_cache(cache: &Connection, branch: &str, commit: &str) -> Result { + let mut stmt = cache + .prepare_cached("SELECT * FROM commits_cache WHERE branch = ?1 AND commit_hash = ?2")?; + log::trace!("query cache: ({branch}, {commit})"); + let mut query_result = stmt.query(params!(branch, commit))?; + if let Some(_row) = query_result.next()? { + Ok(true) + } else { + Ok(false) + } +} + +pub fn store_cache(cache: &Connection, branch: &str, commit: &str) -> Result<(), Error> { + let mut stmt = + cache.prepare_cached("INSERT INTO commits_cache (branch, commit_hash) VALUES (?1, ?2)")?; + log::trace!("insert new cache: ({branch}, {commit})"); + let inserted = stmt.execute(params!(branch, commit))?; + assert_eq!(inserted, 1); + Ok(()) +} + +pub fn batch_store_cache(cache: &Connection, branch: &str, commits: I) -> Result<(), Error> +where + I: IntoIterator, +{ + let mut count = 0usize; + let tx = cache.unchecked_transaction()?; + for c in commits.into_iter() { + store_cache(&tx, branch, &c)?; + count += 1; + if count % 100000 == 0 { + log::debug!("batch storing cache, current count: {count}",); + } + } + tx.commit()?; + Ok(()) +} + +pub fn remove_cache(cache: &Connection, branch: &str, commit: &str) -> Result<(), Error> { + let mut stmt = + cache.prepare_cached("DELETE FROM commits_cache WHERE branch = ?1 AND commit_hash = ?2")?; + log::trace!("delete cache: ({branch}, {commit})"); + stmt.execute(params!(branch, commit))?; + Ok(()) +} diff --git a/src/repo/mod.rs b/src/repo/mod.rs new file mode 100644 index 0000000..f580aa3 --- /dev/null +++ b/src/repo/mod.rs @@ -0,0 +1,320 @@ +use std::{ + collections::{BTreeSet, VecDeque}, + process::{Command, Output}, + sync::{Arc, LazyLock}, +}; + +use git2::{Commit, Oid, Repository}; +use regex::Regex; +use tokio::{ + fs::{create_dir_all, read_dir, remove_dir_all}, + sync::Mutex, + task, +}; + +use crate::{ + error::Error, + repo::{ + cache::batch_store_cache, + paths::RepoPaths, + resources::{RESOURCES_MAP, RepoResources}, + }, +}; + +pub mod cache; +pub mod paths; +pub mod resources; +pub mod settings; + +pub async fn create(name: &str, url: &str) -> Result { + let paths = RepoPaths::new(name); + log::info!("try clone '{url}' into {:?}", paths.repo); + if paths.outer.exists() { + return Err(Error::RepoExists(name.to_string())); + } + create_dir_all(paths.outer).await?; + let output = { + let url = url.to_owned(); + let path = paths.repo.clone(); + task::spawn_blocking(move || { + Command::new("git") + .arg("clone") + .arg(url) + .arg(path) + // blobless clone + .arg("--filter=tree:0") + .output() + }) + .await + .unwrap()? + }; + if !output.status.success() { + return Err(Error::GitClone { + url: url.to_owned(), + name: name.to_owned(), + output, + }); + } + // try open the repository + let _repo = Repository::open(&paths.repo)?; + log::info!("cloned git repository {:?}", paths.repo); + + Ok(output) +} + +pub async fn remove(name: &str) -> Result<(), Error> { + RESOURCES_MAP + .remove(&name.to_string(), async |r: Arc| { + log::info!("try remove repository outer directory: {:?}", r.paths.outer); + remove_dir_all(&r.paths.outer).await?; + log::info!("repository outer directory removed: {:?}", &r.paths.outer); + Ok(()) + }) + .await?; + Ok(()) +} + +pub async fn list() -> Result, Error> { + let mut dir = read_dir(&*paths::GLOBAL_REPO_OUTER).await?; + let mut result = BTreeSet::new(); + while let Some(entry) = dir.next_entry().await? { + let filename = entry.file_name(); + result.insert( + filename + .to_str() + .ok_or_else(|| Error::InvalidOsString(filename.clone()))? + .to_owned(), + ); + } + Ok(result) +} + +pub async fn fetch_and_update_cache(resources: &RepoResources) -> Result<(), Error> { + fetch(resources).await?; + update_cache(resources).await?; + Ok(()) +} + +pub async fn fetch(resources: &RepoResources) -> Result { + let paths = &resources.paths; + let repo_path = &paths.repo; + log::info!("fetch {repo_path:?}"); + + let output = { + let path = repo_path.clone(); + task::spawn_blocking(move || { + Command::new("git") + .arg("fetch") + .arg("--all") + .current_dir(path) + .output() + }) + .await?? + }; + if !output.status.success() { + return Err(Error::GitFetch { + name: resources.name.to_owned(), + output, + }); + } + + Ok(output) +} + +pub async fn update_cache(resources: &RepoResources) -> Result<(), Error> { + let branches: BTreeSet = watching_branches(resources).await?; + log::debug!( + "update cache for branches of {repo}: {branches:?}", + repo = resources.name + ); + let cache = resources.cache().await?; + let old_branches = cache + .interact(move |c| cache::branches(c)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))??; + let mut new_branches: BTreeSet = branches.difference(&old_branches).cloned().collect(); + let update_branches = branches.intersection(&old_branches); + let mut remove_branches: BTreeSet = + old_branches.difference(&branches).cloned().collect(); + let repo = resources.repo.lock().await; + for b in update_branches { + let commit = branch_commit(&repo, b)?; + let b_cloned = b.clone(); + let old_commit_str = cache + .interact(move |conn| cache::query_branch(conn, &b_cloned)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))??; + let old_commit = repo.find_commit(Oid::from_str(&old_commit_str)?)?; + if old_commit.id() == commit.id() { + log::debug!( + "branch ({repo}, {b}) does not change, skip...", + repo = resources.name + ); + } else if is_parent(old_commit.clone(), commit.clone()) { + log::debug!("updating branch ({repo}, {b})...", repo = resources.name); + let mut queue = VecDeque::new(); + let mut new_commits = BTreeSet::new(); + queue.push_back(commit.clone()); + while let Some(c) = queue.pop_front() { + let id = c.id().to_string(); + let exist = { + let b = b.clone(); + let id = id.clone(); + cache + .interact(move |conn| cache::query_cache(conn, &b, &id)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))?? + }; + if !exist && !new_commits.contains(&id) { + new_commits.insert(id); + if new_commits.len() % 100000 == 0 { + log::debug!( + "gathering new commits, current count: {count}, current queue size: {size}", + count = new_commits.len(), + size = queue.len() + ); + } + for p in c.parents() { + queue.push_back(p); + } + } + } + log::debug!("find {} new commits", new_commits.len()); + { + let b = b.clone(); + cache + .interact(move |conn| batch_store_cache(conn, &b, new_commits)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))??; + } + { + let commit_str = commit.id().to_string(); + let b = b.clone(); + cache + .interact(move |conn| cache::update_branch(conn, &b, &commit_str)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))??; + } + } else { + remove_branches.insert(b.to_owned()); + new_branches.insert(b.to_owned()); + } + } + for b in remove_branches { + log::debug!("removing branch ({repo}, {b})...", repo = resources.name); + let b = b.clone(); + cache + .interact(move |conn| cache::remove_branch(conn, &b)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))??; + } + for b in new_branches { + log::debug!("adding branch ({repo}, {b})...", repo = resources.name); + let commit = branch_commit(&repo, &b)?; + let commits = gather_commits(commit.clone()); + { + let b = b.clone(); + cache + .interact(move |conn| batch_store_cache(conn, &b, commits)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))??; + } + let commit_str = commit.id().to_string(); + cache + .interact(move |conn| cache::store_branch(conn, &b, &commit_str)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))??; + } + Ok(()) +} + +fn branch_commit<'repo>(repo: &'repo Repository, branch: &str) -> Result, Error> { + let full_name = format!("origin/{branch}"); + let branch = repo.find_branch(&full_name, git2::BranchType::Remote)?; + let commit = branch.into_reference().peel_to_commit()?; + Ok(commit) +} + +fn gather_commits<'repo>(commit: Commit<'repo>) -> BTreeSet { + let mut commits = BTreeSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(commit); + while let Some(c) = queue.pop_front() { + if commits.insert(c.id().to_string()) { + if commits.len() % 100000 == 0 { + log::debug!( + "gathering commits, current count: {count}, current queue size: {size}", + count = commits.len(), + size = queue.len() + ); + } + for p in c.parents() { + queue.push_back(p); + } + } + } + commits +} + +fn is_parent<'repo>(parent: Commit<'repo>, child: Commit<'repo>) -> bool { + let mut queue = VecDeque::new(); + let mut visited = BTreeSet::new(); + queue.push_back(child); + while let Some(c) = queue.pop_front() { + if c.id() == parent.id() { + return true; + } + if visited.insert(c.id()) { + // not visited + if visited.len() % 100000 == 0 { + log::debug!( + "testing parent commit, current count: {count}, current queue size: {size}", + count = visited.len(), + size = queue.len() + ); + } + for p in c.parents() { + queue.push_back(p); + } + } + } + false +} + +pub async fn watching_branches(resources: &RepoResources) -> Result, Error> { + let repo = resources.repo.lock().await; + let remote_branches = repo.branches(Some(git2::BranchType::Remote))?; + let branch_regex = { + let settings = resources.settings.read().await; + Regex::new(&format!("^{}$", settings.branch_regex))? + }; + let mut matched_branches = BTreeSet::new(); + for branch_iter_res in remote_branches { + let (branch, _) = branch_iter_res?; + // clean up name + let branch_name = match branch.name()?.and_then(branch_name_map_filter) { + None => continue, + Some(n) => n, + }; + // skip if not match + if branch_regex.is_match(branch_name) { + matched_branches.insert(branch_name.to_string()); + } + } + Ok(matched_branches) +} + +static ORIGIN_RE: LazyLock = LazyLock::new(|| Regex::new("^origin/(.*)$").unwrap()); + +fn branch_name_map_filter(name: &str) -> Option<&str> { + if name == "origin/HEAD" { + return None; + } + + let captures = match ORIGIN_RE.captures(name) { + Some(cap) => cap, + None => return Some(name), + }; + + Some(captures.get(1).unwrap().as_str()) +} diff --git a/src/repo/paths.rs b/src/repo/paths.rs index a943465..1a21669 100644 --- a/src/repo/paths.rs +++ b/src/repo/paths.rs @@ -1,102 +1,26 @@ -use regex::Regex; -use std::collections::BTreeSet; -use std::path::PathBuf; -use teloxide::types::ChatId; +use std::{path::PathBuf, sync::LazyLock}; -use crate::error::Error; use crate::options; -use std::fs; #[derive(Debug, Clone)] -pub struct Paths { +pub struct RepoPaths { pub outer: PathBuf, pub repo: PathBuf, - pub cache: PathBuf, pub settings: PathBuf, - pub results: PathBuf, -} - -static NAME_RE: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(|| Regex::new("^[a-zA-Z0-9_\\-]*$").unwrap()); - -pub fn get(chat_id: ChatId, repo: &str) -> Result { - if !NAME_RE.is_match(repo) { - return Err(Error::Name(repo.to_owned())); - } - - let chat_working_dir = chat_dir(chat_id); - if !chat_working_dir.is_dir() { - return Err(Error::NotInAllowList(chat_id)); - } - - let outer_dir = chat_working_dir.join(repo); - Ok(Paths { - outer: outer_dir.clone(), - repo: outer_dir.join("repo"), - cache: outer_dir.join("cache.sqlite"), - settings: outer_dir.join("settings.json"), - results: outer_dir.join("results.json"), - }) -} - -fn chat_dir(chat: ChatId) -> PathBuf { - let ChatId(num) = chat; - let working_dir = &options::get().working_dir; - let chat_dir_name = if num < 0 { - format!("_{}", num.unsigned_abs()) - } else { - format!("{chat}") - }; - working_dir.join(chat_dir_name) + pub cache: PathBuf, } -pub fn chats() -> Result, Error> { - let mut chats = BTreeSet::new(); - let working_dir = &options::get().working_dir; - let dirs = fs::read_dir(working_dir)?; - for dir_res in dirs { - let dir = dir_res?; - let name_os = dir.file_name(); - let name = name_os.into_string().map_err(Error::InvalidOsString)?; - - let invalid_error = Err(Error::InvalidChatDir(name.clone())); - if name.is_empty() { - return invalid_error; +pub static GLOBAL_REPO_OUTER: LazyLock = + LazyLock::new(|| options::get().working_dir.join("repositories")); + +impl RepoPaths { + pub fn new(name: &str) -> RepoPaths { + let outer = GLOBAL_REPO_OUTER.join(name); + Self { + outer: outer.clone(), + repo: outer.join("repo"), + settings: outer.join("settings.json"), + cache: outer.join("cache.sqlite"), } - - let name_vec: Vec<_> = name.chars().collect(); - let (sign, num_str) = if name_vec[0] == '_' { - (-1, &name_vec[1..]) - } else { - (1, &name_vec[..]) - }; - let n: i64 = match num_str.iter().collect::().parse() { - Ok(n) => n, - Err(e) => { - log::warn!("invalid chat directory '{name}': {e}, ignoring"); - continue; - } - }; - chats.insert(ChatId(sign * n)); - } - Ok(chats) -} - -pub fn repos(chat: ChatId) -> Result, Error> { - let mut repos = BTreeSet::new(); - - let chat_working_dir = chat_dir(chat); - if !chat_working_dir.is_dir() { - return Err(Error::NotInAllowList(chat)); } - - let dirs = fs::read_dir(chat_working_dir)?; - for dir_res in dirs { - let dir = dir_res?; - let name_os = dir.file_name(); - let name = name_os.into_string().map_err(Error::InvalidOsString)?; - repos.insert(name); - } - - Ok(repos) } diff --git a/src/repo/resources.rs b/src/repo/resources.rs new file mode 100644 index 0000000..e5960f6 --- /dev/null +++ b/src/repo/resources.rs @@ -0,0 +1,66 @@ +use std::sync::LazyLock; + +use deadpool_sqlite::Pool; +use git2::Repository; +use tokio::sync::{Mutex, RwLock}; + +use crate::{ + error::Error, + repo::{cache, paths::RepoPaths, settings::RepoSettings}, + resources::{Resource, ResourcesMap}, + utils::{read_json, write_json}, +}; + +pub static RESOURCES_MAP: LazyLock> = + LazyLock::new(ResourcesMap::new); + +pub struct RepoResources { + pub name: String, + pub paths: RepoPaths, + pub repo: Mutex, + pub cache: Pool, + pub settings: RwLock, +} + +impl Resource for RepoResources { + async fn open(name: &String) -> Result { + let paths = RepoPaths::new(name); + if !paths.outer.is_dir() { + return Err(Error::UnknownRepository(name.to_string())); + } + // load repo + let repo = Mutex::new(Repository::open(&paths.repo)?); + // load cache + let cache_exists = paths.cache.is_file(); + let cache_cfg = deadpool_sqlite::Config::new(&paths.cache); + let cache = cache_cfg.create_pool(deadpool_sqlite::Runtime::Tokio1)?; + if !cache_exists { + log::debug!("initializing cache for {name}..."); + let conn = cache.get().await?; + conn.interact(|c| cache::initialize(c)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))??; + } + // load settings + let settings = RwLock::new(read_json(&paths.settings)?); + + Ok(Self { + name: name.clone(), + paths, + repo, + cache, + settings, + }) + } +} + +impl RepoResources { + pub async fn save_settings(&self) -> Result<(), Error> { + let in_mem = self.settings.read().await; + write_json(&self.paths.settings, &*in_mem) + } + + pub async fn cache(&self) -> Result { + Ok(self.cache.get().await?) + } +} diff --git a/src/repo/settings.rs b/src/repo/settings.rs index e5e6844..3b26c9e 100644 --- a/src/repo/settings.rs +++ b/src/repo/settings.rs @@ -1,98 +1,10 @@ -use std::collections::{BTreeMap, BTreeSet}; - use serde::{Deserialize, Serialize}; -use teloxide::utils::markdown; -use url::Url; -use crate::{condition::GeneralCondition, github::GitHubInfo}; +use crate::github::GitHubInfo; #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct Settings { +pub struct RepoSettings { pub branch_regex: String, #[serde(default)] pub github_info: Option, - #[serde(default)] - pub pull_requests: BTreeMap, - #[serde(default)] - pub commits: BTreeMap, - #[serde(default)] - pub branches: BTreeMap, - #[serde(default)] - pub conditions: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommitSettings { - pub url: Option, - #[serde(flatten)] - pub notify: NotifySettings, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PullRequestSettings { - pub url: Url, - #[serde(flatten)] - pub notify: NotifySettings, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct BranchSettings { - #[serde(flatten)] - pub notify: NotifySettings, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct NotifySettings { - #[serde(default)] - pub comment: String, - #[serde(default)] - pub subscribers: BTreeSet, -} - -impl NotifySettings { - pub fn notify_markdown(&self) -> String { - let mut result = String::new(); - let comment = self.comment.trim(); - if !comment.is_empty() { - result.push_str("*comment*:\n"); - result.push_str(&markdown::escape(self.comment.trim())); - } - if !self.subscribers.is_empty() { - if !result.is_empty() { - result.push_str("\n\n"); - } - result.push_str("*subscribers*: "); - result.push_str( - &self - .subscribers - .iter() - .map(Subscriber::markdown) - .collect::>() - .join(" "), - ); - } - result - } - - pub fn description_markdown(&self) -> String { - markdown::escape(self.comment.trim().lines().next().unwrap_or_default()) - } -} - -#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Serialize, Deserialize)] -pub enum Subscriber { - Telegram { username: String }, -} - -impl Subscriber { - fn markdown(&self) -> String { - match self { - Subscriber::Telegram { username } => format!("@{}", markdown::escape(username)), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConditionSettings { - pub condition: GeneralCondition, } diff --git a/src/repo/tasks.rs b/src/repo/tasks.rs deleted file mode 100644 index 91911a3..0000000 --- a/src/repo/tasks.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::collections::BTreeMap; -use std::fmt; -use std::fs::{File, OpenOptions}; -use std::io::{BufReader, BufWriter}; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; - -use deadpool_sqlite::Pool; -use fs4::fs_std::FileExt; -use git2::Repository; -use lockable::LockPool; -use once_cell::sync::Lazy; -use serde::Serialize; -use serde::de::DeserializeOwned; -use teloxide::types::ChatId; -use tokio::sync::{Mutex, RwLock}; -use tokio::time::sleep; - -use super::paths::{self, Paths}; -use super::results::Results; -use super::settings::Settings; -use crate::cache; -use crate::error::Error; - -#[derive(Default)] -pub struct ResourcesMap { - pub map: Lazy>>>, -} - -pub static RESOURCES_MAP: ResourcesMap = ResourcesMap { - map: Lazy::new(|| Mutex::new(Default::default())), -}; - -impl ResourcesMap { - pub async fn get(task: &Task) -> Result, Error> { - let mut map = RESOURCES_MAP.map.lock().await; - match map.get(task) { - Some(resources) => Ok(resources.clone()), - None => { - let resources = Arc::new(Resources::open(task).await?); - map.insert(task.clone(), resources.clone()); - Ok(resources) - } - } - } - - pub async fn remove(task: &Task, cleanup: F) -> Result<(), Error> - where - F: FnOnce() -> Result<(), Error>, - { - let mut map = RESOURCES_MAP.map.lock().await; - if let Some(arc) = map.remove(task) { - wait_for_resources_drop(task, arc).await; - cleanup()?; // run before the map unlock - Ok(()) - } else { - Err(Error::UnknownRepository(task.repo.clone())) - } - } - - pub async fn clear() -> Result<(), Error> { - let mut map = RESOURCES_MAP.map.lock().await; - while let Some((task, resources)) = map.pop_first() { - wait_for_resources_drop(&task, resources).await; - } - Ok(()) - } -} - -pub async fn wait_for_resources_drop(task: &Task, mut arc: Arc) { - loop { - match Arc::try_unwrap(arc) { - Ok(_resource) => { - // do nothing - // just drop - break; - } - Err(a) => { - arc = a; - log::info!( - "removing {}/{}, waiting for existing jobs", - task.chat, - task.repo - ); - sleep(Duration::from_secs(1)).await; - } - } - } -} - -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Debug)] -pub struct Task { - pub chat: ChatId, - pub repo: String, -} - -impl Task { - pub fn paths(&self) -> Result { - paths::get(self.chat, &self.repo) - } -} - -pub struct Resources { - pub task: Task, - pub paths: Paths, - pub repo: Mutex, - pub cache: Pool, - pub settings: RwLock, - pub results: RwLock, - - pub commit_locks: LockPool, - pub branch_locks: LockPool, -} - -impl Resources { - pub async fn open(task: &Task) -> Result { - let paths = task.paths()?; - - if !paths.outer.is_dir() { - return Err(Error::UnknownRepository(task.repo.clone())); - } - - // load repo - let repo = Mutex::new(Repository::open(&paths.repo)?); - // load cache - let cache_exists = paths.cache.is_file(); - let cache_cfg = deadpool_sqlite::Config::new(&paths.cache); - let cache = cache_cfg.create_pool(deadpool_sqlite::Runtime::Tokio1)?; - if !cache_exists { - let conn = cache.get().await?; - conn.interact(|c| cache::initialize(c)) - .await - .map_err(|e| Error::DBInteract(Mutex::new(e)))??; - } - // load settings - let settings = RwLock::new(read_json(&paths.settings)?); - // load results - let results = RwLock::new(read_json(&paths.results)?); - - Ok(Resources { - task: task.clone(), - paths, - repo, - cache, - settings, - results, - commit_locks: LockPool::new(), - branch_locks: LockPool::new(), - }) - } - - pub async fn save_settings(&self) -> Result<(), Error> { - let paths = self.task.paths()?; - let in_mem = self.settings.read().await; - write_json(&paths.settings, &*in_mem) - } - - pub async fn save_results(&self) -> Result<(), Error> { - let paths = self.task.paths()?; - let in_mem = self.results.read().await; - write_json(&paths.results, &*in_mem) - } - - pub async fn cache(&self) -> Result { - Ok(self.cache.get().await?) - } - - pub async fn commit_lock(&self, key: String) -> impl Drop + '_ { - self.commit_locks.async_lock(key).await - } - - pub async fn branch_lock(&self, key: String) -> impl Drop + '_ { - self.branch_locks.async_lock(key).await - } -} - -fn read_json(path: P) -> Result -where - P: AsRef + fmt::Debug, - T: Serialize + DeserializeOwned + Default, -{ - if !path.as_ref().is_file() { - log::info!("auto create file: {path:?}"); - write_json::<_, T>(&path, &Default::default())?; - } - log::debug!("read from file: {path:?}"); - let file = File::open(path)?; - // TODO lock_shared maybe added to the std lib in the future - FileExt::lock_shared(&file)?; // close of file automatically release the lock - let reader = BufReader::new(file); - Ok(serde_json::from_reader(reader)?) -} - -fn write_json(path: P, rs: &T) -> Result<(), Error> -where - P: AsRef + fmt::Debug, - T: Serialize, -{ - log::debug!("write to file: {path:?}"); - let file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(path)?; - file.lock_exclusive()?; - let writer = BufWriter::new(file); - Ok(serde_json::to_writer_pretty(writer, rs)?) -} diff --git a/src/resources.rs b/src/resources.rs new file mode 100644 index 0000000..9b0348c --- /dev/null +++ b/src/resources.rs @@ -0,0 +1,92 @@ +use std::{ + collections::BTreeMap, + fmt::{self}, + sync::Arc, +}; + +use tokio::{sync::Mutex, time::sleep}; + +use crate::error::Error; + +#[derive(Default)] +pub struct ResourcesMap { + pub map: Mutex>>, +} + +pub trait Resource +where + Self: Sized, +{ + async fn open(index: &I) -> Result; +} + +impl ResourcesMap { + pub fn new() -> Self { + Self { + map: Mutex::new(BTreeMap::new()), + } + } + + pub async fn get(&self, index: &I) -> Result, Error> + where + R: Resource, + I: Ord + Clone, + { + let mut map = self.map.lock().await; + match map.get(index) { + Some(resources) => Ok(resources.clone()), + None => { + let resources = Arc::new(R::open(index).await?); + map.insert(index.clone(), resources.clone()); + Ok(resources) + } + } + } + + pub async fn remove(&self, index: &I, cleanup: C) -> Result<(), Error> + where + I: Ord + Clone + fmt::Display + fmt::Debug, + C: FnOnce(Arc) -> F, + F: Future>, + { + let mut map = self.map.lock().await; + if let Some(arc) = map.remove(index) { + wait_for_resources_drop(index, arc.clone()).await; + cleanup(arc).await?; // run before the map unlock + Ok(()) + } else { + Err(Error::UnknownResource(format!("{index}"))) + } + } + + pub async fn clear(&self) -> Result<(), Error> + where + I: Ord + fmt::Display, + { + let mut map = self.map.lock().await; + while let Some((task, resources)) = map.pop_first() { + wait_for_resources_drop(&task, resources).await; + } + Ok(()) + } +} + +pub async fn wait_for_resources_drop(index: &I, mut arc: Arc) +where + I: fmt::Display, +{ + loop { + match Arc::try_unwrap(arc) { + Ok(_resource) => { + // do nothing + // just drop + break; + } + Err(a) => { + arc = a; + log::info!("removing {}, waiting for existing jobs", index); + sleep(std::time::Duration::from_secs(1)).await; + } + } + } +} diff --git a/src/utils.rs b/src/utils.rs index 216c8f1..433107b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,16 @@ +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{BufReader, BufWriter}; +use std::path::Path; + +use fs4::fs_std::FileExt; +use serde::Serialize; +use serde::de::DeserializeOwned; use teloxide::types::ReplyParameters; use teloxide::{payloads::SendMessage, prelude::*, requests::JsonRequest}; +use crate::error::Error; + pub fn reply_to_msg(bot: &Bot, msg: &Message, text: T) -> JsonRequest where T: Into, @@ -19,3 +29,36 @@ pub fn push_empty_line(s: &str) -> String { result } } + +pub fn read_json(path: P) -> Result +where + P: AsRef + fmt::Debug, + T: Serialize + DeserializeOwned + Default, +{ + if !path.as_ref().is_file() { + log::info!("auto create file: {path:?}"); + write_json::<_, T>(&path, &Default::default())?; + } + log::debug!("read from file: {path:?}"); + let file = File::open(path)?; + // TODO lock_shared maybe added to the std lib in the future + FileExt::lock_shared(&file)?; // close of file automatically release the lock + let reader = BufReader::new(file); + Ok(serde_json::from_reader(reader)?) +} + +pub fn write_json(path: P, rs: &T) -> Result<(), Error> +where + P: AsRef + fmt::Debug, + T: Serialize, +{ + log::debug!("write to file: {path:?}"); + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path)?; + file.lock_exclusive()?; + let writer = BufWriter::new(file); + Ok(serde_json::to_writer_pretty(writer, rs)?) +} From c04c08393a49a2467836146e888027ae33969f24 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 12:48:46 +0800 Subject: [PATCH 03/27] WIP --- src/chat/mod.rs | 81 +++++++++++++++++++++++++++++++ src/chat/paths.rs | 39 +++++++++++++++ src/chat/resources.rs | 67 ++++++++++++++++++++++++++ src/main.rs | 108 +++++++++++++++++++++++++++++++++++++++--- src/repo/mod.rs | 6 ++- 5 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 src/chat/paths.rs create mode 100644 src/chat/resources.rs diff --git a/src/chat/mod.rs b/src/chat/mod.rs index def87d8..9db247d 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -1,2 +1,83 @@ +use std::sync::Arc; + +use teloxide::types::{ChatId, Message}; + +use crate::{ + chat::{resources::ChatRepoResources, results::CommitCheckResult, settings::CommitSettings}, + error::Error, + repo::resources::RepoResources, +}; + +pub mod paths; +pub mod resources; pub mod results; pub mod settings; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Task { + chat: ChatId, + repo: String, +} + +pub async fn resources(task: &Task) -> Result, Error> { + Ok(resources::RESOURCES_MAP.get(task).await?) +} + +pub async fn resources_chat_repo( + chat: ChatId, + repo: String, +) -> Result, Error> { + let task = Task { chat, repo }; + resources(&task).await +} + +pub async fn resources_msg_repo( + msg: &Message, + repo: String, +) -> Result, Error> { + let chat = msg.chat.id; + resources_chat_repo(chat, repo).await +} + +pub async fn commit_add( + resources: &ChatRepoResources, + hash: &str, + settings: CommitSettings, +) -> Result<(), Error> { + let _guard = resources.commit_lock(hash.to_string()).await; + { + let mut locked = resources.settings.write().await; + if locked.commits.contains_key(hash) { + return Err(Error::CommitExists(hash.to_owned())); + } + locked.commits.insert(hash.to_owned(), settings); + } + resources.save_settings().await; + Ok(()) +} + +pub async fn commit_remove(resources: &ChatRepoResources, hash: &str) -> Result<(), Error> { + let _guard = resources.commit_lock(hash.to_string()).await; + { + let mut settings = resources.settings.write().await; + if !settings.commits.contains_key(hash) { + return Err(Error::UnknownCommit(hash.to_owned())); + } + settings.commits.remove(hash); + } + { + let mut results = resources.results.write().await; + results.commits.remove(hash); + } + resources.save_settings().await; + resources.save_results().await; + Ok(()) +} + +pub(crate) async fn commit_check( + resources: Arc, + repo_resources: &RepoResources, + hash: &str, +) -> Result { + todo!() +} diff --git a/src/chat/paths.rs b/src/chat/paths.rs new file mode 100644 index 0000000..d2bbb03 --- /dev/null +++ b/src/chat/paths.rs @@ -0,0 +1,39 @@ +use std::{path::PathBuf, sync::LazyLock}; + +use teloxide::types::ChatId; + +use crate::{chat::Task, options}; + +#[derive(Debug, Clone)] +pub struct ChatRepoPaths { + pub chat: PathBuf, + pub repo: PathBuf, + pub settings: PathBuf, + pub results: PathBuf, +} + +pub static GLOBAL_CHATS_OUTER: LazyLock = + LazyLock::new(|| options::get().working_dir.join("chats")); + +impl ChatRepoPaths { + pub fn new(task: &Task) -> ChatRepoPaths { + let chat_path = GLOBAL_CHATS_OUTER.join(Self::outer_dir_name(task.chat)); + let repo = chat_path.join(&task.repo); + Self { + chat: chat_path, + settings: repo.join("settings.json"), + results: repo.join("results.json"), + repo, + } + } + + fn outer_dir_name(chat: ChatId) -> PathBuf { + let ChatId(num) = chat; + let chat_dir_name = if num < 0 { + format!("_{}", num.unsigned_abs()) + } else { + format!("{chat}") + }; + chat_dir_name.into() + } +} diff --git a/src/chat/resources.rs b/src/chat/resources.rs new file mode 100644 index 0000000..5ed9b08 --- /dev/null +++ b/src/chat/resources.rs @@ -0,0 +1,67 @@ +use std::sync::LazyLock; + +use git2::Repository; +use lockable::LockPool; +use teloxide::types::ChatId; +use tokio::{ + fs::create_dir_all, + sync::{Mutex, RwLock}, +}; + +use crate::{ + chat::{Task, paths::ChatRepoPaths, results::ChatRepoResults, settings::ChatRepoSettings}, + error::Error, + repo::{cache, paths::RepoPaths, settings::RepoSettings}, + resources::{Resource, ResourcesMap}, + utils::{read_json, write_json}, +}; + +pub static RESOURCES_MAP: LazyLock> = + LazyLock::new(ResourcesMap::new); + +pub struct ChatRepoResources { + pub task: Task, + pub paths: ChatRepoPaths, + pub settings: RwLock, + pub results: RwLock, + + pub commit_locks: LockPool, + pub branch_locks: LockPool, +} + +impl Resource for ChatRepoResources { + async fn open(task: &Task) -> Result { + let paths = ChatRepoPaths::new(&task); + if !paths.repo.is_dir() { + create_dir_all(&paths.repo).await?; + } + let settings = RwLock::new(read_json(&paths.settings)?); + let results = RwLock::new(read_json(&paths.results)?); + Ok(Self { + task: task.clone(), + paths, + settings, + results, + commit_locks: LockPool::new(), + branch_locks: LockPool::new(), + }) + } +} + +impl ChatRepoResources { + pub async fn save_settings(&self) -> Result<(), Error> { + let in_mem = self.settings.read().await; + write_json(&self.paths.settings, &*in_mem) + } + pub async fn save_results(&self) -> Result<(), Error> { + let in_mem = self.results.read().await; + write_json(&self.paths.results, &*in_mem) + } + pub async fn commit_lock(&self, key: String) -> impl Drop + '_ { + self.commit_locks.async_lock(key).await + } + + pub async fn branch_lock(&self, key: String) -> impl Drop + '_ { + self.branch_locks.async_lock(key).await + } +} diff --git a/src/main.rs b/src/main.rs index ba93b41..ec6c05e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,15 +21,24 @@ use serde::Deserialize; use serde::Serialize; use teloxide::dispatching::dialogue::GetChatId; use teloxide::payloads; +use teloxide::payloads::SendMessage; use teloxide::prelude::*; +use teloxide::requests::JsonRequest; +use teloxide::sugar::request::RequestLinkPreviewExt; use teloxide::types::InlineKeyboardButton; use teloxide::types::InlineKeyboardButtonKind; use teloxide::types::InlineKeyboardMarkup; +use teloxide::types::ParseMode; use teloxide::update_listeners; use teloxide::utils::command::BotCommands; +use teloxide::utils::markdown; use tokio::time::sleep; use url::Url; +use crate::chat::settings::CommitSettings; +use crate::chat::settings::NotifySettings; +use crate::message::commit_check_message; +use crate::message::subscriber_from_msg; use crate::utils::reply_to_msg; #[derive(BotCommands, Clone, Debug)] @@ -328,13 +337,43 @@ async fn update(bot: Bot) -> Result<(), CommandError> { log::info!("updating repositories..."); let repos = repo::list().await?; for repo in repos { - let resources = repo::resources::RESOURCES_MAP.get(&repo).await?; + let resources = repo::resources(&repo).await?; repo::fetch_and_update_cache(&resources).await?; } Ok(()) } async fn list(bot: Bot, msg: Message) -> Result<(), CommandError> { + let options = options::get(); + let chat = msg.chat.id; + if ChatId(options.admin_chat_id) == chat { + list_for_admin(bot, msg).await + } else { + list_for_normal(bot, msg).await + } +} + +async fn list_for_admin(bot: Bot, msg: Message) -> Result<(), CommandError> { + let chat = msg.chat.id; + let mut result = String::new(); + + let repos = repo::list().await?; + for repo in repos { + result.push('*'); + result.push_str(&markdown::escape(&repo)); + result.push_str("*\n"); + } + if result.is_empty() { + result.push_str("(nothing)\n"); + } + reply_to_msg(&bot, &msg, result) + .parse_mode(ParseMode::MarkdownV2) + .await?; + + Ok(()) +} + +async fn list_for_normal(bot: Bot, msg: Message) -> Result<(), CommandError> { todo!() } @@ -346,7 +385,7 @@ async fn return_chat_id(bot: Bot, msg: Message) -> Result<(), CommandError> { async fn repo_add(bot: Bot, msg: Message, name: String, url: String) -> Result<(), CommandError> { ensure_admin_chat(&msg)?; let _output = repo::create(&name, &url).await?; - let resources = repo::resources::RESOURCES_MAP.get(&name).await?; + let resources = repo::resources(&name).await?; let github_info = Url::parse(&url) .ok() .and_then(|u| GitHubInfo::parse_from_url(u).ok()); @@ -374,7 +413,7 @@ async fn repo_edit( clear_github_info: bool, ) -> Result<(), CommandError> { ensure_admin_chat(&msg)?; - let resources = repo::resources::RESOURCES_MAP.get(&name).await?; + let resources = repo::resources(&name).await?; let new_settings = { let mut locked = resources.settings.write().await; if let Some(r) = branch_regex { @@ -415,7 +454,25 @@ async fn commit_add( comment: String, url: Option, ) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let subscribers = subscriber_from_msg(&msg).into_iter().collect(); + let settings = CommitSettings { + url, + notify: NotifySettings { + comment, + subscribers, + }, + }; + match chat::commit_add(&resources, &hash, settings).await { + Ok(()) => { + commit_check(bot, msg, repo, hash).await?; + } + Err(Error::CommitExists(_)) => { + commit_subscribe(bot.clone(), msg.clone(), repo.clone(), hash.clone(), false).await?; + } + Err(e) => return Err(e.into()), + } + Ok(()) } async fn commit_remove( @@ -424,7 +481,10 @@ async fn commit_remove( repo: String, hash: String, ) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + chat::commit_remove(&resources, &hash).await?; + reply_to_msg(&bot, &msg, format!("commit {hash} removed")).await?; + Ok(()) } async fn commit_check( @@ -433,7 +493,27 @@ async fn commit_check( repo: String, hash: String, ) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let repo_resources = repo::resources(&repo).await?; + let commit_settings = { + let settings = resources.settings.read().await; + settings + .commits + .get(&hash) + .ok_or_else(|| Error::UnknownCommit(hash.clone()))? + .clone() + }; + repo::fetch_and_update_cache(&repo_resources).await?; + let result = chat::commit_check(resources, &repo_resources, &hash).await?; + let reply = commit_check_message(&repo, &hash, &commit_settings, &result); + let mut send = reply_to_msg(&bot, &msg, reply) + .parse_mode(ParseMode::MarkdownV2) + .disable_link_preview(true); + if result.removed_by_condition.is_none() { + send = try_attach_subscribe_button_markup(msg.chat.id, send, "c", &repo, &hash); + } + send.await?; + Ok(()) } async fn commit_subscribe( @@ -557,6 +637,22 @@ fn ensure_admin_chat(msg: &Message) -> Result<(), CommandError> { } } +fn try_attach_subscribe_button_markup( + chat: ChatId, + send: JsonRequest, + kind: &str, + repo: &str, + hash: &str, +) -> JsonRequest { + match subscribe_button_markup(kind, &repo, &hash) { + Ok(m) => send.reply_markup(m), + Err(e) => { + log::error!("failed to create markup for ({chat}, {repo}, {hash}): {e}"); + send + } + } +} + fn subscribe_button_markup( kind: &str, repo: &str, diff --git a/src/repo/mod.rs b/src/repo/mod.rs index f580aa3..5d9a6ca 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -26,13 +26,17 @@ pub mod paths; pub mod resources; pub mod settings; +pub async fn resources(repo: &str) -> Result, Error> { + Ok(resources::RESOURCES_MAP.get(&repo.to_string()).await?) +} + pub async fn create(name: &str, url: &str) -> Result { let paths = RepoPaths::new(name); log::info!("try clone '{url}' into {:?}", paths.repo); if paths.outer.exists() { return Err(Error::RepoExists(name.to_string())); } - create_dir_all(paths.outer).await?; + create_dir_all(&paths.outer).await?; let output = { let url = url.to_owned(); let path = paths.repo.clone(); From acd5ef2df5980f3f3de97be6a155a6a5c9f9b64c Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 15:46:16 +0800 Subject: [PATCH 04/27] Full refactor --- Cargo.lock | 11 + Cargo.toml | 1 + nixos/commit-notifier.nix | 9 +- src/chat/mod.rs | 322 ++++++++++++++++++- src/chat/paths.rs | 10 +- src/chat/resources.rs | 10 +- src/chat/results.rs | 18 +- src/chat/settings.rs | 12 - src/command.rs | 2 - src/condition/in_branch.rs | 32 +- src/condition/mod.rs | 39 ++- src/condition/suppress_from_to.rs | 36 +++ src/error.rs | 4 - src/main.rs | 516 +++++++++++++++++++++--------- src/message.rs | 55 +--- src/repo/cache.rs | 23 +- src/repo/mod.rs | 137 +++++--- src/repo/paths.rs | 17 +- src/repo/resources.rs | 4 +- src/repo/settings.rs | 31 +- src/update.rs | 210 ++++++++++++ src/utils.rs | 21 ++ 22 files changed, 1192 insertions(+), 328 deletions(-) create mode 100644 src/condition/suppress_from_to.rs create mode 100644 src/update.rs diff --git a/Cargo.lock b/Cargo.lock index dfe8a6e..ef8d2ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,7 @@ dependencies = [ "scopeguard", "serde", "serde_json", + "serde_regex", "teloxide", "thiserror", "tokio", @@ -1999,6 +2000,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index 179bf36..726b371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ thiserror = "*" clap = { version = "*", features = [ "cargo", "derive" ] } regex = "*" serde_json = "*" +serde_regex = "*" serde = "*" cron = "*" chrono = "*" diff --git a/nixos/commit-notifier.nix b/nixos/commit-notifier.nix index f15ad2c..f22af60 100644 --- a/nixos/commit-notifier.nix +++ b/nixos/commit-notifier.nix @@ -28,6 +28,12 @@ in { Update cron expression. ''; }; + adminChatId = lib.mkOption { + type = lib.types.str; + description = '' + Chat id of the admin chat. + ''; + }; tokenFiles = { telegramBot = lib.mkOption { type = lib.types.str; @@ -61,7 +67,8 @@ in { "${cfg.package}/bin/commit-notifier" \ --working-dir /var/lib/commit-notifier \ - --cron "${cfg.cron}" + --cron "${cfg.cron}" \ + --admin-chat-id="${cfg.adminChatId}" ''; path = [ diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 9db247d..f6bdde5 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -1,11 +1,21 @@ -use std::sync::Arc; +use std::{collections::BTreeSet, fmt, sync::Arc}; +use git2::BranchType; use teloxide::types::{ChatId, Message}; +use tokio::{fs::read_dir, sync::Mutex}; use crate::{ - chat::{resources::ChatRepoResources, results::CommitCheckResult, settings::CommitSettings}, + chat::{ + paths::ChatRepoPaths, + resources::ChatRepoResources, + results::{BranchCheckResult, BranchResults, CommitCheckResult, CommitResults}, + settings::{BranchSettings, CommitSettings, NotifySettings, PullRequestSettings}, + }, + condition::{Action, Condition}, error::Error, - repo::resources::RepoResources, + github::{self, GitHubInfo}, + repo::{cache::query_cache_commit, resources::RepoResources}, + utils::push_empty_line, }; pub mod paths; @@ -13,14 +23,70 @@ pub mod resources; pub mod results; pub mod settings; +pub async fn chats() -> Result, Error> { + let mut chats = BTreeSet::new(); + let dir_path = &paths::GLOBAL_CHATS_OUTER; + let mut dir = read_dir(dir_path.as_path()).await?; + while let Some(entry) = dir.next_entry().await? { + let name_os = entry.file_name(); + let name = name_os.into_string().map_err(Error::InvalidOsString)?; + + let invalid_error = Err(Error::InvalidChatDir(name.clone())); + if name.is_empty() { + return invalid_error; + } + + let name_vec: Vec<_> = name.chars().collect(); + let (sign, num_str) = if name_vec[0] == '_' { + (-1, &name_vec[1..]) + } else { + (1, &name_vec[..]) + }; + let n: i64 = match num_str.iter().collect::().parse() { + Ok(n) => n, + Err(e) => { + log::warn!("invalid chat directory '{name}': {e}, ignoring"); + continue; + } + }; + chats.insert(ChatId(sign * n)); + } + Ok(chats) +} + +pub async fn repos(chat: ChatId) -> Result, Error> { + let directory = ChatRepoPaths::outer_dir(chat); + if !directory.exists() { + Ok(BTreeSet::new()) + } else { + let mut results = BTreeSet::new(); + let mut dir = read_dir(&directory).await?; + while let Some(entry) = dir.next_entry().await? { + results.insert( + entry + .file_name() + .into_string() + .map_err(Error::InvalidOsString)?, + ); + } + Ok(results) + } +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Task { - chat: ChatId, - repo: String, + pub chat: ChatId, + pub repo: String, +} + +impl fmt::Display for Task { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Task({}, {})", self.chat, self.repo) + } } pub async fn resources(task: &Task) -> Result, Error> { - Ok(resources::RESOURCES_MAP.get(task).await?) + resources::RESOURCES_MAP.get(task).await } pub async fn resources_chat_repo( @@ -52,8 +118,7 @@ pub async fn commit_add( } locked.commits.insert(hash.to_owned(), settings); } - resources.save_settings().await; - Ok(()) + resources.save_settings().await } pub async fn commit_remove(resources: &ChatRepoResources, hash: &str) -> Result<(), Error> { @@ -69,15 +134,246 @@ pub async fn commit_remove(resources: &ChatRepoResources, hash: &str) -> Result< let mut results = resources.results.write().await; results.commits.remove(hash); } - resources.save_settings().await; - resources.save_results().await; + resources.save_settings().await?; + resources.save_results().await?; Ok(()) } -pub(crate) async fn commit_check( - resources: Arc, +pub async fn commit_check( + resources: &ChatRepoResources, repo_resources: &RepoResources, hash: &str, ) -> Result { - todo!() + log::info!("checking commit ({task}, {hash})", task = resources.task); + let _guard = resources.commit_lock(hash.to_string()).await; + let cache = repo_resources.cache().await?; + let all_branches = { + let commit = hash.to_string(); + cache + .interact(move |conn| query_cache_commit(conn, &commit)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))?? + }; + let new_results = CommitResults { + branches: all_branches.clone(), + }; + let old_results = { + let mut results = resources.results.write().await; + results + .commits + .insert(hash.to_string(), new_results) + .unwrap_or_default() + }; + let new_branches = all_branches + .difference(&old_results.branches) + .cloned() + .collect(); + let mut check_result = CommitCheckResult { + all: all_branches, + new: new_branches, + conditions: Default::default(), + }; + let mut remove = false; + { + let settings = repo_resources.settings.read().await; + for (condition_name, condition_setting) in &settings.conditions { + let action = condition_setting.condition.check(&check_result); + if action.is_none() { + continue; + } else { + check_result + .conditions + .insert(condition_name.clone(), action); + if action == Action::Remove { + remove = true; + } + } + } + } + if remove { + let mut settings = resources.settings.write().await; + let mut results = resources.results.write().await; + settings.commits.remove(hash); + results.commits.remove(hash); + } + resources.save_settings().await?; + resources.save_results().await?; + Ok(check_result) +} + +pub async fn pr_add( + resources: &ChatRepoResources, + pr_id: u64, + settings: PullRequestSettings, +) -> Result<(), Error> { + { + let mut locked = resources.settings.write().await; + if locked.pull_requests.contains_key(&pr_id) { + return Err(Error::PullRequestExists(pr_id)); + } + locked.pull_requests.insert(pr_id, settings); + } + resources.save_settings().await +} + +pub async fn pr_remove(resources: &ChatRepoResources, id: u64) -> Result<(), Error> { + { + let mut locked = resources.settings.write().await; + if !locked.pull_requests.contains_key(&id) { + return Err(Error::UnknownPullRequest(id)); + } + locked.pull_requests.remove(&id); + } + resources.save_settings().await +} + +pub async fn pr_check( + resources: &ChatRepoResources, + repo_resources: &RepoResources, + id: u64, +) -> Result, Error> { + log::info!("checking PR ({task}, {id})", task = resources.task); + let github_info = { + let locked = repo_resources.settings.read().await; + locked + .github_info + .clone() + .ok_or(Error::NoGitHubInfo(resources.task.repo.clone()))? + }; + log::debug!("checking PR {github_info}#{id}"); + if github::is_merged(&github_info, id).await? { + let settings = { + let mut locked = resources.settings.write().await; + locked + .pull_requests + .remove(&id) + .ok_or(Error::UnknownPullRequest(id))? + }; + resources.save_settings().await?; + let commit = merged_pr_to_commit(resources, github_info, id, settings).await?; + Ok(Some(commit)) + } else { + Ok(None) + } +} + +pub async fn merged_pr_to_commit( + resources: &ChatRepoResources, + github_info: GitHubInfo, + pr_id: u64, + settings: PullRequestSettings, +) -> Result { + let pr = github::get_pr(&github_info, pr_id).await?; + let commit = pr + .merge_commit_sha + .ok_or(Error::NoMergeCommit { github_info, pr_id })?; + let comment = format!( + "{title}{comment}", + title = pr.title.as_deref().unwrap_or("untitled"), + comment = push_empty_line(&settings.notify.comment), + ); + let commit_settings = CommitSettings { + url: Some(settings.url), + notify: NotifySettings { + comment, + subscribers: settings.notify.subscribers, + }, + }; + + commit_add(resources, &commit, commit_settings) + .await + .map(|()| commit) +} + +pub async fn branch_add( + resources: &ChatRepoResources, + branch: &str, + settings: BranchSettings, +) -> Result<(), Error> { + let _guard = resources.branch_lock(branch.to_string()).await; + { + let mut locked = resources.settings.write().await; + if locked.branches.contains_key(branch) { + return Err(Error::BranchExists(branch.to_owned())); + } + locked.branches.insert(branch.to_owned(), settings); + } + resources.save_settings().await +} + +pub async fn branch_remove(resources: &ChatRepoResources, branch: &str) -> Result<(), Error> { + let _guard = resources.branch_lock(branch.to_string()).await; + { + let mut locked = resources.settings.write().await; + if !locked.branches.contains_key(branch) { + return Err(Error::UnknownBranch(branch.to_owned())); + } + locked.branches.remove(branch); + } + { + let mut locked = resources.results.write().await; + locked.branches.remove(branch); + } + resources.save_settings().await?; + resources.save_results().await +} + +pub async fn branch_check( + resources: &ChatRepoResources, + repo_resources: &RepoResources, + branch_name: &str, +) -> Result { + log::info!( + "checking branch ({task}, {branch_name})", + task = resources.task + ); + let _guard = resources.branch_lock(branch_name.to_string()).await; + let result = { + let old_result = { + let results = resources.results.read().await; + match results.branches.get(branch_name) { + Some(r) => r.clone(), + None => Default::default(), + } + }; + + // get the new commit (optional) + let commit = { + let repo = repo_resources.repo.lock().await; + let remote_branch_name = format!("origin/{branch_name}"); + + match repo.find_branch(&remote_branch_name, BranchType::Remote) { + Ok(branch) => { + let commit: String = branch.into_reference().peel_to_commit()?.id().to_string(); + Some(commit) + } + Err(_error) => { + log::warn!( + "branch {} not found in ({}, {})", + branch_name, + resources.task.chat, + resources.task.repo, + ); + None + } + } + }; + + { + let mut results = resources.results.write().await; + results.branches.insert( + branch_name.to_owned(), + BranchResults { + commit: commit.clone(), + }, + ); + } + resources.save_results().await?; + + BranchCheckResult { + old: old_result.commit, + new: commit, + } + }; + Ok(result) } diff --git a/src/chat/paths.rs b/src/chat/paths.rs index d2bbb03..bed6d79 100644 --- a/src/chat/paths.rs +++ b/src/chat/paths.rs @@ -6,7 +6,7 @@ use crate::{chat::Task, options}; #[derive(Debug, Clone)] pub struct ChatRepoPaths { - pub chat: PathBuf, + // pub chat: PathBuf, pub repo: PathBuf, pub settings: PathBuf, pub results: PathBuf, @@ -17,16 +17,20 @@ pub static GLOBAL_CHATS_OUTER: LazyLock = impl ChatRepoPaths { pub fn new(task: &Task) -> ChatRepoPaths { - let chat_path = GLOBAL_CHATS_OUTER.join(Self::outer_dir_name(task.chat)); + let chat_path = Self::outer_dir(task.chat); let repo = chat_path.join(&task.repo); Self { - chat: chat_path, + // chat: chat_path, settings: repo.join("settings.json"), results: repo.join("results.json"), repo, } } + pub fn outer_dir(chat: ChatId) -> PathBuf { + GLOBAL_CHATS_OUTER.join(Self::outer_dir_name(chat)) + } + fn outer_dir_name(chat: ChatId) -> PathBuf { let ChatId(num) = chat; let chat_dir_name = if num < 0 { diff --git a/src/chat/resources.rs b/src/chat/resources.rs index 5ed9b08..b2b461b 100644 --- a/src/chat/resources.rs +++ b/src/chat/resources.rs @@ -1,17 +1,11 @@ use std::sync::LazyLock; -use git2::Repository; use lockable::LockPool; -use teloxide::types::ChatId; -use tokio::{ - fs::create_dir_all, - sync::{Mutex, RwLock}, -}; +use tokio::{fs::create_dir_all, sync::RwLock}; use crate::{ chat::{Task, paths::ChatRepoPaths, results::ChatRepoResults, settings::ChatRepoSettings}, error::Error, - repo::{cache, paths::RepoPaths, settings::RepoSettings}, resources::{Resource, ResourcesMap}, utils::{read_json, write_json}, }; @@ -31,7 +25,7 @@ pub struct ChatRepoResources { impl Resource for ChatRepoResources { async fn open(task: &Task) -> Result { - let paths = ChatRepoPaths::new(&task); + let paths = ChatRepoPaths::new(task); if !paths.repo.is_dir() { create_dir_all(&paths.repo).await?; } diff --git a/src/chat/results.rs b/src/chat/results.rs index 0018ad2..b0f70d2 100644 --- a/src/chat/results.rs +++ b/src/chat/results.rs @@ -2,6 +2,8 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; +use crate::condition::Action; + #[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct ChatRepoResults { pub commits: BTreeMap, @@ -22,7 +24,16 @@ pub struct BranchResults { pub struct CommitCheckResult { pub all: BTreeSet, pub new: BTreeSet, - pub removed_by_condition: Option, + pub conditions: BTreeMap, +} + +impl CommitCheckResult { + pub fn conditions_of_action(&self, action: Action) -> BTreeSet<&String> { + self.conditions + .iter() + .filter_map(|(condition, a)| if *a == action { Some(condition) } else { None }) + .collect() + } } #[derive(Debug)] @@ -30,8 +41,3 @@ pub struct BranchCheckResult { pub old: Option, pub new: Option, } - -#[derive(Debug)] -pub struct ConditionCheckResult { - pub removed: Vec, -} diff --git a/src/chat/settings.rs b/src/chat/settings.rs index b2dea2e..fee74c5 100644 --- a/src/chat/settings.rs +++ b/src/chat/settings.rs @@ -4,21 +4,14 @@ use serde::{Deserialize, Serialize}; use teloxide::utils::markdown; use url::Url; -use crate::{condition::GeneralCondition, github::GitHubInfo}; - #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ChatRepoSettings { - pub branch_regex: String, - #[serde(default)] - pub github_info: Option, #[serde(default)] pub pull_requests: BTreeMap, #[serde(default)] pub commits: BTreeMap, #[serde(default)] pub branches: BTreeMap, - #[serde(default)] - pub conditions: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -91,8 +84,3 @@ impl Subscriber { } } } - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConditionSettings { - pub condition: GeneralCondition, -} diff --git a/src/command.rs b/src/command.rs index 6c5d204..1aa64a4 100644 --- a/src/command.rs +++ b/src/command.rs @@ -90,8 +90,6 @@ pub enum Notifier { }, #[command(about = "remove an auto clean condition")] ConditionRemove { repo: String, identifier: String }, - #[command(about = "manually trigger an auto clean condition check")] - ConditionTrigger { repo: String, identifier: String }, #[command(about = "list repositories and commits")] List, } diff --git a/src/condition/in_branch.rs b/src/condition/in_branch.rs index 5140f94..07d791a 100644 --- a/src/condition/in_branch.rs +++ b/src/condition/in_branch.rs @@ -1,32 +1,34 @@ use regex::Regex; use serde::{Deserialize, Serialize}; -use crate::chat::results::CommitResults; -use crate::condition::Condition; +use crate::chat::results::CommitCheckResult; +use crate::condition::{Action, Condition}; use crate::error::Error; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InBranchCondition { - pub branch_regex: String, + #[serde(with = "serde_regex")] + pub branch_regex: Regex, } impl Condition for InBranchCondition { - fn meet(&self, result: &CommitResults) -> bool { - let regex = self.regex().unwrap(); - result.branches.iter().any(|b| regex.is_match(b)) + fn check(&self, check_results: &CommitCheckResult) -> Action { + if check_results + .all + .iter() + .any(|b| self.branch_regex.is_match(b)) + { + Action::Remove + } else { + Action::None + } } } impl InBranchCondition { pub fn parse(s: &str) -> Result { - let result = InBranchCondition { - branch_regex: s.to_string(), - }; - let _ = result.regex()?; - Ok(result) - } - - pub fn regex(&self) -> Result { - Ok(Regex::new(&format!("^{}$", self.branch_regex))?) + Ok(InBranchCondition { + branch_regex: Regex::new(&format!("^{s}$"))?, + }) } } diff --git a/src/condition/mod.rs b/src/condition/mod.rs index 0987f51..0362827 100644 --- a/src/condition/mod.rs +++ b/src/condition/mod.rs @@ -1,37 +1,64 @@ pub mod in_branch; +pub mod suppress_from_to; use serde::{Deserialize, Serialize}; -use crate::{chat::results::CommitResults, error::Error}; +use crate::{ + chat::results::CommitCheckResult, condition::suppress_from_to::SuppressFromToCondition, + error::Error, +}; use self::in_branch::InBranchCondition; pub trait Condition { - fn meet(&self, result: &CommitResults) -> bool; + fn check(&self, check_results: &CommitCheckResult) -> Action; } #[derive(clap::ValueEnum, Serialize, Deserialize, Clone, Debug, Copy)] pub enum Kind { - InBranch, + RemoveIfInBranch, + SuppressFromTo, +} + +#[derive( + clap::ValueEnum, Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, PartialOrd, Ord, +)] +pub enum Action { + None, + Remove, + SuppressNotification, +} + +impl Action { + pub fn is_none(self) -> bool { + matches!(self, Action::None) + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum GeneralCondition { InBranch(InBranchCondition), + SuppressFromTo(SuppressFromToCondition), } impl GeneralCondition { pub fn parse(kind: Kind, expr: &str) -> Result { match kind { - Kind::InBranch => Ok(GeneralCondition::InBranch(InBranchCondition::parse(expr)?)), + Kind::RemoveIfInBranch => { + Ok(GeneralCondition::InBranch(InBranchCondition::parse(expr)?)) + } + Kind::SuppressFromTo => Ok(GeneralCondition::SuppressFromTo( + SuppressFromToCondition::parse(expr)?, + )), } } } impl Condition for GeneralCondition { - fn meet(&self, result: &CommitResults) -> bool { + fn check(&self, check_results: &CommitCheckResult) -> Action { match self { - GeneralCondition::InBranch(c) => c.meet(result), + GeneralCondition::InBranch(c) => c.check(check_results), + GeneralCondition::SuppressFromTo(c) => c.check(check_results), } } } diff --git a/src/condition/suppress_from_to.rs b/src/condition/suppress_from_to.rs new file mode 100644 index 0000000..94478b2 --- /dev/null +++ b/src/condition/suppress_from_to.rs @@ -0,0 +1,36 @@ +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::chat::results::CommitCheckResult; +use crate::condition::{Action, Condition}; +use crate::error::Error; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SuppressFromToCondition { + #[serde(with = "serde_regex")] + pub from_regex: Regex, + #[serde(with = "serde_regex")] + pub to_regex: Regex, +} + +impl Condition for SuppressFromToCondition { + fn check(&self, check_results: &CommitCheckResult) -> Action { + let mut old = check_results.all.difference(&check_results.new); + if old.any(|old_branch| self.from_regex.is_match(old_branch)) + && check_results + .new + .iter() + .any(|new_branch| self.to_regex.is_match(new_branch)) + { + Action::SuppressNotification + } else { + Action::None + } + } +} + +impl SuppressFromToCondition { + pub fn parse(s: &str) -> Result { + Ok(serde_json::from_str(s)?) + } +} diff --git a/src/error.rs b/src/error.rs index 773f75b..591ae37 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,8 +33,6 @@ pub enum Error { TaskJoin(#[from] tokio::task::JoinError), #[error("invalid name: {0}")] Name(String), - #[error("chat id {0} is not in allow list")] - NotInAllowList(ChatId), #[error("git error: {0}")] Git(#[from] git2::Error), #[error("failed to clone git repository '{url}' into '{name}', output: {output:?}")] @@ -58,8 +56,6 @@ pub enum Error { UnknownPullRequest(u64), #[error("unknown branch: '{0}'")] UnknownBranch(String), - #[error("unknown branch in cache: '{0}'")] - UnknownBranchInCache(String), #[error("unknown repository: '{0}'")] UnknownRepository(String), #[error("commit already exists: '{0}'")] diff --git a/src/main.rs b/src/main.rs index ec6c05e..ad2955c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,10 +7,14 @@ mod message; mod options; mod repo; mod resources; +mod update; mod utils; +use std::collections::BTreeSet; use std::env; +use std::fmt; use std::str::FromStr; +use std::sync::LazyLock; use chrono::Utc; use cron::Schedule; @@ -35,10 +39,19 @@ use teloxide::utils::markdown; use tokio::time::sleep; use url::Url; +use crate::chat::settings::BranchSettings; use crate::chat::settings::CommitSettings; use crate::chat::settings::NotifySettings; +use crate::chat::settings::PullRequestSettings; +use crate::chat::settings::Subscriber; +use crate::condition::Action; +use crate::condition::GeneralCondition; +use crate::message::branch_check_message; use crate::message::commit_check_message; use crate::message::subscriber_from_msg; +use crate::repo::settings::ConditionSettings; +use crate::update::update_and_report_error; +use crate::utils::modify_subscriber_set; use crate::utils::reply_to_msg; #[derive(BotCommands, Clone, Debug)] @@ -80,11 +93,20 @@ async fn run() { LoggingErrorHandler::with_custom_text("An error from the update listener"), ) => { }, } + + log::info!("cleaning up resources for chats"); + if let Err(e) = chat::resources::RESOURCES_MAP.clear().await { + log::error!("failed to clear resources map for chats: {e}"); + } + log::info!("cleaning up resources for repositories"); + if let Err(e) = repo::resources::RESOURCES_MAP.clear().await { + log::error!("failed to clear resources map for repositories: {e}"); + } log::info!("exit"); } async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { - log::debug!("message: {msg:?}"); + log::trace!("message: {msg:?}"); log::trace!("bot command: {bc:?}"); let BCommand::Notifier(input) = bc; let result = match command::parse(input) { @@ -152,9 +174,6 @@ async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { command::Notifier::ConditionRemove { repo, identifier } => { condition_remove(bot, msg, repo, identifier).await } - command::Notifier::ConditionTrigger { repo, identifier } => { - condition_trigger(bot, msg, repo, identifier).await - } command::Notifier::List => list(bot, msg).await, } } @@ -192,69 +211,68 @@ async fn handle_callback_query_command_result( _bot: &Bot, query: &CallbackQuery, ) -> Result { - todo!() - // log::debug!("query = {query:?}"); - // let (chat_id, username) = get_chat_id_and_username_from_query(query)?; - // let subscriber = Subscriber::Telegram { username }; - // let _msg = query - // .message - // .as_ref() - // .ok_or(Error::SubscribeCallbackNoMsgId)?; - // let data = query.data.as_ref().ok_or(Error::SubscribeCallbackNoData)?; - // let SubscribeTerm(kind, repo, id, subscribe) = - // serde_json::from_str(data).map_err(Error::Serde)?; - // let unsubscribe = subscribe == 0; - // match kind.as_str() { - // "b" => { - // let resources = resources_helper_chat(chat_id, &repo).await?; - // { - // let mut settings = resources.settings.write().await; - // let subscribers = &mut settings - // .branches - // .get_mut(&id) - // .ok_or_else(|| Error::UnknownBranch(id.clone()))? - // .notify - // .subscribers; - // modify_subscriber_set(subscribers, subscriber, unsubscribe)?; - // } - // resources.save_settings().await?; - // } - // "c" => { - // let resources = resources_helper_chat(chat_id, &repo).await?; - // { - // let mut settings = resources.settings.write().await; - // let subscribers = &mut settings - // .commits - // .get_mut(&id) - // .ok_or_else(|| Error::UnknownCommit(id.clone()))? - // .notify - // .subscribers; - // modify_subscriber_set(subscribers, subscriber, unsubscribe)?; - // } - // resources.save_settings().await?; - // } - // "p" => { - // let pr_id: u64 = id.parse().map_err(Error::ParseInt)?; - // let resources = resources_helper_chat(chat_id, &repo).await?; - // { - // let mut settings = resources.settings.write().await; - // let subscribers = &mut settings - // .pull_requests - // .get_mut(&pr_id) - // .ok_or_else(|| Error::UnknownPullRequest(pr_id))? - // .notify - // .subscribers; - // modify_subscriber_set(subscribers, subscriber, unsubscribe)?; - // } - // resources.save_settings().await?; - // } - // _ => Err(Error::SubscribeCallbackDataInvalidKind(kind))?, - // } - // if unsubscribe { - // Ok(format!("unsubscribed from {repo}/{id}")) - // } else { - // Ok(format!("subscribed to {repo}/{id}")) - // } + log::debug!("query = {query:?}"); + let (chat_id, username) = get_chat_id_and_username_from_query(query)?; + let subscriber = Subscriber::Telegram { username }; + let _msg = query + .message + .as_ref() + .ok_or(Error::SubscribeCallbackNoMsgId)?; + let data = query.data.as_ref().ok_or(Error::SubscribeCallbackNoData)?; + let SubscribeTerm(kind, repo, id, subscribe) = + serde_json::from_str(data).map_err(Error::Serde)?; + let unsubscribe = subscribe == 0; + match kind.as_str() { + "b" => { + let resources = chat::resources_chat_repo(chat_id, repo.clone()).await?; + { + let mut settings = resources.settings.write().await; + let subscribers = &mut settings + .branches + .get_mut(&id) + .ok_or_else(|| Error::UnknownBranch(id.clone()))? + .notify + .subscribers; + modify_subscriber_set(subscribers, subscriber, unsubscribe)?; + } + resources.save_settings().await?; + } + "c" => { + let resources = chat::resources_chat_repo(chat_id, repo.clone()).await?; + { + let mut settings = resources.settings.write().await; + let subscribers = &mut settings + .commits + .get_mut(&id) + .ok_or_else(|| Error::UnknownCommit(id.clone()))? + .notify + .subscribers; + modify_subscriber_set(subscribers, subscriber, unsubscribe)?; + } + resources.save_settings().await?; + } + "p" => { + let pr_id: u64 = id.parse().map_err(Error::ParseInt)?; + let resources = chat::resources_chat_repo(chat_id, repo.clone()).await?; + { + let mut settings = resources.settings.write().await; + let subscribers = &mut settings + .pull_requests + .get_mut(&pr_id) + .ok_or_else(|| Error::UnknownPullRequest(pr_id))? + .notify + .subscribers; + modify_subscriber_set(subscribers, subscriber, unsubscribe)?; + } + resources.save_settings().await?; + } + _ => Err(Error::SubscribeCallbackDataInvalidKind(kind))?, + } + if unsubscribe { + Ok(format!("unsubscribed from {repo}/{id}")) + } else { + Ok(format!("subscribed to {repo}/{id}")) + } } fn get_chat_id_and_username_from_query(query: &CallbackQuery) -> Result<(ChatId, String), Error> { @@ -282,6 +300,14 @@ impl From for CommandError { CommandError::Teloxide(e) } } +impl fmt::Display for CommandError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + CommandError::Normal(e) => write!(f, "{e}"), + CommandError::Teloxide(e) => write!(f, "{e}"), + } + } +} fn octocrab_initialize() { let builder = octocrab::Octocrab::builder(); @@ -319,42 +345,17 @@ async fn schedule(bot: Bot) { } } -async fn update_and_report_error(bot: Bot) -> Result<(), teloxide::RequestError> { - match update(bot.clone()).await { - Ok(r) => Ok(r), - Err(CommandError::Normal(e)) => { - log::error!("update error: {e}"); - let options = options::get(); - bot.send_message(ChatId(options.admin_chat_id), format!("update error: {e}")) - .await?; - Ok(()) - } - Err(CommandError::Teloxide(e)) => Err(e), - } -} - -async fn update(bot: Bot) -> Result<(), CommandError> { - log::info!("updating repositories..."); - let repos = repo::list().await?; - for repo in repos { - let resources = repo::resources(&repo).await?; - repo::fetch_and_update_cache(&resources).await?; - } - Ok(()) -} - async fn list(bot: Bot, msg: Message) -> Result<(), CommandError> { let options = options::get(); let chat = msg.chat.id; if ChatId(options.admin_chat_id) == chat { - list_for_admin(bot, msg).await - } else { - list_for_normal(bot, msg).await + list_for_admin(bot.clone(), &msg).await?; } + list_for_normal(bot, &msg).await } -async fn list_for_admin(bot: Bot, msg: Message) -> Result<(), CommandError> { - let chat = msg.chat.id; +async fn list_for_admin(bot: Bot, msg: &Message) -> Result<(), CommandError> { + log::info!("list for admin"); let mut result = String::new(); let repos = repo::list().await?; @@ -366,15 +367,72 @@ async fn list_for_admin(bot: Bot, msg: Message) -> Result<(), CommandError> { if result.is_empty() { result.push_str("(nothing)\n"); } - reply_to_msg(&bot, &msg, result) + reply_to_msg(&bot, msg, result) .parse_mode(ParseMode::MarkdownV2) .await?; Ok(()) } -async fn list_for_normal(bot: Bot, msg: Message) -> Result<(), CommandError> { - todo!() +async fn list_for_normal(bot: Bot, msg: &Message) -> Result<(), CommandError> { + let chat = msg.chat.id; + log::info!("list for chat: {chat}"); + let mut result = String::new(); + + let repos = chat::repos(chat).await?; + for repo in repos { + result.push('*'); + result.push_str(&markdown::escape(&repo)); + result.push_str("*\n"); + + let resources = chat::resources_chat_repo(chat, repo).await?; + let settings = { + let locked = resources.settings.read().await; + locked.clone() + }; + + result.push_str(" *commits*:\n"); + let commits = &settings.commits; + if commits.is_empty() { + result.push_str(" \\(nothing\\)\n"); + } + for (commit, settings) in commits { + result.push_str(&format!( + " \\- `{}`\n {}\n", + markdown::escape(commit), + settings.notify.description_markdown() + )); + } + result.push_str(" *pull requests*:\n"); + let pull_requests = &settings.pull_requests; + if pull_requests.is_empty() { + result.push_str(" \\(nothing\\)\n"); + } + for (pr, settings) in pull_requests { + result.push_str(&format!( + " \\- `{pr}`\n {}\n", + markdown::escape(settings.url.as_str()) + )); + } + result.push_str(" *branches*:\n"); + let branches = &settings.branches; + if branches.is_empty() { + result.push_str(" \\(nothing\\)\n"); + } + for branch in branches.keys() { + result.push_str(&format!(" \\- `{}`\n", markdown::escape(branch))); + } + + result.push('\n'); + } + if result.is_empty() { + result.push_str("\\(nothing\\)\n"); + } + reply_to_msg(&bot, msg, result) + .parse_mode(ParseMode::MarkdownV2) + .await?; + + Ok(()) } async fn return_chat_id(bot: Bot, msg: Message) -> Result<(), CommandError> { @@ -401,7 +459,7 @@ async fn repo_add(bot: Bot, msg: Message, name: String, url: String) -> Result<( format!("repository '{name}' added, settings:\n{settings:#?}"), ) .await?; - todo!() + Ok(()) } async fn repo_edit( @@ -417,9 +475,8 @@ async fn repo_edit( let new_settings = { let mut locked = resources.settings.write().await; if let Some(r) = branch_regex { - // ensure regex is valid - let _: Regex = Regex::new(&format!("^{r}$")).map_err(Error::from)?; - locked.branch_regex = r; + let regex = Regex::new(&format!("^{r}$")).map_err(Error::from)?; + locked.branch_regex = regex; } if let Some(info) = github_info { locked.github_info = Some(info); @@ -446,6 +503,37 @@ async fn repo_remove(bot: Bot, msg: Message, name: String) -> Result<(), Command Ok(()) } +async fn condition_add( + bot: Bot, + msg: Message, + repo: String, + identifier: String, + kind: condition::Kind, + expr: String, +) -> Result<(), CommandError> { + ensure_admin_chat(&msg)?; + let resources = repo::resources(&repo).await?; + let settings = ConditionSettings { + condition: GeneralCondition::parse(kind, &expr)?, + }; + repo::condition_add(&resources, &identifier, settings).await?; + reply_to_msg(&bot, &msg, format!("condition {identifier} added")).await?; + Ok(()) +} + +async fn condition_remove( + bot: Bot, + msg: Message, + repo: String, + identifier: String, +) -> Result<(), CommandError> { + ensure_admin_chat(&msg)?; + let resources = repo::resources(&repo).await?; + repo::condition_remove(&resources, &identifier).await?; + reply_to_msg(&bot, &msg, format!("condition {identifier} removed")).await?; + Ok(()) +} + async fn commit_add( bot: Bot, msg: Message, @@ -503,13 +591,13 @@ async fn commit_check( .ok_or_else(|| Error::UnknownCommit(hash.clone()))? .clone() }; - repo::fetch_and_update_cache(&repo_resources).await?; - let result = chat::commit_check(resources, &repo_resources, &hash).await?; + let result = chat::commit_check(&resources, &repo_resources, &hash).await?; let reply = commit_check_message(&repo, &hash, &commit_settings, &result); let mut send = reply_to_msg(&bot, &msg, reply) .parse_mode(ParseMode::MarkdownV2) .disable_link_preview(true); - if result.removed_by_condition.is_none() { + let remove_conditions: BTreeSet<&String> = result.conditions_of_action(Action::Remove); + if remove_conditions.is_empty() { send = try_attach_subscribe_button_markup(msg.chat.id, send, "c", &repo, &hash); } send.await?; @@ -523,7 +611,21 @@ async fn commit_subscribe( hash: String, unsubscribe: bool, ) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; + { + let mut settings = resources.settings.write().await; + let subscribers = &mut settings + .commits + .get_mut(&hash) + .ok_or_else(|| Error::UnknownCommit(hash.clone()))? + .notify + .subscribers; + modify_subscriber_set(subscribers, subscriber, unsubscribe)?; + } + resources.save_settings().await?; + reply_to_msg(&bot, &msg, "done").await?; + Ok(()) } async fn pr_add( @@ -533,23 +635,113 @@ async fn pr_add( pr_id: Option, optional_comment: Option, ) -> Result<(), CommandError> { - todo!() + let (repo, pr_id) = resolve_pr_repo_or_url(repo_or_url, pr_id).await?; + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let repo_resources = repo::resources(&repo).await?; + let github_info = { + let settings = repo_resources.settings.read().await; + settings + .github_info + .clone() + .ok_or_else(|| Error::NoGitHubInfo(repo_resources.name.clone()))? + }; + let url_str = format!("https://github.com/{github_info}/pull/{pr_id}"); + let url = Url::parse(&url_str).map_err(Error::UrlParse)?; + let subscribers = subscriber_from_msg(&msg).into_iter().collect(); + let comment = optional_comment.unwrap_or_default(); + let settings = PullRequestSettings { + url, + notify: NotifySettings { + comment, + subscribers, + }, + }; + match chat::pr_add(&resources, pr_id, settings).await { + Ok(()) => { + pr_check(bot, msg, repo, pr_id).await?; + } + Err(Error::PullRequestExists(_)) => { + pr_subscribe(bot.clone(), msg.clone(), repo.clone(), pr_id, false).await?; + } + Err(e) => return Err(e.into()), + }; + Ok(()) } async fn resolve_pr_repo_or_url( - chat: ChatId, repo_or_url: String, pr_id: Option, ) -> Result<(String, u64), Error> { - todo!() + static GITHUB_URL_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r#"https://github\.com/([^/]+)/([^/]+)/pull/(\d+)"#).unwrap()); + match pr_id { + Some(id) => Ok((repo_or_url, id)), + None => { + if let Some(captures) = GITHUB_URL_REGEX.captures(&repo_or_url) { + let owner = &captures[1]; + let repo = &captures[2]; + let github_info = GitHubInfo::new(owner.to_string(), repo.to_string()); + let pr: u64 = captures[3].parse().map_err(Error::ParseInt)?; + let repos = repo::list().await?; + let mut repos_found = Vec::new(); + for repo in repos { + let resources = repo::resources(&repo).await?; + let repo_github_info = &resources.settings.read().await.github_info; + if repo_github_info.as_ref() == Some(&github_info) { + repos_found.push(repo); + } + } + if repos_found.is_empty() { + return Err(Error::NoRepoHaveGitHubInfo(github_info)); + } else if repos_found.len() != 1 { + return Err(Error::MultipleReposHaveSameGitHubInfo(repos_found)); + } else { + let repo = repos_found.pop().unwrap(); + return Ok((repo, pr)); + } + } + Err(Error::UnsupportedPrUrl(repo_or_url)) + } + } } async fn pr_check(bot: Bot, msg: Message, repo: String, pr_id: u64) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let repo_resources = repo::resources(&repo).await?; + match chat::pr_check(&resources, &repo_resources, pr_id).await { + Ok(Some(commit)) => { + reply_to_msg( + &bot, + &msg, + format!("pr {pr_id} has been merged (and removed)\ncommit `{commit}` added"), + ) + .parse_mode(ParseMode::MarkdownV2) + .await?; + commit_check(bot, msg, repo, commit).await + } + Ok(None) => { + let mut send = reply_to_msg(&bot, &msg, format!("pr {pr_id} has not been merged yet")) + .parse_mode(ParseMode::MarkdownV2); + send = try_attach_subscribe_button_markup( + msg.chat.id, + send, + "p", + &repo, + &pr_id.to_string(), + ); + send.await?; + Ok(()) + } + Err(Error::CommitExists(commit)) => commit_subscribe(bot, msg, repo, commit, false).await, + Err(e) => Err(e.into()), + } } async fn pr_remove(bot: Bot, msg: Message, repo: String, pr_id: u64) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo).await?; + chat::pr_remove(&resources, pr_id).await?; + reply_to_msg(&bot, &msg, format!("pr {pr_id} removed")).await?; + Ok(()) } async fn pr_subscribe( @@ -559,7 +751,21 @@ async fn pr_subscribe( pr_id: u64, unsubscribe: bool, ) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo).await?; + let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; + { + let mut settings = resources.settings.write().await; + let subscribers = &mut settings + .pull_requests + .get_mut(&pr_id) + .ok_or_else(|| Error::UnknownPullRequest(pr_id))? + .notify + .subscribers; + modify_subscriber_set(subscribers, subscriber, unsubscribe)?; + } + resources.save_settings().await?; + reply_to_msg(&bot, &msg, "done").await?; + Ok(()) } async fn branch_add( @@ -568,7 +774,15 @@ async fn branch_add( repo: String, branch: String, ) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let settings = BranchSettings { + notify: Default::default(), + }; + match chat::branch_add(&resources, &branch, settings).await { + Ok(()) => branch_check(bot, msg, repo, branch).await, + Err(Error::BranchExists(_)) => branch_subscribe(bot, msg, repo, branch, false).await, + Err(e) => Err(e.into()), + } } async fn branch_remove( @@ -577,7 +791,10 @@ async fn branch_remove( repo: String, branch: String, ) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo).await?; + chat::branch_remove(&resources, &branch).await?; + reply_to_msg(&bot, &msg, format!("branch {branch} removed")).await?; + Ok(()) } async fn branch_check( @@ -586,7 +803,23 @@ async fn branch_check( repo: String, branch: String, ) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let repo_resources = repo::resources(&repo).await?; + let branch_settings = { + let settings = resources.settings.read().await; + settings + .branches + .get(&branch) + .ok_or_else(|| Error::UnknownBranch(branch.clone()))? + .clone() + }; + let result = chat::branch_check(&resources, &repo_resources, &branch).await?; + let reply = branch_check_message(&repo, &branch, &branch_settings, &result); + + let mut send = reply_to_msg(&bot, &msg, reply).parse_mode(ParseMode::MarkdownV2); + send = try_attach_subscribe_button_markup(msg.chat.id, send, "b", &repo, &branch); + send.await?; + Ok(()) } async fn branch_subscribe( @@ -596,36 +829,21 @@ async fn branch_subscribe( branch: String, unsubscribe: bool, ) -> Result<(), CommandError> { - todo!() -} - -async fn condition_add( - bot: Bot, - msg: Message, - repo: String, - identifier: String, - kind: condition::Kind, - expr: String, -) -> Result<(), CommandError> { - todo!() -} - -async fn condition_remove( - bot: Bot, - msg: Message, - repo: String, - identifier: String, -) -> Result<(), CommandError> { - todo!() -} - -async fn condition_trigger( - bot: Bot, - msg: Message, - repo: String, - identifier: String, -) -> Result<(), CommandError> { - todo!() + let resources = chat::resources_msg_repo(&msg, repo).await?; + let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; + { + let mut settings = resources.settings.write().await; + let subscribers = &mut settings + .branches + .get_mut(&branch) + .ok_or_else(|| Error::UnknownBranch(branch.clone()))? + .notify + .subscribers; + modify_subscriber_set(subscribers, subscriber, unsubscribe)?; + } + resources.save_settings().await?; + reply_to_msg(&bot, &msg, "done").await?; + Ok(()) } fn ensure_admin_chat(msg: &Message) -> Result<(), CommandError> { @@ -642,12 +860,12 @@ fn try_attach_subscribe_button_markup( send: JsonRequest, kind: &str, repo: &str, - hash: &str, + id: &str, ) -> JsonRequest { - match subscribe_button_markup(kind, &repo, &hash) { + match subscribe_button_markup(kind, repo, id) { Ok(m) => send.reply_markup(m), Err(e) => { - log::error!("failed to create markup for ({chat}, {repo}, {hash}): {e}"); + log::error!("failed to create markup for ({chat}, {repo}, {id}): {e}"); send } } diff --git a/src/message.rs b/src/message.rs index d99e5b4..785b961 100644 --- a/src/message.rs +++ b/src/message.rs @@ -4,10 +4,10 @@ use teloxide::{types::Message, utils::markdown}; use crate::{ chat::{ - results::{BranchCheckResult, CommitCheckResult, ConditionCheckResult}, + results::{BranchCheckResult, CommitCheckResult}, settings::{BranchSettings, CommitSettings, PullRequestSettings, Subscriber}, }, - error::Error, + condition::Action, utils::push_empty_line, }; @@ -46,12 +46,15 @@ pub fn commit_check_message_detail( settings: &CommitSettings, result: &CommitCheckResult, ) -> String { - let auto_remove_msg = match &result.removed_by_condition { - None => String::new(), - Some(condition) => format!( - "\n*auto removed* by condition: `{}`", - markdown::escape(condition) - ), + let remove_conditions: BTreeSet<&String> = result.conditions_of_action(Action::Remove); + let auto_remove_msg = if remove_conditions.is_empty() { + "".to_string() + } else { + format!( + "\n*auto removed* by conditions: +{}", + markdown_list(remove_conditions.iter()) + ) }; format!( "{repo}/`{commit}`{url}{notify} @@ -120,23 +123,6 @@ pub fn branch_check_message( ) } -pub fn condition_check_message( - repo: &str, - identifier: &str, - result: &ConditionCheckResult, -) -> String { - format!( - "{repo}/`{identifier}` - -branches removed by this condition: -{removed} -", - repo = markdown::escape(repo), - identifier = markdown::escape(identifier), - removed = markdown_list(result.removed.iter()), - ) -} - pub fn markdown_optional_commit(commit: Option<&str>) -> String { match &commit { None => "\\(nothing\\)".to_owned(), @@ -186,22 +172,3 @@ pub fn subscriber_from_msg(msg: &Message) -> Option { }), } } - -pub fn modify_subscriber_set( - set: &mut BTreeSet, - subscriber: Subscriber, - unsubscribe: bool, -) -> Result<(), Error> { - if unsubscribe { - if !set.contains(&subscriber) { - return Err(Error::NotSubscribed); - } - set.remove(&subscriber); - } else { - if set.contains(&subscriber) { - return Err(Error::AlreadySubscribed); - } - set.insert(subscriber); - } - Ok(()) -} diff --git a/src/repo/cache.rs b/src/repo/cache.rs index 6fcd8f9..89cea2d 100644 --- a/src/repo/cache.rs +++ b/src/repo/cache.rs @@ -83,6 +83,15 @@ pub fn query_cache(cache: &Connection, branch: &str, commit: &str) -> Result Result, Error> { + let mut stmt = + cache.prepare_cached("SELECT branch FROM commits_cache WHERE commit_hash = ?1")?; + log::trace!("query cache: {commit}"); + Ok(stmt + .query_map(params!(commit), |row| row.get(0))? + .collect::>()?) +} + pub fn store_cache(cache: &Connection, branch: &str, commit: &str) -> Result<(), Error> { let mut stmt = cache.prepare_cached("INSERT INTO commits_cache (branch, commit_hash) VALUES (?1, ?2)")?; @@ -97,22 +106,12 @@ where I: IntoIterator, { let mut count = 0usize; - let tx = cache.unchecked_transaction()?; for c in commits.into_iter() { - store_cache(&tx, branch, &c)?; + store_cache(cache, branch, &c)?; count += 1; - if count % 100000 == 0 { + if count.is_multiple_of(100000) { log::debug!("batch storing cache, current count: {count}",); } } - tx.commit()?; - Ok(()) -} - -pub fn remove_cache(cache: &Connection, branch: &str, commit: &str) -> Result<(), Error> { - let mut stmt = - cache.prepare_cached("DELETE FROM commits_cache WHERE branch = ?1 AND commit_hash = ?2")?; - log::trace!("delete cache: ({branch}, {commit})"); - stmt.execute(params!(branch, commit))?; Ok(()) } diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 5d9a6ca..dd9208f 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -18,6 +18,7 @@ use crate::{ cache::batch_store_cache, paths::RepoPaths, resources::{RESOURCES_MAP, RepoResources}, + settings::ConditionSettings, }, }; @@ -27,11 +28,11 @@ pub mod resources; pub mod settings; pub async fn resources(repo: &str) -> Result, Error> { - Ok(resources::RESOURCES_MAP.get(&repo.to_string()).await?) + resources::RESOURCES_MAP.get(&repo.to_string()).await } pub async fn create(name: &str, url: &str) -> Result { - let paths = RepoPaths::new(name); + let paths = RepoPaths::new(name)?; log::info!("try clone '{url}' into {:?}", paths.repo); if paths.outer.exists() { return Err(Error::RepoExists(name.to_string())); @@ -83,18 +84,13 @@ pub async fn list() -> Result, Error> { let mut result = BTreeSet::new(); while let Some(entry) = dir.next_entry().await? { let filename = entry.file_name(); - result.insert( - filename - .to_str() - .ok_or_else(|| Error::InvalidOsString(filename.clone()))? - .to_owned(), - ); + result.insert(filename.into_string().map_err(Error::InvalidOsString)?); } Ok(result) } -pub async fn fetch_and_update_cache(resources: &RepoResources) -> Result<(), Error> { - fetch(resources).await?; +pub async fn fetch_and_update_cache(resources: Arc) -> Result<(), Error> { + fetch(&resources).await?; update_cache(resources).await?; Ok(()) } @@ -125,12 +121,15 @@ pub async fn fetch(resources: &RepoResources) -> Result { Ok(output) } -pub async fn update_cache(resources: &RepoResources) -> Result<(), Error> { - let branches: BTreeSet = watching_branches(resources).await?; - log::debug!( - "update cache for branches of {repo}: {branches:?}", - repo = resources.name - ); +pub async fn update_cache(resources: Arc) -> Result<(), Error> { + // get the lock before update + let _guard = resources.cache_update_lock.lock().await; + let repo = &resources.name; + let branches: BTreeSet = { + let repo_guard = resources.repo.lock().await; + watching_branches(&resources, &repo_guard).await? + }; + log::debug!("update cache for branches of {repo}: {branches:?}"); let cache = resources.cache().await?; let old_branches = cache .interact(move |c| cache::branches(c)) @@ -140,22 +139,20 @@ pub async fn update_cache(resources: &RepoResources) -> Result<(), Error> { let update_branches = branches.intersection(&old_branches); let mut remove_branches: BTreeSet = old_branches.difference(&branches).cloned().collect(); - let repo = resources.repo.lock().await; for b in update_branches { - let commit = branch_commit(&repo, b)?; + let repo_guard = resources.repo.lock().await; + let commit: Commit<'_> = branch_commit(&repo_guard, b)?; let b_cloned = b.clone(); let old_commit_str = cache .interact(move |conn| cache::query_branch(conn, &b_cloned)) .await .map_err(|e| Error::DBInteract(Mutex::new(e)))??; - let old_commit = repo.find_commit(Oid::from_str(&old_commit_str)?)?; + let old_commit = repo_guard.find_commit(Oid::from_str(&old_commit_str)?)?; + if old_commit.id() == commit.id() { - log::debug!( - "branch ({repo}, {b}) does not change, skip...", - repo = resources.name - ); + log::debug!("branch ({repo}, {b}) does not change, skip..."); } else if is_parent(old_commit.clone(), commit.clone()) { - log::debug!("updating branch ({repo}, {b})...", repo = resources.name); + log::debug!("updating branch ({repo}, {b})..."); let mut queue = VecDeque::new(); let mut new_commits = BTreeSet::new(); queue.push_back(commit.clone()); @@ -183,19 +180,21 @@ pub async fn update_cache(resources: &RepoResources) -> Result<(), Error> { } } } - log::debug!("find {} new commits", new_commits.len()); - { - let b = b.clone(); - cache - .interact(move |conn| batch_store_cache(conn, &b, new_commits)) - .await - .map_err(|e| Error::DBInteract(Mutex::new(e)))??; - } + log::info!( + "find {} new commits when updating ({repo}, {b})", + new_commits.len() + ); { let commit_str = commit.id().to_string(); let b = b.clone(); cache - .interact(move |conn| cache::update_branch(conn, &b, &commit_str)) + .interact(move |conn| -> Result<(), Error> { + let tx = conn.unchecked_transaction()?; + batch_store_cache(&tx, &b, new_commits)?; + cache::update_branch(conn, &b, &commit_str)?; + tx.commit()?; + Ok(()) + }) .await .map_err(|e| Error::DBInteract(Mutex::new(e)))??; } @@ -205,7 +204,7 @@ pub async fn update_cache(resources: &RepoResources) -> Result<(), Error> { } } for b in remove_branches { - log::debug!("removing branch ({repo}, {b})...", repo = resources.name); + log::info!("removing branch ({repo}, {b})...",); let b = b.clone(); cache .interact(move |conn| cache::remove_branch(conn, &b)) @@ -213,21 +212,29 @@ pub async fn update_cache(resources: &RepoResources) -> Result<(), Error> { .map_err(|e| Error::DBInteract(Mutex::new(e)))??; } for b in new_branches { - log::debug!("adding branch ({repo}, {b})...", repo = resources.name); - let commit = branch_commit(&repo, &b)?; - let commits = gather_commits(commit.clone()); + log::info!("adding branch ({repo}, {b})..."); + let commit_id = { + let repo_guard = resources.repo.lock().await; + branch_commit(&repo_guard, &b)?.id() + }; + let commits = { + let resources = resources.clone(); + spawn_gather_commits(resources, commit_id).await? + }; { + let commit_str = commit_id.to_string(); let b = b.clone(); cache - .interact(move |conn| batch_store_cache(conn, &b, commits)) + .interact(move |conn| -> Result<(), Error> { + let tx = conn.unchecked_transaction()?; + batch_store_cache(conn, &b, commits)?; + cache::store_branch(conn, &b, &commit_str)?; + tx.commit()?; + Ok(()) + }) .await .map_err(|e| Error::DBInteract(Mutex::new(e)))??; } - let commit_str = commit.id().to_string(); - cache - .interact(move |conn| cache::store_branch(conn, &b, &commit_str)) - .await - .map_err(|e| Error::DBInteract(Mutex::new(e)))??; } Ok(()) } @@ -239,6 +246,18 @@ fn branch_commit<'repo>(repo: &'repo Repository, branch: &str) -> Result, + commit_id: Oid, +) -> Result, Error> { + tokio::task::spawn_blocking(move || { + let repo = resources.repo.blocking_lock(); + let commit = repo.find_commit(commit_id)?; + Ok(gather_commits(commit)) + }) + .await? +} + fn gather_commits<'repo>(commit: Commit<'repo>) -> BTreeSet { let mut commits = BTreeSet::new(); let mut queue = VecDeque::new(); @@ -285,8 +304,10 @@ fn is_parent<'repo>(parent: Commit<'repo>, child: Commit<'repo>) -> bool { false } -pub async fn watching_branches(resources: &RepoResources) -> Result, Error> { - let repo = resources.repo.lock().await; +pub async fn watching_branches( + resources: &RepoResources, + repo: &Repository, +) -> Result, Error> { let remote_branches = repo.branches(Some(git2::BranchType::Remote))?; let branch_regex = { let settings = resources.settings.read().await; @@ -322,3 +343,29 @@ fn branch_name_map_filter(name: &str) -> Option<&str> { Some(captures.get(1).unwrap().as_str()) } + +pub async fn condition_add( + resources: &RepoResources, + identifier: &str, + settings: ConditionSettings, +) -> Result<(), Error> { + { + let mut locked = resources.settings.write().await; + if locked.conditions.contains_key(identifier) { + return Err(Error::ConditionExists(identifier.to_owned())); + } + locked.conditions.insert(identifier.to_owned(), settings); + } + resources.save_settings().await +} + +pub async fn condition_remove(resources: &RepoResources, identifier: &str) -> Result<(), Error> { + { + let mut locked = resources.settings.write().await; + if !locked.conditions.contains_key(identifier) { + return Err(Error::UnknownCondition(identifier.to_owned())); + } + locked.conditions.remove(identifier); + } + resources.save_settings().await +} diff --git a/src/repo/paths.rs b/src/repo/paths.rs index 1a21669..b39b2c1 100644 --- a/src/repo/paths.rs +++ b/src/repo/paths.rs @@ -1,6 +1,8 @@ use std::{path::PathBuf, sync::LazyLock}; -use crate::options; +use regex::Regex; + +use crate::{error::Error, options}; #[derive(Debug, Clone)] pub struct RepoPaths { @@ -13,14 +15,21 @@ pub struct RepoPaths { pub static GLOBAL_REPO_OUTER: LazyLock = LazyLock::new(|| options::get().working_dir.join("repositories")); +static NAME_RE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| Regex::new("^[a-zA-Z0-9_\\-]*$").unwrap()); + impl RepoPaths { - pub fn new(name: &str) -> RepoPaths { + pub fn new(name: &str) -> Result { + if !NAME_RE.is_match(name) { + return Err(Error::Name(name.to_string())); + } + let outer = GLOBAL_REPO_OUTER.join(name); - Self { + Ok(Self { outer: outer.clone(), repo: outer.join("repo"), settings: outer.join("settings.json"), cache: outer.join("cache.sqlite"), - } + }) } } diff --git a/src/repo/resources.rs b/src/repo/resources.rs index e5960f6..a450018 100644 --- a/src/repo/resources.rs +++ b/src/repo/resources.rs @@ -19,12 +19,13 @@ pub struct RepoResources { pub paths: RepoPaths, pub repo: Mutex, pub cache: Pool, + pub cache_update_lock: Mutex<()>, pub settings: RwLock, } impl Resource for RepoResources { async fn open(name: &String) -> Result { - let paths = RepoPaths::new(name); + let paths = RepoPaths::new(name)?; if !paths.outer.is_dir() { return Err(Error::UnknownRepository(name.to_string())); } @@ -49,6 +50,7 @@ impl Resource for RepoResources { paths, repo, cache, + cache_update_lock: Mutex::new(()), settings, }) } diff --git a/src/repo/settings.rs b/src/repo/settings.rs index 3b26c9e..00b8718 100644 --- a/src/repo/settings.rs +++ b/src/repo/settings.rs @@ -1,10 +1,35 @@ +use std::collections::BTreeMap; + +use regex::Regex; use serde::{Deserialize, Serialize}; -use crate::github::GitHubInfo; +use crate::{condition::GeneralCondition, github::GitHubInfo}; -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RepoSettings { - pub branch_regex: String, + #[serde(with = "serde_regex", default = "default_branch_regex")] + pub branch_regex: Regex, #[serde(default)] pub github_info: Option, + #[serde(default)] + pub conditions: BTreeMap, +} + +fn default_branch_regex() -> Regex { + Regex::new("^$").unwrap() +} + +impl Default for RepoSettings { + fn default() -> Self { + Self { + branch_regex: default_branch_regex(), + github_info: Default::default(), + conditions: Default::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConditionSettings { + pub condition: GeneralCondition, } diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..a3f0f47 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,210 @@ +use std::collections::BTreeSet; + +use teloxide::{ + Bot, + payloads::SendMessageSetters, + prelude::Requester, + sugar::request::RequestLinkPreviewExt, + types::{ChatId, ParseMode}, +}; + +use crate::{ + CommandError, + chat::{ + self, + resources::ChatRepoResources, + settings::{BranchSettings, CommitSettings, PullRequestSettings}, + }, + condition::Action, + message::{branch_check_message, commit_check_message, pr_merged_message}, + options, + repo::{self, resources::RepoResources}, + try_attach_subscribe_button_markup, +}; + +pub async fn update_and_report_error(bot: Bot) -> Result<(), teloxide::RequestError> { + match update(bot.clone()).await { + Ok(r) => Ok(r), + Err(CommandError::Normal(e)) => { + log::error!("update error: {e}"); + let options = options::get(); + bot.send_message(ChatId(options.admin_chat_id), format!("update error: {e}")) + .await?; + Ok(()) + } + Err(CommandError::Teloxide(e)) => Err(e), + } +} + +async fn update(bot: Bot) -> Result<(), CommandError> { + log::info!("updating repositories..."); + let repos = repo::list().await?; + for repo in repos { + let resources = repo::resources(&repo).await?; + log::info!("updating {repo}..."); + if let Err(e) = repo::fetch_and_update_cache(resources).await { + log::error!("update error for repository {repo}: {e}"); + } + } + let chats = chat::chats().await?; + for chat in chats { + if let Err(e) = update_chat(bot.clone(), chat).await { + log::error!("update error for chat {chat}: {e}"); + } + } + Ok(()) +} + +async fn update_chat(bot: Bot, chat: ChatId) -> Result<(), CommandError> { + let repos = chat::repos(chat).await?; + for repo in repos { + if let Err(e) = update_chat_repo(bot.clone(), chat, &repo).await { + log::error!("update error for repository of chat ({chat}, {repo}): {e}"); + } + } + Ok(()) +} + +async fn update_chat_repo(bot: Bot, chat: ChatId, repo: &str) -> Result<(), CommandError> { + log::info!("updating ({chat}, {repo})..."); + let resources = chat::resources_chat_repo(chat, repo.to_string()).await?; + let repo_resources = repo::resources(repo).await?; + + // check pull requests before checking commits + let pull_requests = { + let settings = resources.settings.read().await; + settings.pull_requests.clone() + }; + for (pr, settings) in pull_requests { + if let Err(e) = update_chat_repo_pr( + bot.clone(), + &resources, + &repo_resources, + chat, + repo, + pr, + &settings, + ) + .await + { + log::error!("update error for PR ({chat}, {repo}, {pr}): {e}"); + } + } + + // check branches of the repo + let branches = { + let settings = resources.settings.read().await; + settings.branches.clone() + }; + for (branch, settings) in branches { + if let Err(e) = update_chat_repo_branch( + bot.clone(), + &resources, + &repo_resources, + chat, + repo, + &branch, + &settings, + ) + .await + { + log::error!("update error for branch ({chat}, {repo}, {branch}): {e}"); + } + } + + // check commits of the repo + let commits = { + let settings = resources.settings.read().await; + settings.commits.clone() + }; + for (commit, settings) in commits { + if let Err(e) = update_chat_repo_commit( + bot.clone(), + &resources, + &repo_resources, + chat, + repo, + &commit, + &settings, + ) + .await + { + log::error!("update error for commit ({chat}, {repo}, {commit}): {e}"); + } + } + Ok(()) +} + +async fn update_chat_repo_pr( + bot: Bot, + resources: &ChatRepoResources, + repo_resources: &RepoResources, + chat: ChatId, + repo: &str, + pr: u64, + settings: &PullRequestSettings, +) -> Result<(), CommandError> { + let result = chat::pr_check(resources, repo_resources, pr).await?; + log::info!("finished pr check ({chat}, {repo}, {pr})"); + if let Some(commit) = result { + let message = pr_merged_message(repo, pr, settings, &commit); + bot.send_message(chat, message) + .parse_mode(ParseMode::MarkdownV2) + .await?; + } + Ok(()) +} + +async fn update_chat_repo_commit( + bot: Bot, + resources: &ChatRepoResources, + repo_resources: &RepoResources, + chat: ChatId, + repo: &str, + commit: &str, + settings: &CommitSettings, +) -> Result<(), CommandError> { + let result = chat::commit_check(resources, repo_resources, commit).await?; + log::info!("finished commit check ({chat}, {repo}, {commit})"); + if !result.new.is_empty() { + let suppress_notification_conditions: BTreeSet<&String> = + result.conditions_of_action(Action::SuppressNotification); + if !suppress_notification_conditions.is_empty() { + log::info!("suppress notification for check result of ({chat}, {repo}): {result:?}",); + } else { + let message = commit_check_message(repo, commit, settings, &result); + let mut send = bot + .send_message(chat, message) + .parse_mode(ParseMode::MarkdownV2) + .disable_link_preview(true); + let remove_conditions: BTreeSet<&String> = result.conditions_of_action(Action::Remove); + if remove_conditions.is_empty() { + send = try_attach_subscribe_button_markup(chat, send, "c", repo, commit); + } + send.await?; + } + } + Ok(()) +} + +async fn update_chat_repo_branch( + bot: Bot, + resources: &ChatRepoResources, + repo_resources: &RepoResources, + chat: ChatId, + repo: &str, + branch: &str, + settings: &BranchSettings, +) -> Result<(), CommandError> { + let result = chat::branch_check(resources, repo_resources, branch).await?; + log::info!("finished branch check ({chat}, {repo}, {branch})"); + if result.new != result.old { + let message = branch_check_message(repo, branch, settings, &result); + let mut send = bot + .send_message(chat, message) + .parse_mode(ParseMode::MarkdownV2); + send = try_attach_subscribe_button_markup(chat, send, "b", repo, branch); + send.await?; + } + Ok(()) +} diff --git a/src/utils.rs b/src/utils.rs index 433107b..63cc5d6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::fmt; use std::fs::{File, OpenOptions}; use std::io::{BufReader, BufWriter}; @@ -9,6 +10,7 @@ use serde::de::DeserializeOwned; use teloxide::types::ReplyParameters; use teloxide::{payloads::SendMessage, prelude::*, requests::JsonRequest}; +use crate::chat::settings::Subscriber; use crate::error::Error; pub fn reply_to_msg(bot: &Bot, msg: &Message, text: T) -> JsonRequest @@ -62,3 +64,22 @@ where let writer = BufWriter::new(file); Ok(serde_json::to_writer_pretty(writer, rs)?) } + +pub fn modify_subscriber_set( + set: &mut BTreeSet, + subscriber: Subscriber, + unsubscribe: bool, +) -> Result<(), Error> { + if unsubscribe { + if !set.contains(&subscriber) { + return Err(Error::NotSubscribed); + } + set.remove(&subscriber); + } else { + if set.contains(&subscriber) { + return Err(Error::AlreadySubscribed); + } + set.insert(subscriber); + } + Ok(()) +} From 1a0f9031a21aed87f4fd4eea5f13c5c111869053 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 17:58:00 +0800 Subject: [PATCH 05/27] Better repo creation test --- src/repo/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repo/mod.rs b/src/repo/mod.rs index dd9208f..292b534 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -34,7 +34,7 @@ pub async fn resources(repo: &str) -> Result, Error> { pub async fn create(name: &str, url: &str) -> Result { let paths = RepoPaths::new(name)?; log::info!("try clone '{url}' into {:?}", paths.repo); - if paths.outer.exists() { + if paths.repo.exists() { return Err(Error::RepoExists(name.to_string())); } create_dir_all(&paths.outer).await?; From 407a86cda1fd2d7dced2dfb0c9cc384a91118f94 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 18:22:35 +0800 Subject: [PATCH 06/27] Better default repo settings --- src/github.rs | 4 ++-- src/main.rs | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/github.rs b/src/github.rs index f74ad6a..0a4ee47 100644 --- a/src/github.rs +++ b/src/github.rs @@ -10,8 +10,8 @@ use crate::error::Error; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GitHubInfo { - owner: String, - repo: String, + pub owner: String, + pub repo: String, } impl Display for GitHubInfo { diff --git a/src/main.rs b/src/main.rs index ad2955c..0b95b82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod resources; mod update; mod utils; +use std::collections::BTreeMap; use std::collections::BTreeSet; use std::env; use std::fmt; @@ -46,6 +47,7 @@ use crate::chat::settings::PullRequestSettings; use crate::chat::settings::Subscriber; use crate::condition::Action; use crate::condition::GeneralCondition; +use crate::condition::in_branch::InBranchCondition; use crate::message::branch_check_message; use crate::message::commit_check_message; use crate::message::subscriber_from_msg; @@ -447,8 +449,28 @@ async fn repo_add(bot: Bot, msg: Message, name: String, url: String) -> Result<( let github_info = Url::parse(&url) .ok() .and_then(|u| GitHubInfo::parse_from_url(u).ok()); + let settings = { let mut locked = resources.settings.write().await; + if let Some(info) = &github_info { + let repository = octocrab::instance().repos(&info.owner, &info.repo) + .get().await.map_err(|e| Error::Octocrab(Box::new(e)))?; + if let Some(default_branch) = repository.default_branch { + let default_regex_str = format!("^{}$", regex::escape(&default_branch)); + let default_regex = Regex::new(&default_regex_str).map_err(Error::from)?; + let default_condition = ConditionSettings { + condition: GeneralCondition::InBranch(InBranchCondition { + branch_regex: default_regex.clone(), + }), + }; + locked.branch_regex = default_regex; + locked.conditions = { + let mut map = BTreeMap::new(); + map.insert(format!("in-{default_branch}"), default_condition); + map + }; + } + } locked.github_info = github_info; locked.clone() }; From dfde2e1d814688d632ed2ad752a99f09cf5987f8 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 18:29:20 +0800 Subject: [PATCH 07/27] Fix repo remove --- src/main.rs | 7 +++++-- src/repo/mod.rs | 6 ++++-- src/resources.rs | 14 +++++++------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0b95b82..115d5f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -453,8 +453,11 @@ async fn repo_add(bot: Bot, msg: Message, name: String, url: String) -> Result<( let settings = { let mut locked = resources.settings.write().await; if let Some(info) = &github_info { - let repository = octocrab::instance().repos(&info.owner, &info.repo) - .get().await.map_err(|e| Error::Octocrab(Box::new(e)))?; + let repository = octocrab::instance() + .repos(&info.owner, &info.repo) + .get() + .await + .map_err(|e| Error::Octocrab(Box::new(e)))?; if let Some(default_branch) = repository.default_branch { let default_regex_str = format!("^{}$", regex::escape(&default_branch)); let default_regex = Regex::new(&default_regex_str).map_err(Error::from)?; diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 292b534..d12d9e6 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -68,11 +68,13 @@ pub async fn create(name: &str, url: &str) -> Result { } pub async fn remove(name: &str) -> Result<(), Error> { + let resource = resources(name).await?; + drop(resource); RESOURCES_MAP - .remove(&name.to_string(), async |r: Arc| { + .remove(&name.to_string(), async |r: RepoResources| { log::info!("try remove repository outer directory: {:?}", r.paths.outer); remove_dir_all(&r.paths.outer).await?; - log::info!("repository outer directory removed: {:?}", &r.paths.outer); + log::info!("repository outer directory removed: {:?}", r.paths.outer); Ok(()) }) .await?; diff --git a/src/resources.rs b/src/resources.rs index 9b0348c..913bc25 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -46,13 +46,13 @@ impl ResourcesMap { pub async fn remove(&self, index: &I, cleanup: C) -> Result<(), Error> where I: Ord + Clone + fmt::Display + fmt::Debug, - C: FnOnce(Arc) -> F, + C: FnOnce(R) -> F, F: Future>, { let mut map = self.map.lock().await; if let Some(arc) = map.remove(index) { - wait_for_resources_drop(index, arc.clone()).await; - cleanup(arc).await?; // run before the map unlock + let resource = wait_for_resources_drop(index, arc).await; + cleanup(resource).await?; Ok(()) } else { Err(Error::UnknownResource(format!("{index}"))) @@ -65,22 +65,22 @@ impl ResourcesMap { { let mut map = self.map.lock().await; while let Some((task, resources)) = map.pop_first() { - wait_for_resources_drop(&task, resources).await; + let _resource = wait_for_resources_drop(&task, resources).await; } Ok(()) } } -pub async fn wait_for_resources_drop(index: &I, mut arc: Arc) +pub async fn wait_for_resources_drop(index: &I, mut arc: Arc) -> R where I: fmt::Display, { loop { match Arc::try_unwrap(arc) { - Ok(_resource) => { + Ok(resource) => { // do nothing // just drop - break; + return resource; } Err(a) => { arc = a; From d5fa7b97d0c8506a67e7019c7e6575108da43340 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 18:58:01 +0800 Subject: [PATCH 08/27] Small fixes --- src/main.rs | 2 +- src/update.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 115d5f8..eff285f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -738,7 +738,7 @@ async fn pr_check(bot: Bot, msg: Message, repo: String, pr_id: u64) -> Result<() reply_to_msg( &bot, &msg, - format!("pr {pr_id} has been merged (and removed)\ncommit `{commit}` added"), + format!("pr {pr_id} has been merged \\(and removed\\)\ncommit `{commit}` added"), ) .parse_mode(ParseMode::MarkdownV2) .await?; diff --git a/src/update.rs b/src/update.rs index a3f0f47..29981bf 100644 --- a/src/update.rs +++ b/src/update.rs @@ -37,15 +37,16 @@ pub async fn update_and_report_error(bot: Bot) -> Result<(), teloxide::RequestEr } async fn update(bot: Bot) -> Result<(), CommandError> { - log::info!("updating repositories..."); let repos = repo::list().await?; for repo in repos { + log::info!("updating repository {repo}..."); let resources = repo::resources(&repo).await?; log::info!("updating {repo}..."); if let Err(e) = repo::fetch_and_update_cache(resources).await { log::error!("update error for repository {repo}: {e}"); } } + log::info!("updating chats..."); let chats = chat::chats().await?; for chat in chats { if let Err(e) = update_chat(bot.clone(), chat).await { @@ -58,6 +59,7 @@ async fn update(bot: Bot) -> Result<(), CommandError> { async fn update_chat(bot: Bot, chat: ChatId) -> Result<(), CommandError> { let repos = chat::repos(chat).await?; for repo in repos { + log::info!("updating repository of chat ({chat}, {repo})..."); if let Err(e) = update_chat_repo(bot.clone(), chat, &repo).await { log::error!("update error for repository of chat ({chat}, {repo}): {e}"); } From 9994971deebc85d3ed9b11fb88e792725f607692 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 19:05:33 +0800 Subject: [PATCH 09/27] Fix branch regex --- src/repo/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repo/mod.rs b/src/repo/mod.rs index d12d9e6..806b7a1 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -313,7 +313,7 @@ pub async fn watching_branches( let remote_branches = repo.branches(Some(git2::BranchType::Remote))?; let branch_regex = { let settings = resources.settings.read().await; - Regex::new(&format!("^{}$", settings.branch_regex))? + settings.branch_regex.clone() }; let mut matched_branches = BTreeSet::new(); for branch_iter_res in remote_branches { From a79071aa58b6a9f5895940d12005ec1b3a569b9d Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 19:13:18 +0800 Subject: [PATCH 10/27] Add bracket to regex templates --- src/condition/in_branch.rs | 2 +- src/main.rs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/condition/in_branch.rs b/src/condition/in_branch.rs index 07d791a..9ce4d5c 100644 --- a/src/condition/in_branch.rs +++ b/src/condition/in_branch.rs @@ -28,7 +28,7 @@ impl Condition for InBranchCondition { impl InBranchCondition { pub fn parse(s: &str) -> Result { Ok(InBranchCondition { - branch_regex: Regex::new(&format!("^{s}$"))?, + branch_regex: Regex::new(&format!("^({s})$"))?, }) } } diff --git a/src/main.rs b/src/main.rs index eff285f..3eff6ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -328,6 +328,11 @@ fn octocrab_initialize() { } async fn schedule(bot: Bot) { + // always update once on startup + if let Err(e) = update_and_report_error(bot.clone()).await { + log::error!("teloxide error in update: {e}"); + } + let expression = &options::get().cron; let schedule = Schedule::from_str(expression).expect("cron expression"); for datetime in schedule.upcoming(Utc) { @@ -459,7 +464,7 @@ async fn repo_add(bot: Bot, msg: Message, name: String, url: String) -> Result<( .await .map_err(|e| Error::Octocrab(Box::new(e)))?; if let Some(default_branch) = repository.default_branch { - let default_regex_str = format!("^{}$", regex::escape(&default_branch)); + let default_regex_str = format!("^({})$", regex::escape(&default_branch)); let default_regex = Regex::new(&default_regex_str).map_err(Error::from)?; let default_condition = ConditionSettings { condition: GeneralCondition::InBranch(InBranchCondition { @@ -500,7 +505,7 @@ async fn repo_edit( let new_settings = { let mut locked = resources.settings.write().await; if let Some(r) = branch_regex { - let regex = Regex::new(&format!("^{r}$")).map_err(Error::from)?; + let regex = Regex::new(&format!("^({r})$")).map_err(Error::from)?; locked.branch_regex = regex; } if let Some(info) = github_info { From 0331f3e7ef1782f2a7055837811520dae1f16d65 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 19:19:48 +0800 Subject: [PATCH 11/27] Enforce allow list --- src/chat/paths.rs | 11 +++++++---- src/chat/resources.rs | 2 +- src/error.rs | 2 ++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/chat/paths.rs b/src/chat/paths.rs index bed6d79..c08042b 100644 --- a/src/chat/paths.rs +++ b/src/chat/paths.rs @@ -2,7 +2,7 @@ use std::{path::PathBuf, sync::LazyLock}; use teloxide::types::ChatId; -use crate::{chat::Task, options}; +use crate::{chat::Task, error::Error, options}; #[derive(Debug, Clone)] pub struct ChatRepoPaths { @@ -16,15 +16,18 @@ pub static GLOBAL_CHATS_OUTER: LazyLock = LazyLock::new(|| options::get().working_dir.join("chats")); impl ChatRepoPaths { - pub fn new(task: &Task) -> ChatRepoPaths { + pub fn new(task: &Task) -> Result { let chat_path = Self::outer_dir(task.chat); + if !chat_path.is_dir() { + return Err(Error::NotInAllowList(task.chat)); + } let repo = chat_path.join(&task.repo); - Self { + Ok(Self { // chat: chat_path, settings: repo.join("settings.json"), results: repo.join("results.json"), repo, - } + }) } pub fn outer_dir(chat: ChatId) -> PathBuf { diff --git a/src/chat/resources.rs b/src/chat/resources.rs index b2b461b..b187569 100644 --- a/src/chat/resources.rs +++ b/src/chat/resources.rs @@ -25,7 +25,7 @@ pub struct ChatRepoResources { impl Resource for ChatRepoResources { async fn open(task: &Task) -> Result { - let paths = ChatRepoPaths::new(task); + let paths = ChatRepoPaths::new(task)?; if !paths.repo.is_dir() { create_dir_all(&paths.repo).await?; } diff --git a/src/error.rs b/src/error.rs index 591ae37..3fb891f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,6 +33,8 @@ pub enum Error { TaskJoin(#[from] tokio::task::JoinError), #[error("invalid name: {0}")] Name(String), + #[error("chat id {0} is not in allow list")] + NotInAllowList(ChatId), #[error("git error: {0}")] Git(#[from] git2::Error), #[error("failed to clone git repository '{url}' into '{name}', output: {output:?}")] From 317ae2350a8ea5b72f3a8a8eed1f0ff6574f8129 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 19:26:13 +0800 Subject: [PATCH 12/27] Fix duplicated message --- src/update.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/update.rs b/src/update.rs index 29981bf..f60323f 100644 --- a/src/update.rs +++ b/src/update.rs @@ -166,6 +166,15 @@ async fn update_chat_repo_commit( commit: &str, settings: &CommitSettings, ) -> Result<(), CommandError> { + // check again commit existence after acquiring the lock + { + let _guard = resources.commit_lock(commit.to_string()).await; + let settings = resources.settings.read().await; + if !settings.commits.contains_key(commit) { + return Ok(()) + } + } + let result = chat::commit_check(resources, repo_resources, commit).await?; log::info!("finished commit check ({chat}, {repo}, {commit})"); if !result.new.is_empty() { @@ -198,6 +207,15 @@ async fn update_chat_repo_branch( branch: &str, settings: &BranchSettings, ) -> Result<(), CommandError> { + // check again commit existence after acquiring the lock + { + let _guard = resources.branch_lock(branch.to_string()).await; + let settings = resources.settings.read().await; + if !settings.branches.contains_key(branch) { + return Ok(()) + } + } + let result = chat::branch_check(resources, repo_resources, branch).await?; log::info!("finished branch check ({chat}, {repo}, {branch})"); if result.new != result.old { From 9750f30e5e273cc363a5af782199e750d21a3c6d Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 19:42:15 +0800 Subject: [PATCH 13/27] Lock poll refactor --- src/chat/mod.rs | 6 ------ src/main.rs | 8 ++++++++ src/update.rs | 20 ++------------------ 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/chat/mod.rs b/src/chat/mod.rs index f6bdde5..79b5bfd 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -110,7 +110,6 @@ pub async fn commit_add( hash: &str, settings: CommitSettings, ) -> Result<(), Error> { - let _guard = resources.commit_lock(hash.to_string()).await; { let mut locked = resources.settings.write().await; if locked.commits.contains_key(hash) { @@ -122,7 +121,6 @@ pub async fn commit_add( } pub async fn commit_remove(resources: &ChatRepoResources, hash: &str) -> Result<(), Error> { - let _guard = resources.commit_lock(hash.to_string()).await; { let mut settings = resources.settings.write().await; if !settings.commits.contains_key(hash) { @@ -145,7 +143,6 @@ pub async fn commit_check( hash: &str, ) -> Result { log::info!("checking commit ({task}, {hash})", task = resources.task); - let _guard = resources.commit_lock(hash.to_string()).await; let cache = repo_resources.cache().await?; let all_branches = { let commit = hash.to_string(); @@ -290,7 +287,6 @@ pub async fn branch_add( branch: &str, settings: BranchSettings, ) -> Result<(), Error> { - let _guard = resources.branch_lock(branch.to_string()).await; { let mut locked = resources.settings.write().await; if locked.branches.contains_key(branch) { @@ -302,7 +298,6 @@ pub async fn branch_add( } pub async fn branch_remove(resources: &ChatRepoResources, branch: &str) -> Result<(), Error> { - let _guard = resources.branch_lock(branch.to_string()).await; { let mut locked = resources.settings.write().await; if !locked.branches.contains_key(branch) { @@ -327,7 +322,6 @@ pub async fn branch_check( "checking branch ({task}, {branch_name})", task = resources.task ); - let _guard = resources.branch_lock(branch_name.to_string()).await; let result = { let old_result = { let results = resources.results.read().await; diff --git a/src/main.rs b/src/main.rs index 3eff6ae..67ee50d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -573,6 +573,7 @@ async fn commit_add( url: Option, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let _guard = resources.commit_lock(hash.clone()).await; let subscribers = subscriber_from_msg(&msg).into_iter().collect(); let settings = CommitSettings { url, @@ -600,6 +601,7 @@ async fn commit_remove( hash: String, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let _guard = resources.commit_lock(hash.clone()).await; chat::commit_remove(&resources, &hash).await?; reply_to_msg(&bot, &msg, format!("commit {hash} removed")).await?; Ok(()) @@ -612,6 +614,7 @@ async fn commit_check( hash: String, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let _guard = resources.commit_lock(hash.clone()).await; let repo_resources = repo::resources(&repo).await?; let commit_settings = { let settings = resources.settings.read().await; @@ -642,6 +645,7 @@ async fn commit_subscribe( unsubscribe: bool, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let _guard = resources.commit_lock(hash.clone()).await; let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; { let mut settings = resources.settings.write().await; @@ -805,6 +809,7 @@ async fn branch_add( branch: String, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let _guard = resources.branch_lock(branch.clone()).await; let settings = BranchSettings { notify: Default::default(), }; @@ -822,6 +827,7 @@ async fn branch_remove( branch: String, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo).await?; + let _guard = resources.branch_lock(branch.clone()).await; chat::branch_remove(&resources, &branch).await?; reply_to_msg(&bot, &msg, format!("branch {branch} removed")).await?; Ok(()) @@ -834,6 +840,7 @@ async fn branch_check( branch: String, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let _guard = resources.branch_lock(branch.clone()).await; let repo_resources = repo::resources(&repo).await?; let branch_settings = { let settings = resources.settings.read().await; @@ -860,6 +867,7 @@ async fn branch_subscribe( unsubscribe: bool, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo).await?; + let _guard = resources.branch_lock(branch.clone()).await; let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; { let mut settings = resources.settings.write().await; diff --git a/src/update.rs b/src/update.rs index f60323f..684d078 100644 --- a/src/update.rs +++ b/src/update.rs @@ -99,6 +99,7 @@ async fn update_chat_repo(bot: Bot, chat: ChatId, repo: &str) -> Result<(), Comm settings.branches.clone() }; for (branch, settings) in branches { + let _guard = resources.branch_lock(branch.clone()).await; if let Err(e) = update_chat_repo_branch( bot.clone(), &resources, @@ -120,6 +121,7 @@ async fn update_chat_repo(bot: Bot, chat: ChatId, repo: &str) -> Result<(), Comm settings.commits.clone() }; for (commit, settings) in commits { + let _guard = resources.commit_lock(commit.clone()).await; if let Err(e) = update_chat_repo_commit( bot.clone(), &resources, @@ -166,15 +168,6 @@ async fn update_chat_repo_commit( commit: &str, settings: &CommitSettings, ) -> Result<(), CommandError> { - // check again commit existence after acquiring the lock - { - let _guard = resources.commit_lock(commit.to_string()).await; - let settings = resources.settings.read().await; - if !settings.commits.contains_key(commit) { - return Ok(()) - } - } - let result = chat::commit_check(resources, repo_resources, commit).await?; log::info!("finished commit check ({chat}, {repo}, {commit})"); if !result.new.is_empty() { @@ -207,15 +200,6 @@ async fn update_chat_repo_branch( branch: &str, settings: &BranchSettings, ) -> Result<(), CommandError> { - // check again commit existence after acquiring the lock - { - let _guard = resources.branch_lock(branch.to_string()).await; - let settings = resources.settings.read().await; - if !settings.branches.contains_key(branch) { - return Ok(()) - } - } - let result = chat::branch_check(resources, repo_resources, branch).await?; log::info!("finished branch check ({chat}, {repo}, {branch})"); if result.new != result.old { From b2ec20df98e8887fd77b2d8025a18b5f0519c6e4 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 19:53:49 +0800 Subject: [PATCH 14/27] Add index for cache --- src/repo/cache.rs | 21 +++++++++++++-------- src/repo/resources.rs | 13 +++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/repo/cache.rs b/src/repo/cache.rs index 89cea2d..e2f6954 100644 --- a/src/repo/cache.rs +++ b/src/repo/cache.rs @@ -6,17 +6,22 @@ use crate::error::Error; pub fn initialize(cache: &Connection) -> Result<(), Error> { cache.execute( - "CREATE TABLE commits_cache ( - branch TEXT NOT NULL, - commit_hash TEXT NOT NULL - )", + "CREATE TABLE IF NOT EXISTS commits_cache ( + branch TEXT NOT NULL, + commit_hash TEXT NOT NULL + )", [], )?; cache.execute( - "CREATE TABLE branches ( - branch TEXT NOT NULL PRIMARY KEY, - current_commit TEXT NOT NULL - )", + "CREATE TABLE IF NOT EXISTS branches ( + branch TEXT NOT NULL PRIMARY KEY, + current_commit TEXT NOT NULL + )", + [], + )?; + cache.execute( + "CREATE INDEX IF NOT EXISTS idx_commit_branches + ON commits_cache (commit_hash)", [], )?; diff --git a/src/repo/resources.rs b/src/repo/resources.rs index a450018..05069ca 100644 --- a/src/repo/resources.rs +++ b/src/repo/resources.rs @@ -32,16 +32,13 @@ impl Resource for RepoResources { // load repo let repo = Mutex::new(Repository::open(&paths.repo)?); // load cache - let cache_exists = paths.cache.is_file(); let cache_cfg = deadpool_sqlite::Config::new(&paths.cache); let cache = cache_cfg.create_pool(deadpool_sqlite::Runtime::Tokio1)?; - if !cache_exists { - log::debug!("initializing cache for {name}..."); - let conn = cache.get().await?; - conn.interact(|c| cache::initialize(c)) - .await - .map_err(|e| Error::DBInteract(Mutex::new(e)))??; - } + log::debug!("initializing cache for {name}..."); + let conn = cache.get().await?; + conn.interact(|c| cache::initialize(c)) + .await + .map_err(|e| Error::DBInteract(Mutex::new(e)))??; // load settings let settings = RwLock::new(read_json(&paths.settings)?); From 41dcac75fc926ee54e64c030332c07b5da111b05 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 20:28:30 +0800 Subject: [PATCH 15/27] Check PR closing --- src/chat/mod.rs | 29 +++++++++++++++++--- src/chat/results.rs | 7 +++++ src/github.rs | 4 +++ src/main.rs | 64 ++++++++++++++++++++++++++++----------------- src/message.rs | 7 +++++ src/update.rs | 25 +++++++++++++----- 6 files changed, 101 insertions(+), 35 deletions(-) diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 79b5bfd..07d3613 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -8,7 +8,9 @@ use crate::{ chat::{ paths::ChatRepoPaths, resources::ChatRepoResources, - results::{BranchCheckResult, BranchResults, CommitCheckResult, CommitResults}, + results::{ + BranchCheckResult, BranchResults, CommitCheckResult, CommitResults, PRCheckResult, + }, settings::{BranchSettings, CommitSettings, NotifySettings, PullRequestSettings}, }, condition::{Action, Condition}, @@ -200,9 +202,18 @@ pub async fn commit_check( pub async fn pr_add( resources: &ChatRepoResources, + repo_resources: &RepoResources, pr_id: u64, settings: PullRequestSettings, ) -> Result<(), Error> { + let github_info = { + let locked = repo_resources.settings.read().await; + locked + .github_info + .clone() + .ok_or(Error::NoGitHubInfo(resources.task.repo.clone()))? + }; + let _pr = github::get_pr(&github_info, pr_id).await?; // ensure the PR id is real { let mut locked = resources.settings.write().await; if locked.pull_requests.contains_key(&pr_id) { @@ -228,7 +239,7 @@ pub async fn pr_check( resources: &ChatRepoResources, repo_resources: &RepoResources, id: u64, -) -> Result, Error> { +) -> Result { log::info!("checking PR ({task}, {id})", task = resources.task); let github_info = { let locked = repo_resources.settings.read().await; @@ -248,9 +259,19 @@ pub async fn pr_check( }; resources.save_settings().await?; let commit = merged_pr_to_commit(resources, github_info, id, settings).await?; - Ok(Some(commit)) + Ok(PRCheckResult::Merged(commit)) + } else if github::is_closed(&github_info, id).await? { + { + let mut locked = resources.settings.write().await; + locked + .pull_requests + .remove(&id) + .ok_or(Error::UnknownPullRequest(id))?; + }; + resources.save_settings().await?; + Ok(PRCheckResult::Closed) } else { - Ok(None) + Ok(PRCheckResult::Waiting) } } diff --git a/src/chat/results.rs b/src/chat/results.rs index b0f70d2..1a01931 100644 --- a/src/chat/results.rs +++ b/src/chat/results.rs @@ -41,3 +41,10 @@ pub struct BranchCheckResult { pub old: Option, pub new: Option, } + +#[derive(Debug)] +pub enum PRCheckResult { + Merged(String), + Closed, + Waiting, +} diff --git a/src/github.rs b/src/github.rs index 0a4ee47..167b070 100644 --- a/src/github.rs +++ b/src/github.rs @@ -77,6 +77,10 @@ pub async fn is_merged(info: &GitHubInfo, pr_id: u64) -> Result { .map_err(Box::new)?) } +pub async fn is_closed(info: &GitHubInfo, pr_id: u64) -> Result { + Ok(get_pr(info, pr_id).await?.closed_at.is_some()) +} + pub async fn get_pr(info: &GitHubInfo, pr_id: u64) -> Result { Ok(octocrab::instance() .pulls(&info.owner, &info.repo) diff --git a/src/main.rs b/src/main.rs index 67ee50d..e09bfe9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,6 +40,7 @@ use teloxide::utils::markdown; use tokio::time::sleep; use url::Url; +use crate::chat::results::PRCheckResult; use crate::chat::settings::BranchSettings; use crate::chat::settings::CommitSettings; use crate::chat::settings::NotifySettings; @@ -690,7 +691,7 @@ async fn pr_add( subscribers, }, }; - match chat::pr_add(&resources, pr_id, settings).await { + match chat::pr_add(&resources, &repo_resources, pr_id, settings).await { Ok(()) => { pr_check(bot, msg, repo, pr_id).await?; } @@ -743,29 +744,44 @@ async fn pr_check(bot: Bot, msg: Message, repo: String, pr_id: u64) -> Result<() let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; let repo_resources = repo::resources(&repo).await?; match chat::pr_check(&resources, &repo_resources, pr_id).await { - Ok(Some(commit)) => { - reply_to_msg( - &bot, - &msg, - format!("pr {pr_id} has been merged \\(and removed\\)\ncommit `{commit}` added"), - ) - .parse_mode(ParseMode::MarkdownV2) - .await?; - commit_check(bot, msg, repo, commit).await - } - Ok(None) => { - let mut send = reply_to_msg(&bot, &msg, format!("pr {pr_id} has not been merged yet")) - .parse_mode(ParseMode::MarkdownV2); - send = try_attach_subscribe_button_markup( - msg.chat.id, - send, - "p", - &repo, - &pr_id.to_string(), - ); - send.await?; - Ok(()) - } + Ok(result) => match result { + PRCheckResult::Merged(commit) => { + reply_to_msg( + &bot, + &msg, + format!( + "pr {pr_id} has been merged \\(and removed\\)\ncommit `{commit}` added" + ), + ) + .parse_mode(ParseMode::MarkdownV2) + .await?; + commit_check(bot, msg, repo, commit).await + } + PRCheckResult::Closed => { + reply_to_msg( + &bot, + &msg, + format!("pr {pr_id} has been closed \\(and removed\\)"), + ) + .parse_mode(ParseMode::MarkdownV2) + .await?; + Ok(()) + } + PRCheckResult::Waiting => { + let mut send = + reply_to_msg(&bot, &msg, format!("pr {pr_id} has not been merged yet")) + .parse_mode(ParseMode::MarkdownV2); + send = try_attach_subscribe_button_markup( + msg.chat.id, + send, + "p", + &repo, + &pr_id.to_string(), + ); + send.await?; + Ok(()) + } + }, Err(Error::CommitExists(commit)) => commit_subscribe(bot, msg, repo, commit, false).await, Err(e) => Err(e.into()), } diff --git a/src/message.rs b/src/message.rs index 785b961..8e8f5e7 100644 --- a/src/message.rs +++ b/src/message.rs @@ -93,6 +93,13 @@ pub fn pr_merged_message( ) } +pub fn pr_closed_message(repo: &str, pr: u64, settings: &PullRequestSettings) -> String { + format!( + "{repo}/{pr} has been closed{notify}", + notify = push_empty_line(&settings.notify.notify_markdown()), + ) +} + pub fn branch_check_message( repo: &str, branch: &str, diff --git a/src/update.rs b/src/update.rs index 684d078..db7bb2e 100644 --- a/src/update.rs +++ b/src/update.rs @@ -13,10 +13,11 @@ use crate::{ chat::{ self, resources::ChatRepoResources, + results::PRCheckResult, settings::{BranchSettings, CommitSettings, PullRequestSettings}, }, condition::Action, - message::{branch_check_message, commit_check_message, pr_merged_message}, + message::{branch_check_message, commit_check_message, pr_closed_message, pr_merged_message}, options, repo::{self, resources::RepoResources}, try_attach_subscribe_button_markup, @@ -150,13 +151,23 @@ async fn update_chat_repo_pr( ) -> Result<(), CommandError> { let result = chat::pr_check(resources, repo_resources, pr).await?; log::info!("finished pr check ({chat}, {repo}, {pr})"); - if let Some(commit) = result { - let message = pr_merged_message(repo, pr, settings, &commit); - bot.send_message(chat, message) - .parse_mode(ParseMode::MarkdownV2) - .await?; + match result { + PRCheckResult::Merged(commit) => { + let message = pr_merged_message(repo, pr, settings, &commit); + bot.send_message(chat, message) + .parse_mode(ParseMode::MarkdownV2) + .await?; + Ok(()) + } + PRCheckResult::Closed => { + let message = pr_closed_message(repo, pr, settings); + bot.send_message(chat, message) + .parse_mode(ParseMode::MarkdownV2) + .await?; + Ok(()) + } + PRCheckResult::Waiting => Ok(()), } - Ok(()) } async fn update_chat_repo_commit( From 5439d9f42ff005c91c5da624ab455b6af88cc224 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 21:19:24 +0800 Subject: [PATCH 16/27] Issue tracking --- src/chat/mod.rs | 62 ++++++++-------- src/chat/results.rs | 2 +- src/chat/settings.rs | 4 +- src/command.rs | 20 ++--- src/error.rs | 4 +- src/github.rs | 16 +++- src/main.rs | 171 +++++++++++++++++++++++-------------------- src/message.rs | 44 +++++++---- src/repo/mod.rs | 10 +++ src/update.rs | 41 ++++++----- 10 files changed, 212 insertions(+), 162 deletions(-) diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 07d3613..61f91ad 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -9,9 +9,9 @@ use crate::{ paths::ChatRepoPaths, resources::ChatRepoResources, results::{ - BranchCheckResult, BranchResults, CommitCheckResult, CommitResults, PRCheckResult, + BranchCheckResult, BranchResults, CommitCheckResult, CommitResults, PRIssueCheckResult, }, - settings::{BranchSettings, CommitSettings, NotifySettings, PullRequestSettings}, + settings::{BranchSettings, CommitSettings, NotifySettings, PRIssueSettings}, }, condition::{Action, Condition}, error::Error, @@ -200,11 +200,11 @@ pub async fn commit_check( Ok(check_result) } -pub async fn pr_add( +pub async fn pr_issue_add( resources: &ChatRepoResources, repo_resources: &RepoResources, - pr_id: u64, - settings: PullRequestSettings, + id: u64, + settings: PRIssueSettings, ) -> Result<(), Error> { let github_info = { let locked = repo_resources.settings.read().await; @@ -213,34 +213,35 @@ pub async fn pr_add( .clone() .ok_or(Error::NoGitHubInfo(resources.task.repo.clone()))? }; - let _pr = github::get_pr(&github_info, pr_id).await?; // ensure the PR id is real + // every PR id is also an issue id + let _pr = github::get_issue(&github_info, id).await?; // ensure the PR/issue id is real { let mut locked = resources.settings.write().await; - if locked.pull_requests.contains_key(&pr_id) { - return Err(Error::PullRequestExists(pr_id)); + if locked.pr_issues.contains_key(&id) { + return Err(Error::PullRequestExists(id)); } - locked.pull_requests.insert(pr_id, settings); + locked.pr_issues.insert(id, settings); } resources.save_settings().await } -pub async fn pr_remove(resources: &ChatRepoResources, id: u64) -> Result<(), Error> { +pub async fn pr_issue_remove(resources: &ChatRepoResources, id: u64) -> Result<(), Error> { { let mut locked = resources.settings.write().await; - if !locked.pull_requests.contains_key(&id) { + if !locked.pr_issues.contains_key(&id) { return Err(Error::UnknownPullRequest(id)); } - locked.pull_requests.remove(&id); + locked.pr_issues.remove(&id); } resources.save_settings().await } -pub async fn pr_check( +pub async fn pr_issue_check( resources: &ChatRepoResources, repo_resources: &RepoResources, id: u64, -) -> Result { - log::info!("checking PR ({task}, {id})", task = resources.task); +) -> Result { + log::info!("checking PR/issue ({task}, {id})", task = resources.task); let github_info = { let locked = repo_resources.settings.read().await; locked @@ -248,38 +249,39 @@ pub async fn pr_check( .clone() .ok_or(Error::NoGitHubInfo(resources.task.repo.clone()))? }; - log::debug!("checking PR {github_info}#{id}"); - if github::is_merged(&github_info, id).await? { - let settings = { + log::debug!("checking PR/issue {github_info}#{id}"); + if github::is_closed(&github_info, id).await? { + { let mut locked = resources.settings.write().await; locked - .pull_requests + .pr_issues .remove(&id) - .ok_or(Error::UnknownPullRequest(id))? + .ok_or(Error::UnknownPullRequest(id))?; }; resources.save_settings().await?; - let commit = merged_pr_to_commit(resources, github_info, id, settings).await?; - Ok(PRCheckResult::Merged(commit)) - } else if github::is_closed(&github_info, id).await? { - { + return Ok(PRIssueCheckResult::Closed); + } + let issue = github::get_issue(&github_info, id).await?; + if issue.pull_request.is_some() && github::is_merged(&github_info, id).await? { + let settings = { let mut locked = resources.settings.write().await; locked - .pull_requests + .pr_issues .remove(&id) - .ok_or(Error::UnknownPullRequest(id))?; + .ok_or(Error::UnknownPullRequest(id))? }; resources.save_settings().await?; - Ok(PRCheckResult::Closed) - } else { - Ok(PRCheckResult::Waiting) + let commit = merged_pr_to_commit(resources, github_info, id, settings).await?; + return Ok(PRIssueCheckResult::Merged(commit)); } + Ok(PRIssueCheckResult::Waiting) } pub async fn merged_pr_to_commit( resources: &ChatRepoResources, github_info: GitHubInfo, pr_id: u64, - settings: PullRequestSettings, + settings: PRIssueSettings, ) -> Result { let pr = github::get_pr(&github_info, pr_id).await?; let commit = pr diff --git a/src/chat/results.rs b/src/chat/results.rs index 1a01931..75da427 100644 --- a/src/chat/results.rs +++ b/src/chat/results.rs @@ -43,7 +43,7 @@ pub struct BranchCheckResult { } #[derive(Debug)] -pub enum PRCheckResult { +pub enum PRIssueCheckResult { Merged(String), Closed, Waiting, diff --git a/src/chat/settings.rs b/src/chat/settings.rs index fee74c5..bcdeae8 100644 --- a/src/chat/settings.rs +++ b/src/chat/settings.rs @@ -7,7 +7,7 @@ use url::Url; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ChatRepoSettings { #[serde(default)] - pub pull_requests: BTreeMap, + pub pr_issues: BTreeMap, #[serde(default)] pub commits: BTreeMap, #[serde(default)] @@ -22,7 +22,7 @@ pub struct CommitSettings { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PullRequestSettings { +pub struct PRIssueSettings { pub url: Url, #[serde(flatten)] pub notify: NotifySettings, diff --git a/src/command.rs b/src/command.rs index 1aa64a4..a52df92 100644 --- a/src/command.rs +++ b/src/command.rs @@ -41,28 +41,28 @@ pub enum Notifier { CommitRemove { repo: String, hash: String }, #[command(about = "fire a commit check immediately")] CommitCheck { repo: String, hash: String }, - #[command(about = "subscribe a commit")] + #[command(about = "subscribe to a commit")] CommitSubscribe { repo: String, hash: String, #[arg(short, long)] unsubscribe: bool, }, - #[command(about = "add a pull request")] + #[command(alias("issue-add"), about = "add a pull request/issue")] PrAdd { repo_or_url: String, - pr: Option, + id: Option, #[arg(long, short)] comment: Option, }, - #[command(about = "remove a pull request")] - PrRemove { repo: String, pr: u64 }, - #[command(about = "check a pull request")] - PrCheck { repo: String, pr: u64 }, - #[command(about = "subscribe a pull request")] + #[command(alias("issue-remove"), about = "remove a pull request/issue")] + PrRemove { repo: String, id: u64 }, + #[command(alias("issue-check"), about = "check a pull request/issue")] + PrCheck { repo: String, id: u64 }, + #[command(alias("issue-subscribe"), about = "subscribe to a pull request/issue")] PrSubscribe { repo: String, - pr: u64, + id: u64, #[arg(short, long)] unsubscribe: bool, }, @@ -72,7 +72,7 @@ pub enum Notifier { BranchRemove { repo: String, branch: String }, #[command(about = "fire a branch check immediately")] BranchCheck { repo: String, branch: String }, - #[command(about = "subscribe a branch")] + #[command(about = "subscribe to a branch")] BranchSubscribe { repo: String, branch: String, diff --git a/src/error.rs b/src/error.rs index 3fb891f..a01abc8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -108,8 +108,8 @@ pub enum Error { MultipleReposHaveSameGitHubInfo(Vec), #[error("no repository is associated with the github info: {0:?}")] NoRepoHaveGitHubInfo(GitHubInfo), - #[error("unsupported pr url: {0}")] - UnsupportedPrUrl(String), + #[error("unsupported PR/issue url: {0}")] + UnsupportedPRIssueUrl(String), #[error("not in an admin chat")] NotAdminChat, } diff --git a/src/github.rs b/src/github.rs index 167b070..ff6468c 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,6 +1,6 @@ use std::fmt::{self, Display}; -use octocrab::models::pulls::PullRequest; +use octocrab::models::{IssueState, issues::Issue, pulls::PullRequest}; use once_cell::sync::Lazy; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -78,13 +78,21 @@ pub async fn is_merged(info: &GitHubInfo, pr_id: u64) -> Result { } pub async fn is_closed(info: &GitHubInfo, pr_id: u64) -> Result { - Ok(get_pr(info, pr_id).await?.closed_at.is_some()) + Ok(get_issue(info, pr_id).await?.state == IssueState::Closed) } -pub async fn get_pr(info: &GitHubInfo, pr_id: u64) -> Result { +pub async fn get_issue(info: &GitHubInfo, id: u64) -> Result { + Ok(octocrab::instance() + .issues(&info.owner, &info.repo) + .get(id) + .await + .map_err(Box::new)?) +} + +pub async fn get_pr(info: &GitHubInfo, id: u64) -> Result { Ok(octocrab::instance() .pulls(&info.owner, &info.repo) - .get(pr_id) + .get(id) .await .map_err(Box::new)?) } diff --git a/src/main.rs b/src/main.rs index e09bfe9..85b66e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,18 +40,20 @@ use teloxide::utils::markdown; use tokio::time::sleep; use url::Url; -use crate::chat::results::PRCheckResult; +use crate::chat::results::PRIssueCheckResult; use crate::chat::settings::BranchSettings; use crate::chat::settings::CommitSettings; use crate::chat::settings::NotifySettings; -use crate::chat::settings::PullRequestSettings; +use crate::chat::settings::PRIssueSettings; use crate::chat::settings::Subscriber; use crate::condition::Action; use crate::condition::GeneralCondition; use crate::condition::in_branch::InBranchCondition; use crate::message::branch_check_message; use crate::message::commit_check_message; +use crate::message::pr_issue_id_pretty; use crate::message::subscriber_from_msg; +use crate::repo::pr_issue_url; use crate::repo::settings::ConditionSettings; use crate::update::update_and_report_error; use crate::utils::modify_subscriber_set; @@ -144,16 +146,18 @@ async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { } => commit_subscribe(bot, msg, repo, hash, unsubscribe).await, command::Notifier::PrAdd { repo_or_url, - pr, + id, comment, - } => pr_add(bot, msg, repo_or_url, pr, comment).await, - command::Notifier::PrRemove { repo, pr } => pr_remove(bot, msg, repo, pr).await, - command::Notifier::PrCheck { repo, pr } => pr_check(bot, msg, repo, pr).await, + } => pr_issue_add(bot, msg, repo_or_url, id, comment).await, + command::Notifier::PrRemove { repo, id } => { + pr_issue_remove(bot, msg, repo, id).await + } + command::Notifier::PrCheck { repo, id } => pr_issue_check(bot, msg, repo, id).await, command::Notifier::PrSubscribe { repo, - pr, + id, unsubscribe, - } => pr_subscribe(bot, msg, repo, pr, unsubscribe).await, + } => pr_issue_subscribe(bot, msg, repo, id, unsubscribe).await, command::Notifier::BranchAdd { repo, branch } => { branch_add(bot, msg, repo, branch).await } @@ -260,7 +264,7 @@ async fn handle_callback_query_command_result( { let mut settings = resources.settings.write().await; let subscribers = &mut settings - .pull_requests + .pr_issues .get_mut(&pr_id) .ok_or_else(|| Error::UnknownPullRequest(pr_id))? .notify @@ -377,6 +381,7 @@ async fn list_for_admin(bot: Bot, msg: &Message) -> Result<(), CommandError> { } reply_to_msg(&bot, msg, result) .parse_mode(ParseMode::MarkdownV2) + .disable_link_preview(true) .await?; Ok(()) @@ -412,13 +417,13 @@ async fn list_for_normal(bot: Bot, msg: &Message) -> Result<(), CommandError> { )); } result.push_str(" *pull requests*:\n"); - let pull_requests = &settings.pull_requests; - if pull_requests.is_empty() { + let pr_issues = &settings.pr_issues; + if pr_issues.is_empty() { result.push_str(" \\(nothing\\)\n"); } - for (pr, settings) in pull_requests { + for (id, settings) in pr_issues { result.push_str(&format!( - " \\- `{pr}`\n {}\n", + " \\- `{id}`\n {}\n", markdown::escape(settings.url.as_str()) )); } @@ -438,6 +443,7 @@ async fn list_for_normal(bot: Bot, msg: &Message) -> Result<(), CommandError> { } reply_to_msg(&bot, msg, result) .parse_mode(ParseMode::MarkdownV2) + .disable_link_preview(true) .await?; Ok(()) @@ -663,40 +669,32 @@ async fn commit_subscribe( Ok(()) } -async fn pr_add( +async fn pr_issue_add( bot: Bot, msg: Message, repo_or_url: String, - pr_id: Option, + optional_id: Option, optional_comment: Option, ) -> Result<(), CommandError> { - let (repo, pr_id) = resolve_pr_repo_or_url(repo_or_url, pr_id).await?; + let (repo, id) = resolve_pr_repo_or_url(repo_or_url, optional_id).await?; let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; let repo_resources = repo::resources(&repo).await?; - let github_info = { - let settings = repo_resources.settings.read().await; - settings - .github_info - .clone() - .ok_or_else(|| Error::NoGitHubInfo(repo_resources.name.clone()))? - }; - let url_str = format!("https://github.com/{github_info}/pull/{pr_id}"); - let url = Url::parse(&url_str).map_err(Error::UrlParse)?; + let url = pr_issue_url(&repo_resources, id).await?; let subscribers = subscriber_from_msg(&msg).into_iter().collect(); let comment = optional_comment.unwrap_or_default(); - let settings = PullRequestSettings { + let settings = PRIssueSettings { url, notify: NotifySettings { comment, subscribers, }, }; - match chat::pr_add(&resources, &repo_resources, pr_id, settings).await { + match chat::pr_issue_add(&resources, &repo_resources, id, settings).await { Ok(()) => { - pr_check(bot, msg, repo, pr_id).await?; + pr_issue_check(bot, msg, repo, id).await?; } Err(Error::PullRequestExists(_)) => { - pr_subscribe(bot.clone(), msg.clone(), repo.clone(), pr_id, false).await?; + pr_issue_subscribe(bot.clone(), msg.clone(), repo.clone(), id, false).await?; } Err(e) => return Err(e.into()), }; @@ -707,8 +705,9 @@ async fn resolve_pr_repo_or_url( repo_or_url: String, pr_id: Option, ) -> Result<(String, u64), Error> { - static GITHUB_URL_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r#"https://github\.com/([^/]+)/([^/]+)/pull/(\d+)"#).unwrap()); + static GITHUB_URL_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"https://github\.com/([^/]+)/([^/]+)/(pull|issues)/(\d+)"#).unwrap() + }); match pr_id { Some(id) => Ok((repo_or_url, id)), None => { @@ -716,7 +715,8 @@ async fn resolve_pr_repo_or_url( let owner = &captures[1]; let repo = &captures[2]; let github_info = GitHubInfo::new(owner.to_string(), repo.to_string()); - let pr: u64 = captures[3].parse().map_err(Error::ParseInt)?; + log::trace!("PR/issue id to parse: {}", &captures[4]); + let id: u64 = captures[4].parse().map_err(Error::ParseInt)?; let repos = repo::list().await?; let mut repos_found = Vec::new(); for repo in repos { @@ -732,69 +732,82 @@ async fn resolve_pr_repo_or_url( return Err(Error::MultipleReposHaveSameGitHubInfo(repos_found)); } else { let repo = repos_found.pop().unwrap(); - return Ok((repo, pr)); + return Ok((repo, id)); } } - Err(Error::UnsupportedPrUrl(repo_or_url)) + Err(Error::UnsupportedPRIssueUrl(repo_or_url)) } } } -async fn pr_check(bot: Bot, msg: Message, repo: String, pr_id: u64) -> Result<(), CommandError> { +async fn pr_issue_check(bot: Bot, msg: Message, repo: String, id: u64) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; let repo_resources = repo::resources(&repo).await?; - match chat::pr_check(&resources, &repo_resources, pr_id).await { - Ok(result) => match result { - PRCheckResult::Merged(commit) => { - reply_to_msg( - &bot, - &msg, - format!( - "pr {pr_id} has been merged \\(and removed\\)\ncommit `{commit}` added" - ), - ) - .parse_mode(ParseMode::MarkdownV2) - .await?; - commit_check(bot, msg, repo, commit).await - } - PRCheckResult::Closed => { - reply_to_msg( - &bot, - &msg, - format!("pr {pr_id} has been closed \\(and removed\\)"), - ) - .parse_mode(ParseMode::MarkdownV2) - .await?; - Ok(()) - } - PRCheckResult::Waiting => { - let mut send = - reply_to_msg(&bot, &msg, format!("pr {pr_id} has not been merged yet")) - .parse_mode(ParseMode::MarkdownV2); - send = try_attach_subscribe_button_markup( - msg.chat.id, - send, - "p", - &repo, - &pr_id.to_string(), - ); - send.await?; - Ok(()) + match chat::pr_issue_check(&resources, &repo_resources, id).await { + Ok(result) => { + let pretty_id = pr_issue_id_pretty(&repo_resources, id).await?; + match result { + PRIssueCheckResult::Merged(commit) => { + reply_to_msg( + &bot, + &msg, + format!( + "{pretty_id} has been merged \\(and removed\\)\ncommit `{commit}` added" + ), + ) + .parse_mode(ParseMode::MarkdownV2) + .await?; + commit_check(bot, msg, repo, commit).await + } + PRIssueCheckResult::Closed => { + reply_to_msg( + &bot, + &msg, + format!("{pretty_id} has been closed \\(and removed\\)"), + ) + .parse_mode(ParseMode::MarkdownV2) + .await?; + Ok(()) + } + PRIssueCheckResult::Waiting => { + let mut send = reply_to_msg( + &bot, + &msg, + format!("{pretty_id} has not been merged/closed yet"), + ) + .parse_mode(ParseMode::MarkdownV2); + send = try_attach_subscribe_button_markup( + msg.chat.id, + send, + "p", + &repo, + &id.to_string(), + ); + send.await?; + Ok(()) + } } - }, + } Err(Error::CommitExists(commit)) => commit_subscribe(bot, msg, repo, commit, false).await, Err(e) => Err(e.into()), } } -async fn pr_remove(bot: Bot, msg: Message, repo: String, pr_id: u64) -> Result<(), CommandError> { - let resources = chat::resources_msg_repo(&msg, repo).await?; - chat::pr_remove(&resources, pr_id).await?; - reply_to_msg(&bot, &msg, format!("pr {pr_id} removed")).await?; +async fn pr_issue_remove( + bot: Bot, + msg: Message, + repo: String, + id: u64, +) -> Result<(), CommandError> { + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; + let repo_resources = repo::resources(&repo).await?; + chat::pr_issue_remove(&resources, id).await?; + let pretty_id = pr_issue_id_pretty(&repo_resources, id).await?; + reply_to_msg(&bot, &msg, format!("{pretty_id} removed")).await?; Ok(()) } -async fn pr_subscribe( +async fn pr_issue_subscribe( bot: Bot, msg: Message, repo: String, @@ -806,7 +819,7 @@ async fn pr_subscribe( { let mut settings = resources.settings.write().await; let subscribers = &mut settings - .pull_requests + .pr_issues .get_mut(&pr_id) .ok_or_else(|| Error::UnknownPullRequest(pr_id))? .notify diff --git a/src/message.rs b/src/message.rs index 8e8f5e7..cb63423 100644 --- a/src/message.rs +++ b/src/message.rs @@ -5,9 +5,11 @@ use teloxide::{types::Message, utils::markdown}; use crate::{ chat::{ results::{BranchCheckResult, CommitCheckResult}, - settings::{BranchSettings, CommitSettings, PullRequestSettings, Subscriber}, + settings::{BranchSettings, CommitSettings, PRIssueSettings, Subscriber}, }, condition::Action, + error::Error, + repo::{pr_issue_url, resources::RepoResources}, utils::push_empty_line, }; @@ -79,25 +81,37 @@ pub fn commit_check_message_detail( ) } -pub fn pr_merged_message( - repo: &str, - pr: u64, - settings: &PullRequestSettings, +pub async fn pr_issue_id_pretty(resources: &RepoResources, id: u64) -> Result { + let url = pr_issue_url(resources, id).await?; + Ok(markdown::link( + url.as_ref(), + &format!("{repo}/{id}", repo = resources.name), + )) +} + +pub async fn pr_issue_merged_message( + resources: &RepoResources, + id: u64, + settings: &PRIssueSettings, commit: &String, -) -> String { - format!( - "{repo}/{pr} - merged as `{commit}`{notify} -", +) -> Result { + Ok(format!( + "{pretty_id} merged as `{commit}`{notify}", + pretty_id = pr_issue_id_pretty(resources, id).await?, notify = push_empty_line(&settings.notify.notify_markdown()), - ) + )) } -pub fn pr_closed_message(repo: &str, pr: u64, settings: &PullRequestSettings) -> String { - format!( - "{repo}/{pr} has been closed{notify}", +pub async fn pr_issue_closed_message( + resources: &RepoResources, + id: u64, + settings: &PRIssueSettings, +) -> Result { + Ok(format!( + "{pretty_id} has been closed{notify}", + pretty_id = pr_issue_id_pretty(resources, id).await?, notify = push_empty_line(&settings.notify.notify_markdown()), - ) + )) } pub fn branch_check_message( diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 806b7a1..f10d34d 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -11,9 +11,11 @@ use tokio::{ sync::Mutex, task, }; +use url::Url; use crate::{ error::Error, + github, repo::{ cache::batch_store_cache, paths::RepoPaths, @@ -371,3 +373,11 @@ pub async fn condition_remove(resources: &RepoResources, identifier: &str) -> Re } resources.save_settings().await } + +pub async fn pr_issue_url(resources: &RepoResources, id: u64) -> Result { + let locked = resources.settings.read().await; + match &locked.github_info { + Some(info) => Ok(github::get_issue(info, id).await?.html_url), + None => Err(Error::NoGitHubInfo(resources.name.clone())), + } +} diff --git a/src/update.rs b/src/update.rs index db7bb2e..a14bfa3 100644 --- a/src/update.rs +++ b/src/update.rs @@ -13,11 +13,14 @@ use crate::{ chat::{ self, resources::ChatRepoResources, - results::PRCheckResult, - settings::{BranchSettings, CommitSettings, PullRequestSettings}, + results::PRIssueCheckResult, + settings::{BranchSettings, CommitSettings, PRIssueSettings}, }, condition::Action, - message::{branch_check_message, commit_check_message, pr_closed_message, pr_merged_message}, + message::{ + branch_check_message, commit_check_message, pr_issue_closed_message, + pr_issue_merged_message, + }, options, repo::{self, resources::RepoResources}, try_attach_subscribe_button_markup, @@ -74,23 +77,23 @@ async fn update_chat_repo(bot: Bot, chat: ChatId, repo: &str) -> Result<(), Comm let repo_resources = repo::resources(repo).await?; // check pull requests before checking commits - let pull_requests = { + let pr_issues = { let settings = resources.settings.read().await; - settings.pull_requests.clone() + settings.pr_issues.clone() }; - for (pr, settings) in pull_requests { - if let Err(e) = update_chat_repo_pr( + for (id, settings) in pr_issues { + if let Err(e) = update_chat_repo_pr_issue( bot.clone(), &resources, &repo_resources, chat, repo, - pr, + id, &settings, ) .await { - log::error!("update error for PR ({chat}, {repo}, {pr}): {e}"); + log::error!("update error for PR ({chat}, {repo}, {id}): {e}"); } } @@ -140,33 +143,33 @@ async fn update_chat_repo(bot: Bot, chat: ChatId, repo: &str) -> Result<(), Comm Ok(()) } -async fn update_chat_repo_pr( +async fn update_chat_repo_pr_issue( bot: Bot, resources: &ChatRepoResources, repo_resources: &RepoResources, chat: ChatId, repo: &str, - pr: u64, - settings: &PullRequestSettings, + id: u64, + settings: &PRIssueSettings, ) -> Result<(), CommandError> { - let result = chat::pr_check(resources, repo_resources, pr).await?; - log::info!("finished pr check ({chat}, {repo}, {pr})"); + let result = chat::pr_issue_check(resources, repo_resources, id).await?; + log::info!("finished PR/issue check ({chat}, {repo}, {id})"); match result { - PRCheckResult::Merged(commit) => { - let message = pr_merged_message(repo, pr, settings, &commit); + PRIssueCheckResult::Merged(commit) => { + let message = pr_issue_merged_message(repo_resources, id, settings, &commit).await?; bot.send_message(chat, message) .parse_mode(ParseMode::MarkdownV2) .await?; Ok(()) } - PRCheckResult::Closed => { - let message = pr_closed_message(repo, pr, settings); + PRIssueCheckResult::Closed => { + let message = pr_issue_closed_message(repo_resources, id, settings).await?; bot.send_message(chat, message) .parse_mode(ParseMode::MarkdownV2) .await?; Ok(()) } - PRCheckResult::Waiting => Ok(()), + PRIssueCheckResult::Waiting => Ok(()), } } From 5c4b795fcdf6f847cc7ed999acbbf32aca4ff8e7 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 21:22:44 +0800 Subject: [PATCH 17/27] Add alias for pr_issues --- src/chat/settings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chat/settings.rs b/src/chat/settings.rs index bcdeae8..5e412cb 100644 --- a/src/chat/settings.rs +++ b/src/chat/settings.rs @@ -6,7 +6,7 @@ use url::Url; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ChatRepoSettings { - #[serde(default)] + #[serde(default, alias="pull_requests")] pub pr_issues: BTreeMap, #[serde(default)] pub commits: BTreeMap, From 704eeb73d9e9932613b1f403536957a11afa8abc Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 21:31:06 +0800 Subject: [PATCH 18/27] Refactor more "pull requests" to "pr/issues" --- src/chat/mod.rs | 8 ++++---- src/chat/settings.rs | 2 +- src/error.rs | 8 ++++---- src/main.rs | 12 ++++++------ src/update.rs | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 61f91ad..77be45e 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -218,7 +218,7 @@ pub async fn pr_issue_add( { let mut locked = resources.settings.write().await; if locked.pr_issues.contains_key(&id) { - return Err(Error::PullRequestExists(id)); + return Err(Error::PRIssueExists(id)); } locked.pr_issues.insert(id, settings); } @@ -229,7 +229,7 @@ pub async fn pr_issue_remove(resources: &ChatRepoResources, id: u64) -> Result<( { let mut locked = resources.settings.write().await; if !locked.pr_issues.contains_key(&id) { - return Err(Error::UnknownPullRequest(id)); + return Err(Error::UnknownPRIssue(id)); } locked.pr_issues.remove(&id); } @@ -256,7 +256,7 @@ pub async fn pr_issue_check( locked .pr_issues .remove(&id) - .ok_or(Error::UnknownPullRequest(id))?; + .ok_or(Error::UnknownPRIssue(id))?; }; resources.save_settings().await?; return Ok(PRIssueCheckResult::Closed); @@ -268,7 +268,7 @@ pub async fn pr_issue_check( locked .pr_issues .remove(&id) - .ok_or(Error::UnknownPullRequest(id))? + .ok_or(Error::UnknownPRIssue(id))? }; resources.save_settings().await?; let commit = merged_pr_to_commit(resources, github_info, id, settings).await?; diff --git a/src/chat/settings.rs b/src/chat/settings.rs index 5e412cb..f2d665c 100644 --- a/src/chat/settings.rs +++ b/src/chat/settings.rs @@ -6,7 +6,7 @@ use url::Url; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ChatRepoSettings { - #[serde(default, alias="pull_requests")] + #[serde(default, alias = "pull_requests")] pub pr_issues: BTreeMap, #[serde(default)] pub commits: BTreeMap, diff --git a/src/error.rs b/src/error.rs index a01abc8..119b6b3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -54,16 +54,16 @@ pub enum Error { Serde(#[from] serde_json::Error), #[error("unknown commit: '{0}'")] UnknownCommit(String), - #[error("unknown pull request: '{0}'")] - UnknownPullRequest(u64), + #[error("unknown PR/issue: '{0}'")] + UnknownPRIssue(u64), #[error("unknown branch: '{0}'")] UnknownBranch(String), #[error("unknown repository: '{0}'")] UnknownRepository(String), #[error("commit already exists: '{0}'")] CommitExists(String), - #[error("pull request already exists: '{0}'")] - PullRequestExists(u64), + #[error("PR/issue already exists: '{0}'")] + PRIssueExists(u64), #[error("branch already exists: '{0}'")] BranchExists(String), #[error("invalid os string: '{0:?}'")] diff --git a/src/main.rs b/src/main.rs index 85b66e8..3a6119e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -259,14 +259,14 @@ async fn handle_callback_query_command_result( resources.save_settings().await?; } "p" => { - let pr_id: u64 = id.parse().map_err(Error::ParseInt)?; + let issue_id: u64 = id.parse().map_err(Error::ParseInt)?; let resources = chat::resources_chat_repo(chat_id, repo.clone()).await?; { let mut settings = resources.settings.write().await; let subscribers = &mut settings .pr_issues - .get_mut(&pr_id) - .ok_or_else(|| Error::UnknownPullRequest(pr_id))? + .get_mut(&issue_id) + .ok_or_else(|| Error::UnknownPRIssue(issue_id))? .notify .subscribers; modify_subscriber_set(subscribers, subscriber, unsubscribe)?; @@ -416,7 +416,7 @@ async fn list_for_normal(bot: Bot, msg: &Message) -> Result<(), CommandError> { settings.notify.description_markdown() )); } - result.push_str(" *pull requests*:\n"); + result.push_str(" *PRs/issues*:\n"); let pr_issues = &settings.pr_issues; if pr_issues.is_empty() { result.push_str(" \\(nothing\\)\n"); @@ -693,7 +693,7 @@ async fn pr_issue_add( Ok(()) => { pr_issue_check(bot, msg, repo, id).await?; } - Err(Error::PullRequestExists(_)) => { + Err(Error::PRIssueExists(_)) => { pr_issue_subscribe(bot.clone(), msg.clone(), repo.clone(), id, false).await?; } Err(e) => return Err(e.into()), @@ -821,7 +821,7 @@ async fn pr_issue_subscribe( let subscribers = &mut settings .pr_issues .get_mut(&pr_id) - .ok_or_else(|| Error::UnknownPullRequest(pr_id))? + .ok_or_else(|| Error::UnknownPRIssue(pr_id))? .notify .subscribers; modify_subscriber_set(subscribers, subscriber, unsubscribe)?; diff --git a/src/update.rs b/src/update.rs index a14bfa3..c771442 100644 --- a/src/update.rs +++ b/src/update.rs @@ -76,7 +76,7 @@ async fn update_chat_repo(bot: Bot, chat: ChatId, repo: &str) -> Result<(), Comm let resources = chat::resources_chat_repo(chat, repo.to_string()).await?; let repo_resources = repo::resources(repo).await?; - // check pull requests before checking commits + // check pull requests/issues before checking commits let pr_issues = { let settings = resources.settings.read().await; settings.pr_issues.clone() From fcdfc05f688ec9ff9802f74315fa35bfbe9b2b53 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Mon, 22 Sep 2025 21:33:15 +0800 Subject: [PATCH 19/27] Improve message format --- src/chat/mod.rs | 4 ++-- src/main.rs | 12 ++++++++++-- src/message.rs | 10 +++++----- src/utils.rs | 4 ++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 77be45e..5b1ff2b 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -17,7 +17,7 @@ use crate::{ error::Error, github::{self, GitHubInfo}, repo::{cache::query_cache_commit, resources::RepoResources}, - utils::push_empty_line, + utils::empty_or_start_new_line, }; pub mod paths; @@ -290,7 +290,7 @@ pub async fn merged_pr_to_commit( let comment = format!( "{title}{comment}", title = pr.title.as_deref().unwrap_or("untitled"), - comment = push_empty_line(&settings.notify.comment), + comment = empty_or_start_new_line(&settings.notify.comment), ); let commit_settings = CommitSettings { url: Some(settings.url), diff --git a/src/main.rs b/src/main.rs index 3a6119e..44b2b00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -372,9 +372,17 @@ async fn list_for_admin(bot: Bot, msg: &Message) -> Result<(), CommandError> { let repos = repo::list().await?; for repo in repos { - result.push('*'); + result.push_str("\\- *"); result.push_str(&markdown::escape(&repo)); result.push_str("*\n"); + let settings_json = { + let resources = repo::resources(&repo).await?; + let settings = resources.settings.read().await; + serde_json::to_string(&*settings).map_err(Error::Serde)? + }; + result.push_str(" "); + result.push_str(&markdown::escape(&settings_json)); + result.push('\n'); } if result.is_empty() { result.push_str("(nothing)\n"); @@ -441,7 +449,7 @@ async fn list_for_normal(bot: Bot, msg: &Message) -> Result<(), CommandError> { if result.is_empty() { result.push_str("\\(nothing\\)\n"); } - reply_to_msg(&bot, msg, result) + reply_to_msg(&bot, msg, markdown::expandable_blockquote(&result)) .parse_mode(ParseMode::MarkdownV2) .disable_link_preview(true) .await?; diff --git a/src/message.rs b/src/message.rs index cb63423..bc07823 100644 --- a/src/message.rs +++ b/src/message.rs @@ -10,7 +10,7 @@ use crate::{ condition::Action, error::Error, repo::{pr_issue_url, resources::RepoResources}, - utils::push_empty_line, + utils::empty_or_start_new_line, }; pub fn commit_check_message( @@ -75,7 +75,7 @@ pub fn commit_check_message_detail( .as_ref() .map(|u| format!("\n{}", markdown::escape(u.as_str()))) .unwrap_or_default(), - notify = push_empty_line(&settings.notify.notify_markdown()), + notify = empty_or_start_new_line(&settings.notify.notify_markdown()), new = markdown_list(result.new.iter()), all = markdown_list(result.all.iter()) ) @@ -98,7 +98,7 @@ pub async fn pr_issue_merged_message( Ok(format!( "{pretty_id} merged as `{commit}`{notify}", pretty_id = pr_issue_id_pretty(resources, id).await?, - notify = push_empty_line(&settings.notify.notify_markdown()), + notify = empty_or_start_new_line(&settings.notify.notify_markdown()), )) } @@ -110,7 +110,7 @@ pub async fn pr_issue_closed_message( Ok(format!( "{pretty_id} has been closed{notify}", pretty_id = pr_issue_id_pretty(resources, id).await?, - notify = push_empty_line(&settings.notify.notify_markdown()), + notify = empty_or_start_new_line(&settings.notify.notify_markdown()), )) } @@ -140,7 +140,7 @@ pub fn branch_check_message( ", repo = markdown::escape(repo), branch = markdown::escape(branch), - notify = push_empty_line(&settings.notify.notify_markdown()), + notify = empty_or_start_new_line(&settings.notify.notify_markdown()), ) } diff --git a/src/utils.rs b/src/utils.rs index 63cc5d6..5a07e54 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -21,12 +21,12 @@ where .reply_parameters(ReplyParameters::new(msg.id)) } -pub fn push_empty_line(s: &str) -> String { +pub fn empty_or_start_new_line(s: &str) -> String { let trimmed = s.trim().to_string(); if trimmed.is_empty() { trimmed } else { - let mut result = "\n\n".to_string(); + let mut result = "\n".to_string(); result.push_str(&trimmed); result } From 61c07c9103fc2e9cbea7e550a15f8ff51097b560 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Tue, 23 Sep 2025 15:01:52 +0800 Subject: [PATCH 20/27] Fix pr check logic --- src/chat/mod.rs | 21 +++++++++++---------- src/github.rs | 6 +----- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/chat/mod.rs b/src/chat/mod.rs index 5b1ff2b..cae911b 100644 --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -1,6 +1,7 @@ use std::{collections::BTreeSet, fmt, sync::Arc}; use git2::BranchType; +use octocrab::models::IssueState; use teloxide::types::{ChatId, Message}; use tokio::{fs::read_dir, sync::Mutex}; @@ -250,29 +251,29 @@ pub async fn pr_issue_check( .ok_or(Error::NoGitHubInfo(resources.task.repo.clone()))? }; log::debug!("checking PR/issue {github_info}#{id}"); - if github::is_closed(&github_info, id).await? { - { + let issue = github::get_issue(&github_info, id).await?; + if issue.pull_request.is_some() && github::is_merged(&github_info, id).await? { + let settings = { let mut locked = resources.settings.write().await; locked .pr_issues .remove(&id) - .ok_or(Error::UnknownPRIssue(id))?; + .ok_or(Error::UnknownPRIssue(id))? }; resources.save_settings().await?; - return Ok(PRIssueCheckResult::Closed); + let commit = merged_pr_to_commit(resources, github_info, id, settings).await?; + return Ok(PRIssueCheckResult::Merged(commit)); } - let issue = github::get_issue(&github_info, id).await?; - if issue.pull_request.is_some() && github::is_merged(&github_info, id).await? { - let settings = { + if issue.state == IssueState::Closed { + { let mut locked = resources.settings.write().await; locked .pr_issues .remove(&id) - .ok_or(Error::UnknownPRIssue(id))? + .ok_or(Error::UnknownPRIssue(id))?; }; resources.save_settings().await?; - let commit = merged_pr_to_commit(resources, github_info, id, settings).await?; - return Ok(PRIssueCheckResult::Merged(commit)); + return Ok(PRIssueCheckResult::Closed); } Ok(PRIssueCheckResult::Waiting) } diff --git a/src/github.rs b/src/github.rs index ff6468c..027f703 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,6 +1,6 @@ use std::fmt::{self, Display}; -use octocrab::models::{IssueState, issues::Issue, pulls::PullRequest}; +use octocrab::models::{issues::Issue, pulls::PullRequest}; use once_cell::sync::Lazy; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -77,10 +77,6 @@ pub async fn is_merged(info: &GitHubInfo, pr_id: u64) -> Result { .map_err(Box::new)?) } -pub async fn is_closed(info: &GitHubInfo, pr_id: u64) -> Result { - Ok(get_issue(info, pr_id).await?.state == IssueState::Closed) -} - pub async fn get_issue(info: &GitHubInfo, id: u64) -> Result { Ok(octocrab::instance() .issues(&info.owner, &info.repo) From 3c4a7872567e1d900266c5438eec6db22fac92ad Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Tue, 23 Sep 2025 16:19:23 +0800 Subject: [PATCH 21/27] Improve command interface --- src/command.rs | 25 +++++-- src/main.rs | 195 +++++++++++++++++++++++++++---------------------- src/message.rs | 8 +- src/utils.rs | 74 +++++++++++++++++++ 4 files changed, 204 insertions(+), 98 deletions(-) diff --git a/src/command.rs b/src/command.rs index a52df92..675cf70 100644 --- a/src/command.rs +++ b/src/command.rs @@ -48,21 +48,30 @@ pub enum Notifier { #[arg(short, long)] unsubscribe: bool, }, - #[command(alias("issue-add"), about = "add a pull request/issue")] + #[command(visible_alias("issue-add"), about = "add a pull request/issue")] PrAdd { repo_or_url: String, id: Option, #[arg(long, short)] comment: Option, }, - #[command(alias("issue-remove"), about = "remove a pull request/issue")] - PrRemove { repo: String, id: u64 }, - #[command(alias("issue-check"), about = "check a pull request/issue")] - PrCheck { repo: String, id: u64 }, - #[command(alias("issue-subscribe"), about = "subscribe to a pull request/issue")] + #[command(visible_alias("issue-remove"), about = "remove a pull request/issue")] + PrRemove { + repo_or_url: String, + id: Option, + }, + #[command(visible_alias("issue-check"), about = "check a pull request/issue")] + PrCheck { + repo_or_url: String, + id: Option, + }, + #[command( + visible_alias("issue-subscribe"), + about = "subscribe to a pull request/issue" + )] PrSubscribe { - repo: String, - id: u64, + repo_or_url: String, + id: Option, #[arg(short, long)] unsubscribe: bool, }, diff --git a/src/main.rs b/src/main.rs index 44b2b00..911f992 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,6 @@ use std::collections::BTreeSet; use std::env; use std::fmt; use std::str::FromStr; -use std::sync::LazyLock; use chrono::Utc; use cron::Schedule; @@ -58,6 +57,9 @@ use crate::repo::settings::ConditionSettings; use crate::update::update_and_report_error; use crate::utils::modify_subscriber_set; use crate::utils::reply_to_msg; +use crate::utils::report_command_error; +use crate::utils::report_error; +use crate::utils::resolve_repo_or_url_and_id; #[derive(BotCommands, Clone, Debug)] #[command(rename_rule = "lowercase", description = "Supported commands:")] @@ -148,16 +150,50 @@ async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { repo_or_url, id, comment, - } => pr_issue_add(bot, msg, repo_or_url, id, comment).await, - command::Notifier::PrRemove { repo, id } => { - pr_issue_remove(bot, msg, repo, id).await - } - command::Notifier::PrCheck { repo, id } => pr_issue_check(bot, msg, repo, id).await, + } => match report_error( + &bot, + &msg, + resolve_repo_or_url_and_id(repo_or_url, id).await, + ) + .await? + { + Some((repo, id)) => pr_issue_add(bot, msg, repo, id, comment).await, + None => Ok(()), + }, + command::Notifier::PrRemove { repo_or_url, id } => match report_error( + &bot, + &msg, + resolve_repo_or_url_and_id(repo_or_url, id).await, + ) + .await? + { + Some((repo, id)) => pr_issue_remove(bot, msg, repo, id).await, + None => Ok(()), + }, + command::Notifier::PrCheck { repo_or_url, id } => match report_error( + &bot, + &msg, + resolve_repo_or_url_and_id(repo_or_url, id).await, + ) + .await? + { + Some((repo, id)) => pr_issue_check(bot, msg, repo, id).await, + None => Ok(()), + }, command::Notifier::PrSubscribe { - repo, + repo_or_url, id, unsubscribe, - } => pr_issue_subscribe(bot, msg, repo, id, unsubscribe).await, + } => match report_error( + &bot, + &msg, + resolve_repo_or_url_and_id(repo_or_url, id).await, + ) + .await? + { + Some((repo, id)) => pr_issue_subscribe(bot, msg, repo, id, unsubscribe).await, + None => Ok(()), + }, command::Notifier::BranchAdd { repo, branch } => { branch_add(bot, msg, repo, branch).await } @@ -186,15 +222,8 @@ async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { } Err(e) => Err(e.into()), }; - match result { - Ok(()) => Ok(()), - Err(CommandError::Normal(e)) => { - // report normal errors to user - e.report(&bot, &msg).await?; - Ok(()) - } - Err(CommandError::Teloxide(e)) => Err(e), - } + report_command_error(&bot, &msg, result).await?; + Ok(()) } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -588,7 +617,7 @@ async fn commit_add( url: Option, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; - let _guard = resources.commit_lock(hash.clone()).await; + let guard = resources.commit_lock(hash.clone()).await; let subscribers = subscriber_from_msg(&msg).into_iter().collect(); let settings = CommitSettings { url, @@ -599,10 +628,22 @@ async fn commit_add( }; match chat::commit_add(&resources, &hash, settings).await { Ok(()) => { + drop(guard); commit_check(bot, msg, repo, hash).await?; } Err(Error::CommitExists(_)) => { - commit_subscribe(bot.clone(), msg.clone(), repo.clone(), hash.clone(), false).await?; + { + let mut settings = resources.settings.write().await; + settings + .commits + .get_mut(&hash) + .ok_or_else(|| Error::UnknownCommit(hash.clone()))? + .notify + .subscribers + .extend(subscriber_from_msg(&msg)); + } + drop(guard); + commit_check(bot, msg, repo, hash).await?; } Err(e) => return Err(e.into()), } @@ -680,11 +721,10 @@ async fn commit_subscribe( async fn pr_issue_add( bot: Bot, msg: Message, - repo_or_url: String, - optional_id: Option, + repo: String, + id: u64, optional_comment: Option, ) -> Result<(), CommandError> { - let (repo, id) = resolve_pr_repo_or_url(repo_or_url, optional_id).await?; let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; let repo_resources = repo::resources(&repo).await?; let url = pr_issue_url(&repo_resources, id).await?; @@ -698,53 +738,21 @@ async fn pr_issue_add( }, }; match chat::pr_issue_add(&resources, &repo_resources, id, settings).await { - Ok(()) => { - pr_issue_check(bot, msg, repo, id).await?; - } + Ok(()) => pr_issue_check(bot, msg, repo, id).await, Err(Error::PRIssueExists(_)) => { - pr_issue_subscribe(bot.clone(), msg.clone(), repo.clone(), id, false).await?; - } - Err(e) => return Err(e.into()), - }; - Ok(()) -} - -async fn resolve_pr_repo_or_url( - repo_or_url: String, - pr_id: Option, -) -> Result<(String, u64), Error> { - static GITHUB_URL_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r#"https://github\.com/([^/]+)/([^/]+)/(pull|issues)/(\d+)"#).unwrap() - }); - match pr_id { - Some(id) => Ok((repo_or_url, id)), - None => { - if let Some(captures) = GITHUB_URL_REGEX.captures(&repo_or_url) { - let owner = &captures[1]; - let repo = &captures[2]; - let github_info = GitHubInfo::new(owner.to_string(), repo.to_string()); - log::trace!("PR/issue id to parse: {}", &captures[4]); - let id: u64 = captures[4].parse().map_err(Error::ParseInt)?; - let repos = repo::list().await?; - let mut repos_found = Vec::new(); - for repo in repos { - let resources = repo::resources(&repo).await?; - let repo_github_info = &resources.settings.read().await.github_info; - if repo_github_info.as_ref() == Some(&github_info) { - repos_found.push(repo); - } - } - if repos_found.is_empty() { - return Err(Error::NoRepoHaveGitHubInfo(github_info)); - } else if repos_found.len() != 1 { - return Err(Error::MultipleReposHaveSameGitHubInfo(repos_found)); - } else { - let repo = repos_found.pop().unwrap(); - return Ok((repo, id)); - } + { + let mut settings = resources.settings.write().await; + settings + .pr_issues + .get_mut(&id) + .ok_or_else(|| Error::UnknownPRIssue(id))? + .notify + .subscribers + .extend(subscriber_from_msg(&msg)); } - Err(Error::UnsupportedPRIssueUrl(repo_or_url)) + pr_issue_check(bot, msg, repo, id).await } + Err(e) => Err(e.into()), } } @@ -755,18 +763,7 @@ async fn pr_issue_check(bot: Bot, msg: Message, repo: String, id: u64) -> Result Ok(result) => { let pretty_id = pr_issue_id_pretty(&repo_resources, id).await?; match result { - PRIssueCheckResult::Merged(commit) => { - reply_to_msg( - &bot, - &msg, - format!( - "{pretty_id} has been merged \\(and removed\\)\ncommit `{commit}` added" - ), - ) - .parse_mode(ParseMode::MarkdownV2) - .await?; - commit_check(bot, msg, repo, commit).await - } + PRIssueCheckResult::Merged(commit) => commit_check(bot, msg, repo, commit).await, PRIssueCheckResult::Closed => { reply_to_msg( &bot, @@ -796,7 +793,19 @@ async fn pr_issue_check(bot: Bot, msg: Message, repo: String, id: u64) -> Result } } } - Err(Error::CommitExists(commit)) => commit_subscribe(bot, msg, repo, commit, false).await, + Err(Error::CommitExists(commit)) => { + { + let mut settings = resources.settings.write().await; + settings + .commits + .get_mut(&commit) + .ok_or_else(|| Error::UnknownCommit(commit.clone()))? + .notify + .subscribers + .extend(subscriber_from_msg(&msg)); + } + commit_check(bot, msg, repo, commit).await + } Err(e) => Err(e.into()), } } @@ -811,7 +820,9 @@ async fn pr_issue_remove( let repo_resources = repo::resources(&repo).await?; chat::pr_issue_remove(&resources, id).await?; let pretty_id = pr_issue_id_pretty(&repo_resources, id).await?; - reply_to_msg(&bot, &msg, format!("{pretty_id} removed")).await?; + reply_to_msg(&bot, &msg, format!("{pretty_id} removed")) + .parse_mode(ParseMode::MarkdownV2) + .await?; Ok(()) } @@ -819,7 +830,7 @@ async fn pr_issue_subscribe( bot: Bot, msg: Message, repo: String, - pr_id: u64, + id: u64, unsubscribe: bool, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo).await?; @@ -828,8 +839,8 @@ async fn pr_issue_subscribe( let mut settings = resources.settings.write().await; let subscribers = &mut settings .pr_issues - .get_mut(&pr_id) - .ok_or_else(|| Error::UnknownPRIssue(pr_id))? + .get_mut(&id) + .ok_or_else(|| Error::UnknownPRIssue(id))? .notify .subscribers; modify_subscriber_set(subscribers, subscriber, unsubscribe)?; @@ -846,13 +857,19 @@ async fn branch_add( branch: String, ) -> Result<(), CommandError> { let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; - let _guard = resources.branch_lock(branch.clone()).await; + let guard = resources.branch_lock(branch.clone()).await; let settings = BranchSettings { notify: Default::default(), }; match chat::branch_add(&resources, &branch, settings).await { - Ok(()) => branch_check(bot, msg, repo, branch).await, - Err(Error::BranchExists(_)) => branch_subscribe(bot, msg, repo, branch, false).await, + Ok(()) => { + drop(guard); + branch_check(bot, msg, repo, branch).await + } + Err(Error::BranchExists(_)) => { + drop(guard); + branch_subscribe(bot, msg, repo, branch, false).await + } Err(e) => Err(e.into()), } } @@ -863,10 +880,12 @@ async fn branch_remove( repo: String, branch: String, ) -> Result<(), CommandError> { - let resources = chat::resources_msg_repo(&msg, repo).await?; + let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; let _guard = resources.branch_lock(branch.clone()).await; chat::branch_remove(&resources, &branch).await?; - reply_to_msg(&bot, &msg, format!("branch {branch} removed")).await?; + reply_to_msg(&bot, &msg, format!("branch `{repo}`/`{branch}` removed")) + .parse_mode(ParseMode::MarkdownV2) + .await?; Ok(()) } diff --git a/src/message.rs b/src/message.rs index bc07823..43e51ab 100644 --- a/src/message.rs +++ b/src/message.rs @@ -34,10 +34,14 @@ pub fn commit_check_message_summary( settings: &CommitSettings, result: &CommitCheckResult, ) -> String { + let escaped_comment = markdown::escape(&settings.notify.comment); + let comment_link = match &settings.url { + Some(url) => markdown::link(url.as_ref(), &escaped_comment), + None => escaped_comment, + }; format!( - "\\[{repo}\\] {comment} \\+{new}", + "\\[{repo}\\] {comment_link} \\+{new}", repo = markdown::escape(repo), - comment = markdown::escape(&settings.notify.comment), new = markdown_list_compat(result.new.iter()), ) } diff --git a/src/utils.rs b/src/utils.rs index 5a07e54..8487c27 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,8 +3,10 @@ use std::fmt; use std::fs::{File, OpenOptions}; use std::io::{BufReader, BufWriter}; use std::path::Path; +use std::sync::LazyLock; use fs4::fs_std::FileExt; +use regex::Regex; use serde::Serialize; use serde::de::DeserializeOwned; use teloxide::types::ReplyParameters; @@ -12,6 +14,39 @@ use teloxide::{payloads::SendMessage, prelude::*, requests::JsonRequest}; use crate::chat::settings::Subscriber; use crate::error::Error; +use crate::github::GitHubInfo; +use crate::{CommandError, repo}; + +pub async fn report_error( + bot: &Bot, + msg: &Message, + result: Result, +) -> Result, teloxide::RequestError> { + match result { + Ok(r) => Ok(Some(r)), + Err(e) => { + // report normal errors to user + e.report(bot, msg).await?; + Ok(None) + } + } +} + +pub async fn report_command_error( + bot: &Bot, + msg: &Message, + result: Result, +) -> Result, teloxide::RequestError> { + match result { + Ok(r) => Ok(Some(r)), + Err(CommandError::Normal(e)) => { + // report normal errors to user + e.report(bot, msg).await?; + Ok(None) + } + Err(CommandError::Teloxide(e)) => Err(e), + } +} pub fn reply_to_msg(bot: &Bot, msg: &Message, text: T) -> JsonRequest where @@ -83,3 +118,42 @@ pub fn modify_subscriber_set( } Ok(()) } + +pub async fn resolve_repo_or_url_and_id( + repo_or_url: String, + pr_id: Option, +) -> Result<(String, u64), Error> { + static GITHUB_URL_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"https://github\.com/([^/]+)/([^/]+)/(pull|issues)/(\d+)"#).unwrap() + }); + match pr_id { + Some(id) => Ok((repo_or_url, id)), + None => { + if let Some(captures) = GITHUB_URL_REGEX.captures(&repo_or_url) { + let owner = &captures[1]; + let repo = &captures[2]; + let github_info = GitHubInfo::new(owner.to_string(), repo.to_string()); + log::trace!("PR/issue id to parse: {}", &captures[4]); + let id: u64 = captures[4].parse().map_err(Error::ParseInt)?; + let repos = repo::list().await?; + let mut repos_found = Vec::new(); + for repo in repos { + let resources = repo::resources(&repo).await?; + let repo_github_info = &resources.settings.read().await.github_info; + if repo_github_info.as_ref() == Some(&github_info) { + repos_found.push(repo); + } + } + if repos_found.is_empty() { + return Err(Error::NoRepoHaveGitHubInfo(github_info)); + } else if repos_found.len() != 1 { + return Err(Error::MultipleReposHaveSameGitHubInfo(repos_found)); + } else { + let repo = repos_found.pop().unwrap(); + return Ok((repo, id)); + } + } + Err(Error::UnsupportedPRIssueUrl(repo_or_url)) + } + } +} From a44f2a15ebb55e9443f92810c8574ccac98c3397 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Tue, 23 Sep 2025 16:33:04 +0800 Subject: [PATCH 22/27] Better help text --- src/command.rs | 25 +++++++++++++++++++------ src/main.rs | 14 ++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/command.rs b/src/command.rs index 675cf70..cdc7512 100644 --- a/src/command.rs +++ b/src/command.rs @@ -5,13 +5,26 @@ use clap::ColorChoice; use clap::Parser; use std::{ffi::OsString, iter}; +const HELP_TEMPLATE: &str = "\ +{before-help}{name} {version} +{author-with-newline}{about} +{usage-heading} {usage} + +{all-args}{after-help} +"; + #[derive(Debug, Parser)] -#[command(name = "/notifier", - author, - version, - about, - color = ColorChoice::Never, - no_binary_name = true, +#[command( + name = "/notifier", + author, + version, + about, + color = ColorChoice::Never, + no_binary_name = true, + propagate_version = true, + infer_long_args = true, + infer_subcommands = true, + help_template = HELP_TEMPLATE )] pub enum Notifier { #[command(about = "return current chat id")] diff --git a/src/main.rs b/src/main.rs index 911f992..0ba7e80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -220,6 +220,20 @@ async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { command::Notifier::List => list(bot, msg).await, } } + Err(Error::Clap(e)) + if e.kind() == clap::error::ErrorKind::DisplayHelp + || e.kind() == clap::error::ErrorKind::DisplayHelp => + { + let help_text = e.render().to_string(); + reply_to_msg( + &bot, + &msg, + markdown::expandable_blockquote(&markdown::escape(&help_text)), + ) + .parse_mode(ParseMode::MarkdownV2) + .await?; + Ok(()) + } Err(e) => Err(e.into()), }; report_command_error(&bot, &msg, result).await?; From 2f3f30a5b568d2b2b7cb97aaa60a160c2118674b Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Tue, 23 Sep 2025 16:34:35 +0800 Subject: [PATCH 23/27] flake.lock: Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flake lock file updates: • Updated input 'nixpkgs': 'github:nixos/nixpkgs/0147c2f1d54b30b5dd6d4a8c8542e8d7edf93b5d?narHash=sha256-7To75JlpekfUmdkUZewnT6MoBANS0XVypW6kjUOXQwc%3D' (2025-09-18) → 'github:nixos/nixpkgs/554be6495561ff07b6c724047bdd7e0716aa7b46?narHash=sha256-pHpxZ/IyCwoTQPtFIAG2QaxuSm8jWzrzBGjwQZIttJc%3D' (2025-09-21) • Updated input 'rust-overlay': 'github:oxalica/rust-overlay/e26a009e7edab102bd569dc041459deb6c0009f4?narHash=sha256-bg228atm49IZ8koNOlT3bsrFKE9sFjq6vn6Tx8eVgpc%3D' (2025-09-19) → 'github:oxalica/rust-overlay/96722b8da34a7d796668b9a1cbcb7e799cc524b5?narHash=sha256-loYxdliGF/ytyAorc36Tt/PwBpc2rAfMSJycNxc2oeg%3D' (2025-09-23) --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index c8a7dfd..612d5ea 100644 --- a/flake.lock +++ b/flake.lock @@ -57,11 +57,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1758198701, - "narHash": "sha256-7To75JlpekfUmdkUZewnT6MoBANS0XVypW6kjUOXQwc=", + "lastModified": 1758427187, + "narHash": "sha256-pHpxZ/IyCwoTQPtFIAG2QaxuSm8jWzrzBGjwQZIttJc=", "owner": "nixos", "repo": "nixpkgs", - "rev": "0147c2f1d54b30b5dd6d4a8c8542e8d7edf93b5d", + "rev": "554be6495561ff07b6c724047bdd7e0716aa7b46", "type": "github" }, "original": { @@ -89,11 +89,11 @@ ] }, "locked": { - "lastModified": 1758249250, - "narHash": "sha256-bg228atm49IZ8koNOlT3bsrFKE9sFjq6vn6Tx8eVgpc=", + "lastModified": 1758594771, + "narHash": "sha256-loYxdliGF/ytyAorc36Tt/PwBpc2rAfMSJycNxc2oeg=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "e26a009e7edab102bd569dc041459deb6c0009f4", + "rev": "96722b8da34a7d796668b9a1cbcb7e799cc524b5", "type": "github" }, "original": { From 1a729d1e384ff5ba6f5517058ff22355e58d9d11 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Tue, 23 Sep 2025 16:35:42 +0800 Subject: [PATCH 24/27] Bump version and cargo update --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef8d2ad..235e9a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,7 +268,7 @@ dependencies = [ [[package]] name = "commit-notifier" -version = "0.1.3" +version = "0.2.0" dependencies = [ "chrono", "clap", @@ -1827,7 +1827,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.4.0", + "security-framework 3.5.0", ] [[package]] @@ -1925,9 +1925,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" +checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" dependencies = [ "bitflags", "core-foundation 0.10.1", @@ -1948,9 +1948,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.225" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" dependencies = [ "serde_core", "serde_derive", @@ -1958,18 +1958,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.225" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.225" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", @@ -2270,9 +2270,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.22.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.3", diff --git a/Cargo.toml b/Cargo.toml index 726b371..05f4c14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "commit-notifier" authors = [ "Lin Yinfeng " ] -version = "0.1.3" +version = "0.2.0" edition = "2024" description = """ -A simple bot tracking commits in branches +A simple bot tracking git commits/PRs/issues/branches """ # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html From 16d53254ea980aa5ccdf8660ed85dea56d5f1c8a Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Tue, 23 Sep 2025 16:43:09 +0800 Subject: [PATCH 25/27] Report all clap errors in expandable blockquote --- src/main.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0ba7e80..b2a737b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -220,10 +220,7 @@ async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { command::Notifier::List => list(bot, msg).await, } } - Err(Error::Clap(e)) - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelp => - { + Err(Error::Clap(e)) => { let help_text = e.render().to_string(); reply_to_msg( &bot, From a01c3b917a8e4db433b101215ef042eef1fa591c Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Tue, 23 Sep 2025 16:52:37 +0800 Subject: [PATCH 26/27] Cleanup dependencies --- Cargo.lock | 1 - Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 235e9a5..2a29201 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -284,7 +284,6 @@ dependencies = [ "pretty_env_logger", "regex", "rusqlite", - "scopeguard", "serde", "serde_json", "serde_regex", diff --git a/Cargo.toml b/Cargo.toml index 05f4c14..67defe3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,5 +30,4 @@ once_cell = "*" fs4 = "*" octocrab = "*" url = "*" -scopeguard = "*" lockable = "*" From 25eb1d223f12b46b8151218bd051a11a552af627 Mon Sep 17 00:00:00 2001 From: Lin Yinfeng Date: Tue, 23 Sep 2025 17:58:54 +0800 Subject: [PATCH 27/27] Basic migration guide and version check --- Cargo.lock | 7 +++++ Cargo.toml | 1 + README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++----- src/error.rs | 6 +++++ src/main.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++ src/utils.rs | 13 +++++++++ 6 files changed, 163 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a29201..78bd4d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,6 +291,7 @@ dependencies = [ "thiserror", "tokio", "url", + "version-compare", ] [[package]] @@ -2575,6 +2576,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 67defe3..76a8ed6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,4 @@ fs4 = "*" octocrab = "*" url = "*" lockable = "*" +version-compare = "*" diff --git a/README.md b/README.md index facba4f..12d3081 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ A simple telegram bot monitoring commit status. ```console $ commit-notifier" \ --working-dir /var/lib/commit-notifier \ - --cron "0 */5 * * * *" + --cron "0 */5 * * * *" \ + --admin-chat-id="{YOUR_ADMIN_CHAT_ID}" ``` Automatic check will be triggered based on the cron expression. In the example, `0 */5 * * * *` means "at every 5th minute". cron documentation: . @@ -44,6 +45,7 @@ My instance: + An example configuration for nixpkgs + + ```json + { + "branch_regex": "^(master|nixos-unstable|nixpkgs-unstable|staging|release-\\d\\d\\.\\d\\d|nixos-\\d\\d\\.\\d\\d)$", + "github_info": { + "owner": "nixos", + "repo": "nixpkgs" + }, + "conditions": { + "in-nixos-release": { + "condition": { + "InBranch": { + "branch_regex": "^nixos-\\d\\d\\.\\d\\d$" + } + } + }, + "in-nixos-unstable": { + "condition": { + "InBranch": { + "branch_regex": "^nixos-unstable$" + } + } + }, + "master-to-staging": { + "condition": { + "SuppressFromTo": { + "from_regex": "main", + "to_regex": "staging(-next)?" + } + } + } + } + } + ``` + + + +6. Wait for the first update (first-time cache building can be slow). Restart the bot to trigger update immediately. +7. Restore chat configurations. `rsync --recursive {BACKUP_DIR}/ {WORKING_DIR}/chats/ --exclude cache.sqlite --exclude lock --exclude repo --verbose` (trailing `/` is important.) diff --git a/src/error.rs b/src/error.rs index 119b6b3..168be92 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,6 +9,12 @@ use crate::github::GitHubInfo; #[derive(Error, Debug)] pub enum Error { + #[error("needs manual migration")] + NeedsManualMigration, + #[error("invalid version: {0}")] + InvalidVersion(String), + #[error("downgrading from version {0} to {1}")] + VersionDowngrading(String, String), #[error("unknown resource: {0}")] UnknownResource(String), #[error("unclosed quote")] diff --git a/src/main.rs b/src/main.rs index b2a737b..c7aca42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use std::fmt; use std::str::FromStr; use chrono::Utc; +use clap::crate_version; use cron::Schedule; use error::Error; use github::GitHubInfo; @@ -36,6 +37,7 @@ use teloxide::types::ParseMode; use teloxide::update_listeners; use teloxide::utils::command::BotCommands; use teloxide::utils::markdown; +use tokio::fs::read_dir; use tokio::time::sleep; use url::Url; @@ -56,10 +58,12 @@ use crate::repo::pr_issue_url; use crate::repo::settings::ConditionSettings; use crate::update::update_and_report_error; use crate::utils::modify_subscriber_set; +use crate::utils::read_json_strict; use crate::utils::reply_to_msg; use crate::utils::report_command_error; use crate::utils::report_error; use crate::utils::resolve_repo_or_url_and_id; +use crate::utils::write_json; #[derive(BotCommands, Clone, Debug)] #[command(rename_rule = "lowercase", description = "Supported commands:")] @@ -79,6 +83,11 @@ async fn run() { options::initialize(); log::info!("config = {:?}", options::get()); + if let Err(e) = version_check().await { + log::error!("error: {e}"); + std::process::exit(1); + } + octocrab_initialize(); let bot = Bot::from_env(); @@ -112,6 +121,63 @@ async fn run() { log::info!("exit"); } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionInfo { + version: String, +} + +async fn version_check() -> Result<(), Error> { + let options = options::get(); + let working_dir = &options.working_dir; + let version_json_path = working_dir.join("version.json"); + let version_info = VersionInfo { + version: crate_version!().to_string(), + }; + log::debug!( + "version checking, read working directory: {:?}", + working_dir + ); + let mut dir = read_dir(working_dir).await?; + let mut version_info_found = false; + let mut non_empty = false; + while let Some(entry) = dir.next_entry().await? { + non_empty = true; + let file_name = entry.file_name(); + if file_name == "version.json" { + version_info_found = true; + } + } + if non_empty && !version_info_found { + log::error!("working directory is non-empty, but no version information can be found "); + log::error!( + "if you are upgrading from version `0.1.x`, please follow for manual migration" + ); + return Err(Error::NeedsManualMigration); + } else if non_empty { + // get version information + let old_version_info: VersionInfo = read_json_strict(&version_json_path)?; + let old_version = version_compare::Version::from(&old_version_info.version) + .ok_or_else(|| Error::InvalidVersion(old_version_info.version.clone()))?; + let new_version = version_compare::Version::from(&version_info.version) + .ok_or_else(|| Error::InvalidVersion(old_version_info.version.clone()))?; + if old_version > new_version { + return Err(Error::VersionDowngrading( + old_version_info.version, + version_info.version, + )); + } + } else { + // do nothing, start from an empty configuration + } + log::debug!( + "save version information to {:?}: {:?}", + version_json_path, + version_info + ); + write_json(version_json_path, &version_info)?; + Ok(()) +} + async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { log::trace!("message: {msg:?}"); log::trace!("bot command: {bc:?}"); diff --git a/src/utils.rs b/src/utils.rs index 8487c27..ec4b7bb 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -84,6 +84,19 @@ where Ok(serde_json::from_reader(reader)?) } +pub fn read_json_strict(path: P) -> Result +where + P: AsRef + fmt::Debug, + T: Serialize + DeserializeOwned, +{ + log::debug!("read from file: {path:?}"); + let file = File::open(path)?; + // TODO lock_shared maybe added to the std lib in the future + FileExt::lock_shared(&file)?; // close of file automatically release the lock + let reader = BufReader::new(file); + Ok(serde_json::from_reader(reader)?) +} + pub fn write_json(path: P, rs: &T) -> Result<(), Error> where P: AsRef + fmt::Debug,