From 756bc0d729dc2ae7005175e28a01d44700e50ba8 Mon Sep 17 00:00:00 2001 From: zombocoder Date: Sun, 30 Aug 2026 19:05:48 +0300 Subject: [PATCH] feat: add repository visibility filtering to dashboard - Introduced `RepoVisibility` enum to manage public/private repo visibility. - Updated configuration to allow setting visibility in `github.visibility`. - Enhanced filtering logic to restrict displayed repositories based on visibility settings. - Modified relevant queries to include visibility constraints. - Updated UI to reflect visibility status in the status bar. - Added tests for visibility functionality and repo filtering behavior. --- README.md | 18 +++++ src/app/event_loop.rs | 33 +++++--- src/app/state.rs | 8 +- src/github/graphql.rs | 92 +++++++++++++-------- src/github/models.rs | 57 +++++++++++++ src/github/queries.rs | 2 + src/ui/widgets.rs | 71 +++++++++++++--- src/util/config.rs | 7 ++ tests/cache_tests.rs | 30 +++++++ tests/config_tests.rs | 53 ++++++++++++ tests/graphql_parse_tests.rs | 27 +++++++ tests/state_tests.rs | 1 + tests/visibility_tests.rs | 152 +++++++++++++++++++++++++++++++++++ 13 files changed, 496 insertions(+), 55 deletions(-) create mode 100644 tests/visibility_tests.rs diff --git a/README.md b/README.md index 46425ff..792ca88 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A terminal UI dashboard for monitoring GitHub repositories, pull requests, and y - Inbox view for PRs where you're requested for review or assigned - Expand/collapse organizations in the navigation tree - Client-side search filtering across PR titles, authors, and repos +- Restrict the whole dashboard to public-only or private-only repos (handy for streaming) - Open any PR or repo in your browser with a single keypress - Disk caching with configurable TTL to minimize API calls - Auto-refresh on a configurable interval @@ -81,6 +82,8 @@ users = ["my-username"] include_repos = ["important-*"] # Optional: exclude repos matching these globs exclude_repos = ["*-archived", "legacy-*"] +# Optional: restrict to public or private repos only (default: "all") +visibility = "all" # Optional: GitHub Enterprise # api_url = "https://github.example.com/api/graphql" @@ -103,6 +106,21 @@ nav_width_percent = 30 On macOS, `~/Library/Application Support/ghdash/config.toml` is also supported. +### Repo visibility + +`github.visibility` accepts `all` (default), `public`, or `private`. It applies to +everything on screen at once — the navigation tree, the "All PRs" view, and the +inbox — because the PR searches are narrowed with GitHub's `is:public` / +`is:private` qualifiers rather than filtered after the fact. A restricted session +says so in the status bar (`public only` / `private only`). + +Setting `visibility = "public"` is the intended way to run ghdash while streaming +or screen-sharing: no private repo name, PR title, or review request reaches the +screen. Keep a separate config for it and start with `ghdash --config ~/stream.toml`. + +Cached PR lists are keyed by visibility, so switching modes never serves data +fetched under the other one. + ## Usage ```sh diff --git a/src/app/event_loop.rs b/src/app/event_loop.rs index 5d665af..4c7e686 100644 --- a/src/app/event_loop.rs +++ b/src/app/event_loop.rs @@ -65,6 +65,7 @@ async fn run_loop( .cloned() .collect(); let mut state = AppState::new(viewer_login.clone(), all_owners); + state.visibility = config.github.visibility; let (action_tx, mut action_rx) = mpsc::unbounded_channel::(); let semaphore = Arc::new(Semaphore::new(4)); @@ -367,6 +368,7 @@ fn spawn_side_effect( let cache = cache_store.clone(); let include_repos = config.github.include_repos.clone(); let exclude_repos = config.github.exclude_repos.clone(); + let visibility = config.github.visibility; let org_clone = org.clone(); // Mark org as loading via action @@ -385,7 +387,7 @@ fn spawn_side_effect( if let Some(ref cache) = cache && let Some(repos) = cache.get::>(&cache_key) { - let filtered = filter_repos(repos, &include_repos, &exclude_repos); + let filtered = filter_repos(repos, &include_repos, &exclude_repos, visibility); let _ = tx.send(Action::DataLoaded(DataPayload::OrgRepos { org: org_clone, repos: filtered, @@ -403,7 +405,8 @@ fn spawn_side_effect( error!(error = %e, "Failed to cache org repos"); } - let filtered = filter_repos(repos, &include_repos, &exclude_repos); + let filtered = + filter_repos(repos, &include_repos, &exclude_repos, visibility); let _ = tx.send(Action::DataLoaded(DataPayload::OrgRepos { org: org_clone, repos: filtered, @@ -427,6 +430,7 @@ fn spawn_side_effect( let cache = cache_store.clone(); let include_repos = config.github.include_repos.clone(); let exclude_repos = config.github.exclude_repos.clone(); + let visibility = config.github.visibility; let user_clone = user.clone(); // Mark user as loading via action (reuse OrgRepos payload) @@ -444,7 +448,7 @@ fn spawn_side_effect( if let Some(ref cache) = cache && let Some(repos) = cache.get::>(&cache_key) { - let filtered = filter_repos(repos, &include_repos, &exclude_repos); + let filtered = filter_repos(repos, &include_repos, &exclude_repos, visibility); let _ = tx.send(Action::DataLoaded(DataPayload::OrgRepos { org: user_clone, repos: filtered, @@ -461,7 +465,8 @@ fn spawn_side_effect( error!(error = %e, "Failed to cache user repos"); } - let filtered = filter_repos(repos, &include_repos, &exclude_repos); + let filtered = + filter_repos(repos, &include_repos, &exclude_repos, visibility); let _ = tx.send(Action::DataLoaded(DataPayload::OrgRepos { org: user_clone, repos: filtered, @@ -484,12 +489,16 @@ fn spawn_side_effect( let sem = semaphore.clone(); let cache = cache_store.clone(); let login = viewer_login.to_string(); + let visibility = config.github.visibility; tokio::spawn(async move { let _permit = sem.acquire().await; debug!("Fetching inbox"); - let cache_key = format!("inbox_{}", login); + // Visibility is part of the key: a list fetched as "public only" + // must never be served to a session running unrestricted, or + // vice versa. + let cache_key = format!("inbox_{}_{}", login, visibility.cache_suffix()); if let Some(ref cache) = cache && let Some(prs) = cache.get::>(&cache_key) { @@ -500,7 +509,7 @@ fn spawn_side_effect( return; } - match client.fetch_inbox(&login).await { + match client.fetch_inbox(&login, visibility).await { Ok((prs, rate_limit)) => { if let Some(ref cache) = cache && let Err(e) = cache.set(&cache_key, &prs) @@ -526,12 +535,13 @@ fn spawn_side_effect( let cache = cache_store.clone(); let orgs = config.github.orgs.clone(); let users = config.github.users.clone(); + let visibility = config.github.visibility; tokio::spawn(async move { let _permit = sem.acquire().await; debug!("Fetching all open PRs"); - let cache_key = "all_open_prs".to_string(); + let cache_key = format!("all_open_prs_{}", visibility.cache_suffix()); if let Some(ref cache) = cache && let Some(prs) = cache.get::>(&cache_key) { @@ -542,7 +552,7 @@ fn spawn_side_effect( return; } - match client.fetch_all_open_prs(&orgs, &users).await { + match client.fetch_all_open_prs(&orgs, &users, visibility).await { Ok((prs, rate_limit)) => { if let Some(ref cache) = cache && let Err(e) = cache.set(&cache_key, &prs) @@ -635,14 +645,19 @@ fn spawn_side_effect( } } -fn filter_repos( +pub fn filter_repos( repos: Vec, include_patterns: &[String], exclude_patterns: &[String], + visibility: crate::github::RepoVisibility, ) -> Vec { repos .into_iter() .filter(|repo| { + if !visibility.allows(repo.is_private) { + return false; + } + let full_name = repo.full_name(); let name = &repo.name; diff --git a/src/app/state.rs b/src/app/state.rs index df6c434..06f64d4 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; -use crate::github::models::{PrDetail, PullRequest, RateLimit, Repo}; +use crate::github::models::{PrDetail, PullRequest, RateLimit, Repo, RepoVisibility}; /// State of an on-demand PR detail fetch, keyed by PR url in `AppState::pr_details`. #[derive(Debug, Clone)] @@ -131,6 +131,11 @@ pub struct AppState { pub merge_filter: MergeFilter, pub help_open: bool, + /// Repo visibility the session is restricted to (`github.visibility`). + /// Read-only here; the fetches are already scoped, this only drives the + /// status-bar indicator so a restricted session is visibly restricted. + pub visibility: RepoVisibility, + // UI flags pub loading: bool, pub loading_orgs: HashSet, @@ -175,6 +180,7 @@ impl AppState { diff_scroll: 0, merge_filter: MergeFilter::All, help_open: false, + visibility: RepoVisibility::All, loading: true, loading_orgs: HashSet::new(), error_message: None, diff --git a/src/github/graphql.rs b/src/github/graphql.rs index f17d9c3..d76e6d6 100644 --- a/src/github/graphql.rs +++ b/src/github/graphql.rs @@ -126,15 +126,7 @@ impl GithubClient { .context("Missing repository nodes")?; for node in nodes { - let repo = Repo { - name: node["name"].as_str().unwrap_or("").to_string(), - owner: node["owner"]["login"].as_str().unwrap_or("").to_string(), - url: node["url"].as_str().unwrap_or("").to_string(), - description: node["description"].as_str().map(|s| s.to_string()), - open_pr_count: node["pullRequests"]["totalCount"].as_u64().unwrap_or(0) as u32, - is_archived: node["isArchived"].as_bool().unwrap_or(false), - }; - all_repos.push(repo); + all_repos.push(parse_repo(node)); } let page_info = &repos_data["pageInfo"]; @@ -169,15 +161,7 @@ impl GithubClient { .context("Missing repository nodes")?; for node in nodes { - let repo = Repo { - name: node["name"].as_str().unwrap_or("").to_string(), - owner: node["owner"]["login"].as_str().unwrap_or("").to_string(), - url: node["url"].as_str().unwrap_or("").to_string(), - description: node["description"].as_str().map(|s| s.to_string()), - open_pr_count: node["pullRequests"]["totalCount"].as_u64().unwrap_or(0) as u32, - is_archived: node["isArchived"].as_bool().unwrap_or(false), - }; - all_repos.push(repo); + all_repos.push(parse_repo(node)); } let page_info = &repos_data["pageInfo"]; @@ -235,12 +219,12 @@ impl GithubClient { Ok((all_prs, rate_limit)) } - pub async fn fetch_inbox(&self, viewer_login: &str) -> Result<(Vec, RateLimit)> { - let review_query = format!( - "is:open is:pr review-requested:{} archived:false", - viewer_login - ); - let assigned_query = format!("is:open is:pr assignee:{} archived:false", viewer_login); + pub async fn fetch_inbox( + &self, + viewer_login: &str, + visibility: RepoVisibility, + ) -> Result<(Vec, RateLimit)> { + let (review_query, assigned_query) = build_inbox_queries(viewer_login, visibility); let (review_result, assigned_result) = tokio::join!( self.search_prs(&review_query), @@ -272,16 +256,9 @@ impl GithubClient { &self, orgs: &[String], users: &[String], + visibility: RepoVisibility, ) -> Result<(Vec, RateLimit)> { - let mut owner_filters: Vec = Vec::new(); - for o in orgs { - owner_filters.push(format!("org:{}", o)); - } - for u in users { - owner_filters.push(format!("user:{}", u)); - } - let filter = owner_filters.join(" "); - let query_string = format!("is:open is:pr archived:false {}", filter); + let query_string = build_all_open_prs_query(orgs, users, visibility); self.search_prs(&query_string).await } @@ -353,6 +330,55 @@ impl GithubClient { } } +/// Search query for the "All PRs" view: every open PR across the configured +/// orgs and users, narrowed to `visibility`. +pub fn build_all_open_prs_query( + orgs: &[String], + users: &[String], + visibility: RepoVisibility, +) -> String { + let mut parts = vec![ + "is:open".to_string(), + "is:pr".to_string(), + "archived:false".to_string(), + ]; + parts.extend(orgs.iter().map(|o| format!("org:{}", o))); + parts.extend(users.iter().map(|u| format!("user:{}", u))); + parts.extend(visibility.search_qualifier().map(|q| q.to_string())); + parts.join(" ") +} + +/// The two search queries backing the inbox (review-requested + assigned), +/// each narrowed to `visibility`. +pub fn build_inbox_queries(viewer_login: &str, visibility: RepoVisibility) -> (String, String) { + let suffix = visibility + .search_qualifier() + .map(|q| format!(" {}", q)) + .unwrap_or_default(); + ( + format!( + "is:open is:pr review-requested:{} archived:false{}", + viewer_login, suffix + ), + format!( + "is:open is:pr assignee:{} archived:false{}", + viewer_login, suffix + ), + ) +} + +fn parse_repo(node: &Value) -> Repo { + Repo { + name: node["name"].as_str().unwrap_or("").to_string(), + owner: node["owner"]["login"].as_str().unwrap_or("").to_string(), + url: node["url"].as_str().unwrap_or("").to_string(), + description: node["description"].as_str().map(|s| s.to_string()), + open_pr_count: node["pullRequests"]["totalCount"].as_u64().unwrap_or(0) as u32, + is_archived: node["isArchived"].as_bool().unwrap_or(false), + is_private: node["isPrivate"].as_bool().unwrap_or(false), + } +} + fn parse_pr_detail(node: &Value) -> PrDetail { let commit_nodes = node["commits"]["nodes"].as_array(); diff --git a/src/github/models.rs b/src/github/models.rs index affbfb4..00e2a3a 100644 --- a/src/github/models.rs +++ b/src/github/models.rs @@ -1,6 +1,59 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +/// Which repositories the dashboard is allowed to show. Configured once via +/// `github.visibility`; `All` is the default. Restricting it hides matching +/// repos from the nav tree *and* narrows the PR searches, so nothing from a +/// hidden repo reaches the screen (useful when screen-sharing or streaming). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RepoVisibility { + #[default] + All, + Public, + Private, +} + +impl RepoVisibility { + /// Whether a repo with this privacy flag may be shown. + pub fn allows(self, is_private: bool) -> bool { + match self { + RepoVisibility::All => true, + RepoVisibility::Public => !is_private, + RepoVisibility::Private => is_private, + } + } + + /// GitHub search qualifier that restricts results to this visibility. + /// `None` for `All` (no qualifier needed). + pub fn search_qualifier(self) -> Option<&'static str> { + match self { + RepoVisibility::All => None, + RepoVisibility::Public => Some("is:public"), + RepoVisibility::Private => Some("is:private"), + } + } + + /// Status-bar label; `None` when unrestricted. + pub fn label(self) -> Option<&'static str> { + match self { + RepoVisibility::All => None, + RepoVisibility::Public => Some("public only"), + RepoVisibility::Private => Some("private only"), + } + } + + /// Discriminator for cache keys, so PR lists fetched under one visibility + /// are never served to a session running under another. + pub fn cache_suffix(self) -> &'static str { + match self { + RepoVisibility::All => "all", + RepoVisibility::Public => "public", + RepoVisibility::Private => "private", + } + } +} + #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ViewerInfo { @@ -15,6 +68,10 @@ pub struct Repo { pub description: Option, pub open_pr_count: u32, pub is_archived: bool, + /// Required (not `#[serde(default)]`) on purpose: cache entries written + /// before this field existed must fail to parse and be refetched rather + /// than defaulting private repos to public. See `CacheStore::get`. + pub is_private: bool, } impl Repo { diff --git a/src/github/queries.rs b/src/github/queries.rs index 7cc36eb..3892e39 100644 --- a/src/github/queries.rs +++ b/src/github/queries.rs @@ -25,6 +25,7 @@ query($org: String!, $cursor: String) { url description isArchived + isPrivate pullRequests(states: OPEN) { totalCount } @@ -53,6 +54,7 @@ query($user: String!, $cursor: String) { url description isArchived + isPrivate pullRequests(states: OPEN) { totalCount } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 209f81a..68b2076 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -357,6 +357,27 @@ fn truncate_status(status: &str, center_width: usize) -> String { format!("{}...", &status[..end]) } +/// Right-hand side of the status bar: visibility restriction (when any), +/// API budget, and time since the last refresh. +fn right_status_text(state: &AppState) -> String { + let visibility_info = state + .visibility + .label() + .map(|l| format!("{} | ", l)) + .unwrap_or_default(); + + let refresh_info = state + .last_refresh + .as_ref() + .map(|t| format!(" | {}", relative_time(t))) + .unwrap_or_default(); + + format!( + "{}API: {}/{}{}", + visibility_info, state.rate_limit.remaining, state.rate_limit.limit, refresh_info + ) +} + pub fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { let key_hints = if state.search_active { "Esc: close search | Enter: filter" @@ -372,18 +393,7 @@ pub fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { String::new() }; - let rate_info = format!( - "API: {}/{}", - state.rate_limit.remaining, state.rate_limit.limit - ); - - let refresh_info = state - .last_refresh - .as_ref() - .map(|t| format!(" | {}", relative_time(t))) - .unwrap_or_default(); - - let right_text = format!("{}{}", rate_info, refresh_info); + let right_text = right_status_text(state); // Calculate available space let total_width = area.width as usize; @@ -776,3 +786,40 @@ mod status_truncate_tests { } } } + +#[cfg(test)] +mod right_status_text_tests { + use super::right_status_text; + use crate::app::state::AppState; + use crate::github::models::{RateLimit, RepoVisibility}; + + fn state() -> AppState { + let mut s = AppState::new("me".into(), vec![]); + s.rate_limit = RateLimit { + remaining: 4999, + limit: 5000, + reset_at: None, + }; + s + } + + #[test] + fn shows_only_rate_limit_when_visibility_is_unrestricted() { + let s = state(); + assert_eq!(right_status_text(&s), "API: 4999/5000"); + } + + #[test] + fn announces_a_public_only_session() { + let mut s = state(); + s.visibility = RepoVisibility::Public; + assert_eq!(right_status_text(&s), "public only | API: 4999/5000"); + } + + #[test] + fn announces_a_private_only_session() { + let mut s = state(); + s.visibility = RepoVisibility::Private; + assert_eq!(right_status_text(&s), "private only | API: 4999/5000"); + } +} diff --git a/src/util/config.rs b/src/util/config.rs index 322bde5..f71630b 100644 --- a/src/util/config.rs +++ b/src/util/config.rs @@ -3,6 +3,8 @@ use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use crate::github::models::RepoVisibility; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AppConfig { #[serde(default)] @@ -27,6 +29,10 @@ pub struct GithubConfig { pub exclude_repos: Vec, #[serde(default = "default_api_url")] pub api_url: String, + /// Restrict the whole dashboard to public or private repos. Applies to the + /// nav tree and to both PR searches. Defaults to `all`. + #[serde(default)] + pub visibility: RepoVisibility, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -75,6 +81,7 @@ impl Default for GithubConfig { include_repos: Vec::new(), exclude_repos: Vec::new(), api_url: default_api_url(), + visibility: RepoVisibility::default(), } } } diff --git a/tests/cache_tests.rs b/tests/cache_tests.rs index abf9332..81713cb 100644 --- a/tests/cache_tests.rs +++ b/tests/cache_tests.rs @@ -137,3 +137,33 @@ fn test_creates_cache_dir_on_set() { store.set("key", &"val".to_string()).unwrap(); assert!(nested.exists()); } + +/// Repo cache entries written before `is_private` existed must NOT be reused: +/// deserializing them would silently mark private repos as public, which would +/// leak them into a `visibility = "public"` session. `Repo::is_private` is a +/// required field precisely so those entries fail to parse and are refetched. +#[test] +fn test_legacy_repo_cache_entry_without_visibility_is_a_miss() { + let dir = TempDir::new().unwrap(); + let store = CacheStore::new(dir.path().to_path_buf(), 600); + + let legacy = serde_json::json!({ + "timestamp": chrono::Utc::now(), + "data": [{ + "name": "secret-repo", + "owner": "acme", + "url": "https://github.com/acme/secret-repo", + "description": null, + "open_pr_count": 1, + "is_archived": false + }] + }); + std::fs::write( + dir.path().join("org_repos_acme.json"), + serde_json::to_string(&legacy).unwrap(), + ) + .unwrap(); + + let result: Option> = store.get("org_repos_acme"); + assert!(result.is_none()); +} diff --git a/tests/config_tests.rs b/tests/config_tests.rs index f4bd1f8..881ea91 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -1,6 +1,7 @@ use std::io::Write; use tempfile::NamedTempFile; +use ghdash::github::models::RepoVisibility; use ghdash::util::config::AppConfig; #[test] @@ -115,3 +116,55 @@ fn test_default_config() { assert!(config.cache.dir.is_none()); assert_eq!(config.ui.nav_width_percent, 30); } + +#[test] +fn test_visibility_defaults_to_all() { + let toml = r#" +[github] +orgs = ["my-org"] +"#; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(toml.as_bytes()).unwrap(); + + let config = AppConfig::load(Some(f.path())).unwrap(); + assert_eq!(config.github.visibility, RepoVisibility::All); +} + +#[test] +fn test_load_public_only_visibility() { + let toml = r#" +[github] +orgs = ["my-org"] +visibility = "public" +"#; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(toml.as_bytes()).unwrap(); + + let config = AppConfig::load(Some(f.path())).unwrap(); + assert_eq!(config.github.visibility, RepoVisibility::Public); +} + +#[test] +fn test_load_private_only_visibility() { + let toml = r#" +[github] +visibility = "private" +"#; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(toml.as_bytes()).unwrap(); + + let config = AppConfig::load(Some(f.path())).unwrap(); + assert_eq!(config.github.visibility, RepoVisibility::Private); +} + +#[test] +fn test_unknown_visibility_value_fails() { + let toml = r#" +[github] +visibility = "secret" +"#; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(toml.as_bytes()).unwrap(); + + assert!(AppConfig::load(Some(f.path())).is_err()); +} diff --git a/tests/graphql_parse_tests.rs b/tests/graphql_parse_tests.rs index ee0d3f3..a6b1cd8 100644 --- a/tests/graphql_parse_tests.rs +++ b/tests/graphql_parse_tests.rs @@ -9,6 +9,7 @@ fn test_repo_full_name() { description: Some("A repo".into()), open_pr_count: 5, is_archived: false, + is_private: false, }; assert_eq!(repo.full_name(), "my-org/my-repo"); } @@ -45,6 +46,7 @@ fn test_repo_serialization_roundtrip() { description: None, open_pr_count: 3, is_archived: false, + is_private: false, }; let json = serde_json::to_string(&repo).unwrap(); @@ -109,6 +111,7 @@ fn test_repo_with_description() { description: Some("A cool project".into()), open_pr_count: 0, is_archived: true, + is_private: false, }; assert_eq!(repo.description, Some("A cool project".into())); @@ -254,3 +257,27 @@ fn test_ci_status_none() { // Unknown/other states fall back to None rather than misreporting. assert_eq!(pr_with_checks(Some("WEIRD")).ci_status(), CiStatus::None); } + +#[test] +fn test_repo_visibility_survives_serialization_roundtrip() { + let repo = Repo { + name: "secret".into(), + owner: "acme".into(), + url: "https://github.com/acme/secret".into(), + description: None, + open_pr_count: 0, + is_archived: false, + is_private: true, + }; + + let json = serde_json::to_string(&repo).unwrap(); + let deserialized: Repo = serde_json::from_str(&json).unwrap(); + + assert!(deserialized.is_private); +} + +#[test] +fn test_repo_queries_request_the_is_private_field() { + assert!(ghdash::github::queries::ORG_REPOS_QUERY.contains("isPrivate")); + assert!(ghdash::github::queries::USER_REPOS_QUERY.contains("isPrivate")); +} diff --git a/tests/state_tests.rs b/tests/state_tests.rs index 76aae30..fce751a 100644 --- a/tests/state_tests.rs +++ b/tests/state_tests.rs @@ -15,6 +15,7 @@ fn make_repo(owner: &str, name: &str, open_prs: u32) -> Repo { description: None, open_pr_count: open_prs, is_archived: false, + is_private: false, } } diff --git a/tests/visibility_tests.rs b/tests/visibility_tests.rs new file mode 100644 index 0000000..ed71c8d --- /dev/null +++ b/tests/visibility_tests.rs @@ -0,0 +1,152 @@ +use ghdash::app::event_loop::filter_repos; +use ghdash::github::graphql::{build_all_open_prs_query, build_inbox_queries}; +use ghdash::github::models::{Repo, RepoVisibility}; + +fn repo(name: &str, is_private: bool) -> Repo { + Repo { + name: name.into(), + owner: "acme".into(), + url: format!("https://github.com/acme/{}", name), + description: None, + open_pr_count: 0, + is_archived: false, + is_private, + } +} + +#[test] +fn default_visibility_is_all() { + assert_eq!(RepoVisibility::default(), RepoVisibility::All); +} + +#[test] +fn all_allows_public_and_private() { + assert!(RepoVisibility::All.allows(false)); + assert!(RepoVisibility::All.allows(true)); +} + +#[test] +fn public_allows_only_public() { + assert!(RepoVisibility::Public.allows(false)); + assert!(!RepoVisibility::Public.allows(true)); +} + +#[test] +fn private_allows_only_private() { + assert!(!RepoVisibility::Private.allows(false)); + assert!(RepoVisibility::Private.allows(true)); +} + +#[test] +fn all_has_no_search_qualifier() { + assert_eq!(RepoVisibility::All.search_qualifier(), None); +} + +#[test] +fn public_search_qualifier_is_is_public() { + assert_eq!(RepoVisibility::Public.search_qualifier(), Some("is:public")); +} + +#[test] +fn private_search_qualifier_is_is_private() { + assert_eq!( + RepoVisibility::Private.search_qualifier(), + Some("is:private") + ); +} + +#[test] +fn all_has_no_label_but_restricted_modes_do() { + assert_eq!(RepoVisibility::All.label(), None); + assert_eq!(RepoVisibility::Public.label(), Some("public only")); + assert_eq!(RepoVisibility::Private.label(), Some("private only")); +} + +#[test] +fn cache_suffix_distinguishes_modes() { + assert_eq!(RepoVisibility::All.cache_suffix(), "all"); + assert_eq!(RepoVisibility::Public.cache_suffix(), "public"); + assert_eq!(RepoVisibility::Private.cache_suffix(), "private"); +} + +#[test] +fn all_open_prs_query_has_no_qualifier_when_visibility_is_all() { + let q = build_all_open_prs_query(&["acme".into()], &["alice".into()], RepoVisibility::All); + assert_eq!(q, "is:open is:pr archived:false org:acme user:alice"); +} + +#[test] +fn all_open_prs_query_restricts_to_public_repos() { + let q = build_all_open_prs_query(&["acme".into()], &[], RepoVisibility::Public); + assert_eq!(q, "is:open is:pr archived:false org:acme is:public"); +} + +#[test] +fn all_open_prs_query_restricts_to_private_repos() { + let q = build_all_open_prs_query(&[], &["alice".into()], RepoVisibility::Private); + assert_eq!(q, "is:open is:pr archived:false user:alice is:private"); +} + +#[test] +fn inbox_queries_have_no_qualifier_when_visibility_is_all() { + let (review, assigned) = build_inbox_queries("alice", RepoVisibility::All); + assert_eq!( + review, + "is:open is:pr review-requested:alice archived:false" + ); + assert_eq!(assigned, "is:open is:pr assignee:alice archived:false"); +} + +#[test] +fn both_inbox_queries_restrict_to_public_repos() { + let (review, assigned) = build_inbox_queries("alice", RepoVisibility::Public); + assert_eq!( + review, + "is:open is:pr review-requested:alice archived:false is:public" + ); + assert_eq!( + assigned, + "is:open is:pr assignee:alice archived:false is:public" + ); +} + +#[test] +fn filter_repos_keeps_both_visibilities_by_default() { + let repos = vec![repo("pub", false), repo("priv", true)]; + let kept = filter_repos(repos, &[], &[], RepoVisibility::All); + assert_eq!(kept.len(), 2); +} + +#[test] +fn filter_repos_drops_private_repos_in_public_mode() { + let repos = vec![repo("pub", false), repo("priv", true)]; + let kept = filter_repos(repos, &[], &[], RepoVisibility::Public); + let names: Vec<_> = kept.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["pub"]); +} + +#[test] +fn filter_repos_drops_public_repos_in_private_mode() { + let repos = vec![repo("pub", false), repo("priv", true)]; + let kept = filter_repos(repos, &[], &[], RepoVisibility::Private); + let names: Vec<_> = kept.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["priv"]); +} + +#[test] +fn filter_repos_applies_visibility_on_top_of_include_and_exclude() { + let repos = vec![ + repo("important-pub", false), + repo("important-priv", true), + repo("other-pub", false), + repo("important-legacy", false), + ]; + let kept = filter_repos( + repos, + &["important-*".into()], + &["*-legacy".into()], + RepoVisibility::Public, + ); + let names: Vec<_> = kept.iter().map(|r| r.name.as_str()).collect(); + assert_eq!(names, vec!["important-pub"]); +}