├── .gitignore ├── src ├── options.rs ├── condition │ ├── in_branch.rs │ ├── suppress_from_to.rs │ └── mod.rs ├── repo │ ├── settings.rs │ ├── paths.rs │ ├── resources.rs │ ├── cache.rs │ └── mod.rs ├── chat │ ├── results.rs │ ├── paths.rs │ ├── resources.rs │ ├── settings.rs │ └── mod.rs ├── resources.rs ├── migration.rs ├── github.rs ├── error.rs ├── utils.rs ├── message.rs ├── command.rs ├── update.rs └── main.rs ├── Cargo.toml ├── LICENSE ├── .github └── workflows │ ├── update.yml │ └── check.yml ├── nixos └── commit-notifier.nix ├── flake.lock ├── flake.nix └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.envrc 2 | /.direnv 3 | /.vscode 4 | /target 5 | /result* 6 | -------------------------------------------------------------------------------- /src/options.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use std::path::PathBuf; 3 | 4 | #[derive(Debug, Parser)] 5 | #[command(author, version, about, long_about = None)] 6 | pub struct Options { 7 | #[arg(short, long)] 8 | pub working_dir: PathBuf, 9 | #[arg(short, long)] 10 | pub cron: String, 11 | #[arg(short, long)] 12 | pub admin_chat_id: i64, 13 | } 14 | 15 | pub static OPTIONS: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); 16 | 17 | pub fn initialize() { 18 | once_cell::sync::OnceCell::set(&OPTIONS, Options::parse()).unwrap(); 19 | } 20 | 21 | pub fn get() -> &'static Options { 22 | OPTIONS.get().expect("options not initialized") 23 | } 24 | 25 | #[cfg(test)] 26 | mod tests { 27 | use super::*; 28 | 29 | #[test] 30 | fn verify_cli() { 31 | use clap::CommandFactory; 32 | Options::command().debug_assert() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "commit-notifier" 3 | authors = [ "Lin Yinfeng " ] 4 | version = "0.2.2" 5 | edition = "2024" 6 | description = """ 7 | A simple bot tracking git commits/PRs/issues/branches 8 | """ 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [dependencies] 13 | teloxide = { version = "*", features = [ "macros" ] } 14 | git2 = "*" 15 | tokio = { version = "*", features = [ "macros", "rt-multi-thread" ] } 16 | futures = "*" 17 | deadpool-sqlite = "*" 18 | rusqlite = "*" 19 | log = "*" 20 | pretty_env_logger = "*" 21 | thiserror = "*" 22 | clap = { version = "*", features = [ "cargo", "derive" ] } 23 | regex = "*" 24 | serde_json = "*" 25 | serde_regex = "*" 26 | serde = "*" 27 | cron = "*" 28 | chrono = "*" 29 | once_cell = "*" 30 | fs4 = "*" 31 | octocrab = "*" 32 | url = "*" 33 | lockable = "*" 34 | version-compare = "*" 35 | -------------------------------------------------------------------------------- /src/condition/in_branch.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::chat::results::CommitCheckResult; 5 | use crate::condition::{Action, Condition}; 6 | use crate::error::Error; 7 | 8 | #[derive(Debug, Clone, Serialize, Deserialize)] 9 | pub struct InBranchCondition { 10 | #[serde(with = "serde_regex")] 11 | pub branch_regex: Regex, 12 | } 13 | 14 | impl Condition for InBranchCondition { 15 | fn check(&self, check_results: &CommitCheckResult) -> Action { 16 | if check_results 17 | .all 18 | .iter() 19 | .any(|b| self.branch_regex.is_match(b)) 20 | { 21 | Action::Remove 22 | } else { 23 | Action::None 24 | } 25 | } 26 | } 27 | 28 | impl InBranchCondition { 29 | pub fn parse(s: &str) -> Result { 30 | Ok(InBranchCondition { 31 | branch_regex: Regex::new(&format!("^({s})$"))?, 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/repo/settings.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use regex::Regex; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::{condition::GeneralCondition, github::GitHubInfo}; 7 | 8 | #[derive(Debug, Clone, Serialize, Deserialize)] 9 | pub struct RepoSettings { 10 | #[serde(with = "serde_regex", default = "default_branch_regex")] 11 | pub branch_regex: Regex, 12 | #[serde(default)] 13 | pub github_info: Option, 14 | #[serde(default)] 15 | pub conditions: BTreeMap, 16 | } 17 | 18 | fn default_branch_regex() -> Regex { 19 | Regex::new("^$").unwrap() 20 | } 21 | 22 | impl Default for RepoSettings { 23 | fn default() -> Self { 24 | Self { 25 | branch_regex: default_branch_regex(), 26 | github_info: Default::default(), 27 | conditions: Default::default(), 28 | } 29 | } 30 | } 31 | 32 | #[derive(Debug, Clone, Serialize, Deserialize)] 33 | pub struct ConditionSettings { 34 | pub condition: GeneralCondition, 35 | } 36 | -------------------------------------------------------------------------------- /src/repo/paths.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, sync::LazyLock}; 2 | 3 | use regex::Regex; 4 | 5 | use crate::{error::Error, options}; 6 | 7 | #[derive(Debug, Clone)] 8 | pub struct RepoPaths { 9 | pub outer: PathBuf, 10 | pub repo: PathBuf, 11 | pub settings: PathBuf, 12 | pub cache: PathBuf, 13 | } 14 | 15 | pub static GLOBAL_REPO_OUTER: LazyLock = 16 | LazyLock::new(|| options::get().working_dir.join("repositories")); 17 | 18 | static NAME_RE: once_cell::sync::Lazy = 19 | once_cell::sync::Lazy::new(|| Regex::new("^[a-zA-Z0-9_\\-]*$").unwrap()); 20 | 21 | impl RepoPaths { 22 | pub fn new(name: &str) -> Result { 23 | if !NAME_RE.is_match(name) { 24 | return Err(Error::Name(name.to_string())); 25 | } 26 | 27 | let outer = GLOBAL_REPO_OUTER.join(name); 28 | Ok(Self { 29 | outer: outer.clone(), 30 | repo: outer.join("repo"), 31 | settings: outer.join("settings.json"), 32 | cache: outer.join("cache.sqlite"), 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Lin Yinfeng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/condition/suppress_from_to.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::chat::results::CommitCheckResult; 5 | use crate::condition::{Action, Condition}; 6 | use crate::error::Error; 7 | 8 | #[derive(Debug, Clone, Serialize, Deserialize)] 9 | pub struct SuppressFromToCondition { 10 | #[serde(with = "serde_regex")] 11 | pub from_regex: Regex, 12 | #[serde(with = "serde_regex")] 13 | pub to_regex: Regex, 14 | } 15 | 16 | impl Condition for SuppressFromToCondition { 17 | fn check(&self, check_results: &CommitCheckResult) -> Action { 18 | let mut old = check_results.all.difference(&check_results.new); 19 | if old.any(|old_branch| self.from_regex.is_match(old_branch)) 20 | && check_results 21 | .new 22 | .iter() 23 | .any(|new_branch| self.to_regex.is_match(new_branch)) 24 | { 25 | Action::SuppressNotification 26 | } else { 27 | Action::None 28 | } 29 | } 30 | } 31 | 32 | impl SuppressFromToCondition { 33 | pub fn parse(s: &str) -> Result { 34 | Ok(serde_json::from_str(s)?) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/chat/results.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, BTreeSet}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::condition::Action; 6 | 7 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 8 | pub struct ChatRepoResults { 9 | pub commits: BTreeMap, 10 | pub branches: BTreeMap, 11 | } 12 | 13 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 14 | pub struct CommitResults { 15 | pub branches: BTreeSet, 16 | } 17 | 18 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 19 | pub struct BranchResults { 20 | pub commit: Option, 21 | } 22 | 23 | #[derive(Debug)] 24 | pub struct CommitCheckResult { 25 | pub all: BTreeSet, 26 | pub new: BTreeSet, 27 | pub conditions: BTreeMap, 28 | } 29 | 30 | impl CommitCheckResult { 31 | pub fn conditions_of_action(&self, action: Action) -> BTreeSet<&String> { 32 | self.conditions 33 | .iter() 34 | .filter_map(|(condition, a)| if *a == action { Some(condition) } else { None }) 35 | .collect() 36 | } 37 | } 38 | 39 | #[derive(Debug)] 40 | pub struct BranchCheckResult { 41 | pub old: Option, 42 | pub new: Option, 43 | } 44 | 45 | #[derive(Debug)] 46 | pub enum PRIssueCheckResult { 47 | Merged(String), 48 | Closed, 49 | Waiting, 50 | } 51 | -------------------------------------------------------------------------------- /src/chat/paths.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, sync::LazyLock}; 2 | 3 | use teloxide::types::ChatId; 4 | 5 | use crate::{chat::Task, error::Error, options}; 6 | 7 | #[derive(Debug, Clone)] 8 | pub struct ChatRepoPaths { 9 | // pub chat: PathBuf, 10 | pub repo: PathBuf, 11 | pub settings: PathBuf, 12 | pub results: PathBuf, 13 | } 14 | 15 | pub static GLOBAL_CHATS_OUTER: LazyLock = 16 | LazyLock::new(|| options::get().working_dir.join("chats")); 17 | 18 | impl ChatRepoPaths { 19 | pub fn new(task: &Task) -> Result { 20 | let chat_path = Self::outer_dir(task.chat); 21 | if !chat_path.is_dir() { 22 | return Err(Error::NotInAllowList(task.chat)); 23 | } 24 | let repo = chat_path.join(&task.repo); 25 | Ok(Self { 26 | // chat: chat_path, 27 | settings: repo.join("settings.json"), 28 | results: repo.join("results.json"), 29 | repo, 30 | }) 31 | } 32 | 33 | pub fn outer_dir(chat: ChatId) -> PathBuf { 34 | GLOBAL_CHATS_OUTER.join(Self::outer_dir_name(chat)) 35 | } 36 | 37 | fn outer_dir_name(chat: ChatId) -> PathBuf { 38 | let ChatId(num) = chat; 39 | let chat_dir_name = if num < 0 { 40 | format!("_{}", num.unsigned_abs()) 41 | } else { 42 | format!("{chat}") 43 | }; 44 | chat_dir_name.into() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: "Automated update" 2 | on: 3 | schedule: 4 | - cron: '0 0 * * 6' 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | update: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@main 16 | with: 17 | ref: main 18 | token: '${{ secrets.PAT_FOR_AUTOMATED_UPDATE }}' 19 | - name: Install nix 20 | uses: cachix/install-nix-action@master 21 | with: 22 | github_access_token: '${{ secrets.GITHUB_TOKEN }}' 23 | - name: Setup cachix 24 | uses: cachix/cachix-action@master 25 | with: 26 | name: linyinfeng 27 | signingKey: '${{ secrets.CACHIX_SIGNING_KEY }}' 28 | - name: Git config 29 | run: | 30 | git config --global user.email "nano@linyinfeng.com" 31 | git config --global user.name "Nano" 32 | - name: Nix flake update 33 | run: | 34 | nix flake update --commit-lock-file 35 | - name: Cargo update 36 | run: | 37 | nix develop --command cargo update 38 | if [ -z $(git status --porcelain) ]; then 39 | echo "clean, skip..." 40 | else 41 | git add --all 42 | git commit --message "Cargo update" 43 | fi 44 | - name: Nix flake check 45 | run: | 46 | nix flake check 47 | - name: Git push 48 | run: | 49 | git push 50 | -------------------------------------------------------------------------------- /src/condition/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod in_branch; 2 | pub mod suppress_from_to; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::{ 7 | chat::results::CommitCheckResult, condition::suppress_from_to::SuppressFromToCondition, 8 | error::Error, 9 | }; 10 | 11 | use self::in_branch::InBranchCondition; 12 | 13 | pub trait Condition { 14 | fn check(&self, check_results: &CommitCheckResult) -> Action; 15 | } 16 | 17 | #[derive(clap::ValueEnum, Serialize, Deserialize, Clone, Debug, Copy)] 18 | pub enum Kind { 19 | RemoveIfInBranch, 20 | SuppressFromTo, 21 | } 22 | 23 | #[derive( 24 | clap::ValueEnum, Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, PartialOrd, Ord, 25 | )] 26 | pub enum Action { 27 | None, 28 | Remove, 29 | SuppressNotification, 30 | } 31 | 32 | impl Action { 33 | pub fn is_none(self) -> bool { 34 | matches!(self, Action::None) 35 | } 36 | } 37 | 38 | #[derive(Debug, Clone, Serialize, Deserialize)] 39 | pub enum GeneralCondition { 40 | InBranch(InBranchCondition), 41 | SuppressFromTo(SuppressFromToCondition), 42 | } 43 | 44 | impl GeneralCondition { 45 | pub fn parse(kind: Kind, expr: &str) -> Result { 46 | match kind { 47 | Kind::RemoveIfInBranch => { 48 | Ok(GeneralCondition::InBranch(InBranchCondition::parse(expr)?)) 49 | } 50 | Kind::SuppressFromTo => Ok(GeneralCondition::SuppressFromTo( 51 | SuppressFromToCondition::parse(expr)?, 52 | )), 53 | } 54 | } 55 | } 56 | 57 | impl Condition for GeneralCondition { 58 | fn check(&self, check_results: &CommitCheckResult) -> Action { 59 | match self { 60 | GeneralCondition::InBranch(c) => c.check(check_results), 61 | GeneralCondition::SuppressFromTo(c) => c.check(check_results), 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: "Check" 2 | on: 3 | push: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@main 13 | - name: Install nix 14 | uses: cachix/install-nix-action@master 15 | with: 16 | github_access_token: '${{ secrets.GITHUB_TOKEN }}' 17 | - name: Setup cachix 18 | uses: cachix/cachix-action@master 19 | with: 20 | name: linyinfeng 21 | signingKey: '${{ secrets.CACHIX_SIGNING_KEY }}' 22 | - name: Nix flake check 23 | run: nix flake check 24 | 25 | upload-docker-image: 26 | if: ${{ github.event_name == 'push' }} 27 | runs-on: ubuntu-latest 28 | needs: check 29 | permissions: 30 | contents: read 31 | packages: write 32 | outputs: 33 | image_tag: ${{ steps.upload.outputs.image_tag }} 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@main 37 | - name: Install nix 38 | uses: cachix/install-nix-action@master 39 | with: 40 | github_access_token: '${{ secrets.GITHUB_TOKEN }}' 41 | - name: Setup cachix 42 | uses: cachix/cachix-action@master 43 | with: 44 | name: linyinfeng 45 | signingKey: '${{ secrets.CACHIX_SIGNING_KEY }}' 46 | - name: Upload docker image 47 | id: upload 48 | run: | 49 | image_archive=$(nix build .#dockerImage --no-link --print-out-paths) 50 | function push_to { 51 | echo "push to '$1'" 52 | skopeo copy \ 53 | --dest-creds "${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}" \ 54 | "docker-archive:$image_archive" \ 55 | "$1" 56 | } 57 | tag=$(nix eval .#dockerImage.imageTag --raw) 58 | echo "image_tag=$tag" >> $GITHUB_OUTPUT 59 | push_to "docker://ghcr.io/linyinfeng/commit-notifier:$tag" 60 | if [ "${{ github.ref }}" = "refs/heads/main" ]; then 61 | push_to "docker://ghcr.io/linyinfeng/commit-notifier:latest" 62 | fi 63 | -------------------------------------------------------------------------------- /src/chat/resources.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use lockable::LockPool; 4 | use tokio::{fs::create_dir_all, sync::RwLock}; 5 | 6 | use crate::{ 7 | chat::{Task, paths::ChatRepoPaths, results::ChatRepoResults, settings::ChatRepoSettings}, 8 | error::Error, 9 | resources::{Resource, ResourcesMap}, 10 | utils::{read_json, write_json}, 11 | }; 12 | 13 | pub static RESOURCES_MAP: LazyLock> = 14 | LazyLock::new(ResourcesMap::new); 15 | 16 | pub struct ChatRepoResources { 17 | pub task: Task, 18 | pub paths: ChatRepoPaths, 19 | pub settings: RwLock, 20 | pub results: RwLock, 21 | 22 | pub commit_locks: LockPool, 23 | pub branch_locks: LockPool, 24 | } 25 | 26 | impl Resource for ChatRepoResources { 27 | async fn open(task: &Task) -> Result { 28 | let paths = ChatRepoPaths::new(task)?; 29 | if !paths.repo.is_dir() { 30 | create_dir_all(&paths.repo).await?; 31 | } 32 | let settings = RwLock::new(read_json(&paths.settings)?); 33 | let results = RwLock::new(read_json(&paths.results)?); 34 | Ok(Self { 35 | task: task.clone(), 36 | paths, 37 | settings, 38 | results, 39 | commit_locks: LockPool::new(), 40 | branch_locks: LockPool::new(), 41 | }) 42 | } 43 | } 44 | 45 | impl ChatRepoResources { 46 | pub async fn save_settings(&self) -> Result<(), Error> { 47 | let in_mem = self.settings.read().await; 48 | write_json(&self.paths.settings, &*in_mem) 49 | } 50 | pub async fn save_results(&self) -> Result<(), Error> { 51 | let in_mem = self.results.read().await; 52 | write_json(&self.paths.results, &*in_mem) 53 | } 54 | pub async fn commit_lock(&self, key: String) -> impl Drop + '_ { 55 | self.commit_locks.async_lock(key).await 56 | } 57 | 58 | pub async fn branch_lock(&self, key: String) -> impl Drop + '_ { 59 | self.branch_locks.async_lock(key).await 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/repo/resources.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use deadpool_sqlite::Pool; 4 | use git2::Repository; 5 | use tokio::sync::{Mutex, RwLock}; 6 | 7 | use crate::{ 8 | error::Error, 9 | repo::{cache, paths::RepoPaths, settings::RepoSettings}, 10 | resources::{Resource, ResourcesMap}, 11 | utils::{read_json, write_json}, 12 | }; 13 | 14 | pub static RESOURCES_MAP: LazyLock> = 15 | LazyLock::new(ResourcesMap::new); 16 | 17 | pub struct RepoResources { 18 | pub name: String, 19 | pub paths: RepoPaths, 20 | pub repo: Mutex, 21 | pub cache: Pool, 22 | pub cache_update_lock: Mutex<()>, 23 | pub settings: RwLock, 24 | } 25 | 26 | impl Resource for RepoResources { 27 | async fn open(name: &String) -> Result { 28 | let paths = RepoPaths::new(name)?; 29 | if !paths.outer.is_dir() { 30 | return Err(Error::UnknownRepository(name.to_string())); 31 | } 32 | // load repo 33 | let repo = Mutex::new(Repository::open(&paths.repo)?); 34 | // load cache 35 | let cache_cfg = deadpool_sqlite::Config::new(&paths.cache); 36 | let cache = cache_cfg.create_pool(deadpool_sqlite::Runtime::Tokio1)?; 37 | log::debug!("initializing cache for {name}..."); 38 | let conn = cache.get().await?; 39 | conn.interact(|c| cache::initialize(c)) 40 | .await 41 | .map_err(|e| Error::DBInteract(Mutex::new(e)))??; 42 | // load settings 43 | let settings = RwLock::new(read_json(&paths.settings)?); 44 | 45 | Ok(Self { 46 | name: name.clone(), 47 | paths, 48 | repo, 49 | cache, 50 | cache_update_lock: Mutex::new(()), 51 | settings, 52 | }) 53 | } 54 | } 55 | 56 | impl RepoResources { 57 | pub async fn save_settings(&self) -> Result<(), Error> { 58 | let in_mem = self.settings.read().await; 59 | write_json(&self.paths.settings, &*in_mem) 60 | } 61 | 62 | pub async fn cache(&self) -> Result { 63 | Ok(self.cache.get().await?) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/resources.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::BTreeMap, 3 | fmt::{self}, 4 | sync::Arc, 5 | }; 6 | 7 | use tokio::{sync::Mutex, time::sleep}; 8 | 9 | use crate::error::Error; 10 | 11 | #[derive(Default)] 12 | pub struct ResourcesMap { 13 | pub map: Mutex>>, 14 | } 15 | 16 | pub trait Resource 17 | where 18 | Self: Sized, 19 | { 20 | async fn open(index: &I) -> Result; 21 | } 22 | 23 | impl ResourcesMap { 24 | pub fn new() -> Self { 25 | Self { 26 | map: Mutex::new(BTreeMap::new()), 27 | } 28 | } 29 | 30 | pub async fn get(&self, index: &I) -> Result, Error> 31 | where 32 | R: Resource, 33 | I: Ord + Clone, 34 | { 35 | let mut map = self.map.lock().await; 36 | match map.get(index) { 37 | Some(resources) => Ok(resources.clone()), 38 | None => { 39 | let resources = Arc::new(R::open(index).await?); 40 | map.insert(index.clone(), resources.clone()); 41 | Ok(resources) 42 | } 43 | } 44 | } 45 | 46 | pub async fn remove(&self, index: &I, cleanup: C) -> Result<(), Error> 47 | where 48 | I: Ord + Clone + fmt::Display + fmt::Debug, 49 | C: FnOnce(R) -> F, 50 | F: Future>, 51 | { 52 | let mut map = self.map.lock().await; 53 | if let Some(arc) = map.remove(index) { 54 | let resource = wait_for_resources_drop(index, arc).await; 55 | cleanup(resource).await?; 56 | Ok(()) 57 | } else { 58 | Err(Error::UnknownResource(format!("{index}"))) 59 | } 60 | } 61 | 62 | pub async fn clear(&self) -> Result<(), Error> 63 | where 64 | I: Ord + fmt::Display, 65 | { 66 | let mut map = self.map.lock().await; 67 | while let Some((task, resources)) = map.pop_first() { 68 | let _resource = wait_for_resources_drop(&task, resources).await; 69 | } 70 | Ok(()) 71 | } 72 | } 73 | 74 | pub async fn wait_for_resources_drop(index: &I, mut arc: Arc) -> R 75 | where 76 | I: fmt::Display, 77 | { 78 | loop { 79 | match Arc::try_unwrap(arc) { 80 | Ok(resource) => { 81 | // do nothing 82 | // just drop 83 | return resource; 84 | } 85 | Err(a) => { 86 | arc = a; 87 | log::info!("removing {}, waiting for existing jobs", index); 88 | sleep(std::time::Duration::from_secs(1)).await; 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/migration.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::BTreeSet, mem}; 2 | 3 | use teloxide::utils::markdown; 4 | 5 | use crate::{ 6 | chat::{ 7 | self, 8 | settings::{NotifySettings, Subscriber}, 9 | }, 10 | error::Error, 11 | }; 12 | 13 | pub async fn from_0_2_1() -> Result<(), Error> { 14 | fn migrate_notify_settings(settings: &mut NotifySettings) { 15 | let mut subscribers = BTreeSet::new(); 16 | mem::swap(&mut subscribers, &mut settings.subscribers); 17 | for subscriber in subscribers { 18 | match subscriber { 19 | Subscriber::Telegram { markdown_mention } => { 20 | if markdown_mention.starts_with("@") { 21 | let new_mention = markdown::escape(&markdown_mention); 22 | if new_mention != markdown_mention { 23 | log::info!("escape username '{markdown_mention}' to '{new_mention}'"); 24 | } 25 | settings.subscribers.insert(Subscriber::Telegram { 26 | markdown_mention: new_mention, 27 | }); 28 | } else { 29 | settings 30 | .subscribers 31 | .insert(Subscriber::Telegram { markdown_mention }); 32 | } 33 | } 34 | } 35 | } 36 | } 37 | 38 | log::info!("migration from version 0.2.1"); 39 | let chats = chat::chats().await?; 40 | for chat in chats.iter().cloned() { 41 | log::info!("migrating chat {chat}..."); 42 | let repos = chat::repos(chat).await?; 43 | for repo in repos { 44 | log::info!("migrating repo {chat}/{repo}..."); 45 | let resources = chat::resources_chat_repo(chat, repo.to_string()).await?; 46 | let mut settings = resources.settings.write().await; 47 | for (_, settings) in settings.branches.iter_mut() { 48 | migrate_notify_settings(&mut settings.notify) 49 | } 50 | for (_, settings) in settings.commits.iter_mut() { 51 | migrate_notify_settings(&mut settings.notify) 52 | } 53 | for (_, settings) in settings.pr_issues.iter_mut() { 54 | migrate_notify_settings(&mut settings.notify) 55 | } 56 | } 57 | } 58 | 59 | // no errors, save all settings at once 60 | for chat in chats { 61 | let repos = chat::repos(chat).await?; 62 | for repo in repos { 63 | let resources = chat::resources_chat_repo(chat, repo.to_string()).await?; 64 | resources.save_settings().await?; 65 | } 66 | } 67 | Ok(()) 68 | } 69 | -------------------------------------------------------------------------------- /nixos/commit-notifier.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | config, 4 | lib, 5 | ... 6 | }: let 7 | cfg = config.services.commit-notifier; 8 | in { 9 | options.services.commit-notifier = { 10 | enable = lib.mkOption { 11 | type = lib.types.bool; 12 | default = false; 13 | description = '' 14 | Whether to enable commit-notifier service. 15 | ''; 16 | }; 17 | package = lib.mkOption { 18 | type = lib.types.package; 19 | default = pkgs.commit-notifier; 20 | defaultText = "pkgs.commit-notifier"; 21 | description = '' 22 | commit-notifier derivation to use. 23 | ''; 24 | }; 25 | cron = lib.mkOption { 26 | type = lib.types.str; 27 | description = '' 28 | Update cron expression. 29 | ''; 30 | }; 31 | adminChatId = lib.mkOption { 32 | type = lib.types.str; 33 | description = '' 34 | Chat id of the admin chat. 35 | ''; 36 | }; 37 | tokenFiles = { 38 | telegramBot = lib.mkOption { 39 | type = lib.types.str; 40 | description = '' 41 | Telegram bot token file. 42 | ''; 43 | }; 44 | github = lib.mkOption { 45 | type = lib.types.str; 46 | description = '' 47 | GitHub token file. 48 | ''; 49 | }; 50 | }; 51 | rustLog = lib.mkOption { 52 | type = lib.types.str; 53 | default = "info"; 54 | description = '' 55 | RUST_LOG environment variable; 56 | ''; 57 | }; 58 | }; 59 | 60 | config = lib.mkIf cfg.enable { 61 | systemd.services.commit-notifier = { 62 | description = "Git commit notifier"; 63 | 64 | script = '' 65 | export TELOXIDE_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/telegram-bot") 66 | export GITHUB_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/github") 67 | 68 | "${cfg.package}/bin/commit-notifier" \ 69 | --working-dir /var/lib/commit-notifier \ 70 | --cron "${cfg.cron}" \ 71 | --admin-chat-id="${cfg.adminChatId}" 72 | ''; 73 | 74 | path = [ 75 | pkgs.git 76 | ]; 77 | 78 | serviceConfig = { 79 | User = "commit-notifier"; 80 | Group = "commit-notifier"; 81 | StateDirectory = "commit-notifier"; 82 | LoadCredential = [ 83 | "telegram-bot:${cfg.tokenFiles.telegramBot}" 84 | "github:${cfg.tokenFiles.github}" 85 | ]; 86 | }; 87 | 88 | environment."RUST_LOG" = cfg.rustLog; 89 | 90 | wantedBy = ["multi-user.target"]; 91 | after = ["network-online.target"]; 92 | requires = ["network-online.target"]; 93 | }; 94 | 95 | users.users.commit-notifier = { 96 | isSystemUser = true; 97 | group = "commit-notifier"; 98 | }; 99 | users.groups.commit-notifier = {}; 100 | }; 101 | } 102 | -------------------------------------------------------------------------------- /src/github.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display}; 2 | 3 | use octocrab::models::{issues::Issue, pulls::PullRequest}; 4 | use once_cell::sync::Lazy; 5 | use regex::Regex; 6 | use serde::{Deserialize, Serialize}; 7 | use url::Url; 8 | 9 | use crate::error::Error; 10 | 11 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 12 | pub struct GitHubInfo { 13 | pub owner: String, 14 | pub repo: String, 15 | } 16 | 17 | impl Display for GitHubInfo { 18 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 19 | write!(f, "{}/{}", self.owner, self.repo) 20 | } 21 | } 22 | 23 | impl PartialEq for GitHubInfo { 24 | fn eq(&self, other: &Self) -> bool { 25 | // for backward compatibility 26 | self.owner.eq_ignore_ascii_case(&other.owner) && self.repo.eq_ignore_ascii_case(&other.repo) 27 | } 28 | } 29 | 30 | impl Eq for GitHubInfo {} 31 | 32 | pub static GITHUB_PATH_RE: Lazy = 33 | Lazy::new(|| Regex::new("^/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(\\.git)?$").unwrap()); 34 | 35 | impl GitHubInfo { 36 | pub fn new(owner: String, repo: String) -> Self { 37 | Self { owner, repo } 38 | } 39 | 40 | pub fn parse(s: &str) -> Result { 41 | let v: Vec<_> = s.split('/').collect(); 42 | if v.len() != 2 { 43 | Err("invalid github info format, 'owner/repo' required".to_string()) 44 | } else { 45 | Ok(Self::new(v[0].to_string(), v[1].to_string())) 46 | } 47 | } 48 | 49 | pub fn parse_from_url(url: Url) -> Result { 50 | log::debug!("parse github info from url: {url:?}"); 51 | let host = url.host().ok_or_else(|| url.clone())?; 52 | if host != url::Host::Domain("github.com") { 53 | return Err(url); 54 | } 55 | let captures = GITHUB_PATH_RE 56 | .captures(url.path()) 57 | .ok_or_else(|| url.clone())?; 58 | let owner = captures 59 | .get(1) 60 | .ok_or_else(|| url.clone())? 61 | .as_str() 62 | .to_string(); 63 | let repo = captures 64 | .get(2) 65 | .ok_or_else(|| url.clone())? 66 | .as_str() 67 | .to_string(); 68 | Ok(Self::new(owner, repo)) 69 | } 70 | } 71 | 72 | pub async fn is_merged(info: &GitHubInfo, pr_id: u64) -> Result { 73 | Ok(octocrab::instance() 74 | .pulls(&info.owner, &info.repo) 75 | .is_merged(pr_id) 76 | .await 77 | .map_err(Box::new)?) 78 | } 79 | 80 | pub async fn get_issue(info: &GitHubInfo, id: u64) -> Result { 81 | Ok(octocrab::instance() 82 | .issues(&info.owner, &info.repo) 83 | .get(id) 84 | .await 85 | .map_err(Box::new)?) 86 | } 87 | 88 | pub async fn get_pr(info: &GitHubInfo, id: u64) -> Result { 89 | Ok(octocrab::instance() 90 | .pulls(&info.owner, &info.repo) 91 | .get(id) 92 | .await 93 | .map_err(Box::new)?) 94 | } 95 | -------------------------------------------------------------------------------- /src/chat/settings.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, BTreeSet}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use teloxide::{types::User, utils::markdown}; 5 | use url::Url; 6 | 7 | use crate::error::Error; 8 | 9 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 10 | pub struct ChatRepoSettings { 11 | #[serde(default, alias = "pull_requests")] 12 | pub pr_issues: BTreeMap, 13 | #[serde(default)] 14 | pub commits: BTreeMap, 15 | #[serde(default)] 16 | pub branches: BTreeMap, 17 | } 18 | 19 | #[derive(Debug, Clone, Serialize, Deserialize)] 20 | pub struct CommitSettings { 21 | pub url: Option, 22 | #[serde(flatten)] 23 | pub notify: NotifySettings, 24 | } 25 | 26 | #[derive(Debug, Clone, Serialize, Deserialize)] 27 | pub struct PRIssueSettings { 28 | pub url: Url, 29 | #[serde(flatten)] 30 | pub notify: NotifySettings, 31 | } 32 | 33 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 34 | pub struct BranchSettings { 35 | #[serde(flatten)] 36 | pub notify: NotifySettings, 37 | } 38 | 39 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 40 | pub struct NotifySettings { 41 | #[serde(default)] 42 | pub comment: String, 43 | #[serde(default)] 44 | pub subscribers: BTreeSet, 45 | } 46 | 47 | impl NotifySettings { 48 | pub fn subscribers_markdown(&self) -> String { 49 | let mut result = String::new(); 50 | if !self.subscribers.is_empty() { 51 | if !result.is_empty() { 52 | result.push_str("\n\n"); 53 | } 54 | result.push_str("*subscribers*: "); 55 | result.push_str( 56 | &self 57 | .subscribers 58 | .iter() 59 | .map(Subscriber::markdown) 60 | .collect::>() 61 | .join(" "), 62 | ); 63 | } 64 | result 65 | } 66 | 67 | pub fn description_markdown(&self) -> String { 68 | markdown::escape(self.comment.trim().lines().next().unwrap_or_default()) 69 | } 70 | } 71 | 72 | #[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Serialize, Deserialize)] 73 | #[serde(try_from = "SubscriberCompat")] 74 | pub enum Subscriber { 75 | Telegram { markdown_mention: String }, 76 | } 77 | 78 | impl TryFrom for Subscriber { 79 | type Error = Error; 80 | 81 | fn try_from(compat: SubscriberCompat) -> Result { 82 | match &compat { 83 | SubscriberCompat::Telegram { 84 | markdown_mention, 85 | username, 86 | } => match (markdown_mention, username) { 87 | (Some(mention), _) => Ok(Subscriber::Telegram { 88 | markdown_mention: mention.clone(), 89 | }), 90 | (_, Some(username)) => Ok(Subscriber::Telegram { 91 | markdown_mention: format!("@{}", markdown::escape(username)), 92 | }), 93 | (_, _) => Err(Error::InvalidSubscriber(compat)), 94 | }, 95 | } 96 | } 97 | } 98 | 99 | #[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Deserialize)] 100 | pub enum SubscriberCompat { 101 | Telegram { 102 | markdown_mention: Option, 103 | username: Option, // field for compatibility 104 | }, 105 | } 106 | 107 | impl Subscriber { 108 | pub fn from_tg_user(user: &User) -> Self { 109 | // `markdown::user_mention_or_link` does not escape `user.mention()` 110 | let mention = match user.mention() { 111 | Some(mention) => markdown::escape(&mention), 112 | None => markdown::link(user.url().as_str(), &markdown::escape(&user.full_name())), 113 | }; 114 | Self::Telegram { 115 | markdown_mention: mention, 116 | } 117 | } 118 | 119 | pub fn markdown(&self) -> &str { 120 | match self { 121 | Subscriber::Telegram { markdown_mention } => markdown_mention, 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1766194365, 6 | "narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "flake-parts": { 19 | "inputs": { 20 | "nixpkgs-lib": [ 21 | "nixpkgs" 22 | ] 23 | }, 24 | "locked": { 25 | "lastModified": 1765835352, 26 | "narHash": "sha256-XswHlK/Qtjasvhd1nOa1e8MgZ8GS//jBoTqWtrS1Giw=", 27 | "owner": "hercules-ci", 28 | "repo": "flake-parts", 29 | "rev": "a34fae9c08a15ad73f295041fec82323541400a9", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "hercules-ci", 34 | "repo": "flake-parts", 35 | "type": "github" 36 | } 37 | }, 38 | "flake-utils": { 39 | "inputs": { 40 | "systems": [ 41 | "systems" 42 | ] 43 | }, 44 | "locked": { 45 | "lastModified": 1731533236, 46 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 47 | "owner": "numtide", 48 | "repo": "flake-utils", 49 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "numtide", 54 | "repo": "flake-utils", 55 | "type": "github" 56 | } 57 | }, 58 | "nixpkgs": { 59 | "locked": { 60 | "lastModified": 1766070988, 61 | "narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=", 62 | "owner": "nixos", 63 | "repo": "nixpkgs", 64 | "rev": "c6245e83d836d0433170a16eb185cefe0572f8b8", 65 | "type": "github" 66 | }, 67 | "original": { 68 | "owner": "nixos", 69 | "ref": "nixos-unstable", 70 | "repo": "nixpkgs", 71 | "type": "github" 72 | } 73 | }, 74 | "root": { 75 | "inputs": { 76 | "crane": "crane", 77 | "flake-parts": "flake-parts", 78 | "flake-utils": "flake-utils", 79 | "nixpkgs": "nixpkgs", 80 | "rust-overlay": "rust-overlay", 81 | "systems": "systems", 82 | "treefmt-nix": "treefmt-nix" 83 | } 84 | }, 85 | "rust-overlay": { 86 | "inputs": { 87 | "nixpkgs": [ 88 | "nixpkgs" 89 | ] 90 | }, 91 | "locked": { 92 | "lastModified": 1766112155, 93 | "narHash": "sha256-N0KUOJSIBw2fFF2ACZhwYX2e0EGaHBVPlJh7bnxcGE4=", 94 | "owner": "oxalica", 95 | "repo": "rust-overlay", 96 | "rev": "2a6db3fc1c27ae77f9caa553d7609b223cb770b5", 97 | "type": "github" 98 | }, 99 | "original": { 100 | "owner": "oxalica", 101 | "repo": "rust-overlay", 102 | "type": "github" 103 | } 104 | }, 105 | "systems": { 106 | "locked": { 107 | "lastModified": 1681028828, 108 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 109 | "owner": "nix-systems", 110 | "repo": "default", 111 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 112 | "type": "github" 113 | }, 114 | "original": { 115 | "owner": "nix-systems", 116 | "repo": "default", 117 | "type": "github" 118 | } 119 | }, 120 | "treefmt-nix": { 121 | "inputs": { 122 | "nixpkgs": [ 123 | "nixpkgs" 124 | ] 125 | }, 126 | "locked": { 127 | "lastModified": 1766000401, 128 | "narHash": "sha256-+cqN4PJz9y0JQXfAK5J1drd0U05D5fcAGhzhfVrDlsI=", 129 | "owner": "numtide", 130 | "repo": "treefmt-nix", 131 | "rev": "42d96e75aa56a3f70cab7e7dc4a32868db28e8fd", 132 | "type": "github" 133 | }, 134 | "original": { 135 | "owner": "numtide", 136 | "repo": "treefmt-nix", 137 | "type": "github" 138 | } 139 | } 140 | }, 141 | "root": "root", 142 | "version": 7 143 | } 144 | -------------------------------------------------------------------------------- /src/repo/cache.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeSet; 2 | 3 | use rusqlite::{Connection, params}; 4 | 5 | use crate::error::Error; 6 | 7 | pub fn initialize(cache: &Connection) -> Result<(), Error> { 8 | cache.execute( 9 | "CREATE TABLE IF NOT EXISTS commits_cache ( 10 | branch TEXT NOT NULL, 11 | commit_hash TEXT NOT NULL 12 | )", 13 | [], 14 | )?; 15 | cache.execute( 16 | "CREATE TABLE IF NOT EXISTS branches ( 17 | branch TEXT NOT NULL PRIMARY KEY, 18 | current_commit TEXT NOT NULL 19 | )", 20 | [], 21 | )?; 22 | cache.execute( 23 | "CREATE INDEX IF NOT EXISTS idx_commit_branches 24 | ON commits_cache (commit_hash)", 25 | [], 26 | )?; 27 | 28 | Ok(()) 29 | } 30 | 31 | pub fn branches(cache: &Connection) -> Result, Error> { 32 | let mut stmt = cache.prepare_cached("SELECT branch FROM branches;")?; 33 | let query_result: BTreeSet = stmt 34 | .query_map([], |row| row.get(0))? 35 | .collect::>()?; 36 | Ok(query_result) 37 | } 38 | 39 | pub fn remove_branch(cache: &Connection, branch: &str) -> Result<(), Error> { 40 | log::trace!("delete branch \"{branch}\" from cache"); 41 | let mut stmt1 = cache.prepare_cached("DELETE FROM branches WHERE branch = ?1")?; 42 | stmt1.execute(params!(branch))?; 43 | let mut stmt2 = cache.prepare_cached("DELETE FROM commits_cache WHERE branch = ?1")?; 44 | stmt2.execute(params!(branch))?; 45 | Ok(()) 46 | } 47 | 48 | pub fn query_branch(cache: &Connection, branch: &str) -> Result { 49 | let mut stmt = 50 | cache.prepare_cached("SELECT current_commit FROM branches WHERE branch = ?1;")?; 51 | log::trace!("query branch: {branch}"); 52 | let query_result: Vec = stmt 53 | .query_map(params!(branch), |row| row.get(0))? 54 | .collect::>()?; 55 | if query_result.len() != 1 { 56 | Err(Error::UnknownBranch(branch.to_string())) 57 | } else { 58 | Ok(query_result[0].clone()) 59 | } 60 | } 61 | 62 | pub fn store_branch(cache: &Connection, branch: &str, commit: &str) -> Result<(), Error> { 63 | let mut stmt = 64 | cache.prepare_cached("INSERT INTO branches (branch, current_commit) VALUES (?1, ?2)")?; 65 | log::trace!("insert new branch record: ({branch}, {commit})"); 66 | let inserted = stmt.execute(params!(branch, commit))?; 67 | assert_eq!(inserted, 1); 68 | Ok(()) 69 | } 70 | 71 | pub fn update_branch(cache: &Connection, branch: &str, commit: &str) -> Result<(), Error> { 72 | let mut stmt = 73 | cache.prepare_cached("UPDATE branches SET current_commit = ?2 WHERE branch = ?1")?; 74 | log::trace!("update branch record: ({branch}, {commit})"); 75 | stmt.execute(params!(branch, commit))?; 76 | Ok(()) 77 | } 78 | 79 | pub fn query_cache(cache: &Connection, branch: &str, commit: &str) -> Result { 80 | let mut stmt = cache 81 | .prepare_cached("SELECT * FROM commits_cache WHERE branch = ?1 AND commit_hash = ?2")?; 82 | log::trace!("query cache: ({branch}, {commit})"); 83 | let mut query_result = stmt.query(params!(branch, commit))?; 84 | if let Some(_row) = query_result.next()? { 85 | Ok(true) 86 | } else { 87 | Ok(false) 88 | } 89 | } 90 | 91 | pub fn query_cache_commit(cache: &Connection, commit: &str) -> Result, Error> { 92 | let mut stmt = 93 | cache.prepare_cached("SELECT branch FROM commits_cache WHERE commit_hash = ?1")?; 94 | log::trace!("query cache: {commit}"); 95 | Ok(stmt 96 | .query_map(params!(commit), |row| row.get(0))? 97 | .collect::>()?) 98 | } 99 | 100 | pub fn store_cache(cache: &Connection, branch: &str, commit: &str) -> Result<(), Error> { 101 | let mut stmt = 102 | cache.prepare_cached("INSERT INTO commits_cache (branch, commit_hash) VALUES (?1, ?2)")?; 103 | log::trace!("insert new cache: ({branch}, {commit})"); 104 | let inserted = stmt.execute(params!(branch, commit))?; 105 | assert_eq!(inserted, 1); 106 | Ok(()) 107 | } 108 | 109 | pub fn batch_store_cache(cache: &Connection, branch: &str, commits: I) -> Result<(), Error> 110 | where 111 | I: IntoIterator, 112 | { 113 | let mut count = 0usize; 114 | for c in commits.into_iter() { 115 | store_cache(cache, branch, &c)?; 116 | count += 1; 117 | if count.is_multiple_of(100000) { 118 | log::debug!("batch storing cache, current count: {count}",); 119 | } 120 | } 121 | Ok(()) 122 | } 123 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | flake-parts.url = "github:hercules-ci/flake-parts"; 4 | flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; 5 | 6 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 7 | 8 | flake-utils.url = "github:numtide/flake-utils"; 9 | flake-utils.inputs.systems.follows = "systems"; 10 | 11 | treefmt-nix.url = "github:numtide/treefmt-nix"; 12 | treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; 13 | 14 | crane.url = "github:ipetkov/crane"; 15 | 16 | rust-overlay.url = "github:oxalica/rust-overlay"; 17 | rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; 18 | 19 | systems.url = "github:nix-systems/default"; 20 | }; 21 | 22 | outputs = inputs @ {flake-parts, ...}: 23 | flake-parts.lib.mkFlake {inherit inputs;} 24 | ({ 25 | config, 26 | self, 27 | inputs, 28 | lib, 29 | getSystem, 30 | ... 31 | }: { 32 | systems = import inputs.systems; 33 | imports = [ 34 | inputs.flake-parts.flakeModules.easyOverlay 35 | inputs.treefmt-nix.flakeModule 36 | ]; 37 | flake = { 38 | nixosModules.commit-notifier = ./nixos/commit-notifier.nix; 39 | }; 40 | perSystem = { 41 | config, 42 | self', 43 | pkgs, 44 | system, 45 | ... 46 | }: let 47 | craneLib = inputs.crane.mkLib pkgs; 48 | src = craneLib.cleanCargoSource (craneLib.path ./.); 49 | bareCommonArgs = { 50 | inherit src; 51 | nativeBuildInputs = with pkgs; [ 52 | pkg-config 53 | ]; 54 | buildInputs = with pkgs; [ 55 | sqlite 56 | libgit2 57 | openssl 58 | ]; 59 | }; 60 | cargoArtifacts = craneLib.buildDepsOnly bareCommonArgs; 61 | commonArgs = bareCommonArgs // {inherit cargoArtifacts;}; 62 | in { 63 | packages = { 64 | default = config.packages.commit-notifier; 65 | commit-notifier = craneLib.buildPackage commonArgs; 66 | dockerImage = pkgs.dockerTools.buildImage { 67 | name = "commit-notifier"; 68 | tag = self.sourceInfo.rev or null; 69 | copyToRoot = pkgs.buildEnv { 70 | name = "commit-notifier-env"; 71 | paths = 72 | (with pkgs; [ 73 | git 74 | coreutils # for manual operations 75 | ]) 76 | ++ (with pkgs.dockerTools; [ 77 | usrBinEnv 78 | binSh 79 | caCertificates 80 | ]); 81 | }; 82 | config = { 83 | Entrypoint = ["${pkgs.tini}/bin/tini" "--"]; 84 | Cmd = let 85 | start = pkgs.writeShellScript "start-commit-notifier" '' 86 | exec ${config.packages.commit-notifier}/bin/commit-notifier \ 87 | --working-dir "/data" \ 88 | --cron "$COMMIT_NOTIFIER_CRON" \ 89 | $EXTRA_ARGS "$@" 90 | ''; 91 | in ["${start}"]; 92 | Env = [ 93 | "TELOXIDE_TOKEN=" 94 | "GITHUB_TOKEN=" 95 | "RUST_LOG=commit_notifier=info" 96 | "COMMIT_NOTIFIER_CRON=0 */5 * * * *" 97 | "EXTRA_ARGS=" 98 | ]; 99 | WorkingDirectory = "/data"; 100 | Volumes = {"/data" = {};}; 101 | Labels = 102 | { 103 | "org.opencontainers.image.title" = "commit-notifier"; 104 | "org.opencontainers.image.description" = "A simple telegram bot monitoring commit status"; 105 | "org.opencontainers.image.url" = "https://github.com/linyinfeng/commit-notifier"; 106 | "org.opencontainers.image.source" = "https://github.com/linyinfeng/commit-notifier"; 107 | "org.opencontainers.image.licenses" = "MIT"; 108 | } 109 | // lib.optionalAttrs (self.sourceInfo ? rev) { 110 | "org.opencontainers.image.revision" = self.sourceInfo.rev; 111 | }; 112 | }; 113 | }; 114 | }; 115 | overlayAttrs = { 116 | inherit (config.packages) commit-notifier; 117 | }; 118 | checks = { 119 | inherit (self'.packages) commit-notifier dockerImage; 120 | doc = craneLib.cargoDoc commonArgs; 121 | fmt = craneLib.cargoFmt {inherit src;}; 122 | nextest = craneLib.cargoNextest commonArgs; 123 | clippy = craneLib.cargoClippy (commonArgs 124 | // { 125 | cargoClippyExtraArgs = "--all-targets -- --deny warnings"; 126 | }); 127 | }; 128 | treefmt = { 129 | projectRootFile = "flake.nix"; 130 | programs = { 131 | alejandra.enable = true; 132 | rustfmt.enable = true; 133 | shfmt.enable = true; 134 | }; 135 | }; 136 | devShells.default = pkgs.mkShell { 137 | inputsFrom = lib.attrValues self'.checks; 138 | packages = with pkgs; [ 139 | rustup 140 | nil 141 | ]; 142 | }; 143 | }; 144 | }); 145 | } 146 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | 3 | use teloxide::prelude::*; 4 | use teloxide::types::ReplyParameters; 5 | use thiserror::Error; 6 | use tokio::sync::Mutex; 7 | 8 | use crate::chat::settings::SubscriberCompat; 9 | use crate::github::GitHubInfo; 10 | 11 | #[derive(Error, Debug)] 12 | pub enum Error { 13 | #[error("needs manual migration")] 14 | NeedsManualMigration, 15 | #[error("invalid version: {0}")] 16 | InvalidVersion(String), 17 | #[error("downgrading from version {0} to {1}")] 18 | VersionDowngrading(String, String), 19 | #[error("unknown resource: {0}")] 20 | UnknownResource(String), 21 | #[error("unclosed quote")] 22 | UnclosedQuote, 23 | #[error("bad escape")] 24 | BadEscape, 25 | #[error("{0}")] 26 | Clap(#[from] clap::Error), 27 | #[error("repository '{0}' already exists")] 28 | RepoExists(String), 29 | #[error("create db connection pool error: {0}")] 30 | CreatePool(#[from] deadpool_sqlite::CreatePoolError), 31 | #[error("db connection pool error: {0}")] 32 | Pool(#[from] deadpool_sqlite::PoolError), 33 | #[error("db error: {0}")] 34 | DB(#[from] rusqlite::Error), 35 | // `InteractError` is not `Sync` 36 | // wrap it with `Mutex` 37 | #[error("db interact error: {0:?}")] 38 | DBInteract(Mutex), 39 | #[error("task join error: {0}")] 40 | TaskJoin(#[from] tokio::task::JoinError), 41 | #[error("invalid name: {0}")] 42 | Name(String), 43 | #[error("chat id {0} is not in allow list")] 44 | NotInAllowList(ChatId), 45 | #[error("git error: {0}")] 46 | Git(#[from] git2::Error), 47 | #[error("failed to clone git repository '{url}' into '{name}', output: {output:?}")] 48 | GitClone { 49 | url: String, 50 | name: String, 51 | output: std::process::Output, 52 | }, 53 | #[error("failed to fetch git repository ''{name}', output: {output:?}")] 54 | GitFetch { 55 | name: String, 56 | output: std::process::Output, 57 | }, 58 | #[error("io error: {0}")] 59 | Io(#[from] std::io::Error), 60 | #[error("serde error: {0}")] 61 | Serde(#[from] serde_json::Error), 62 | #[error("unknown commit: '{0}'")] 63 | UnknownCommit(String), 64 | #[error("unknown PR/issue: '{0}'")] 65 | UnknownPRIssue(u64), 66 | #[error("unknown branch: '{0}'")] 67 | UnknownBranch(String), 68 | #[error("unknown repository: '{0}'")] 69 | UnknownRepository(String), 70 | #[error("commit already exists: '{0}'")] 71 | CommitExists(String), 72 | #[error("PR/issue already exists: '{0}'")] 73 | PRIssueExists(u64), 74 | #[error("branch already exists: '{0}'")] 75 | BranchExists(String), 76 | #[error("invalid os string: '{0:?}'")] 77 | InvalidOsString(OsString), 78 | #[error("invalid chat directory: '{0}'")] 79 | InvalidChatDir(String), 80 | #[error("parse error: '{0}'")] 81 | ParseInt(#[from] std::num::ParseIntError), 82 | #[error("invalid regex: {0}")] 83 | Regex(#[from] regex::Error), 84 | #[error("condition identifier already exists: '{0}'")] 85 | ConditionExists(String), 86 | #[error("unknown condition identifier: '{0}'")] 87 | UnknownCondition(String), 88 | #[error("github api error: '{0}'")] 89 | Octocrab(#[from] Box), 90 | #[error("no merge commit: '{github_info}#{pr_id}'")] 91 | NoMergeCommit { github_info: GitHubInfo, pr_id: u64 }, 92 | #[error("no associated github info for repo: '{0}'")] 93 | NoGitHubInfo(String), 94 | #[error("url parse error: '{0}'")] 95 | UrlParse(#[from] url::ParseError), 96 | #[error("can not get subscriber from message")] 97 | NoSubscriber, 98 | #[error("already subscribed")] 99 | AlreadySubscribed, 100 | #[error("not subscribed")] 101 | NotSubscribed, 102 | #[error("subscribe term serialize size exceeded: length = {0}, string = {1}")] 103 | SubscribeTermSizeExceeded(usize, String), 104 | #[error("can not determine chat id from subscribe callback query")] 105 | SubscribeCallbackNoChatId, 106 | #[error("can not determine message id from subscribe callback query")] 107 | SubscribeCallbackNoMsgId, 108 | #[error("can not get data from subscribe callback query")] 109 | SubscribeCallbackNoData, 110 | #[error("invalid kind '{0}' in subscribe callback data")] 111 | SubscribeCallbackDataInvalidKind(String), 112 | #[error("ambiguous, multiple repos have same github info: {0:?}")] 113 | MultipleReposHaveSameGitHubInfo(Vec), 114 | #[error("no repository is associated with the github info: {0:?}")] 115 | NoRepoHaveGitHubInfo(GitHubInfo), 116 | #[error("unsupported PR/issue url: {0}")] 117 | UnsupportedPRIssueUrl(String), 118 | #[error("not in an admin chat")] 119 | NotAdminChat, 120 | #[error("invalid subscriber: {0:?}")] 121 | InvalidSubscriber(SubscriberCompat), 122 | } 123 | 124 | impl Error { 125 | pub async fn report(&self, bot: &Bot, msg: &Message) -> Result<(), teloxide::RequestError> { 126 | log::warn!("report error to chat {}: {:?}", msg.chat.id, self); 127 | bot.send_message(msg.chat.id, format!("{self}")) 128 | .reply_parameters(ReplyParameters::new(msg.id)) 129 | .await?; 130 | Ok(()) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeSet; 2 | use std::fmt; 3 | use std::fs::{File, OpenOptions}; 4 | use std::io::{BufReader, BufWriter}; 5 | use std::path::Path; 6 | use std::sync::LazyLock; 7 | 8 | use fs4::fs_std::FileExt; 9 | use regex::Regex; 10 | use serde::Serialize; 11 | use serde::de::DeserializeOwned; 12 | use teloxide::types::ReplyParameters; 13 | use teloxide::{payloads::SendMessage, prelude::*, requests::JsonRequest}; 14 | 15 | use crate::chat::settings::Subscriber; 16 | use crate::error::Error; 17 | use crate::github::GitHubInfo; 18 | use crate::{CommandError, repo}; 19 | 20 | pub async fn report_error( 21 | bot: &Bot, 22 | msg: &Message, 23 | result: Result, 24 | ) -> Result, teloxide::RequestError> { 25 | match result { 26 | Ok(r) => Ok(Some(r)), 27 | Err(e) => { 28 | // report normal errors to user 29 | e.report(bot, msg).await?; 30 | Ok(None) 31 | } 32 | } 33 | } 34 | 35 | pub async fn report_command_error( 36 | bot: &Bot, 37 | msg: &Message, 38 | result: Result, 39 | ) -> Result, teloxide::RequestError> { 40 | match result { 41 | Ok(r) => Ok(Some(r)), 42 | Err(CommandError::Normal(e)) => { 43 | // report normal errors to user 44 | e.report(bot, msg).await?; 45 | Ok(None) 46 | } 47 | Err(CommandError::Teloxide(e)) => Err(e), 48 | } 49 | } 50 | 51 | pub fn reply_to_msg(bot: &Bot, msg: &Message, text: T) -> JsonRequest 52 | where 53 | T: Into, 54 | { 55 | bot.send_message(msg.chat.id, text) 56 | .reply_parameters(ReplyParameters::new(msg.id)) 57 | } 58 | 59 | pub fn empty_or_start_new_line(s: &str) -> String { 60 | let trimmed = s.trim().to_string(); 61 | if trimmed.is_empty() { 62 | trimmed 63 | } else { 64 | let mut result = "\n".to_string(); 65 | result.push_str(&trimmed); 66 | result 67 | } 68 | } 69 | 70 | pub fn read_json(path: P) -> Result 71 | where 72 | P: AsRef + fmt::Debug, 73 | T: Serialize + DeserializeOwned + Default, 74 | { 75 | if !path.as_ref().is_file() { 76 | log::info!("auto create file: {path:?}"); 77 | write_json::<_, T>(&path, &Default::default())?; 78 | } 79 | log::debug!("read from file: {path:?}"); 80 | let file = File::open(path)?; 81 | // TODO lock_shared maybe added to the std lib in the future 82 | FileExt::lock_shared(&file)?; // close of file automatically release the lock 83 | let reader = BufReader::new(file); 84 | Ok(serde_json::from_reader(reader)?) 85 | } 86 | 87 | pub fn read_json_strict(path: P) -> Result 88 | where 89 | P: AsRef + fmt::Debug, 90 | T: Serialize + DeserializeOwned, 91 | { 92 | log::debug!("read from file: {path:?}"); 93 | let file = File::open(path)?; 94 | // TODO lock_shared maybe added to the std lib in the future 95 | FileExt::lock_shared(&file)?; // close of file automatically release the lock 96 | let reader = BufReader::new(file); 97 | Ok(serde_json::from_reader(reader)?) 98 | } 99 | 100 | pub fn write_json(path: P, rs: &T) -> Result<(), Error> 101 | where 102 | P: AsRef + fmt::Debug, 103 | T: Serialize, 104 | { 105 | log::debug!("write to file: {path:?}"); 106 | let file = OpenOptions::new() 107 | .write(true) 108 | .create(true) 109 | .truncate(true) 110 | .open(path)?; 111 | file.lock_exclusive()?; 112 | let writer = BufWriter::new(file); 113 | Ok(serde_json::to_writer_pretty(writer, rs)?) 114 | } 115 | 116 | pub fn modify_subscriber_set( 117 | set: &mut BTreeSet, 118 | subscriber: Subscriber, 119 | unsubscribe: bool, 120 | ) -> Result<(), Error> { 121 | if unsubscribe { 122 | if !set.contains(&subscriber) { 123 | return Err(Error::NotSubscribed); 124 | } 125 | set.remove(&subscriber); 126 | } else { 127 | if set.contains(&subscriber) { 128 | return Err(Error::AlreadySubscribed); 129 | } 130 | set.insert(subscriber); 131 | } 132 | Ok(()) 133 | } 134 | 135 | pub async fn resolve_repo_or_url_and_id( 136 | repo_or_url: String, 137 | pr_id: Option, 138 | ) -> Result<(String, u64), Error> { 139 | static GITHUB_URL_REGEX: LazyLock = LazyLock::new(|| { 140 | Regex::new(r#"https://github\.com/([^/]+)/([^/]+)/(pull|issues)/(\d+)"#).unwrap() 141 | }); 142 | match pr_id { 143 | Some(id) => Ok((repo_or_url, id)), 144 | None => { 145 | if let Some(captures) = GITHUB_URL_REGEX.captures(&repo_or_url) { 146 | let owner = &captures[1]; 147 | let repo = &captures[2]; 148 | let github_info = GitHubInfo::new(owner.to_string(), repo.to_string()); 149 | log::trace!("PR/issue id to parse: {}", &captures[4]); 150 | let id: u64 = captures[4].parse().map_err(Error::ParseInt)?; 151 | let repos = repo::list().await?; 152 | let mut repos_found = Vec::new(); 153 | for repo in repos { 154 | let resources = repo::resources(&repo).await?; 155 | let repo_github_info = &resources.settings.read().await.github_info; 156 | if repo_github_info.as_ref() == Some(&github_info) { 157 | repos_found.push(repo); 158 | } 159 | } 160 | if repos_found.is_empty() { 161 | return Err(Error::NoRepoHaveGitHubInfo(github_info)); 162 | } else if repos_found.len() != 1 { 163 | return Err(Error::MultipleReposHaveSameGitHubInfo(repos_found)); 164 | } else { 165 | let repo = repos_found.pop().unwrap(); 166 | return Ok((repo, id)); 167 | } 168 | } 169 | Err(Error::UnsupportedPRIssueUrl(repo_or_url)) 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # commit-notifier 2 | 3 | A simple telegram bot monitoring commit status. 4 | 5 | ## Self-hosting (non-NixOS) 6 | 7 | 1. Build the project with `cargo build`. 8 | 9 | 2. Specify Telegram bot token and GitHub token through environment variables 10 | 11 | GitHub token are used to check PR merge state and get merge commit, so no permission are required. 12 | 13 | ```console 14 | $ export TELOXIDE_TOKEN="{YOUR_TELEGRAM_BOT_TOKEN}" 15 | $ export GITHUB_TOKEN="{YOUR_GITHUB_TOKEN}" 16 | ``` 17 | 18 | 3. Start `commit-notifier`. 19 | 20 | ```console 21 | $ commit-notifier" \ 22 | --working-dir /var/lib/commit-notifier \ 23 | --cron "0 */5 * * * *" \ 24 | --admin-chat-id="{YOUR_ADMIN_CHAT_ID}" 25 | ``` 26 | 27 | Automatic check will be triggered based on the cron expression. In the example, `0 */5 * * * *` means "at every 5th minute". cron documentation: . 28 | 29 | ## Self-hosting (NixOS) 30 | 31 | This repository is a Nix flake. 32 | 33 | ### Outputs 34 | 35 | 1. Main package: `packages.${system}.commit-notifier`. 36 | 2. Overlay: `overlays.default` (contains `commit-notifier`). 37 | 3. NixOS module: `nixosModules.commit-notifier`. 38 | 39 | ### NixOS module example 40 | 41 | My instance: 42 | 43 | ```nix 44 | { 45 | services.commit-notifier = { 46 | enable = true; 47 | cron = "0 */5 * * * *"; 48 | adminChatId = "{YOUR_ADMIN_CHAT_ID}"; 49 | tokenFiles = { 50 | telegramBot = /path/to/telegram/bot/token; 51 | github = /path/to/github/token; 52 | }; 53 | }; 54 | } 55 | ``` 56 | 57 | ## Self-hosting (docker) 58 | 59 | Docker images are published on GitHub package registry (). 60 | 61 | ```console 62 | $ docker run \ 63 | --env "TELOXIDE_TOKEN={YOUR_TELEGRAM_BOT_TOKEN}" \ 64 | --env "GITHUB_TOKEN={YOUR_GITHUB_TOKEN}" \ 65 | --env "COMMIT_NOTIFIER_CRON=0 */5 * * * *" \ 66 | --volume commit-notifier-data:/data \ 67 | ghcr.io/linyinfeng/commit-notifier:latest 68 | ``` 69 | 70 | ## Usage 71 | 72 | The telegram bot has only one command `/notifier`. But this command provides a full CLI interface. Simply send `/notifier` to the bot without any arguments, the bot will send back the help information. 73 | 74 | ## Allow List 75 | 76 | Currently the bot use `GITHUB_TOKEN` to check status for issues/pull requests, so only manually allowed users/groups can access the bot. 77 | 78 | The bot in a new chat returns this kind of error: 79 | 80 | * Direct chat 81 | 82 | ```text 83 | chat id 888888888 is not in allow list 84 | ``` 85 | 86 | * Group chat 87 | 88 | ```text 89 | chat id -1008888888888 is not in allow list 90 | ``` 91 | 92 | Currently, the bot does not have an admin interface in telegram. So adding chats to the "allow list" requires manual operation: making a new directory. 93 | 94 | * For direct chat: 95 | 96 | ```console 97 | $ mkdir -p {WORKING_DIR}/chats/888888888 # chat id 98 | ``` 99 | 100 | * For group chat 101 | 102 | ```console 103 | $ mkdir -p {WORKING_DIR}/chats/_1008888888888 # chat id (replace "-" with "_") 104 | ``` 105 | 106 | **Make sure the new directory is writable by `commit-notifier`.** All data (repositories, settings, check results) related to the chat will be saved in the directory. 107 | 108 | ## Migration Guide 109 | 110 | ### From `0.1.x` to `0.2.0` 111 | 112 | There are several differences between `0.1.x` and `0.2.x`. 113 | 114 | * In `0.1.x`, chats data are saved at `{WORKING_DIR}`; in `0.2.x`, chats data are saved in `{WORKING_DIR}/chats`. 115 | * In `0.1.x`, repositories and their settings are managed by every chat; in `0.2.x`, repositories and their settings are saved in `{WORKING_DIR}/repositories`, and can only managed by the admin chat. Also, in `0.2.x`, repositories are shared between all chats. 116 | * In `0.1.x`, caches are built in a per-commit manner; in `0.2.x`, caches are built in a per-branch manner, including every branch matches `--branch-regex`. 117 | 118 | #### How to migrate 119 | 120 | 1. Backup old `{WORKING_DIR}` to `{BACKUP_DIR}`. 121 | 2. Start the bot. 122 | 3. Check old configurations in `{BACKUP_DIR}`, find all repositories. 123 | 4. In admin chat, manually run `/notifier repo-add ...` for each repositories. 124 | 5. Properly configure each repositories. 125 | 126 | * Use `/notifier repo-edit ...` to set branch regex. Use `/notifier condition-add ...` to set conditions. 127 | 128 | * Or just edit `repositories/{REPO_NAME}/settings.json` manually. 129 | 130 |
131 | An example configuration for nixpkgs 132 | 133 | ```json 134 | { 135 | "branch_regex": "^(master|nixos-unstable|nixpkgs-unstable|staging|release-\\d\\d\\.\\d\\d|nixos-\\d\\d\\.\\d\\d)$", 136 | "github_info": { 137 | "owner": "nixos", 138 | "repo": "nixpkgs" 139 | }, 140 | "conditions": { 141 | "in-nixos-release": { 142 | "condition": { 143 | "InBranch": { 144 | "branch_regex": "^nixos-\\d\\d\\.\\d\\d$" 145 | } 146 | } 147 | }, 148 | "in-nixos-unstable": { 149 | "condition": { 150 | "InBranch": { 151 | "branch_regex": "^nixos-unstable$" 152 | } 153 | } 154 | }, 155 | "master-to-staging": { 156 | "condition": { 157 | "SuppressFromTo": { 158 | "from_regex": "^master$", 159 | "to_regex": "^staging(-next)?$" 160 | } 161 | } 162 | } 163 | } 164 | } 165 | ``` 166 | 167 |
168 | 169 | 6. Wait for the first update (first-time cache building can be slow). Restart the bot to trigger update immediately. 170 | 7. Restore chat configurations. `rsync --recursive {BACKUP_DIR}/ {WORKING_DIR}/chats/ --exclude cache.sqlite --exclude lock --exclude repo --verbose` (trailing `/` is important.) 171 | -------------------------------------------------------------------------------- /src/message.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::BTreeSet, fmt}; 2 | 3 | use teloxide::{types::Message, utils::markdown}; 4 | 5 | use crate::{ 6 | chat::{ 7 | results::{BranchCheckResult, CommitCheckResult}, 8 | settings::{BranchSettings, CommitSettings, PRIssueSettings, Subscriber}, 9 | }, 10 | condition::Action, 11 | error::Error, 12 | github::GitHubInfo, 13 | repo::{pr_issue_url, resources::RepoResources}, 14 | utils::empty_or_start_new_line, 15 | }; 16 | 17 | pub fn commit_check_message( 18 | repo: &str, 19 | commit: &str, 20 | settings: &CommitSettings, 21 | result: &CommitCheckResult, 22 | mention: bool, 23 | ) -> String { 24 | format!( 25 | "{summary} 26 | {details}", 27 | summary = commit_check_message_summary(repo, settings, result), 28 | details = markdown::expandable_blockquote(&commit_check_message_additional( 29 | commit, settings, result, mention 30 | )), 31 | ) 32 | } 33 | 34 | pub fn commit_check_message_summary( 35 | repo: &str, 36 | settings: &CommitSettings, 37 | result: &CommitCheckResult, 38 | ) -> String { 39 | let escaped_comment = markdown::escape(&settings.notify.comment); 40 | let comment_link = match &settings.url { 41 | Some(url) => markdown::link(url.as_ref(), &escaped_comment), 42 | None => escaped_comment, 43 | }; 44 | format!( 45 | "\\[{repo}\\] {comment_link} \\+{new}", 46 | repo = markdown::escape(repo), 47 | new = markdown_list_compat(result.new.iter()), 48 | ) 49 | } 50 | 51 | pub fn commit_check_message_additional( 52 | commit: &str, 53 | settings: &CommitSettings, 54 | result: &CommitCheckResult, 55 | mention: bool, 56 | ) -> String { 57 | let remove_conditions: BTreeSet<&String> = result.conditions_of_action(Action::Remove); 58 | let auto_remove_msg = if remove_conditions.is_empty() { 59 | "".to_string() 60 | } else { 61 | format!( 62 | "\n*auto removed* by conditions: 63 | {}", 64 | markdown_list(remove_conditions.iter()) 65 | ) 66 | }; 67 | format!( 68 | "`{commit}`{notify} 69 | 70 | *all* branches containing this commit: 71 | {all} 72 | {auto_remove_msg} 73 | ", 74 | commit = markdown::escape(commit), 75 | notify = if mention { 76 | empty_or_start_new_line(&settings.notify.subscribers_markdown()) 77 | } else { 78 | "".to_string() 79 | }, 80 | all = markdown_list(result.all.iter()) 81 | ) 82 | } 83 | 84 | pub async fn pr_issue_id_pretty(resources: &RepoResources, id: u64) -> Result { 85 | let url = pr_issue_url(resources, id).await?; 86 | Ok(markdown::link( 87 | url.as_ref(), 88 | &format!("{repo}/{id}", repo = resources.name), 89 | )) 90 | } 91 | 92 | pub async fn pr_issue_merged_message( 93 | resources: &RepoResources, 94 | id: u64, 95 | settings: &PRIssueSettings, 96 | commit: &String, 97 | ) -> Result { 98 | Ok(format!( 99 | "{pretty_id} merged as `{commit}`{notify}", 100 | pretty_id = pr_issue_id_pretty(resources, id).await?, 101 | notify = empty_or_start_new_line(&settings.notify.subscribers_markdown()), 102 | )) 103 | } 104 | 105 | pub async fn pr_issue_closed_message( 106 | resources: &RepoResources, 107 | id: u64, 108 | settings: &PRIssueSettings, 109 | ) -> Result { 110 | Ok(format!( 111 | "{pretty_id} has been closed{notify}", 112 | pretty_id = pr_issue_id_pretty(resources, id).await?, 113 | notify = empty_or_start_new_line(&settings.notify.subscribers_markdown()), 114 | )) 115 | } 116 | 117 | pub fn branch_check_message( 118 | repo: &str, 119 | branch: &str, 120 | settings: &BranchSettings, 121 | result: &BranchCheckResult, 122 | github_info: Option<&GitHubInfo>, 123 | ) -> String { 124 | let status = if result.old == result.new { 125 | format!( 126 | "{} 127 | \\(not changed\\)", 128 | markdown_optional_commit(result.new.as_deref(), github_info) 129 | ) 130 | } else if let (Some(info), Some(old), Some(new)) = (github_info, &result.old, &result.new) { 131 | github_commit_diff(info, old, new) 132 | } else { 133 | format!( 134 | "{old} \u{2192} 135 | {new}", 136 | old = markdown_optional_commit(result.old.as_deref(), github_info), 137 | new = markdown_optional_commit(result.new.as_deref(), github_info), 138 | ) 139 | }; 140 | format!( 141 | "{repo}/`{branch}` 142 | {status}{notify} 143 | ", 144 | repo = markdown::escape(repo), 145 | branch = markdown::escape(branch), 146 | notify = empty_or_start_new_line(&settings.notify.subscribers_markdown()), 147 | ) 148 | } 149 | 150 | const SHORT_COMMIT_LENGTH: usize = 11; 151 | 152 | pub fn short_commit(commit: &str) -> &str { 153 | &commit[..SHORT_COMMIT_LENGTH.min(commit.len())] 154 | } 155 | 156 | pub fn github_commit_diff(github_info: &GitHubInfo, old: &str, new: &str) -> String { 157 | let GitHubInfo { owner, repo, .. } = github_info; 158 | let old_short = short_commit(old); 159 | let new_short = short_commit(new); 160 | let url = format!("https://github.com/{owner}/{repo}/compare/{old}...{new}"); 161 | let text = format!("{old_short}...{new_short}"); 162 | markdown::link(&url, &markdown::escape(&text)) 163 | } 164 | 165 | pub fn markdown_optional_commit(commit: Option<&str>, github_info: Option<&GitHubInfo>) -> String { 166 | match &commit { 167 | None => "\\(nothing\\)".to_owned(), 168 | Some(commit) => match github_info { 169 | Some(info) => { 170 | let GitHubInfo { owner, repo, .. } = info; 171 | let short = short_commit(commit); 172 | let url = format!("https://github.com/{owner}/{repo}/commit/{short}"); 173 | markdown::link(&url, &markdown::escape(short)) 174 | } 175 | None => markdown::code_inline(&markdown::escape(commit)), 176 | }, 177 | } 178 | } 179 | 180 | pub fn markdown_list(items: Iter) -> String 181 | where 182 | Iter: Iterator, 183 | T: fmt::Display, 184 | { 185 | let mut result = String::new(); 186 | for item in items { 187 | result.push_str(&format!("\\- `{}`\n", markdown::escape(&item.to_string()))); 188 | } 189 | if result.is_empty() { 190 | "\u{2205}".to_owned() // the empty set symbol 191 | } else { 192 | assert_eq!(result.pop(), Some('\n')); 193 | result 194 | } 195 | } 196 | 197 | pub fn markdown_list_compat(items: Iter) -> String 198 | where 199 | Iter: Iterator, 200 | T: fmt::Display, 201 | { 202 | let mut result = String::new(); 203 | for item in items { 204 | result.push_str(&format!("`{}` ", markdown::escape(&item.to_string()))); 205 | } 206 | if result.is_empty() { 207 | "\u{2205}".to_owned() // the empty set symbol 208 | } else { 209 | assert_eq!(result.pop(), Some(' ')); 210 | result 211 | } 212 | } 213 | 214 | pub fn subscriber_from_msg(msg: &Message) -> Option { 215 | msg.from.as_ref().map(Subscriber::from_tg_user) 216 | } 217 | -------------------------------------------------------------------------------- /src/command.rs: -------------------------------------------------------------------------------- 1 | use crate::condition; 2 | use crate::error::Error; 3 | use crate::github::GitHubInfo; 4 | use clap::ColorChoice; 5 | use clap::Parser; 6 | use std::{ffi::OsString, iter}; 7 | 8 | const HELP_TEMPLATE: &str = "\ 9 | {before-help}{name} {version} 10 | {author-with-newline}{about} 11 | {usage-heading} {usage} 12 | 13 | {all-args}{after-help} 14 | "; 15 | 16 | #[derive(Debug, Parser)] 17 | #[command( 18 | name = "/notifier", 19 | author, 20 | version, 21 | about, 22 | color = ColorChoice::Never, 23 | no_binary_name = true, 24 | propagate_version = true, 25 | infer_long_args = true, 26 | infer_subcommands = true, 27 | help_template = HELP_TEMPLATE 28 | )] 29 | pub enum Notifier { 30 | #[command(about = "return current chat id")] 31 | ChatId, 32 | #[command(about = "add a repository")] 33 | RepoAdd { name: String, url: String }, 34 | #[command(about = "edit settings of a repository")] 35 | RepoEdit { 36 | name: String, 37 | #[arg(long, short)] 38 | branch_regex: Option, 39 | #[arg(long, short, value_parser = GitHubInfo::parse, group = "edit_github_info")] 40 | github_info: Option, 41 | #[arg(long, group = "edit_github_info")] 42 | clear_github_info: bool, 43 | }, 44 | #[command(about = "remove a repository")] 45 | RepoRemove { name: String }, 46 | #[command(about = "add a commit")] 47 | CommitAdd { 48 | repo: String, 49 | hash: String, 50 | #[arg(long, short)] 51 | comment: String, 52 | }, 53 | #[command(about = "remove a commit")] 54 | CommitRemove { repo: String, hash: String }, 55 | #[command(about = "fire a commit check immediately")] 56 | CommitCheck { repo: String, hash: String }, 57 | #[command(about = "subscribe to a commit")] 58 | CommitSubscribe { 59 | repo: String, 60 | hash: String, 61 | #[arg(short, long)] 62 | unsubscribe: bool, 63 | }, 64 | #[command(visible_alias("issue-add"), about = "add a pull request/issue")] 65 | PrAdd { 66 | repo_or_url: String, 67 | id: Option, 68 | #[arg(long, short)] 69 | comment: Option, 70 | }, 71 | #[command(visible_alias("issue-remove"), about = "remove a pull request/issue")] 72 | PrRemove { 73 | repo_or_url: String, 74 | id: Option, 75 | }, 76 | #[command(visible_alias("issue-check"), about = "check a pull request/issue")] 77 | PrCheck { 78 | repo_or_url: String, 79 | id: Option, 80 | }, 81 | #[command( 82 | visible_alias("issue-subscribe"), 83 | about = "subscribe to a pull request/issue" 84 | )] 85 | PrSubscribe { 86 | repo_or_url: String, 87 | id: Option, 88 | #[arg(short, long)] 89 | unsubscribe: bool, 90 | }, 91 | #[command(about = "add a branch")] 92 | BranchAdd { repo: String, branch: String }, 93 | #[command(about = "remove a branch")] 94 | BranchRemove { repo: String, branch: String }, 95 | #[command(about = "fire a branch check immediately")] 96 | BranchCheck { repo: String, branch: String }, 97 | #[command(about = "subscribe to a branch")] 98 | BranchSubscribe { 99 | repo: String, 100 | branch: String, 101 | #[arg(short, long)] 102 | unsubscribe: bool, 103 | }, 104 | #[command(about = "add an auto clean condition")] 105 | ConditionAdd { 106 | repo: String, 107 | identifier: String, 108 | #[arg(value_enum, short = 't', long = "type")] 109 | kind: condition::Kind, 110 | #[arg(short, long = "expr")] 111 | expression: String, 112 | }, 113 | #[command(about = "remove an auto clean condition")] 114 | ConditionRemove { repo: String, identifier: String }, 115 | #[command(about = "list repositories and commits")] 116 | List, 117 | } 118 | 119 | pub fn parse(raw_input: String) -> Result { 120 | let input = parse_raw(raw_input)?.into_iter().map(OsString::from); 121 | Ok(Notifier::try_parse_from(input)?) 122 | } 123 | 124 | #[derive(Debug)] 125 | enum PRState { 126 | Out, 127 | InWord, 128 | InSimpleQuote { end_mark: char }, 129 | Escape, 130 | } 131 | 132 | pub fn parse_raw(raw_input: String) -> Result, Error> { 133 | let mut state = PRState::Out; 134 | let mut chars = raw_input.chars().chain(iter::once('\0')).peekable(); 135 | let mut current = Vec::new(); 136 | let mut result = Vec::new(); 137 | 138 | while let Some(c) = chars.peek().cloned() { 139 | let mut next = true; 140 | state = match state { 141 | PRState::Out => { 142 | if c == '\0' || c.is_whitespace() { 143 | PRState::Out 144 | } else { 145 | next = false; 146 | PRState::InWord 147 | } 148 | } 149 | PRState::InWord => match c { 150 | _ if c == '\0' || c.is_whitespace() => { 151 | result.push(current.into_iter().collect()); 152 | current = Vec::new(); 153 | PRState::Out 154 | } 155 | '\'' | '"' => PRState::InSimpleQuote { end_mark: c }, 156 | '\\' => PRState::Escape, 157 | '—' => { 158 | current.push('-'); 159 | current.push('-'); 160 | PRState::InWord 161 | } 162 | _ => { 163 | current.push(c); 164 | PRState::InWord 165 | } 166 | }, 167 | PRState::InSimpleQuote { end_mark } => match c { 168 | _ if c == end_mark => PRState::InWord, 169 | '\0' => return Err(Error::UnclosedQuote), 170 | _ => { 171 | current.push(c); 172 | PRState::InSimpleQuote { end_mark } 173 | } 174 | }, 175 | PRState::Escape => match c { 176 | '\0' => return Err(Error::BadEscape), 177 | _ => { 178 | current.push(c); 179 | PRState::InWord 180 | } 181 | }, 182 | }; 183 | if next { 184 | chars.next(); 185 | } 186 | } 187 | Ok(result) 188 | } 189 | 190 | #[cfg(test)] 191 | mod tests { 192 | use super::*; 193 | 194 | #[test] 195 | fn parse_raw_simple() { 196 | assert_eq!( 197 | parse_raw( 198 | "some simple command —some-option --some-simple=values -b \"a\"b'c'" 199 | .to_owned() 200 | ) 201 | .unwrap(), 202 | vec![ 203 | "some", 204 | "simple", 205 | "command", 206 | "--some-option", 207 | "--some-simple=values", 208 | "-b", 209 | "abc", 210 | ] 211 | .into_iter() 212 | .map(str::to_owned) 213 | .collect::>() 214 | ); 215 | } 216 | 217 | #[test] 218 | fn parse_raw_escape() { 219 | assert_eq!( 220 | parse_raw("\\a\\b\\ aaaaa bbbb".to_owned()).unwrap(), 221 | vec!["ab aaaaa", "bbbb"] 222 | .into_iter() 223 | .map(str::to_owned) 224 | .collect::>() 225 | ); 226 | } 227 | 228 | #[test] 229 | fn verify_cli() { 230 | use clap::CommandFactory; 231 | Notifier::command().debug_assert() 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/update.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeSet; 2 | 3 | use teloxide::{ 4 | Bot, 5 | payloads::SendMessageSetters, 6 | prelude::Requester, 7 | sugar::request::RequestLinkPreviewExt, 8 | types::{ChatId, ParseMode}, 9 | }; 10 | 11 | use crate::{ 12 | CommandError, 13 | chat::{ 14 | self, 15 | resources::ChatRepoResources, 16 | results::PRIssueCheckResult, 17 | settings::{BranchSettings, CommitSettings, PRIssueSettings}, 18 | }, 19 | condition::Action, 20 | message::{ 21 | branch_check_message, commit_check_message, pr_issue_closed_message, 22 | pr_issue_merged_message, 23 | }, 24 | options, 25 | repo::{self, resources::RepoResources}, 26 | try_attach_subscribe_button_markup, 27 | }; 28 | 29 | pub async fn update_and_report_error(bot: Bot) -> Result<(), teloxide::RequestError> { 30 | match update(bot.clone()).await { 31 | Ok(r) => Ok(r), 32 | Err(CommandError::Normal(e)) => { 33 | log::error!("update error: {e}"); 34 | let options = options::get(); 35 | bot.send_message(ChatId(options.admin_chat_id), format!("update error: {e}")) 36 | .await?; 37 | Ok(()) 38 | } 39 | Err(CommandError::Teloxide(e)) => Err(e), 40 | } 41 | } 42 | 43 | async fn update(bot: Bot) -> Result<(), CommandError> { 44 | let repos = repo::list().await?; 45 | for repo in repos { 46 | log::info!("updating repository {repo}..."); 47 | let resources = repo::resources(&repo).await?; 48 | log::info!("updating {repo}..."); 49 | if let Err(e) = repo::fetch_and_update_cache(resources).await { 50 | log::error!("update error for repository {repo}: {e}"); 51 | } 52 | } 53 | log::info!("updating chats..."); 54 | let chats = chat::chats().await?; 55 | for chat in chats { 56 | if let Err(e) = update_chat(bot.clone(), chat).await { 57 | log::error!("update error for chat {chat}: {e}"); 58 | } 59 | } 60 | Ok(()) 61 | } 62 | 63 | async fn update_chat(bot: Bot, chat: ChatId) -> Result<(), CommandError> { 64 | let repos = chat::repos(chat).await?; 65 | for repo in repos { 66 | log::info!("updating repository of chat ({chat}, {repo})..."); 67 | if let Err(e) = update_chat_repo(bot.clone(), chat, &repo).await { 68 | log::error!("update error for repository of chat ({chat}, {repo}): {e}"); 69 | } 70 | } 71 | Ok(()) 72 | } 73 | 74 | async fn update_chat_repo(bot: Bot, chat: ChatId, repo: &str) -> Result<(), CommandError> { 75 | log::info!("updating ({chat}, {repo})..."); 76 | let resources = chat::resources_chat_repo(chat, repo.to_string()).await?; 77 | let repo_resources = repo::resources(repo).await?; 78 | 79 | // check pull requests/issues before checking commits 80 | let pr_issues = { 81 | let settings = resources.settings.read().await; 82 | settings.pr_issues.clone() 83 | }; 84 | for (id, settings) in pr_issues { 85 | if let Err(e) = update_chat_repo_pr_issue( 86 | bot.clone(), 87 | &resources, 88 | &repo_resources, 89 | chat, 90 | repo, 91 | id, 92 | &settings, 93 | ) 94 | .await 95 | { 96 | log::error!("update error for PR ({chat}, {repo}, {id}): {e}"); 97 | } 98 | } 99 | 100 | // check branches of the repo 101 | let branches = { 102 | let settings = resources.settings.read().await; 103 | settings.branches.clone() 104 | }; 105 | for (branch, settings) in branches { 106 | let _guard = resources.branch_lock(branch.clone()).await; 107 | if let Err(e) = update_chat_repo_branch( 108 | bot.clone(), 109 | &resources, 110 | &repo_resources, 111 | chat, 112 | repo, 113 | &branch, 114 | &settings, 115 | ) 116 | .await 117 | { 118 | log::error!("update error for branch ({chat}, {repo}, {branch}): {e}"); 119 | } 120 | } 121 | 122 | // check commits of the repo 123 | let commits = { 124 | let settings = resources.settings.read().await; 125 | settings.commits.clone() 126 | }; 127 | for (commit, settings) in commits { 128 | let _guard = resources.commit_lock(commit.clone()).await; 129 | if let Err(e) = update_chat_repo_commit( 130 | bot.clone(), 131 | &resources, 132 | &repo_resources, 133 | chat, 134 | repo, 135 | &commit, 136 | &settings, 137 | ) 138 | .await 139 | { 140 | log::error!("update error for commit ({chat}, {repo}, {commit}): {e}"); 141 | } 142 | } 143 | Ok(()) 144 | } 145 | 146 | async fn update_chat_repo_pr_issue( 147 | bot: Bot, 148 | resources: &ChatRepoResources, 149 | repo_resources: &RepoResources, 150 | chat: ChatId, 151 | repo: &str, 152 | id: u64, 153 | settings: &PRIssueSettings, 154 | ) -> Result<(), CommandError> { 155 | let result = chat::pr_issue_check(resources, repo_resources, id).await?; 156 | log::info!("finished PR/issue check ({chat}, {repo}, {id})"); 157 | match result { 158 | PRIssueCheckResult::Merged(commit) => { 159 | let message = pr_issue_merged_message(repo_resources, id, settings, &commit).await?; 160 | bot.send_message(chat, message) 161 | .parse_mode(ParseMode::MarkdownV2) 162 | .await?; 163 | Ok(()) 164 | } 165 | PRIssueCheckResult::Closed => { 166 | let message = pr_issue_closed_message(repo_resources, id, settings).await?; 167 | bot.send_message(chat, message) 168 | .parse_mode(ParseMode::MarkdownV2) 169 | .await?; 170 | Ok(()) 171 | } 172 | PRIssueCheckResult::Waiting => Ok(()), 173 | } 174 | } 175 | 176 | async fn update_chat_repo_commit( 177 | bot: Bot, 178 | resources: &ChatRepoResources, 179 | repo_resources: &RepoResources, 180 | chat: ChatId, 181 | repo: &str, 182 | commit: &str, 183 | settings: &CommitSettings, 184 | ) -> Result<(), CommandError> { 185 | let result = chat::commit_check(resources, repo_resources, commit).await?; 186 | log::info!("finished commit check ({chat}, {repo}, {commit})"); 187 | if !result.new.is_empty() { 188 | let suppress_notification_conditions: BTreeSet<&String> = 189 | result.conditions_of_action(Action::SuppressNotification); 190 | if !suppress_notification_conditions.is_empty() { 191 | log::info!("suppress notification for check result of ({chat}, {repo}): {result:?}",); 192 | } else { 193 | // mention in update 194 | let message = commit_check_message(repo, commit, settings, &result, true); 195 | let mut send = bot 196 | .send_message(chat, message) 197 | .parse_mode(ParseMode::MarkdownV2) 198 | .disable_link_preview(true); 199 | let remove_conditions: BTreeSet<&String> = result.conditions_of_action(Action::Remove); 200 | if remove_conditions.is_empty() { 201 | send = try_attach_subscribe_button_markup(chat, send, "c", repo, commit); 202 | } 203 | send.await?; 204 | } 205 | } 206 | Ok(()) 207 | } 208 | 209 | async fn update_chat_repo_branch( 210 | bot: Bot, 211 | resources: &ChatRepoResources, 212 | repo_resources: &RepoResources, 213 | chat: ChatId, 214 | repo: &str, 215 | branch: &str, 216 | settings: &BranchSettings, 217 | ) -> Result<(), CommandError> { 218 | let result = chat::branch_check(resources, repo_resources, branch).await?; 219 | log::info!("finished branch check ({chat}, {repo}, {branch})"); 220 | if result.new != result.old { 221 | let message = { 222 | let repo_settings = repo_resources.settings.read().await; 223 | branch_check_message( 224 | repo, 225 | branch, 226 | settings, 227 | &result, 228 | repo_settings.github_info.as_ref(), 229 | ) 230 | }; 231 | let mut send = bot 232 | .send_message(chat, message) 233 | .parse_mode(ParseMode::MarkdownV2) 234 | .disable_link_preview(true); 235 | send = try_attach_subscribe_button_markup(chat, send, "b", repo, branch); 236 | send.await?; 237 | } 238 | Ok(()) 239 | } 240 | -------------------------------------------------------------------------------- /src/repo/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{BTreeSet, VecDeque}, 3 | process::{Command, Output}, 4 | sync::{Arc, LazyLock}, 5 | }; 6 | 7 | use git2::{Commit, Oid, Repository}; 8 | use regex::Regex; 9 | use tokio::{ 10 | fs::{create_dir_all, read_dir, remove_dir_all}, 11 | sync::Mutex, 12 | task, 13 | }; 14 | use url::Url; 15 | 16 | use crate::{ 17 | error::Error, 18 | github, 19 | repo::{ 20 | cache::batch_store_cache, 21 | paths::RepoPaths, 22 | resources::{RESOURCES_MAP, RepoResources}, 23 | settings::ConditionSettings, 24 | }, 25 | }; 26 | 27 | pub mod cache; 28 | pub mod paths; 29 | pub mod resources; 30 | pub mod settings; 31 | 32 | pub async fn resources(repo: &str) -> Result, Error> { 33 | resources::RESOURCES_MAP.get(&repo.to_string()).await 34 | } 35 | 36 | pub async fn create(name: &str, url: &str) -> Result { 37 | let paths = RepoPaths::new(name)?; 38 | log::info!("try clone '{url}' into {:?}", paths.repo); 39 | if paths.repo.exists() { 40 | return Err(Error::RepoExists(name.to_string())); 41 | } 42 | create_dir_all(&paths.outer).await?; 43 | let output = { 44 | let url = url.to_owned(); 45 | let path = paths.repo.clone(); 46 | task::spawn_blocking(move || { 47 | Command::new("git") 48 | .arg("clone") 49 | .arg(url) 50 | .arg(path) 51 | // blobless clone 52 | .arg("--filter=tree:0") 53 | .output() 54 | }) 55 | .await 56 | .unwrap()? 57 | }; 58 | if !output.status.success() { 59 | return Err(Error::GitClone { 60 | url: url.to_owned(), 61 | name: name.to_owned(), 62 | output, 63 | }); 64 | } 65 | // try open the repository 66 | let _repo = Repository::open(&paths.repo)?; 67 | log::info!("cloned git repository {:?}", paths.repo); 68 | 69 | Ok(output) 70 | } 71 | 72 | pub async fn remove(name: &str) -> Result<(), Error> { 73 | let resource = resources(name).await?; 74 | drop(resource); 75 | RESOURCES_MAP 76 | .remove(&name.to_string(), async |r: RepoResources| { 77 | log::info!("try remove repository outer directory: {:?}", r.paths.outer); 78 | remove_dir_all(&r.paths.outer).await?; 79 | log::info!("repository outer directory removed: {:?}", r.paths.outer); 80 | Ok(()) 81 | }) 82 | .await?; 83 | Ok(()) 84 | } 85 | 86 | pub async fn list() -> Result, Error> { 87 | let mut dir = read_dir(&*paths::GLOBAL_REPO_OUTER).await?; 88 | let mut result = BTreeSet::new(); 89 | while let Some(entry) = dir.next_entry().await? { 90 | let filename = entry.file_name(); 91 | result.insert(filename.into_string().map_err(Error::InvalidOsString)?); 92 | } 93 | Ok(result) 94 | } 95 | 96 | pub async fn fetch_and_update_cache(resources: Arc) -> Result<(), Error> { 97 | fetch(&resources).await?; 98 | update_cache(resources).await?; 99 | Ok(()) 100 | } 101 | 102 | pub async fn fetch(resources: &RepoResources) -> Result { 103 | let paths = &resources.paths; 104 | let repo_path = &paths.repo; 105 | log::info!("fetch {repo_path:?}"); 106 | 107 | let output = { 108 | let path = repo_path.clone(); 109 | task::spawn_blocking(move || { 110 | Command::new("git") 111 | .arg("fetch") 112 | .arg("--all") 113 | .current_dir(path) 114 | .output() 115 | }) 116 | .await?? 117 | }; 118 | if !output.status.success() { 119 | return Err(Error::GitFetch { 120 | name: resources.name.to_owned(), 121 | output, 122 | }); 123 | } 124 | 125 | Ok(output) 126 | } 127 | 128 | pub async fn update_cache(resources: Arc) -> Result<(), Error> { 129 | // get the lock before update 130 | let _guard = resources.cache_update_lock.lock().await; 131 | let repo = &resources.name; 132 | let branches: BTreeSet = { 133 | let repo_guard = resources.repo.lock().await; 134 | watching_branches(&resources, &repo_guard).await? 135 | }; 136 | log::debug!("update cache for branches of {repo}: {branches:?}"); 137 | let cache = resources.cache().await?; 138 | let old_branches = cache 139 | .interact(move |c| cache::branches(c)) 140 | .await 141 | .map_err(|e| Error::DBInteract(Mutex::new(e)))??; 142 | let mut new_branches: BTreeSet = branches.difference(&old_branches).cloned().collect(); 143 | let update_branches = branches.intersection(&old_branches); 144 | let mut remove_branches: BTreeSet = 145 | old_branches.difference(&branches).cloned().collect(); 146 | for b in update_branches { 147 | let repo_guard = resources.repo.lock().await; 148 | let commit: Commit<'_> = branch_commit(&repo_guard, b)?; 149 | let b_cloned = b.clone(); 150 | let old_commit_str = cache 151 | .interact(move |conn| cache::query_branch(conn, &b_cloned)) 152 | .await 153 | .map_err(|e| Error::DBInteract(Mutex::new(e)))??; 154 | let old_commit = repo_guard.find_commit(Oid::from_str(&old_commit_str)?)?; 155 | 156 | if old_commit.id() == commit.id() { 157 | log::debug!("branch ({repo}, {b}) does not change, skip..."); 158 | } else if is_parent(old_commit.clone(), commit.clone()) { 159 | log::debug!("updating branch ({repo}, {b})..."); 160 | let mut queue = VecDeque::new(); 161 | let mut new_commits = BTreeSet::new(); 162 | queue.push_back(commit.clone()); 163 | while let Some(c) = queue.pop_front() { 164 | let id = c.id().to_string(); 165 | let exist = { 166 | let b = b.clone(); 167 | let id = id.clone(); 168 | cache 169 | .interact(move |conn| cache::query_cache(conn, &b, &id)) 170 | .await 171 | .map_err(|e| Error::DBInteract(Mutex::new(e)))?? 172 | }; 173 | if !exist && !new_commits.contains(&id) { 174 | new_commits.insert(id); 175 | if new_commits.len() % 100000 == 0 { 176 | log::debug!( 177 | "gathering new commits, current count: {count}, current queue size: {size}", 178 | count = new_commits.len(), 179 | size = queue.len() 180 | ); 181 | } 182 | for p in c.parents() { 183 | queue.push_back(p); 184 | } 185 | } 186 | } 187 | log::info!( 188 | "find {} new commits when updating ({repo}, {b})", 189 | new_commits.len() 190 | ); 191 | { 192 | let commit_str = commit.id().to_string(); 193 | let b = b.clone(); 194 | cache 195 | .interact(move |conn| -> Result<(), Error> { 196 | let tx = conn.unchecked_transaction()?; 197 | batch_store_cache(&tx, &b, new_commits)?; 198 | cache::update_branch(conn, &b, &commit_str)?; 199 | tx.commit()?; 200 | Ok(()) 201 | }) 202 | .await 203 | .map_err(|e| Error::DBInteract(Mutex::new(e)))??; 204 | } 205 | } else { 206 | remove_branches.insert(b.to_owned()); 207 | new_branches.insert(b.to_owned()); 208 | } 209 | } 210 | for b in remove_branches { 211 | log::info!("removing branch ({repo}, {b})...",); 212 | let b = b.clone(); 213 | cache 214 | .interact(move |conn| cache::remove_branch(conn, &b)) 215 | .await 216 | .map_err(|e| Error::DBInteract(Mutex::new(e)))??; 217 | } 218 | for b in new_branches { 219 | log::info!("adding branch ({repo}, {b})..."); 220 | let commit_id = { 221 | let repo_guard = resources.repo.lock().await; 222 | branch_commit(&repo_guard, &b)?.id() 223 | }; 224 | let commits = { 225 | let resources = resources.clone(); 226 | spawn_gather_commits(resources, commit_id).await? 227 | }; 228 | { 229 | let commit_str = commit_id.to_string(); 230 | let b = b.clone(); 231 | cache 232 | .interact(move |conn| -> Result<(), Error> { 233 | let tx = conn.unchecked_transaction()?; 234 | batch_store_cache(conn, &b, commits)?; 235 | cache::store_branch(conn, &b, &commit_str)?; 236 | tx.commit()?; 237 | Ok(()) 238 | }) 239 | .await 240 | .map_err(|e| Error::DBInteract(Mutex::new(e)))??; 241 | } 242 | } 243 | Ok(()) 244 | } 245 | 246 | fn branch_commit<'repo>(repo: &'repo Repository, branch: &str) -> Result, Error> { 247 | let full_name = format!("origin/{branch}"); 248 | let branch = repo.find_branch(&full_name, git2::BranchType::Remote)?; 249 | let commit = branch.into_reference().peel_to_commit()?; 250 | Ok(commit) 251 | } 252 | 253 | pub async fn spawn_gather_commits( 254 | resources: Arc, 255 | commit_id: Oid, 256 | ) -> Result, Error> { 257 | tokio::task::spawn_blocking(move || { 258 | let repo = resources.repo.blocking_lock(); 259 | let commit = repo.find_commit(commit_id)?; 260 | Ok(gather_commits(commit)) 261 | }) 262 | .await? 263 | } 264 | 265 | fn gather_commits<'repo>(commit: Commit<'repo>) -> BTreeSet { 266 | let mut commits = BTreeSet::new(); 267 | let mut queue = VecDeque::new(); 268 | queue.push_back(commit); 269 | while let Some(c) = queue.pop_front() { 270 | if commits.insert(c.id().to_string()) { 271 | if commits.len() % 100000 == 0 { 272 | log::debug!( 273 | "gathering commits, current count: {count}, current queue size: {size}", 274 | count = commits.len(), 275 | size = queue.len() 276 | ); 277 | } 278 | for p in c.parents() { 279 | queue.push_back(p); 280 | } 281 | } 282 | } 283 | commits 284 | } 285 | 286 | fn is_parent<'repo>(parent: Commit<'repo>, child: Commit<'repo>) -> bool { 287 | let mut queue = VecDeque::new(); 288 | let mut visited = BTreeSet::new(); 289 | queue.push_back(child); 290 | while let Some(c) = queue.pop_front() { 291 | if c.id() == parent.id() { 292 | return true; 293 | } 294 | if visited.insert(c.id()) { 295 | // not visited 296 | if visited.len() % 100000 == 0 { 297 | log::debug!( 298 | "testing parent commit, current count: {count}, current queue size: {size}", 299 | count = visited.len(), 300 | size = queue.len() 301 | ); 302 | } 303 | for p in c.parents() { 304 | queue.push_back(p); 305 | } 306 | } 307 | } 308 | false 309 | } 310 | 311 | pub async fn watching_branches( 312 | resources: &RepoResources, 313 | repo: &Repository, 314 | ) -> Result, Error> { 315 | let remote_branches = repo.branches(Some(git2::BranchType::Remote))?; 316 | let branch_regex = { 317 | let settings = resources.settings.read().await; 318 | settings.branch_regex.clone() 319 | }; 320 | let mut matched_branches = BTreeSet::new(); 321 | for branch_iter_res in remote_branches { 322 | let (branch, _) = branch_iter_res?; 323 | // clean up name 324 | let branch_name = match branch.name()?.and_then(branch_name_map_filter) { 325 | None => continue, 326 | Some(n) => n, 327 | }; 328 | // skip if not match 329 | if branch_regex.is_match(branch_name) { 330 | matched_branches.insert(branch_name.to_string()); 331 | } 332 | } 333 | Ok(matched_branches) 334 | } 335 | 336 | static ORIGIN_RE: LazyLock = LazyLock::new(|| Regex::new("^origin/(.*)$").unwrap()); 337 | 338 | fn branch_name_map_filter(name: &str) -> Option<&str> { 339 | if name == "origin/HEAD" { 340 | return None; 341 | } 342 | 343 | let captures = match ORIGIN_RE.captures(name) { 344 | Some(cap) => cap, 345 | None => return Some(name), 346 | }; 347 | 348 | Some(captures.get(1).unwrap().as_str()) 349 | } 350 | 351 | pub async fn condition_add( 352 | resources: &RepoResources, 353 | identifier: &str, 354 | settings: ConditionSettings, 355 | ) -> Result<(), Error> { 356 | { 357 | let mut locked = resources.settings.write().await; 358 | if locked.conditions.contains_key(identifier) { 359 | return Err(Error::ConditionExists(identifier.to_owned())); 360 | } 361 | locked.conditions.insert(identifier.to_owned(), settings); 362 | } 363 | resources.save_settings().await 364 | } 365 | 366 | pub async fn condition_remove(resources: &RepoResources, identifier: &str) -> Result<(), Error> { 367 | { 368 | let mut locked = resources.settings.write().await; 369 | if !locked.conditions.contains_key(identifier) { 370 | return Err(Error::UnknownCondition(identifier.to_owned())); 371 | } 372 | locked.conditions.remove(identifier); 373 | } 374 | resources.save_settings().await 375 | } 376 | 377 | pub async fn pr_issue_url(resources: &RepoResources, id: u64) -> Result { 378 | let locked = resources.settings.read().await; 379 | match &locked.github_info { 380 | Some(info) => Ok(github::get_issue(info, id).await?.html_url), 381 | None => Err(Error::NoGitHubInfo(resources.name.clone())), 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /src/chat/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::BTreeSet, fmt, sync::Arc}; 2 | 3 | use git2::{BranchType, Oid}; 4 | use octocrab::models::IssueState; 5 | use teloxide::types::{ChatId, Message}; 6 | use tokio::{fs::read_dir, sync::Mutex}; 7 | 8 | use crate::{ 9 | chat::{ 10 | paths::ChatRepoPaths, 11 | resources::ChatRepoResources, 12 | results::{ 13 | BranchCheckResult, BranchResults, CommitCheckResult, CommitResults, PRIssueCheckResult, 14 | }, 15 | settings::{BranchSettings, CommitSettings, NotifySettings, PRIssueSettings}, 16 | }, 17 | condition::{Action, Condition}, 18 | error::Error, 19 | github::{self, GitHubInfo}, 20 | repo::{cache::query_cache_commit, resources::RepoResources}, 21 | utils::empty_or_start_new_line, 22 | }; 23 | 24 | pub mod paths; 25 | pub mod resources; 26 | pub mod results; 27 | pub mod settings; 28 | 29 | pub async fn chats() -> Result, Error> { 30 | let mut chats = BTreeSet::new(); 31 | let dir_path = &paths::GLOBAL_CHATS_OUTER; 32 | let mut dir = read_dir(dir_path.as_path()).await?; 33 | while let Some(entry) = dir.next_entry().await? { 34 | let name_os = entry.file_name(); 35 | let name = name_os.into_string().map_err(Error::InvalidOsString)?; 36 | 37 | let invalid_error = Err(Error::InvalidChatDir(name.clone())); 38 | if name.is_empty() { 39 | return invalid_error; 40 | } 41 | 42 | let name_vec: Vec<_> = name.chars().collect(); 43 | let (sign, num_str) = if name_vec[0] == '_' { 44 | (-1, &name_vec[1..]) 45 | } else { 46 | (1, &name_vec[..]) 47 | }; 48 | let n: i64 = match num_str.iter().collect::().parse() { 49 | Ok(n) => n, 50 | Err(e) => { 51 | log::warn!("invalid chat directory '{name}': {e}, ignoring"); 52 | continue; 53 | } 54 | }; 55 | chats.insert(ChatId(sign * n)); 56 | } 57 | Ok(chats) 58 | } 59 | 60 | pub async fn repos(chat: ChatId) -> Result, Error> { 61 | let directory = ChatRepoPaths::outer_dir(chat); 62 | if !directory.exists() { 63 | Ok(BTreeSet::new()) 64 | } else { 65 | let mut results = BTreeSet::new(); 66 | let mut dir = read_dir(&directory).await?; 67 | while let Some(entry) = dir.next_entry().await? { 68 | results.insert( 69 | entry 70 | .file_name() 71 | .into_string() 72 | .map_err(Error::InvalidOsString)?, 73 | ); 74 | } 75 | Ok(results) 76 | } 77 | } 78 | 79 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 80 | pub struct Task { 81 | pub chat: ChatId, 82 | pub repo: String, 83 | } 84 | 85 | impl fmt::Display for Task { 86 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 87 | write!(f, "Task({}, {})", self.chat, self.repo) 88 | } 89 | } 90 | 91 | pub async fn resources(task: &Task) -> Result, Error> { 92 | resources::RESOURCES_MAP.get(task).await 93 | } 94 | 95 | pub async fn resources_chat_repo( 96 | chat: ChatId, 97 | repo: String, 98 | ) -> Result, Error> { 99 | let task = Task { chat, repo }; 100 | resources(&task).await 101 | } 102 | 103 | pub async fn resources_msg_repo( 104 | msg: &Message, 105 | repo: String, 106 | ) -> Result, Error> { 107 | let chat = msg.chat.id; 108 | resources_chat_repo(chat, repo).await 109 | } 110 | 111 | pub async fn commit_add( 112 | resources: &ChatRepoResources, 113 | hash: &str, 114 | settings: CommitSettings, 115 | ) -> Result<(), Error> { 116 | { 117 | let mut locked = resources.settings.write().await; 118 | if locked.commits.contains_key(hash) { 119 | return Err(Error::CommitExists(hash.to_owned())); 120 | } 121 | locked.commits.insert(hash.to_owned(), settings); 122 | } 123 | resources.save_settings().await 124 | } 125 | 126 | pub async fn commit_remove(resources: &ChatRepoResources, hash: &str) -> Result<(), Error> { 127 | { 128 | let mut settings = resources.settings.write().await; 129 | if !settings.commits.contains_key(hash) { 130 | return Err(Error::UnknownCommit(hash.to_owned())); 131 | } 132 | settings.commits.remove(hash); 133 | } 134 | { 135 | let mut results = resources.results.write().await; 136 | results.commits.remove(hash); 137 | } 138 | resources.save_settings().await?; 139 | resources.save_results().await?; 140 | Ok(()) 141 | } 142 | 143 | pub async fn commit_check( 144 | resources: &ChatRepoResources, 145 | repo_resources: &RepoResources, 146 | hash: &str, 147 | ) -> Result { 148 | log::info!("checking commit ({task}, {hash})", task = resources.task); 149 | if let Err(e) = commit_pre_check(repo_resources, hash).await { 150 | commit_remove(resources, hash).await?; 151 | return Err(e); 152 | } 153 | let cache = repo_resources.cache().await?; 154 | let all_branches = { 155 | let commit = hash.to_string(); 156 | cache 157 | .interact(move |conn| query_cache_commit(conn, &commit)) 158 | .await 159 | .map_err(|e| Error::DBInteract(Mutex::new(e)))?? 160 | }; 161 | let new_results = CommitResults { 162 | branches: all_branches.clone(), 163 | }; 164 | let old_results = { 165 | let mut results = resources.results.write().await; 166 | results 167 | .commits 168 | .insert(hash.to_string(), new_results) 169 | .unwrap_or_default() 170 | }; 171 | let new_branches = all_branches 172 | .difference(&old_results.branches) 173 | .cloned() 174 | .collect(); 175 | let mut check_result = CommitCheckResult { 176 | all: all_branches, 177 | new: new_branches, 178 | conditions: Default::default(), 179 | }; 180 | let mut remove = false; 181 | { 182 | let settings = repo_resources.settings.read().await; 183 | for (condition_name, condition_setting) in &settings.conditions { 184 | let action = condition_setting.condition.check(&check_result); 185 | if action.is_none() { 186 | continue; 187 | } else { 188 | check_result 189 | .conditions 190 | .insert(condition_name.clone(), action); 191 | if action == Action::Remove { 192 | remove = true; 193 | } 194 | } 195 | } 196 | } 197 | if remove { 198 | let mut settings = resources.settings.write().await; 199 | let mut results = resources.results.write().await; 200 | settings.commits.remove(hash); 201 | results.commits.remove(hash); 202 | } 203 | resources.save_settings().await?; 204 | resources.save_results().await?; 205 | Ok(check_result) 206 | } 207 | 208 | pub async fn commit_pre_check(repo_resources: &RepoResources, hash: &str) -> Result<(), Error> { 209 | let repo = repo_resources.repo.lock().await; 210 | let id = Oid::from_str(hash)?; 211 | let _commit = repo.find_commit(id)?; 212 | Ok(()) 213 | } 214 | 215 | pub async fn pr_issue_add( 216 | resources: &ChatRepoResources, 217 | repo_resources: &RepoResources, 218 | id: u64, 219 | settings: PRIssueSettings, 220 | ) -> Result<(), Error> { 221 | let github_info = { 222 | let locked = repo_resources.settings.read().await; 223 | locked 224 | .github_info 225 | .clone() 226 | .ok_or(Error::NoGitHubInfo(resources.task.repo.clone()))? 227 | }; 228 | // every PR id is also an issue id 229 | let _pr = github::get_issue(&github_info, id).await?; // ensure the PR/issue id is real 230 | { 231 | let mut locked = resources.settings.write().await; 232 | if locked.pr_issues.contains_key(&id) { 233 | return Err(Error::PRIssueExists(id)); 234 | } 235 | locked.pr_issues.insert(id, settings); 236 | } 237 | resources.save_settings().await 238 | } 239 | 240 | pub async fn pr_issue_remove(resources: &ChatRepoResources, id: u64) -> Result<(), Error> { 241 | { 242 | let mut locked = resources.settings.write().await; 243 | if !locked.pr_issues.contains_key(&id) { 244 | return Err(Error::UnknownPRIssue(id)); 245 | } 246 | locked.pr_issues.remove(&id); 247 | } 248 | resources.save_settings().await 249 | } 250 | 251 | pub async fn pr_issue_check( 252 | resources: &ChatRepoResources, 253 | repo_resources: &RepoResources, 254 | id: u64, 255 | ) -> Result { 256 | log::info!("checking PR/issue ({task}, {id})", task = resources.task); 257 | let github_info = { 258 | let locked = repo_resources.settings.read().await; 259 | locked 260 | .github_info 261 | .clone() 262 | .ok_or(Error::NoGitHubInfo(resources.task.repo.clone()))? 263 | }; 264 | log::debug!("checking PR/issue {github_info}#{id}"); 265 | let issue = github::get_issue(&github_info, id).await?; 266 | if issue.pull_request.is_some() && github::is_merged(&github_info, id).await? { 267 | let settings = { 268 | let mut locked = resources.settings.write().await; 269 | locked 270 | .pr_issues 271 | .remove(&id) 272 | .ok_or(Error::UnknownPRIssue(id))? 273 | }; 274 | resources.save_settings().await?; 275 | let commit = merged_pr_to_commit(resources, github_info, id, settings).await?; 276 | return Ok(PRIssueCheckResult::Merged(commit)); 277 | } 278 | if issue.state == IssueState::Closed { 279 | { 280 | let mut locked = resources.settings.write().await; 281 | locked 282 | .pr_issues 283 | .remove(&id) 284 | .ok_or(Error::UnknownPRIssue(id))?; 285 | }; 286 | resources.save_settings().await?; 287 | return Ok(PRIssueCheckResult::Closed); 288 | } 289 | Ok(PRIssueCheckResult::Waiting) 290 | } 291 | 292 | pub async fn merged_pr_to_commit( 293 | resources: &ChatRepoResources, 294 | github_info: GitHubInfo, 295 | pr_id: u64, 296 | settings: PRIssueSettings, 297 | ) -> Result { 298 | let pr = github::get_pr(&github_info, pr_id).await?; 299 | let commit = pr 300 | .merge_commit_sha 301 | .ok_or(Error::NoMergeCommit { github_info, pr_id })?; 302 | let comment = format!( 303 | "{title}{comment}", 304 | title = pr.title.as_deref().unwrap_or("untitled"), 305 | comment = empty_or_start_new_line(&settings.notify.comment), 306 | ); 307 | let commit_settings = CommitSettings { 308 | url: Some(settings.url), 309 | notify: NotifySettings { 310 | comment, 311 | subscribers: settings.notify.subscribers, 312 | }, 313 | }; 314 | 315 | commit_add(resources, &commit, commit_settings) 316 | .await 317 | .map(|()| commit) 318 | } 319 | 320 | pub async fn branch_add( 321 | resources: &ChatRepoResources, 322 | branch: &str, 323 | settings: BranchSettings, 324 | ) -> Result<(), Error> { 325 | { 326 | let mut locked = resources.settings.write().await; 327 | if locked.branches.contains_key(branch) { 328 | return Err(Error::BranchExists(branch.to_owned())); 329 | } 330 | locked.branches.insert(branch.to_owned(), settings); 331 | } 332 | resources.save_settings().await 333 | } 334 | 335 | pub async fn branch_remove(resources: &ChatRepoResources, branch: &str) -> Result<(), Error> { 336 | { 337 | let mut locked = resources.settings.write().await; 338 | if !locked.branches.contains_key(branch) { 339 | return Err(Error::UnknownBranch(branch.to_owned())); 340 | } 341 | locked.branches.remove(branch); 342 | } 343 | { 344 | let mut locked = resources.results.write().await; 345 | locked.branches.remove(branch); 346 | } 347 | resources.save_settings().await?; 348 | resources.save_results().await 349 | } 350 | 351 | pub async fn branch_check( 352 | resources: &ChatRepoResources, 353 | repo_resources: &RepoResources, 354 | branch_name: &str, 355 | ) -> Result { 356 | log::info!( 357 | "checking branch ({task}, {branch_name})", 358 | task = resources.task 359 | ); 360 | let result = { 361 | let old_result = { 362 | let results = resources.results.read().await; 363 | match results.branches.get(branch_name) { 364 | Some(r) => r.clone(), 365 | None => Default::default(), 366 | } 367 | }; 368 | 369 | // get the new commit (optional) 370 | let commit = { 371 | let repo = repo_resources.repo.lock().await; 372 | let remote_branch_name = format!("origin/{branch_name}"); 373 | 374 | match repo.find_branch(&remote_branch_name, BranchType::Remote) { 375 | Ok(branch) => { 376 | let commit: String = branch.into_reference().peel_to_commit()?.id().to_string(); 377 | Some(commit) 378 | } 379 | Err(_error) => { 380 | log::warn!( 381 | "branch {} not found in ({}, {})", 382 | branch_name, 383 | resources.task.chat, 384 | resources.task.repo, 385 | ); 386 | None 387 | } 388 | } 389 | }; 390 | 391 | { 392 | let mut results = resources.results.write().await; 393 | results.branches.insert( 394 | branch_name.to_owned(), 395 | BranchResults { 396 | commit: commit.clone(), 397 | }, 398 | ); 399 | } 400 | resources.save_results().await?; 401 | 402 | BranchCheckResult { 403 | old: old_result.commit, 404 | new: commit, 405 | } 406 | }; 407 | Ok(result) 408 | } 409 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod chat; 2 | mod command; 3 | mod condition; 4 | mod error; 5 | mod github; 6 | mod message; 7 | mod migration; 8 | mod options; 9 | mod repo; 10 | mod resources; 11 | mod update; 12 | mod utils; 13 | 14 | use std::collections::BTreeMap; 15 | use std::collections::BTreeSet; 16 | use std::env; 17 | use std::fmt; 18 | use std::str::FromStr; 19 | 20 | use chrono::Utc; 21 | use clap::crate_version; 22 | use cron::Schedule; 23 | use error::Error; 24 | use github::GitHubInfo; 25 | use regex::Regex; 26 | use serde::Deserialize; 27 | use serde::Serialize; 28 | use teloxide::dispatching::dialogue::GetChatId; 29 | use teloxide::payloads; 30 | use teloxide::payloads::SendMessage; 31 | use teloxide::prelude::*; 32 | use teloxide::requests::JsonRequest; 33 | use teloxide::sugar::request::RequestLinkPreviewExt; 34 | use teloxide::types::InlineKeyboardButton; 35 | use teloxide::types::InlineKeyboardButtonKind; 36 | use teloxide::types::InlineKeyboardMarkup; 37 | use teloxide::types::ParseMode; 38 | use teloxide::types::User; 39 | use teloxide::update_listeners; 40 | use teloxide::utils::command::BotCommands; 41 | use teloxide::utils::markdown; 42 | use tokio::fs::read_dir; 43 | use tokio::time::sleep; 44 | use url::Url; 45 | 46 | use crate::chat::results::PRIssueCheckResult; 47 | use crate::chat::settings::BranchSettings; 48 | use crate::chat::settings::CommitSettings; 49 | use crate::chat::settings::NotifySettings; 50 | use crate::chat::settings::PRIssueSettings; 51 | use crate::chat::settings::Subscriber; 52 | use crate::condition::Action; 53 | use crate::condition::GeneralCondition; 54 | use crate::condition::in_branch::InBranchCondition; 55 | use crate::message::branch_check_message; 56 | use crate::message::commit_check_message; 57 | use crate::message::pr_issue_id_pretty; 58 | use crate::message::subscriber_from_msg; 59 | use crate::repo::pr_issue_url; 60 | use crate::repo::settings::ConditionSettings; 61 | use crate::update::update_and_report_error; 62 | use crate::utils::modify_subscriber_set; 63 | use crate::utils::read_json_strict; 64 | use crate::utils::reply_to_msg; 65 | use crate::utils::report_command_error; 66 | use crate::utils::report_error; 67 | use crate::utils::resolve_repo_or_url_and_id; 68 | use crate::utils::write_json; 69 | 70 | #[derive(BotCommands, Clone, Debug)] 71 | #[command(rename_rule = "lowercase", description = "Supported commands:")] 72 | enum BCommand { 73 | #[command(description = "main command.")] 74 | Notifier(String), 75 | #[command(description = "shortcut for/notifier pr-add/issue-add.")] 76 | Add(String), 77 | } 78 | 79 | #[tokio::main] 80 | async fn main() { 81 | run().await; 82 | } 83 | 84 | async fn run() { 85 | pretty_env_logger::init(); 86 | 87 | options::initialize(); 88 | log::info!("config = {:?}", options::get()); 89 | 90 | if let Err(e) = version_check().await { 91 | log::error!("error: {e}"); 92 | std::process::exit(1); 93 | } 94 | 95 | octocrab_initialize(); 96 | 97 | let bot = Bot::from_env(); 98 | let command_handler = teloxide::filter_command::().endpoint(answer); 99 | let message_handler = Update::filter_message().branch(command_handler); 100 | let callback_handler = Update::filter_callback_query().endpoint(handle_callback_query); 101 | let handler = dptree::entry() 102 | .branch(message_handler) 103 | .branch(callback_handler); 104 | let mut dispatcher = Dispatcher::builder(bot.clone(), handler) 105 | .enable_ctrlc_handler() 106 | .build(); 107 | 108 | let update_listener = update_listeners::polling_default(bot.clone()).await; 109 | tokio::select! { 110 | _ = schedule(bot.clone()) => { }, 111 | _ = dispatcher.dispatch_with_listener( 112 | update_listener, 113 | LoggingErrorHandler::with_custom_text("An error from the update listener"), 114 | ) => { }, 115 | } 116 | 117 | log::info!("cleaning up resources for chats"); 118 | if let Err(e) = chat::resources::RESOURCES_MAP.clear().await { 119 | log::error!("failed to clear resources map for chats: {e}"); 120 | } 121 | log::info!("cleaning up resources for repositories"); 122 | if let Err(e) = repo::resources::RESOURCES_MAP.clear().await { 123 | log::error!("failed to clear resources map for repositories: {e}"); 124 | } 125 | log::info!("exit"); 126 | } 127 | 128 | #[derive(Debug, Clone, Serialize, Deserialize)] 129 | pub struct VersionInfo { 130 | version: String, 131 | } 132 | 133 | async fn version_check() -> Result<(), Error> { 134 | let options = options::get(); 135 | let working_dir = &options.working_dir; 136 | let version_json_path = working_dir.join("version.json"); 137 | let version_info = VersionInfo { 138 | version: crate_version!().to_string(), 139 | }; 140 | log::debug!( 141 | "version checking, read working directory: {:?}", 142 | working_dir 143 | ); 144 | let mut dir = read_dir(working_dir).await?; 145 | let mut version_info_found = false; 146 | let mut non_empty = false; 147 | while let Some(entry) = dir.next_entry().await? { 148 | non_empty = true; 149 | let file_name = entry.file_name(); 150 | if file_name == "version.json" { 151 | version_info_found = true; 152 | } 153 | } 154 | if non_empty && !version_info_found { 155 | log::error!("working directory is non-empty, but no version information can be found "); 156 | log::error!( 157 | "if you are upgrading from version `0.1.x`, please follow for manual migration" 158 | ); 159 | return Err(Error::NeedsManualMigration); 160 | } else if non_empty { 161 | // get version information 162 | let old_version_info: VersionInfo = read_json_strict(&version_json_path)?; 163 | let old_version = version_compare::Version::from(&old_version_info.version) 164 | .ok_or_else(|| Error::InvalidVersion(old_version_info.version.clone()))?; 165 | let new_version = version_compare::Version::from(&version_info.version) 166 | .ok_or_else(|| Error::InvalidVersion(old_version_info.version.clone()))?; 167 | if old_version > new_version { 168 | return Err(Error::VersionDowngrading( 169 | old_version_info.version, 170 | version_info.version, 171 | )); 172 | } 173 | if old_version == version_compare::Version::from("0.2.1").unwrap() { 174 | migration::from_0_2_1().await?; 175 | } 176 | } else { 177 | // do nothing, start from an empty configuration 178 | } 179 | log::debug!( 180 | "save version information to {:?}: {:?}", 181 | version_json_path, 182 | version_info 183 | ); 184 | write_json(version_json_path, &version_info)?; 185 | Ok(()) 186 | } 187 | 188 | async fn answer(bot: Bot, msg: Message, bc: BCommand) -> ResponseResult<()> { 189 | log::trace!("message: {msg:?}"); 190 | log::trace!("bot command: {bc:?}"); 191 | let cmd_str = match bc { 192 | BCommand::Notifier(input) => input, 193 | BCommand::Add(input) => format!("pr-add {input}"), 194 | }; 195 | let result = match command::parse(cmd_str) { 196 | Ok(command) => { 197 | log::debug!("command: {command:?}"); 198 | let (bot, msg) = (bot.clone(), msg.clone()); 199 | match command { 200 | command::Notifier::ChatId => return_chat_id(bot, msg).await, 201 | command::Notifier::RepoAdd { name, url } => repo_add(bot, msg, name, url).await, 202 | command::Notifier::RepoEdit { 203 | name, 204 | branch_regex, 205 | github_info, 206 | clear_github_info, 207 | } => repo_edit(bot, msg, name, branch_regex, github_info, clear_github_info).await, 208 | command::Notifier::RepoRemove { name } => repo_remove(bot, msg, name).await, 209 | command::Notifier::CommitAdd { 210 | repo, 211 | hash, 212 | comment, 213 | } => commit_add(bot, msg, repo, hash, comment, None).await, 214 | command::Notifier::CommitRemove { repo, hash } => { 215 | commit_remove(bot, msg, repo, hash).await 216 | } 217 | command::Notifier::CommitCheck { repo, hash } => { 218 | commit_check(bot, msg, repo, hash).await 219 | } 220 | command::Notifier::CommitSubscribe { 221 | repo, 222 | hash, 223 | unsubscribe, 224 | } => commit_subscribe(bot, msg, repo, hash, unsubscribe).await, 225 | command::Notifier::PrAdd { 226 | repo_or_url, 227 | id, 228 | comment, 229 | } => match report_error( 230 | &bot, 231 | &msg, 232 | resolve_repo_or_url_and_id(repo_or_url, id).await, 233 | ) 234 | .await? 235 | { 236 | Some((repo, id)) => pr_issue_add(bot, msg, repo, id, comment).await, 237 | None => Ok(()), 238 | }, 239 | command::Notifier::PrRemove { repo_or_url, id } => match report_error( 240 | &bot, 241 | &msg, 242 | resolve_repo_or_url_and_id(repo_or_url, id).await, 243 | ) 244 | .await? 245 | { 246 | Some((repo, id)) => pr_issue_remove(bot, msg, repo, id).await, 247 | None => Ok(()), 248 | }, 249 | command::Notifier::PrCheck { repo_or_url, id } => match report_error( 250 | &bot, 251 | &msg, 252 | resolve_repo_or_url_and_id(repo_or_url, id).await, 253 | ) 254 | .await? 255 | { 256 | Some((repo, id)) => pr_issue_check(bot, msg, repo, id).await, 257 | None => Ok(()), 258 | }, 259 | command::Notifier::PrSubscribe { 260 | repo_or_url, 261 | id, 262 | unsubscribe, 263 | } => match report_error( 264 | &bot, 265 | &msg, 266 | resolve_repo_or_url_and_id(repo_or_url, id).await, 267 | ) 268 | .await? 269 | { 270 | Some((repo, id)) => pr_issue_subscribe(bot, msg, repo, id, unsubscribe).await, 271 | None => Ok(()), 272 | }, 273 | command::Notifier::BranchAdd { repo, branch } => { 274 | branch_add(bot, msg, repo, branch).await 275 | } 276 | command::Notifier::BranchRemove { repo, branch } => { 277 | branch_remove(bot, msg, repo, branch).await 278 | } 279 | command::Notifier::BranchCheck { repo, branch } => { 280 | branch_check(bot, msg, repo, branch).await 281 | } 282 | command::Notifier::BranchSubscribe { 283 | repo, 284 | branch, 285 | unsubscribe, 286 | } => branch_subscribe(bot, msg, repo, branch, unsubscribe).await, 287 | command::Notifier::ConditionAdd { 288 | repo, 289 | identifier, 290 | kind, 291 | expression, 292 | } => condition_add(bot, msg, repo, identifier, kind, expression).await, 293 | command::Notifier::ConditionRemove { repo, identifier } => { 294 | condition_remove(bot, msg, repo, identifier).await 295 | } 296 | command::Notifier::List => list(bot, msg).await, 297 | } 298 | } 299 | Err(Error::Clap(e)) => { 300 | let help_text = e.render().to_string(); 301 | reply_to_msg( 302 | &bot, 303 | &msg, 304 | markdown::expandable_blockquote(&markdown::escape(&help_text)), 305 | ) 306 | .parse_mode(ParseMode::MarkdownV2) 307 | .await?; 308 | Ok(()) 309 | } 310 | Err(e) => Err(e.into()), 311 | }; 312 | report_command_error(&bot, &msg, result).await?; 313 | Ok(()) 314 | } 315 | 316 | #[derive(Serialize, Deserialize, Clone, Debug)] 317 | struct SubscribeTerm(String, String, String, usize); 318 | 319 | async fn handle_callback_query(bot: Bot, query: CallbackQuery) -> ResponseResult<()> { 320 | let result = handle_callback_query_command_result(&bot, &query).await; 321 | let (message, alert) = match result { 322 | Ok(msg) => (msg, false), 323 | Err(CommandError::Normal(e)) => (format!("{e}"), true), 324 | Err(CommandError::Teloxide(e)) => return Err(e), 325 | }; 326 | let answer = payloads::AnswerCallbackQuery::new(query.id) 327 | .text(message) 328 | .show_alert(alert); 329 | ::AnswerCallbackQuery::new(bot, answer).await?; 330 | Ok(()) 331 | } 332 | 333 | async fn handle_callback_query_command_result( 334 | _bot: &Bot, 335 | query: &CallbackQuery, 336 | ) -> Result { 337 | log::debug!("query = {query:?}"); 338 | let (chat_id, user) = get_chat_id_and_user_from_query(query)?; 339 | let subscriber = Subscriber::from_tg_user(&user); 340 | let _msg = query 341 | .message 342 | .as_ref() 343 | .ok_or(Error::SubscribeCallbackNoMsgId)?; 344 | let data = query.data.as_ref().ok_or(Error::SubscribeCallbackNoData)?; 345 | let SubscribeTerm(kind, repo, id, subscribe) = 346 | serde_json::from_str(data).map_err(Error::Serde)?; 347 | let unsubscribe = subscribe == 0; 348 | match kind.as_str() { 349 | "b" => { 350 | let resources = chat::resources_chat_repo(chat_id, repo.clone()).await?; 351 | { 352 | let mut settings = resources.settings.write().await; 353 | let subscribers = &mut settings 354 | .branches 355 | .get_mut(&id) 356 | .ok_or_else(|| Error::UnknownBranch(id.clone()))? 357 | .notify 358 | .subscribers; 359 | modify_subscriber_set(subscribers, subscriber, unsubscribe)?; 360 | } 361 | resources.save_settings().await?; 362 | } 363 | "c" => { 364 | let resources = chat::resources_chat_repo(chat_id, repo.clone()).await?; 365 | { 366 | let mut settings = resources.settings.write().await; 367 | let subscribers = &mut settings 368 | .commits 369 | .get_mut(&id) 370 | .ok_or_else(|| Error::UnknownCommit(id.clone()))? 371 | .notify 372 | .subscribers; 373 | modify_subscriber_set(subscribers, subscriber, unsubscribe)?; 374 | } 375 | resources.save_settings().await?; 376 | } 377 | "p" => { 378 | let issue_id: u64 = id.parse().map_err(Error::ParseInt)?; 379 | let resources = chat::resources_chat_repo(chat_id, repo.clone()).await?; 380 | { 381 | let mut settings = resources.settings.write().await; 382 | let subscribers = &mut settings 383 | .pr_issues 384 | .get_mut(&issue_id) 385 | .ok_or_else(|| Error::UnknownPRIssue(issue_id))? 386 | .notify 387 | .subscribers; 388 | modify_subscriber_set(subscribers, subscriber, unsubscribe)?; 389 | } 390 | resources.save_settings().await?; 391 | } 392 | _ => Err(Error::SubscribeCallbackDataInvalidKind(kind))?, 393 | } 394 | if unsubscribe { 395 | Ok(format!("unsubscribed from {repo}/{id}")) 396 | } else { 397 | Ok(format!("subscribed to {repo}/{id}")) 398 | } 399 | } 400 | 401 | fn get_chat_id_and_user_from_query(query: &CallbackQuery) -> Result<(ChatId, User), Error> { 402 | let chat_id = query.chat_id().ok_or(Error::SubscribeCallbackNoChatId)?; 403 | let user = query.from.clone(); 404 | Ok((chat_id, user)) 405 | } 406 | 407 | enum CommandError { 408 | Normal(Error), 409 | Teloxide(teloxide::RequestError), 410 | } 411 | impl From for CommandError { 412 | fn from(e: Error) -> Self { 413 | CommandError::Normal(e) 414 | } 415 | } 416 | impl From for CommandError { 417 | fn from(e: teloxide::RequestError) -> Self { 418 | CommandError::Teloxide(e) 419 | } 420 | } 421 | impl fmt::Display for CommandError { 422 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 423 | match self { 424 | CommandError::Normal(e) => write!(f, "{e}"), 425 | CommandError::Teloxide(e) => write!(f, "{e}"), 426 | } 427 | } 428 | } 429 | 430 | fn octocrab_initialize() { 431 | let builder = octocrab::Octocrab::builder(); 432 | let with_token = match env::var("GITHUB_TOKEN") { 433 | Ok(token) => { 434 | log::info!("github token set using environment variable 'GITHUB_TOKEN'"); 435 | builder.personal_token(token) 436 | } 437 | Err(e) => { 438 | log::info!("github token not set: {e}"); 439 | builder 440 | } 441 | }; 442 | let crab = with_token.build().unwrap(); 443 | octocrab::initialise(crab); 444 | } 445 | 446 | async fn schedule(bot: Bot) { 447 | // always update once on startup 448 | if let Err(e) = update_and_report_error(bot.clone()).await { 449 | log::error!("teloxide error in update: {e}"); 450 | } 451 | 452 | let expression = &options::get().cron; 453 | let schedule = Schedule::from_str(expression).expect("cron expression"); 454 | for datetime in schedule.upcoming(Utc) { 455 | let now = Utc::now(); 456 | let dur = match (datetime - now).to_std() { 457 | // duration is less than zero 458 | Err(_) => continue, 459 | Ok(std_dur) => std_dur, 460 | }; 461 | log::info!("update is going to be triggered at '{datetime}', sleep '{dur:?}'"); 462 | sleep(dur).await; 463 | log::info!("perform update '{datetime}'"); 464 | if let Err(e) = update_and_report_error(bot.clone()).await { 465 | log::error!("teloxide error in update: {e}"); 466 | } 467 | log::info!("finished update '{datetime}'"); 468 | } 469 | } 470 | 471 | async fn list(bot: Bot, msg: Message) -> Result<(), CommandError> { 472 | let options = options::get(); 473 | let chat = msg.chat.id; 474 | if ChatId(options.admin_chat_id) == chat { 475 | list_for_admin(bot.clone(), &msg).await?; 476 | } 477 | list_for_normal(bot, &msg).await 478 | } 479 | 480 | async fn list_for_admin(bot: Bot, msg: &Message) -> Result<(), CommandError> { 481 | log::info!("list for admin"); 482 | let mut result = String::new(); 483 | 484 | let repos = repo::list().await?; 485 | for repo in repos { 486 | result.push_str("\\- *"); 487 | result.push_str(&markdown::escape(&repo)); 488 | result.push_str("*\n"); 489 | let settings_json = { 490 | let resources = repo::resources(&repo).await?; 491 | let settings = resources.settings.read().await; 492 | serde_json::to_string(&*settings).map_err(Error::Serde)? 493 | }; 494 | result.push_str(" "); 495 | result.push_str(&markdown::escape(&settings_json)); 496 | result.push('\n'); 497 | } 498 | if result.is_empty() { 499 | result.push_str("(nothing)\n"); 500 | } 501 | reply_to_msg(&bot, msg, result) 502 | .parse_mode(ParseMode::MarkdownV2) 503 | .disable_link_preview(true) 504 | .await?; 505 | 506 | Ok(()) 507 | } 508 | 509 | async fn list_for_normal(bot: Bot, msg: &Message) -> Result<(), CommandError> { 510 | let chat = msg.chat.id; 511 | log::info!("list for chat: {chat}"); 512 | let mut result = String::new(); 513 | 514 | let repos = chat::repos(chat).await?; 515 | for repo in repos { 516 | result.push('*'); 517 | result.push_str(&markdown::escape(&repo)); 518 | result.push_str("*\n"); 519 | 520 | let resources = chat::resources_chat_repo(chat, repo).await?; 521 | let settings = { 522 | let locked = resources.settings.read().await; 523 | locked.clone() 524 | }; 525 | 526 | result.push_str(" *commits*:\n"); 527 | let commits = &settings.commits; 528 | if commits.is_empty() { 529 | result.push_str(" \\(nothing\\)\n"); 530 | } 531 | for (commit, settings) in commits { 532 | result.push_str(&format!( 533 | " \\- `{}`\n {}\n", 534 | markdown::escape(commit), 535 | settings.notify.description_markdown() 536 | )); 537 | } 538 | result.push_str(" *PRs/issues*:\n"); 539 | let pr_issues = &settings.pr_issues; 540 | if pr_issues.is_empty() { 541 | result.push_str(" \\(nothing\\)\n"); 542 | } 543 | for (id, settings) in pr_issues { 544 | result.push_str(&format!( 545 | " \\- `{id}`\n {}\n", 546 | markdown::escape(settings.url.as_str()) 547 | )); 548 | } 549 | result.push_str(" *branches*:\n"); 550 | let branches = &settings.branches; 551 | if branches.is_empty() { 552 | result.push_str(" \\(nothing\\)\n"); 553 | } 554 | for branch in branches.keys() { 555 | result.push_str(&format!(" \\- `{}`\n", markdown::escape(branch))); 556 | } 557 | 558 | result.push('\n'); 559 | } 560 | if result.is_empty() { 561 | result.push_str("\\(nothing\\)\n"); 562 | } 563 | reply_to_msg(&bot, msg, markdown::expandable_blockquote(&result)) 564 | .parse_mode(ParseMode::MarkdownV2) 565 | .disable_link_preview(true) 566 | .await?; 567 | 568 | Ok(()) 569 | } 570 | 571 | async fn return_chat_id(bot: Bot, msg: Message) -> Result<(), CommandError> { 572 | reply_to_msg(&bot, &msg, format!("{}", msg.chat.id)).await?; 573 | Ok(()) 574 | } 575 | 576 | async fn repo_add(bot: Bot, msg: Message, name: String, url: String) -> Result<(), CommandError> { 577 | ensure_admin_chat(&msg)?; 578 | let _output = repo::create(&name, &url).await?; 579 | let resources = repo::resources(&name).await?; 580 | let github_info = Url::parse(&url) 581 | .ok() 582 | .and_then(|u| GitHubInfo::parse_from_url(u).ok()); 583 | 584 | let settings = { 585 | let mut locked = resources.settings.write().await; 586 | if let Some(info) = &github_info { 587 | let repository = octocrab::instance() 588 | .repos(&info.owner, &info.repo) 589 | .get() 590 | .await 591 | .map_err(|e| Error::Octocrab(Box::new(e)))?; 592 | if let Some(default_branch) = repository.default_branch { 593 | let default_regex_str = format!("^({})$", regex::escape(&default_branch)); 594 | let default_regex = Regex::new(&default_regex_str).map_err(Error::from)?; 595 | let default_condition = ConditionSettings { 596 | condition: GeneralCondition::InBranch(InBranchCondition { 597 | branch_regex: default_regex.clone(), 598 | }), 599 | }; 600 | locked.branch_regex = default_regex; 601 | locked.conditions = { 602 | let mut map = BTreeMap::new(); 603 | map.insert(format!("in-{default_branch}"), default_condition); 604 | map 605 | }; 606 | } 607 | } 608 | locked.github_info = github_info; 609 | locked.clone() 610 | }; 611 | resources.save_settings().await?; 612 | reply_to_msg( 613 | &bot, 614 | &msg, 615 | format!("repository '{name}' added, settings:\n{settings:#?}"), 616 | ) 617 | .await?; 618 | Ok(()) 619 | } 620 | 621 | async fn repo_edit( 622 | bot: Bot, 623 | msg: Message, 624 | name: String, 625 | branch_regex: Option, 626 | github_info: Option, 627 | clear_github_info: bool, 628 | ) -> Result<(), CommandError> { 629 | ensure_admin_chat(&msg)?; 630 | let resources = repo::resources(&name).await?; 631 | let new_settings = { 632 | let mut locked = resources.settings.write().await; 633 | if let Some(r) = branch_regex { 634 | let regex = Regex::new(&format!("^({r})$")).map_err(Error::from)?; 635 | locked.branch_regex = regex; 636 | } 637 | if let Some(info) = github_info { 638 | locked.github_info = Some(info); 639 | } 640 | if clear_github_info { 641 | locked.github_info = None; 642 | } 643 | locked.clone() 644 | }; 645 | resources.save_settings().await?; 646 | reply_to_msg( 647 | &bot, 648 | &msg, 649 | format!("repository '{name}' edited, current settings:\n{new_settings:#?}"), 650 | ) 651 | .await?; 652 | Ok(()) 653 | } 654 | 655 | async fn repo_remove(bot: Bot, msg: Message, name: String) -> Result<(), CommandError> { 656 | ensure_admin_chat(&msg)?; 657 | repo::remove(&name).await?; 658 | reply_to_msg(&bot, &msg, format!("repository '{name}' removed")).await?; 659 | Ok(()) 660 | } 661 | 662 | async fn condition_add( 663 | bot: Bot, 664 | msg: Message, 665 | repo: String, 666 | identifier: String, 667 | kind: condition::Kind, 668 | expr: String, 669 | ) -> Result<(), CommandError> { 670 | ensure_admin_chat(&msg)?; 671 | let resources = repo::resources(&repo).await?; 672 | let settings = ConditionSettings { 673 | condition: GeneralCondition::parse(kind, &expr)?, 674 | }; 675 | repo::condition_add(&resources, &identifier, settings).await?; 676 | reply_to_msg(&bot, &msg, format!("condition {identifier} added")).await?; 677 | Ok(()) 678 | } 679 | 680 | async fn condition_remove( 681 | bot: Bot, 682 | msg: Message, 683 | repo: String, 684 | identifier: String, 685 | ) -> Result<(), CommandError> { 686 | ensure_admin_chat(&msg)?; 687 | let resources = repo::resources(&repo).await?; 688 | repo::condition_remove(&resources, &identifier).await?; 689 | reply_to_msg(&bot, &msg, format!("condition {identifier} removed")).await?; 690 | Ok(()) 691 | } 692 | 693 | async fn commit_add( 694 | bot: Bot, 695 | msg: Message, 696 | repo: String, 697 | hash: String, 698 | comment: String, 699 | url: Option, 700 | ) -> Result<(), CommandError> { 701 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 702 | let guard = resources.commit_lock(hash.clone()).await; 703 | let subscribers = subscriber_from_msg(&msg).into_iter().collect(); 704 | let settings = CommitSettings { 705 | url, 706 | notify: NotifySettings { 707 | comment, 708 | subscribers, 709 | }, 710 | }; 711 | match chat::commit_add(&resources, &hash, settings).await { 712 | Ok(()) => { 713 | drop(guard); 714 | commit_check(bot, msg, repo, hash).await?; 715 | } 716 | Err(Error::CommitExists(_)) => { 717 | { 718 | let mut settings = resources.settings.write().await; 719 | settings 720 | .commits 721 | .get_mut(&hash) 722 | .ok_or_else(|| Error::UnknownCommit(hash.clone()))? 723 | .notify 724 | .subscribers 725 | .extend(subscriber_from_msg(&msg)); 726 | } 727 | drop(guard); 728 | commit_check(bot, msg, repo, hash).await?; 729 | } 730 | Err(e) => return Err(e.into()), 731 | } 732 | Ok(()) 733 | } 734 | 735 | async fn commit_remove( 736 | bot: Bot, 737 | msg: Message, 738 | repo: String, 739 | hash: String, 740 | ) -> Result<(), CommandError> { 741 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 742 | let _guard = resources.commit_lock(hash.clone()).await; 743 | chat::commit_remove(&resources, &hash).await?; 744 | reply_to_msg(&bot, &msg, format!("commit {hash} removed")).await?; 745 | Ok(()) 746 | } 747 | 748 | async fn commit_check( 749 | bot: Bot, 750 | msg: Message, 751 | repo: String, 752 | hash: String, 753 | ) -> Result<(), CommandError> { 754 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 755 | let _guard = resources.commit_lock(hash.clone()).await; 756 | let repo_resources = repo::resources(&repo).await?; 757 | let commit_settings = { 758 | let settings = resources.settings.read().await; 759 | settings 760 | .commits 761 | .get(&hash) 762 | .ok_or_else(|| Error::UnknownCommit(hash.clone()))? 763 | .clone() 764 | }; 765 | let result = chat::commit_check(&resources, &repo_resources, &hash).await?; 766 | // do not mention in manual check 767 | let reply = commit_check_message(&repo, &hash, &commit_settings, &result, false); 768 | let mut send = reply_to_msg(&bot, &msg, reply) 769 | .parse_mode(ParseMode::MarkdownV2) 770 | .disable_link_preview(true); 771 | let remove_conditions: BTreeSet<&String> = result.conditions_of_action(Action::Remove); 772 | if remove_conditions.is_empty() { 773 | send = try_attach_subscribe_button_markup(msg.chat.id, send, "c", &repo, &hash); 774 | } 775 | send.await?; 776 | Ok(()) 777 | } 778 | 779 | async fn commit_subscribe( 780 | bot: Bot, 781 | msg: Message, 782 | repo: String, 783 | hash: String, 784 | unsubscribe: bool, 785 | ) -> Result<(), CommandError> { 786 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 787 | let _guard = resources.commit_lock(hash.clone()).await; 788 | let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; 789 | { 790 | let mut settings = resources.settings.write().await; 791 | let subscribers = &mut settings 792 | .commits 793 | .get_mut(&hash) 794 | .ok_or_else(|| Error::UnknownCommit(hash.clone()))? 795 | .notify 796 | .subscribers; 797 | modify_subscriber_set(subscribers, subscriber, unsubscribe)?; 798 | } 799 | resources.save_settings().await?; 800 | reply_to_msg(&bot, &msg, "done").await?; 801 | Ok(()) 802 | } 803 | 804 | async fn pr_issue_add( 805 | bot: Bot, 806 | msg: Message, 807 | repo: String, 808 | id: u64, 809 | optional_comment: Option, 810 | ) -> Result<(), CommandError> { 811 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 812 | let repo_resources = repo::resources(&repo).await?; 813 | let url = pr_issue_url(&repo_resources, id).await?; 814 | let subscribers = subscriber_from_msg(&msg).into_iter().collect(); 815 | let comment = optional_comment.unwrap_or_default(); 816 | let settings = PRIssueSettings { 817 | url, 818 | notify: NotifySettings { 819 | comment, 820 | subscribers, 821 | }, 822 | }; 823 | match chat::pr_issue_add(&resources, &repo_resources, id, settings).await { 824 | Ok(()) => pr_issue_check(bot, msg, repo, id).await, 825 | Err(Error::PRIssueExists(_)) => { 826 | { 827 | let mut settings = resources.settings.write().await; 828 | settings 829 | .pr_issues 830 | .get_mut(&id) 831 | .ok_or_else(|| Error::UnknownPRIssue(id))? 832 | .notify 833 | .subscribers 834 | .extend(subscriber_from_msg(&msg)); 835 | } 836 | pr_issue_check(bot, msg, repo, id).await 837 | } 838 | Err(e) => Err(e.into()), 839 | } 840 | } 841 | 842 | async fn pr_issue_check(bot: Bot, msg: Message, repo: String, id: u64) -> Result<(), CommandError> { 843 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 844 | let repo_resources = repo::resources(&repo).await?; 845 | match chat::pr_issue_check(&resources, &repo_resources, id).await { 846 | Ok(result) => { 847 | let pretty_id = pr_issue_id_pretty(&repo_resources, id).await?; 848 | match result { 849 | PRIssueCheckResult::Merged(commit) => commit_check(bot, msg, repo, commit).await, 850 | PRIssueCheckResult::Closed => { 851 | reply_to_msg( 852 | &bot, 853 | &msg, 854 | format!("{pretty_id} has been closed \\(and removed\\)"), 855 | ) 856 | .parse_mode(ParseMode::MarkdownV2) 857 | .await?; 858 | Ok(()) 859 | } 860 | PRIssueCheckResult::Waiting => { 861 | let mut send = reply_to_msg( 862 | &bot, 863 | &msg, 864 | format!("{pretty_id} has not been merged/closed yet"), 865 | ) 866 | .parse_mode(ParseMode::MarkdownV2); 867 | send = try_attach_subscribe_button_markup( 868 | msg.chat.id, 869 | send, 870 | "p", 871 | &repo, 872 | &id.to_string(), 873 | ); 874 | send.await?; 875 | Ok(()) 876 | } 877 | } 878 | } 879 | Err(Error::CommitExists(commit)) => { 880 | { 881 | let mut settings = resources.settings.write().await; 882 | settings 883 | .commits 884 | .get_mut(&commit) 885 | .ok_or_else(|| Error::UnknownCommit(commit.clone()))? 886 | .notify 887 | .subscribers 888 | .extend(subscriber_from_msg(&msg)); 889 | } 890 | commit_check(bot, msg, repo, commit).await 891 | } 892 | Err(e) => Err(e.into()), 893 | } 894 | } 895 | 896 | async fn pr_issue_remove( 897 | bot: Bot, 898 | msg: Message, 899 | repo: String, 900 | id: u64, 901 | ) -> Result<(), CommandError> { 902 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 903 | let repo_resources = repo::resources(&repo).await?; 904 | chat::pr_issue_remove(&resources, id).await?; 905 | let pretty_id = pr_issue_id_pretty(&repo_resources, id).await?; 906 | reply_to_msg(&bot, &msg, format!("{pretty_id} removed")) 907 | .parse_mode(ParseMode::MarkdownV2) 908 | .await?; 909 | Ok(()) 910 | } 911 | 912 | async fn pr_issue_subscribe( 913 | bot: Bot, 914 | msg: Message, 915 | repo: String, 916 | id: u64, 917 | unsubscribe: bool, 918 | ) -> Result<(), CommandError> { 919 | let resources = chat::resources_msg_repo(&msg, repo).await?; 920 | let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; 921 | { 922 | let mut settings = resources.settings.write().await; 923 | let subscribers = &mut settings 924 | .pr_issues 925 | .get_mut(&id) 926 | .ok_or_else(|| Error::UnknownPRIssue(id))? 927 | .notify 928 | .subscribers; 929 | modify_subscriber_set(subscribers, subscriber, unsubscribe)?; 930 | } 931 | resources.save_settings().await?; 932 | reply_to_msg(&bot, &msg, "done").await?; 933 | Ok(()) 934 | } 935 | 936 | async fn branch_add( 937 | bot: Bot, 938 | msg: Message, 939 | repo: String, 940 | branch: String, 941 | ) -> Result<(), CommandError> { 942 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 943 | let guard = resources.branch_lock(branch.clone()).await; 944 | let settings = BranchSettings { 945 | notify: Default::default(), 946 | }; 947 | match chat::branch_add(&resources, &branch, settings).await { 948 | Ok(()) => { 949 | drop(guard); 950 | branch_check(bot, msg, repo, branch).await 951 | } 952 | Err(Error::BranchExists(_)) => { 953 | drop(guard); 954 | branch_subscribe(bot, msg, repo, branch, false).await 955 | } 956 | Err(e) => Err(e.into()), 957 | } 958 | } 959 | 960 | async fn branch_remove( 961 | bot: Bot, 962 | msg: Message, 963 | repo: String, 964 | branch: String, 965 | ) -> Result<(), CommandError> { 966 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 967 | let _guard = resources.branch_lock(branch.clone()).await; 968 | chat::branch_remove(&resources, &branch).await?; 969 | reply_to_msg(&bot, &msg, format!("branch `{repo}`/`{branch}` removed")) 970 | .parse_mode(ParseMode::MarkdownV2) 971 | .await?; 972 | Ok(()) 973 | } 974 | 975 | async fn branch_check( 976 | bot: Bot, 977 | msg: Message, 978 | repo: String, 979 | branch: String, 980 | ) -> Result<(), CommandError> { 981 | let resources = chat::resources_msg_repo(&msg, repo.clone()).await?; 982 | let _guard = resources.branch_lock(branch.clone()).await; 983 | let repo_resources = repo::resources(&repo).await?; 984 | let branch_settings = { 985 | let settings = resources.settings.read().await; 986 | settings 987 | .branches 988 | .get(&branch) 989 | .ok_or_else(|| Error::UnknownBranch(branch.clone()))? 990 | .clone() 991 | }; 992 | let result = chat::branch_check(&resources, &repo_resources, &branch).await?; 993 | let reply = { 994 | let settings = repo_resources.settings.read().await; 995 | branch_check_message( 996 | &repo, 997 | &branch, 998 | &branch_settings, 999 | &result, 1000 | settings.github_info.as_ref(), 1001 | ) 1002 | }; 1003 | 1004 | let mut send = reply_to_msg(&bot, &msg, reply).parse_mode(ParseMode::MarkdownV2); 1005 | send = try_attach_subscribe_button_markup(msg.chat.id, send, "b", &repo, &branch); 1006 | send.await?; 1007 | Ok(()) 1008 | } 1009 | 1010 | async fn branch_subscribe( 1011 | bot: Bot, 1012 | msg: Message, 1013 | repo: String, 1014 | branch: String, 1015 | unsubscribe: bool, 1016 | ) -> Result<(), CommandError> { 1017 | let resources = chat::resources_msg_repo(&msg, repo).await?; 1018 | let _guard = resources.branch_lock(branch.clone()).await; 1019 | let subscriber = subscriber_from_msg(&msg).ok_or(Error::NoSubscriber)?; 1020 | { 1021 | let mut settings = resources.settings.write().await; 1022 | let subscribers = &mut settings 1023 | .branches 1024 | .get_mut(&branch) 1025 | .ok_or_else(|| Error::UnknownBranch(branch.clone()))? 1026 | .notify 1027 | .subscribers; 1028 | modify_subscriber_set(subscribers, subscriber, unsubscribe)?; 1029 | } 1030 | resources.save_settings().await?; 1031 | reply_to_msg(&bot, &msg, "done").await?; 1032 | Ok(()) 1033 | } 1034 | 1035 | fn ensure_admin_chat(msg: &Message) -> Result<(), CommandError> { 1036 | let options = options::get(); 1037 | if msg.chat_id().map(|id| id.0) == Some(options.admin_chat_id) { 1038 | Ok(()) 1039 | } else { 1040 | Err(Error::NotAdminChat.into()) 1041 | } 1042 | } 1043 | 1044 | fn try_attach_subscribe_button_markup( 1045 | chat: ChatId, 1046 | send: JsonRequest, 1047 | kind: &str, 1048 | repo: &str, 1049 | id: &str, 1050 | ) -> JsonRequest { 1051 | match subscribe_button_markup(kind, repo, id) { 1052 | Ok(m) => send.reply_markup(m), 1053 | Err(e) => { 1054 | log::error!("failed to create markup for ({chat}, {repo}, {id}): {e}"); 1055 | send 1056 | } 1057 | } 1058 | } 1059 | 1060 | fn subscribe_button_markup( 1061 | kind: &str, 1062 | repo: &str, 1063 | id: &str, 1064 | ) -> Result { 1065 | let mut item = SubscribeTerm(kind.to_owned(), repo.to_owned(), id.to_owned(), 1); 1066 | let subscribe_data = serde_json::to_string(&item)?; 1067 | item.3 = 0; 1068 | let unsubscribe_data = serde_json::to_string(&item)?; 1069 | let subscribe_len = subscribe_data.len(); 1070 | if subscribe_len > 64 { 1071 | return Err(Error::SubscribeTermSizeExceeded( 1072 | subscribe_len, 1073 | subscribe_data, 1074 | )); 1075 | } 1076 | let unsubscribe_len = unsubscribe_data.len(); 1077 | if unsubscribe_len > 64 { 1078 | return Err(Error::SubscribeTermSizeExceeded( 1079 | unsubscribe_len, 1080 | unsubscribe_data, 1081 | )); 1082 | } 1083 | let subscribe_button = InlineKeyboardButton::new( 1084 | "Subscribe", 1085 | InlineKeyboardButtonKind::CallbackData(subscribe_data), 1086 | ); 1087 | let unsubscribe_button = InlineKeyboardButton::new( 1088 | "Unsubscribe", 1089 | InlineKeyboardButtonKind::CallbackData(unsubscribe_data), 1090 | ); 1091 | Ok(InlineKeyboardMarkup::new([[ 1092 | subscribe_button, 1093 | unsubscribe_button, 1094 | ]])) 1095 | } 1096 | --------------------------------------------------------------------------------