mod app;
mod config;
mod demo;
mod events;
mod gitlab;
mod models;
mod storage;
mod ui;
mod utils;
use app::App;
use clap::Parser;
use crossterm::event::{self, Event, KeyEventKind};
use events::{handle_key_event, handle_mouse_event};
use gitlab::MAX_CONCURRENT_REQUESTS;
use models::AppEvent;
use std::sync::Arc;
use std::time::Duration;
use storage::{
ensure_gitlab_config, get_or_prompt_token, load_or_create_config_async, load_state_async,
migrate_legacy_keyring_entry, save_state_async,
};
use tokio::sync::Semaphore;
#[derive(Parser, Debug)]
#[command(
name = "gitlab-tracker",
author,
version,
about,
long_about = None
)]
struct Args {
#[arg(long)]
demo: bool,
}
fn init_logging() -> Option<tracing_appender::non_blocking::WorkerGuard> {
let log_dir = storage::get_save_dir()?;
std::fs::create_dir_all(&log_dir).ok()?;
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("gitlab-tracker")
.filename_suffix("log")
.max_log_files(10)
.build(&log_dir)
.ok()?;
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.with_writer(non_blocking)
.with_ansi(false)
.init();
Some(guard)
}
fn resolve_config_values(config: &config::AppConfig) -> (String, String, u64) {
let project_id = std::env::var("GITLAB_PROJECT_ID")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| config.project_id.clone())
.filter(|id| !id.trim().is_empty())
.expect("GITLAB_PROJECT_ID must be set");
let base_url = std::env::var("GITLAB_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| config.gitlab_url.clone())
.filter(|url| !url.trim().is_empty())
.unwrap_or_else(|| "https://gitlab.com".to_string());
let base_url = base_url.trim_end_matches('/').to_string();
let refresh_interval_secs = std::env::var("GITLAB_REFRESH_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.or(config.refresh_interval_secs)
.unwrap_or(900);
(project_id, base_url, refresh_interval_secs)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
ratatui::restore();
default_panic(info);
}));
if dotenvy::dotenv().is_err() {
if let Some(config_dir) = storage::get_save_dir() {
let global_env = config_dir.join(".env");
let _ = dotenvy::from_path(global_env);
}
}
let mut config = load_or_create_config_async().await;
if args.demo {
return demo::run_demo_mode(config).await;
}
ensure_gitlab_config(&mut config).await;
let (project_id, base_url, refresh_interval_secs) = resolve_config_values(&config);
migrate_legacy_keyring_entry();
let token = get_or_prompt_token().to_string();
#[cfg(feature = "redmine")]
let redmine_provider: Option<app::TrackerHandle> = {
use std::sync::Arc;
let mut redmine_cfg = gitlab_tracker_redmine::config::load_or_create_config().await;
gitlab_tracker_redmine::config::ensure_redmine_config(&mut redmine_cfg).await;
if redmine_cfg.redmine_url.trim().is_empty() {
tracing::info!("Redmine URL not configured — integration disabled");
None
} else {
gitlab_tracker_redmine::keyring::get_or_prompt_token().map(|tok| {
let provider =
gitlab_tracker_redmine::RedmineProvider::new(redmine_cfg, tok.to_string());
Arc::new(provider) as Arc<dyn gitlab_tracker_core::TrackerProvider>
})
}
};
let _log_guard = init_logging();
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
let mut terminal = ratatui::init();
let (saved_mrs, saved_branches, mut last_known_branches) = load_state_async().await;
let mut app = App::new(token, project_id, base_url, refresh_interval_secs, config);
app.branches = if saved_branches.is_empty() {
app.config.default_branches.clone()
} else {
saved_branches
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let api_semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS));
#[cfg(feature = "redmine")]
{
app.tracker = redmine_provider;
}
app.restore_from_saved(saved_mrs, api_semaphore.clone(), tx.clone());
gitlab::spawn_milestones_fetch(app.fetch_context(), tx.clone());
let tx_timer = tx.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
interval.tick().await;
let _ = tx_timer.send(AppEvent::Tick);
}
});
loop {
while let Ok(event) = rx.try_recv() {
let needs_save = app
.apply_event(event, api_semaphore.clone(), &tx, &mut last_known_branches)
.await;
if needs_save {
save_state_async(&app.mrs, &app.branches, &last_known_branches).await;
}
}
terminal.draw(|f| ui::render_ui(f, &mut app))?;
if event::poll(Duration::from_millis(50))? {
match event::read()? {
Event::Mouse(mouse) => {
let term_width = terminal.size()?.width;
handle_mouse_event(mouse, term_width, &mut app, &tx);
}
Event::Key(key)
if key.kind == KeyEventKind::Press
&& handle_key_event(
key,
&mut app,
&api_semaphore,
&tx,
&mut last_known_branches,
)
.await =>
{
break;
}
_ => {}
}
}
}
crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)?;
ratatui::restore();
Ok(())
}