Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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
Expand Down
33 changes: 24 additions & 9 deletions src/app/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Action>();
let semaphore = Arc::new(Semaphore::new(4));
Expand Down Expand Up @@ -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
Expand All @@ -385,7 +387,7 @@ fn spawn_side_effect(
if let Some(ref cache) = cache
&& let Some(repos) = cache.get::<Vec<crate::github::Repo>>(&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,
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -444,7 +448,7 @@ fn spawn_side_effect(
if let Some(ref cache) = cache
&& let Some(repos) = cache.get::<Vec<crate::github::Repo>>(&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,
Expand All @@ -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,
Expand All @@ -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::<Vec<crate::github::PullRequest>>(&cache_key)
{
Expand All @@ -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)
Expand All @@ -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::<Vec<crate::github::PullRequest>>(&cache_key)
{
Expand All @@ -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)
Expand Down Expand Up @@ -635,14 +645,19 @@ fn spawn_side_effect(
}
}

fn filter_repos(
pub fn filter_repos(
repos: Vec<crate::github::Repo>,
include_patterns: &[String],
exclude_patterns: &[String],
visibility: crate::github::RepoVisibility,
) -> Vec<crate::github::Repo> {
repos
.into_iter()
.filter(|repo| {
if !visibility.allows(repo.is_private) {
return false;
}

let full_name = repo.full_name();
let name = &repo.name;

Expand Down
8 changes: 7 additions & 1 deletion src/app/state.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -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<String>,
Expand Down Expand Up @@ -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,
Expand Down
92 changes: 59 additions & 33 deletions src/github/graphql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -235,12 +219,12 @@ impl GithubClient {
Ok((all_prs, rate_limit))
}

pub async fn fetch_inbox(&self, viewer_login: &str) -> Result<(Vec<PullRequest>, 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<PullRequest>, RateLimit)> {
let (review_query, assigned_query) = build_inbox_queries(viewer_login, visibility);

let (review_result, assigned_result) = tokio::join!(
self.search_prs(&review_query),
Expand Down Expand Up @@ -272,16 +256,9 @@ impl GithubClient {
&self,
orgs: &[String],
users: &[String],
visibility: RepoVisibility,
) -> Result<(Vec<PullRequest>, RateLimit)> {
let mut owner_filters: Vec<String> = 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
}

Expand Down Expand Up @@ -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();

Expand Down
57 changes: 57 additions & 0 deletions src/github/models.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -15,6 +68,10 @@ pub struct Repo {
pub description: Option<String>,
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 {
Expand Down
Loading
Loading