From abb1e9206463ade6764e4b9b89e316ec306061e4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 11 Jul 2026 22:19:59 +0200 Subject: [PATCH] feat: hide PRs you can neither act on nor learn from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overview and the PRs tab listed open PRs from every repo, including ones cloned for reference (e.g. pterodactyl) whose PRs you can't merge and didn't author — pure noise. A PR is now surfaced only when you authored it but can't merge it (your contribution elsewhere) or someone else authored it and you can (awaiting your review); your own PR in a repo you control, and strangers' PRs you can't touch, are filtered out. Providers report viewer merge permission (RepoMeta.viewer_can_merge, from GitHub/Gitea repo permissions and GitLab access level), stamped onto each PR at refresh. The cache keeps every PR; the filter is applied where the hub builds state.prs, so the per-repo detail view is unchanged. --- src/hub/actions.rs | 28 ++++++++++++++++++++++++++++ src/hub/keys_poll.rs | 2 +- src/hub/mod.rs | 10 +++++++--- src/hub/modals_spawn.rs | 6 +++++- src/providers/gitea.rs | 4 ++++ src/providers/github.rs | 11 +++++++++++ src/providers/gitlab.rs | 1 + src/providers/mod.rs | 11 +++++++++++ 8 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/hub/actions.rs b/src/hub/actions.rs index aa40db0..e99c512 100644 --- a/src/hub/actions.rs +++ b/src/hub/actions.rs @@ -11,6 +11,34 @@ use std::collections::HashSet; use std::path::PathBuf; use super::state::HubState; +// ── PR relevance ────────────────────────────────────────────────────────────── + +/// Account logins to treat as "you" when deciding PR authorship. +pub fn account_logins(accounts: &[crate::auth::Account]) -> Vec { + accounts.iter().map(|a| a.username.clone()).filter(|u| !u.is_empty()).collect() +} + +/// Whether a PR is worth surfacing in the hub. Shown only when you authored +/// it but can't merge it yourself (your contribution to a repo you don't +/// control) or someone else authored it and you can (it's awaiting your +/// review). Your own PR in a repo you can merge in, and strangers' PRs you +/// can't act on, are both noise and filtered out. `logins` are your account +/// usernames; an empty `logins` shows everything (identity unknown). +pub fn pr_is_relevant(pr: &crate::providers::PrDetail, logins: &[String]) -> bool { + if logins.is_empty() { return true; } + let mine = logins.iter().any(|u| u.eq_ignore_ascii_case(&pr.author)); + mine != pr.viewer_can_merge +} + +/// Filters a cached PR list down to the relevant ones (see `pr_is_relevant`). +pub fn relevant_prs( + prs: impl IntoIterator, + accounts: &[crate::auth::Account], +) -> Vec { + let logins = account_logins(accounts); + prs.into_iter().filter(|p| pr_is_relevant(p, &logins)).collect() +} + // ── sync flow ───────────────────────────────────────────────────────────────── /// Runs `action` ("pull", "commit_push", or anything else meaning "push") diff --git a/src/hub/keys_poll.rs b/src/hub/keys_poll.rs index efcbe84..195db41 100644 --- a/src/hub/keys_poll.rs +++ b/src/hub/keys_poll.rs @@ -157,7 +157,7 @@ pub fn poll_refresh(state: &mut HubState) { for (name, i) in issues { fetched_iss += i.len(); cache.set_issues(&name, i); } let _ = crate::cache::save(&cache); - state.prs = cache.all_prs().into_iter().cloned().collect(); + state.prs = super::actions::relevant_prs(cache.all_prs().into_iter().cloned(), &state.accounts); state.issues = cache.all_issues().into_iter().cloned().collect(); if closed_counts.is_some() { state.closed_counts = closed_counts; } diff --git a/src/hub/mod.rs b/src/hub/mod.rs index fc599a7..d10e811 100644 --- a/src/hub/mod.rs +++ b/src/hub/mod.rs @@ -91,14 +91,18 @@ pub async fn run_overview_tui( if r.visibility.is_none() { r.visibility = m.visibility.clone(); } } } - let cached_prs: Vec = meta_cache.all_prs().into_iter().cloned().collect(); - let cached_issues: Vec = meta_cache.all_issues().into_iter().cloned().collect(); - let debug_mode = crate::debug::is_enabled(); let debug_log = crate::debug::take(); let accounts = crate::auth::load_accounts_path(&cfg.data_dir).unwrap_or_default(); let templates = load_templates(); + // The cache keeps every PR; the hub only surfaces the ones you can act on + // (yours-elsewhere or awaiting-your-review — see actions::pr_is_relevant), + // so PRs from repos you merely cloned don't clutter Overview and the tab. + let cached_prs: Vec = + actions::relevant_prs(meta_cache.all_prs().into_iter().cloned(), &accounts); + let cached_issues: Vec = meta_cache.all_issues().into_iter().cloned().collect(); + let mut state = HubState { repos, projects, project_map, todos, prs: cached_prs, diff --git a/src/hub/modals_spawn.rs b/src/hub/modals_spawn.rs index 82ef281..81890ef 100644 --- a/src/hub/modals_spawn.rs +++ b/src/hub/modals_spawn.rs @@ -816,6 +816,7 @@ pub fn spawn_api_refresh(state: &mut HubState) { .await; for (name, slug, meta, p, i, ci) in results { + let can_merge = meta.as_ref().map(|m| m.viewer_can_merge).unwrap_or(false); if let Some(meta) = meta { let slug_lc = slug.to_lowercase(); let dep = dependabot @@ -837,7 +838,10 @@ pub fn spawn_api_refresh(state: &mut HubState) { fetched_at: chrono::Utc::now().timestamp(), })); } - if let Some(p) = p { prs.push((name.clone(), p)); } + if let Some(mut p) = p { + for pr in &mut p { pr.viewer_can_merge = can_merge; } + prs.push((name.clone(), p)); + } if let Some(i) = i { issues.push((name, i)); } } diff --git a/src/providers/gitea.rs b/src/providers/gitea.rs index cf9ae06..ad9c5f4 100644 --- a/src/providers/gitea.rs +++ b/src/providers/gitea.rs @@ -199,6 +199,10 @@ impl Provider for GiteaProvider { stars: v["stars_count"].as_u64().unwrap_or(0) as u32, description: v["description"].as_str().filter(|s| !s.is_empty()).map(String::from), topics: vec![], + viewer_can_merge: { + let p = &v["permissions"]; + p["push"].as_bool().unwrap_or(false) || p["admin"].as_bool().unwrap_or(false) + }, }) } diff --git a/src/providers/github.rs b/src/providers/github.rs index 76589ba..4afaf80 100644 --- a/src/providers/github.rs +++ b/src/providers/github.rs @@ -243,12 +243,20 @@ impl Provider for GitHubProvider { .map(|arr| arr.iter().filter_map(|t| t.as_str().map(String::from)).collect()) .unwrap_or_default(); + // `permissions` is present only on authenticated requests; write, + // maintain or admin all imply the ability to merge PRs. + let perms = &v["permissions"]; + let viewer_can_merge = perms["push"].as_bool().unwrap_or(false) + || perms["maintain"].as_bool().unwrap_or(false) + || perms["admin"].as_bool().unwrap_or(false); + Ok(RepoMeta { open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32, open_prs, stars: v["stargazers_count"].as_u64().unwrap_or(0) as u32, description, topics, + viewer_can_merge, }) } @@ -399,6 +407,9 @@ impl Provider for GitHubProvider { url: pr["html_url"].as_str().unwrap_or("").to_string(), draft: pr["draft"].as_bool().unwrap_or(false), labels, + // Stamped later from the repo's viewer permission (list_pull_requests + // has no repo-level permission field); false until then. + viewer_can_merge: false, } }).collect()).unwrap_or_default(); Ok(prs) diff --git a/src/providers/gitlab.rs b/src/providers/gitlab.rs index ff7addb..285771b 100644 --- a/src/providers/gitlab.rs +++ b/src/providers/gitlab.rs @@ -196,6 +196,7 @@ impl Provider for GitLabProvider { stars: v["star_count"].as_u64().unwrap_or(0) as u32, description: v["description"].as_str().filter(|s| !s.is_empty()).map(String::from), topics: v["topics"].as_array().map(|a| a.iter().filter_map(|t| t.as_str().map(String::from)).collect()).unwrap_or_default(), + viewer_can_merge: v["permissions"]["project_access"]["access_level"].as_u64().unwrap_or(0) >= 30, }) } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 35cef55..afb8595 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -52,6 +52,10 @@ pub struct RepoMeta { pub stars: u32, pub description: Option, pub topics: Vec, + /// Whether the authenticated user can merge PRs here (write/maintain/admin + /// permission). Lets the hub hide PRs it can neither review-merge nor + /// learn anything from. Providers that don't expose this leave it false. + pub viewer_can_merge: bool, } /// One pull/merge request, as shown in the hub's PRs tab and the repo @@ -69,6 +73,13 @@ pub struct PrDetail { pub url: String, pub draft: bool, pub labels: Vec, + /// Whether you can merge this PR (from the repo's viewer permission, + /// stamped at refresh). Combined with `author` it decides relevance: + /// the hub shows a PR only when you authored it but can't merge it + /// (your contribution elsewhere) or someone else authored it and you + /// can (awaiting your review) — never your own PR in your own repo. + #[serde(default)] + pub viewer_can_merge: bool, } /// One issue, as shown in the hub's Issues tab and the repo detail view's