feat: hide PRs you can neither act on nor learn from

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.
This commit is contained in:
dualfroz committed 2026-07-11 22:19:59 +02:00
1 parent 4a3fa58745
commit abb1e92064
8 files changed
+68 -5

No files matched your search

+28
View File
@@ -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<String> {
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<Item = crate::providers::PrDetail>,
accounts: &[crate::auth::Account],
) -> Vec<crate::providers::PrDetail> {
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")
+1 -1
View File
@@ -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; }
+7 -3
View File
@@ -91,14 +91,18 @@ pub async fn run_overview_tui(
if r.visibility.is_none() { r.visibility = m.visibility.clone(); }
}
}
let cached_prs: Vec<PrDetail> = meta_cache.all_prs().into_iter().cloned().collect();
let cached_issues: Vec<IssueDetail> = 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<PrDetail> =
actions::relevant_prs(meta_cache.all_prs().into_iter().cloned(), &accounts);
let cached_issues: Vec<IssueDetail> = meta_cache.all_issues().into_iter().cloned().collect();
let mut state = HubState {
repos, projects, project_map, todos,
prs: cached_prs,
+5 -1
View File
@@ -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)); }
}
+4
View File
@@ -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)
},
})
}
+11
View File
@@ -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)
+1
View File
@@ -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,
})
}
+11
View File
@@ -52,6 +52,10 @@ pub struct RepoMeta {
pub stars: u32,
pub description: Option<String>,
pub topics: Vec<String>,
/// 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<String>,
/// 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