Browse / github.com/storytold/craft-repo-status-app / patch
Add a row for each repo where you have an open pull request
Add a row for each repo where you have an open pull request ## Why The Repos table shows only the repos in the config. A repo where you have an open pull request stays off the table until you add it to `repos` by hand. ## What changes **Added rows** - Craft Status adds a row for each repo where the token's user has an open pull request, when `repos` lists another repo of the same owner. A repo under any other owner is never added. With the default config, that means `storytold` repos only. - An added row is marked `added` (tooltip: "Added because you have an open pull request here"). It has a **Keep** button, which writes the repo into `repos`, and
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #23, open)
- Base
9eaf2b22d3630c5ca0731009a2a3e835f49ee6d0- Size
- +1397 −2 in 14 files
- Review
- pending
- Shared
- 2026-10-28
- Id
6455cc262754dcd6552c214e
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock Cargo.toml README.md craft_status_config.template.toml crates/craft-core/Cargo.toml crates/craft-core/src/config.rs crates/craft-core/src/model.rs crates/craft-github/src/lib.rs crates/craft-status-app/src/added.rs crates/craft-status-app/src/lib.rs ui/src/app.test.ts ui/src/app.ts ui/src/model.ts ui/style.css
@@ -439,6 +439,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "toml 1.1.7+spec-1.1.0",+ "toml_edit 0.25.16+spec-1.1.0", ] [[package]]@@ -3883,6 +3884,7 @@ dependencies = [ "indexmap 2.14.2", "toml_datetime 1.1.2+spec-1.1.0", "toml_parser",+ "toml_writer", "winnow 1.0.4", ] @@ -11,6 +11,7 @@ license = "MIT" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "1"+toml_edit = "0.25" dirs = "7" thiserror = "2" chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }@@ -97,7 +97,7 @@ scored. repo it pauses after each GraphQL request as long as it took: GitHub allows only 60 s of GraphQL server time per minute, and its secondary rate limits punish bursts. Each repo costs about six GraphQL requests and one REST- request. It never uses search, which GitHub limits far more tightly (to+ request. Fetches never use search, which GitHub limits far more tightly (to about 30 a minute); PR and issue counts per window come from walking the recent PRs and issues. - Each repo's icon is `assets/app-icon/hicolor/64x64/apps/*.png` on its default@@ -125,6 +125,17 @@ scored. config, then from `gh auth token`. The app is started by launchd, not a shell, so it sees variables set with `launchctl setenv` (then relaunch it), not those exported in your shell profile.+- The app also adds a row for each repo where you (the token's user) have an+ open pull request, when `repos` lists another repo of the same owner. Every+ `poll_minutes` it searches each such owner once, and it sends each search+ like a repo fetch: 20 s after the request before it, after any row+ refreshed by hand, never during a pause, and not while fewer than 1,500+ points remain. An added row is marked `added`. The app removes it once two+ rounds of searches in a row find none of your open pull requests there;+ its cached activity stays on disk. **Keep** writes the repo into `repos` in+ the config file. **Hide** writes it into `hidden_auto_repos`, and the app+ does not add it again. The app writes the config file only for these two+ buttons. `auto_repos = false` turns this off. ## Tray and window @@ -34,6 +34,15 @@ stale_minutes = 60.0 # this file lives in your home directory, not in the repository. github_token = "" +# Add a row for each repo where the token's user has an open pull request,+# when `repos` lists another repo of the same owner. Every `poll_minutes` the+# app searches each such owner once, 20 s after the request before it. An+# added row has Keep, which writes it into `repos`, and Hide, which writes it+# into `hidden_auto_repos` so that it is not added again. The app does not+# edit this file except for these two buttons.+auto_repos = true+hidden_auto_repos = []+ [window] width = 1340.0 height = 560.0@@ -9,6 +9,7 @@ license.workspace = true serde.workspace = true serde_json.workspace = true toml.workspace = true+toml_edit.workspace = true dirs.workspace = true thiserror.workspace = true chrono.workspace = true@@ -39,6 +39,11 @@ pub struct Config { /// Optional, used only when `CRAFT_STATUS_GITHUB_TOKEN`, `GITHUB_TOKEN` /// and `GH_TOKEN` are unset; otherwise `gh auth token`. pub github_token: String,+ /// Add a row for each repo where the viewer has an open pull request,+ /// when `repos` lists another repo of the same owner ([`Config::may_add`]).+ pub auto_repos: bool,+ /// Repos never added that way: `owner/name` or full GitHub URLs.+ pub hidden_auto_repos: Vec<String>, pub window: WindowConfig, pub tray: TrayConfig, pub shortcuts: Shortcuts,@@ -75,6 +80,8 @@ impl Default for Config { poll_minutes: 30.0, stale_minutes: 60.0, github_token: String::new(),+ auto_repos: true,+ hidden_auto_repos: Vec::new(), window: WindowConfig::default(), tray: TrayConfig::default(), shortcuts: Shortcuts::default(),@@ -179,6 +186,203 @@ impl Config { } } +/// A repo list in the config file that a row added by the poller writes to.+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum RepoList {+ /// `repos`: the row's Keep.+ Watched,+ /// `hidden_auto_repos`: the row's Hide.+ Hidden,+}++impl RepoList {+ fn key(self) -> &'static str {+ match self {+ RepoList::Watched => "repos",+ RepoList::Hidden => "hidden_auto_repos",+ }+ }++ fn of(self, config: &Config) -> &[String] {+ match self {+ RepoList::Watched => &config.repos,+ RepoList::Hidden => &config.hidden_auto_repos,+ }+ }+}++impl Config {+ /// The owners of the configured repos, each once, in config order.+ pub fn owners(&self) -> Vec<String> {+ let mut out: Vec<String> = Vec::new();+ for (owner, _) in self.repos.iter().filter_map(|r| r.split_once('/')) {+ if !out.iter().any(|o| o.eq_ignore_ascii_case(owner)) {+ out.push(owner.to_string());+ }+ }+ out+ }++ /// Whether the poller may add `repo` to the table on its own:+ /// `auto_repos` is on, `repos` lists another repo of the same owner, and+ /// neither `repos` nor `hidden_auto_repos` lists `repo`.+ pub fn may_add(&self, repo: &str) -> bool {+ let Some(repo) = normalize_repo(repo) else {+ return false;+ };+ let owner = repo.split('/').next().unwrap_or_default();+ self.auto_repos+ && self.owners().iter().any(|o| o.eq_ignore_ascii_case(owner))+ && !lists(&self.repos, &repo)+ && !lists(&self.hidden_auto_repos, &repo)+ }++ /// Adds `repo` to `list` in the config file at `path` (written from the+ /// template first when there is none) with [`add_to_list`], through a+ /// temporary file and a rename. A symlink is followed, and the file keeps+ /// its permissions: the temporary file gets them before any text goes in.+ pub fn add_to_file(path: &Path, list: RepoList, repo: &str) -> Result<()> {+ let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());+ let io = |source| Error::Io {+ path: path.display().to_string(),+ source,+ };+ let text = match std::fs::read_to_string(&path) {+ Ok(text) => text,+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {+ return atomic_write(&path, &add_to_list(TEMPLATE, list, repo)?);+ }+ Err(e) => return Err(io(e)),+ };+ let out = add_to_list(&text, list, repo)?;+ let mode = std::fs::metadata(&path).map_err(io)?.permissions();+ let tmp = path.with_extension("tmp");+ let _ = std::fs::remove_file(&tmp);+ let mut file = std::fs::OpenOptions::new()+ .write(true)+ .create_new(true)+ .open(&tmp)+ .map_err(io)?;+ file.set_permissions(mode).map_err(io)?;+ std::io::Write::write_all(&mut file, out.as_bytes()).map_err(io)?;+ drop(file);+ std::fs::rename(&tmp, &path).map_err(io)+ }+}++/// Whether `list` holds `repo` (`owner/name`), in any spelling.+fn lists(list: &[String], repo: &str) -> bool {+ list.iter()+ .filter_map(|r| normalize_repo(r))+ .any(|r| r.eq_ignore_ascii_case(repo))+}++/// `text`, a config file, with `repo` added to `list`. Comments and+/// formatting stay; on a list written one repo per line, the new repo gets a+/// line of its own and a trailing comma, and a comment on the last repo's+/// line stays on that line. A list the file lacks is written with the repos+/// it reads as (for `repos`, the defaults), then `repo`, above the first+/// table. A repo the list already holds is not added again.+pub fn add_to_list(text: &str, list: RepoList, repo: &str) -> Result<String> {+ use toml_edit::{Array, DocumentMut, Item, RawString, TomlError, Value};+ let invalid = |what: &str, e: String| Error::Io {+ path: what.to_string(),+ source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),+ };+ let raw = |s: Option<&RawString>| {+ s.and_then(RawString::as_str)+ .unwrap_or_default()+ .to_string()+ };+ let held = Config::parse(text)?;+ let repo = normalize_repo(repo)+ .ok_or_else(|| invalid(repo, "not owner/name or a GitHub URL".into()))?;+ let held = list.of(&held);+ if lists(held, &repo) {+ return Ok(text.to_string());+ }+ let wanted = repo.clone();+ // `Config::parse` has read the same text, so this is not expected to fail.+ let mut doc: DocumentMut = text+ .parse()+ .map_err(|e: TomlError| invalid("config", e.to_string()))?;+ match doc.get_mut(list.key()).and_then(Item::as_array_mut) {+ Some(array) if !array.is_empty() => {+ // On a list without a trailing comma, the line break after the+ // last repo moves after a new comma.+ let last = array.iter().last();+ let suffix = raw(last.and_then(|v| v.decor().suffix()));+ if suffix.contains('\n') {+ if let Some(last) = array.iter_mut().last() {+ last.decor_mut().set_suffix("");+ }+ let trailing = raw(Some(array.trailing()));+ array.set_trailing(format!("{suffix}{trailing}"));+ array.set_trailing_comma(true);+ }+ // The indent of the last repo, without any comment above it.+ let indent = array+ .iter()+ .last()+ .and_then(|v| v.decor().prefix())+ .and_then(|p| p.as_str())+ .and_then(|p| p.rfind('\n').map(|i| p[i..].to_string()))+ .unwrap_or_else(|| " ".to_string());+ // A comment on the last repo's line stays on that line.+ let trailing = raw(Some(array.trailing()));+ let (line, rest) = trailing.split_at(trailing.find('\n').unwrap_or(trailing.len()));+ let mut value = Value::from(repo);+ if line.contains('#') {+ let indent = if indent.starts_with('\n') {+ indent+ } else {+ "\n ".to_string()+ };+ value.decor_mut().set_prefix(format!("{line}{indent}"));+ array.set_trailing(rest);+ } else {+ value.decor_mut().set_prefix(indent);+ }+ array.push_formatted(value);+ }+ Some(array) => array.push(repo),+ None => {+ let mut array = Array::new();+ for r in held.iter().cloned().chain([repo]) {+ let mut value = Value::from(r);+ value.decor_mut().set_prefix("\n ");+ array.push_formatted(value);+ }+ array.set_trailing_comma(true);+ array.set_trailing("\n");+ doc.insert(list.key(), Item::Value(Value::Array(array)));+ // The new list goes above the file's first table, with a blank+ // line between them.+ let first_table = doc+ .as_table_mut()+ .iter_mut()+ .filter_map(|(_, item)| item.as_table_mut())+ .filter(|t| !t.is_implicit())+ .min_by_key(|t| t.position());+ if let Some(table) = first_table {+ let prefix = raw(table.decor().prefix());+ if !prefix.starts_with('\n') {+ table.decor_mut().set_prefix(format!("\n{prefix}"));+ }+ }+ }+ }+ let out = doc.to_string();+ // Fail when the new text does not list the repo.+ if !lists(list.of(&Config::parse(&out)?), &wanted) {+ return Err(invalid(+ "config",+ format!("could not add {wanted} to {}", list.key()),+ ));+ }+ Ok(out)+}+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct WindowState { pub x: i32,@@ -272,4 +476,173 @@ mod tests { assert!(w.visible_on(&[(0, 0, 1920, 1080)])); assert!(!w.visible_on(&[(2000, 0, 1920, 1080)])); }++ #[test]+ fn repos_are_added_only_under_a_configured_owner() {+ let c = Config::parse(+ r#"repos = ["https://github.com/Acme/one", "acme/two", "other-org/lib"]+ hidden_auto_repos = ["https://github.com/acme/hidden"]"#,+ )+ .unwrap();+ assert_eq!(c.owners(), vec!["Acme", "other-org"]);+ assert!(c.auto_repos, "on by default");+ assert!(c.may_add("acme/three"));+ assert!(c.may_add("OTHER-ORG/tool"));+ assert!(!c.may_add("stranger/three"), "an owner the config lacks");+ assert!(!c.may_add("ACME/Two"), "already configured");+ assert!(!c.may_add("acme/hidden"));+ assert!(!c.may_add("not a repo"));+ let off = Config::parse("auto_repos = false\nrepos = [\"acme/one\"]").unwrap();+ assert!(!off.may_add("acme/three"));+ }++ #[test]+ fn keep_and_hide_add_to_the_lists_and_keep_the_comments() {+ let kept = add_to_list(+ TEMPLATE,+ RepoList::Watched,+ "https://github.com/storytold/newcraft",+ )+ .unwrap();+ assert!(kept.contains("# Repositories to watch"));+ assert!(kept.contains(" \"storytold/figcraft\",\n \"storytold/newcraft\",\n]"));+ let c = Config::parse(&kept).unwrap();+ assert_eq!(+ c.repos.last().map(String::as_str),+ Some("storytold/newcraft")+ );+ assert_eq!(c.repos.len(), DEFAULT_REPOS.len() + 1);+ assert_eq!(+ add_to_list(&kept, RepoList::Watched, "Storytold/NewCraft").unwrap(),+ kept,+ "already listed"+ );++ let hidden = add_to_list(&kept, RepoList::Hidden, "storytold/scratch").unwrap();+ assert!(hidden.contains("hidden_auto_repos = [\"storytold/scratch\"]"));+ let c = Config::parse(&hidden).unwrap();+ assert_eq!(c.hidden_auto_repos, vec!["storytold/scratch"]);+ assert!(!c.may_add("storytold/scratch"));++ // A comment above the last repo stays where it is, once.+ let commented = "repos = [\n \"a/one\",\n # libraries\n \"a/lib\",\n]\n";+ assert_eq!(+ add_to_list(commented, RepoList::Watched, "a/two").unwrap(),+ "repos = [\n \"a/one\",\n # libraries\n \"a/lib\",\n \"a/two\",\n]\n"+ );+ assert_eq!(+ add_to_list("repos = [\"a/one\"]\n", RepoList::Watched, "a/two").unwrap(),+ "repos = [\"a/one\", \"a/two\"]\n"+ );+ }++ #[test]+ fn a_list_without_a_trailing_comma_or_with_a_comment_on_its_last_line_keeps_its_shape() {+ let add = |text: &str| add_to_list(text, RepoList::Watched, "a/two").unwrap();+ assert_eq!(+ add("repos = [\n \"a/one\",\n \"a/lib\"\n]\n"),+ "repos = [\n \"a/one\",\n \"a/lib\",\n \"a/two\",\n]\n"+ );+ assert_eq!(+ add("repos = [\n \"a/one\",\n \"a/lib\", # libraries\n]\n"),+ "repos = [\n \"a/one\",\n \"a/lib\", # libraries\n \"a/two\",\n]\n",+ "the comment stays on the line of the repo it follows"+ );+ assert_eq!(+ add("repos = [\n \"a/one\",\n \"a/lib\" # libraries\n]\n"),+ "repos = [\n \"a/one\",\n \"a/lib\", # libraries\n \"a/two\",\n]\n"+ );+ assert_eq!(+ add("repos = [\n \"a/one\",\n \"a/lib\", # libraries\n # the end\n]\n"),+ "repos = [\n \"a/one\",\n \"a/lib\", # libraries\n \"a/two\",\n # the end\n]\n"+ );+ // The last repo on the bracket's line, with a comment after it.+ for text in [+ "repos = [\"a/one\", # one\n]\n",+ "repos = [\"a/one\" # one\n]\n",+ ] {+ let out = add(text);+ assert_eq!(out, "repos = [\"a/one\", # one\n \"a/two\",\n]\n");+ assert_eq!(Config::parse(&out).unwrap().repos, ["a/one", "a/two"]);+ }+ }++ #[test]+ fn a_list_the_file_lacks_is_followed_by_a_blank_line_before_a_table() {+ let out = add_to_list("[window]\nwidth = 900.0\n", RepoList::Hidden, "a/two").unwrap();+ assert_eq!(+ out,+ "hidden_auto_repos = [\n \"a/two\",\n]\n\n[window]\nwidth = 900.0\n"+ );+ let spaced = "poll_minutes = 5.0\n\n[window]\nwidth = 900.0\n";+ let out = add_to_list(spaced, RepoList::Hidden, "a/two").unwrap();+ assert_eq!(+ out,+ "poll_minutes = 5.0\nhidden_auto_repos = [\n \"a/two\",\n]\n\n[window]\nwidth = 900.0\n",+ "one blank line, not two"+ );+ }++ #[test]+ fn a_list_the_file_lacks_starts_from_the_repos_it_reads_as() {+ let text = "# mine\npoll_minutes = 5.0\n\n[window]\nwidth = 900.0\n";+ let out = add_to_list(text, RepoList::Watched, "storytold/newcraft").unwrap();+ assert!(out.starts_with("# mine\npoll_minutes = 5.0\n"));+ let c = Config::parse(&out).unwrap();+ let mut want: Vec<String> = DEFAULT_REPOS.iter().map(|s| s.to_string()).collect();+ want.push("storytold/newcraft".into());+ assert_eq!(c.repos, want, "the defaults stay");+ assert_eq!((c.poll_minutes, c.window.width), (5.0, 900.0));+ let out = add_to_list(text, RepoList::Hidden, "storytold/scratch").unwrap();+ assert_eq!(+ Config::parse(&out).unwrap().hidden_auto_repos,+ vec!["storytold/scratch"]+ );+ assert!(add_to_list("repos = [", RepoList::Watched, "a/b").is_err());+ assert!(add_to_list("", RepoList::Watched, "not a repo").is_err());+ }++ #[test]+ fn keep_and_hide_survive_a_reload_and_keep_the_files_permissions() {+ let dir = std::env::temp_dir().join(format!("craft-config-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let path = dir.join("config.toml");+ std::fs::write(&path, "# keep me\nrepos = [\"acme/one\"]\n").unwrap();+ #[cfg(unix)]+ {+ use std::os::unix::fs::PermissionsExt;+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();+ }+ Config::add_to_file(&path, RepoList::Watched, "acme/two").unwrap();+ Config::add_to_file(&path, RepoList::Hidden, "acme/three").unwrap();+ let c = Config::load_from(&path).unwrap();+ assert_eq!(c.repos, vec!["acme/one", "acme/two"]);+ assert_eq!(c.hidden_auto_repos, vec!["acme/three"]);+ assert!(std::fs::read_to_string(&path)+ .unwrap()+ .starts_with("# keep me\n"));+ assert!(!path.with_extension("tmp").exists());+ #[cfg(unix)]+ {+ use std::os::unix::fs::PermissionsExt;+ let mode = std::fs::metadata(&path).unwrap().permissions().mode();+ assert_eq!(mode & 0o777, 0o600);+ // Through a symlink, the file it points at is written.+ let link = dir.join("link.toml");+ std::os::unix::fs::symlink(&path, &link).unwrap();+ Config::add_to_file(&link, RepoList::Watched, "acme/four").unwrap();+ assert!(std::fs::symlink_metadata(&link).unwrap().is_symlink());+ assert_eq!(Config::load_from(&path).unwrap().repos.len(), 3);+ }+ let fresh = dir.join("missing.toml");+ Config::add_to_file(&fresh, RepoList::Hidden, "storytold/scratch").unwrap();+ let c = Config::load_from(&fresh).unwrap();+ assert_eq!(+ c.repos.len(),+ DEFAULT_REPOS.len(),+ "written from the template"+ );+ assert_eq!(c.hidden_auto_repos, vec!["storytold/scratch"]);+ std::fs::remove_dir_all(&dir).ok();+ } }@@ -113,6 +113,9 @@ pub struct RepoEntry { pub last_attempt_at: Option<Time>, /// Error from the most recent attempt, cleared on success. pub error: Option<String>,+ /// The poller added this row on its own: the config doesn't list the+ /// repo, but the token's user has an open pull request in it.+ pub auto: bool, /// GitHub reported the repo missing (e.g. private and not visible to the /// token); shown as an empty row rather than an error. pub inaccessible: bool,@@ -281,6 +284,8 @@ impl Snapshot { /// Make `repos` match the configured list, keeping data for repos that remain. pub fn sync_repos(&mut self, wanted: &[String]) { let mut old = std::mem::take(&mut self.repos);+ let added = old.iter().filter(|e| e.auto).map(|e| e.repo.clone());+ let added: Vec<String> = added.collect(); self.repos = wanted .iter() .map(|w| {@@ -290,6 +295,41 @@ impl Snapshot { .unwrap_or_else(|| RepoEntry::new(w)) }) .collect();+ // Rows the poller added stay, after the configured ones, until+ // `set_added` drops them; a repo the config now lists is no longer one.+ for e in &mut self.repos {+ e.auto = false;+ }+ for repo in added {+ if let Some(i) = old.iter().position(|e| e.repo == repo) {+ self.repos.push(old.swap_remove(i));+ }+ }+ }++ /// Make the rows the poller added ([`RepoEntry::auto`]) match `added`:+ /// rows it no longer lists go, keeping the others' data, and new repos+ /// are appended in its order. A repo already in the table isn't added.+ pub fn set_added(&mut self, added: &[String]) {+ self.repos+ .retain(|e| !e.auto || added.iter().any(|a| a.eq_ignore_ascii_case(&e.repo)));+ for repo in added {+ if !self.repos.iter().any(|e| e.repo.eq_ignore_ascii_case(repo)) {+ self.repos.push(RepoEntry {+ auto: true,+ ..RepoEntry::new(repo)+ });+ }+ }+ }++ /// The repos of the rows the poller added, in table order.+ pub fn added(&self) -> Vec<String> {+ self.repos+ .iter()+ .filter(|e| e.auto)+ .map(|e| e.repo.clone())+ .collect() } } @@ -421,6 +461,47 @@ mod tests { assert_eq!(s.take_requested(), None); } + #[test]+ fn added_rows_follow_the_configured_ones_and_keep_their_data() {+ let mut s = Snapshot::default();+ s.sync_repos(&["o/a".into(), "o/b".into()]);+ s.set_added(&["o/x".into(), "O/A".into(), "o/y".into()]);+ let rows = |s: &Snapshot| {+ s.repos+ .iter()+ .map(|e| format!("{}{}", e.repo, if e.auto { "+" } else { "" }))+ .collect::<Vec<_>>()+ };+ assert_eq!(+ rows(&s),+ ["o/a", "o/b", "o/x+", "o/y+"],+ "o/a is configured"+ );+ assert_eq!(s.added(), ["o/x", "o/y"]);+ s.repos[2].fetched_at = Some(Utc::now());+ assert!(s.request("o/y"), "an added row refreshes like any other");++ s.sync_repos(&["o/b".into()]);+ assert_eq!(+ rows(&s),+ ["o/b", "o/x+", "o/y+"],+ "a config reload keeps them"+ );+ assert!(s.repos[1].fetched_at.is_some());++ s.sync_repos(&["o/b".into(), "o/x".into()]);+ assert_eq!(rows(&s), ["o/b", "o/x", "o/y+"], "kept: now configured");+ assert!(s.repos[1].fetched_at.is_some(), "with its data");++ s.set_added(&[]);+ assert_eq!(rows(&s), ["o/b", "o/x"]);+ assert_eq!(s.take_requested(), None, "its refresh went with it");++ let back: Snapshot =+ serde_json::from_str(r#"{"repos": [{"repo": "o/z", "auto": true}]}"#).unwrap();+ assert!(back.repos[0].auto, "restored from the cache");+ }+ #[test] fn hand_refreshes_are_not_restored_from_the_cache() { let mut s = Snapshot::default();@@ -431,6 +431,69 @@ impl GitHub { } Ok(out) }++ /// The login of the user the token belongs to, from one+ /// `viewer { login }` query.+ pub fn token_login(&self) -> Result<String> {+ let v = self.graphql("query { viewer { login } }", json!({}))?;+ v["viewer"]["login"]+ .as_str()+ .map(str::to_string)+ .ok_or_else(|| Error::GraphQl("no login for the token's user".into()))+ }++ /// The repos owned by `owner` where `login` has an open pull request,+ /// from one search ([`OPEN_PRS_QUERY`]) read to its last page, and the+ /// GraphQL points left after it. This is the app's only search; the+ /// poller sends it once per owner every `poll_minutes`.+ pub fn open_pr_repos(&self, login: &str, owner: &str) -> Result<OpenPrs> {+ let q = format!("is:pr is:open author:{login} user:{owner}");+ walk_open_prs(|after| self.graphql(OPEN_PRS_QUERY, json!({ "q": q, "after": after })))+ }+}++/// What [`GitHub::open_pr_repos`] found.+#[derive(Debug, Clone, Default, PartialEq)]+pub struct OpenPrs {+ /// `owner/name` of each repo with one of the pull requests.+ pub repos: std::collections::BTreeSet<String>,+ /// GraphQL points left after the last page.+ pub graphql_remaining: Option<u64>,+ /// The search was read to its last page. False when it held more than+ /// [`SEARCH_PAGES`] pages, so a repo missing from `repos` may still have+ /// an open pull request.+ pub complete: bool,+}++/// Reads [`OPEN_PRS_QUERY`]'s pages, each from `page` given the cursor to+/// start after (`None` first), until the last page or [`SEARCH_PAGES`].+pub fn walk_open_prs(mut page: impl FnMut(Option<String>) -> Result<Value>) -> Result<OpenPrs> {+ let mut out = OpenPrs::default();+ let mut after = None;+ for _ in 0..SEARCH_PAGES {+ let v = page(after.take())?;+ let conn = &v["search"];+ out.repos.extend(+ conn["nodes"]+ .as_array()+ .into_iter()+ .flatten()+ .filter_map(|n| n["repository"]["nameWithOwner"].as_str())+ .map(str::to_string),+ );+ out.graphql_remaining = v["rateLimit"]["remaining"]+ .as_u64()+ .or(out.graphql_remaining);+ if conn["pageInfo"]["hasNextPage"].as_bool() != Some(true) {+ out.complete = true;+ break;+ }+ after = conn["pageInfo"]["endCursor"].as_str().map(str::to_string);+ if after.is_none() {+ break;+ }+ }+ Ok(out) } pub fn parse_contributors(body: &Value) -> Vec<String> {@@ -947,6 +1010,20 @@ query($owner: String!, $name: String!, $tag: String!, $branch: String!, $at: Git } "#; +/// Pages of 100 a search may take: GitHub returns at most 1,000 results.+const SEARCH_PAGES: usize = 10;++/// One page of the pull requests a search finds, by repo.+const OPEN_PRS_QUERY: &str = r#"+query($q: String!, $after: String) {+ search(type: ISSUE, query: $q, first: 100, after: $after) {+ pageInfo { hasNextPage endCursor }+ nodes { ... on PullRequest { repository { nameWithOwner } } }+ }+ rateLimit { remaining }+}+"#;+ #[cfg(test)] mod tests { use super::*;@@ -1218,6 +1295,51 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + #[test]+ fn the_open_pull_request_search_is_read_to_its_last_page() {+ let pages = [+ json!({"search": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}, "nodes": [+ {"repository": {"nameWithOwner": "acme/one"}},+ {"repository": {"nameWithOwner": "acme/two"}},+ {"repository": {"nameWithOwner": "acme/one"}}+ ]}, "rateLimit": {"remaining": 4990}}),+ json!({"search": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}, "nodes": [+ {"repository": {"nameWithOwner": "acme/three"}}, {}+ ]}, "rateLimit": {"remaining": 4989}}),+ ];+ let mut cursors = Vec::new();+ let found = walk_open_prs(|after| {+ let v = pages[cursors.len()].clone();+ cursors.push(after);+ Ok(v)+ })+ .unwrap();+ assert_eq!(cursors, [None, Some("c1".to_string())]);+ assert_eq!(+ found.repos.into_iter().collect::<Vec<_>>(),+ ["acme/one", "acme/three", "acme/two"]+ );+ assert_eq!(found.graphql_remaining, Some(4989));+ assert!(found.complete);++ let mut calls = 0;+ let failed = walk_open_prs(|_| {+ calls += 1;+ Err(Error::RateLimited("later".into()))+ });+ assert!(matches!(failed, Err(Error::RateLimited(_))));+ assert_eq!(calls, 1);++ let mut calls = 0;+ let endless = walk_open_prs(|_| {+ calls += 1;+ Ok(json!({"search": {"pageInfo": {"hasNextPage": true, "endCursor": "again"}, "nodes": []}}))+ })+ .unwrap();+ assert_eq!((calls, endless.graphql_remaining), (SEARCH_PAGES, None));+ assert!(!endless.complete, "more pages than a search may take");+ }+ #[test] fn empty_repository_is_all_zero() { let mut s = build_stats(new file mode 100644@@ -0,0 +1,735 @@+//! Rows the poller adds to the Repos table on its own: each repo where the+//! token's user has an open pull request, when the config lists another repo+//! of the same owner ([`Config::may_add`]).+//!+//! Every `poll_minutes`, a round of searches finds them: one search per+//! configured owner. The poll loop sends each search the way it sends a repo+//! fetch: not during the gap after a fetch or a pause, not ahead of a row+//! refreshed by hand, and not while fewer than [`OPTIONAL_FLOOR`] GraphQL+//! points are left. The gap follows each search, and a rate limit backs off+//! as after a fetch. An added row is removed once two rounds in a row find+//! none of the user's open pull requests there. Only a row's Keep and Hide+//! write the config file.++use std::collections::{BTreeMap, BTreeSet};++use chrono::{DateTime, Utc};+use craft_core::config::RepoList;+use craft_core::{Config, Snapshot};+use craft_github::{resolve_token, Error as GhError, GitHub, OpenPrs};+use tauri::{AppHandle, Manager};++use super::{+ clock, emit_snapshot, rate_limit_end, reload_config_if_changed, AppState, Pacing, BACKOFF_MAX,+ BACKOFF_MIN, OPTIONAL_FLOOR, REPO_GAP, REPO_RETRY,+};++/// Rounds in a row that find no open pull request in an added repo before+/// its row is removed.+const MISSES: u32 = 2;++/// What the poll thread keeps between searches.+#[derive(Default)]+pub struct Found {+ /// The token the login was read with, and the login.+ login: Option<(String, String)>,+ /// Rounds in a row that missed each added repo, for those they missed.+ misses: BTreeMap<String, u32>,+ /// The round under way.+ round: Option<Round>,+ /// When the last round started.+ ran_at: Option<DateTime<Utc>>,+ /// No round starts before this.+ next_at: Option<DateTime<Utc>>,+}++/// A round of searches, one per configured owner.+#[derive(Debug, PartialEq)]+struct Round {+ /// The owners still to search, in config order.+ owners: Vec<String>,+ /// What the searches so far found.+ open: OpenPrs,+ /// The owners whose search failed or was not read to its end.+ incomplete: Vec<String>,+}++/// A search that failed, by the request that failed.+#[derive(Debug)]+enum SearchError {+ /// Reading the token, or the login of its user, failed.+ Token(GhError),+ /// The search of the owner's pull requests failed.+ Owner(GhError),+}++impl SearchError {+ fn error(&self) -> &GhError {+ match self {+ SearchError::Token(e) | SearchError::Owner(e) => e,+ }+ }+}++/// Keep: writes an added row's repo into the config's `repos`.+#[tauri::command]+pub fn keep_repo(app: AppHandle, repo: String) -> Result<(), String> {+ write_list(&app, RepoList::Watched, &repo)+}++/// Hide: writes an added row's repo into the config's `hidden_auto_repos`,+/// so it isn't added again.+#[tauri::command]+pub fn hide_repo(app: AppHandle, repo: String) -> Result<(), String> {+ write_list(&app, RepoList::Hidden, &repo)+}++fn write_list(app: &AppHandle, list: RepoList, repo: &str) -> Result<(), String> {+ let state = app.state::<AppState>();+ let added = state.snapshot.lock().unwrap().added();+ if !added.iter().any(|r| r == repo) {+ return Err(format!("{repo} is not a row the app added"));+ }+ let written = Config::path().and_then(|path| Config::add_to_file(&path, list, repo));+ if let Err(e) = written {+ let msg = format!("Could not write the config: {e}");+ state.snapshot.lock().unwrap().error = Some(msg.clone());+ emit_snapshot(app);+ return Err(msg);+ }+ reload_config_if_changed(app);+ let cfg = state.config.lock().unwrap().clone();+ if prune(&cfg, &mut state.snapshot.lock().unwrap()) {+ emit_snapshot(app);+ }+ Ok(())+}++/// Called by the poll loop before each step: drops the added rows the config+/// no longer allows, then sends the next search if one is due.+pub fn discover(app: &AppHandle, pacing: &mut Pacing, found: &mut Found) {+ let state = app.state::<AppState>();+ let cfg = state.config.lock().unwrap().clone();+ let now = Utc::now();+ let (pruned, run) = {+ let mut snap = state.snapshot.lock().unwrap();+ let pruned = prune(&cfg, &mut snap);+ (pruned, due(found, pacing, &cfg, &snap, now))+ };+ let owner = run.then(|| next_owner(found, &cfg, now)).flatten();+ let Some(owner) = owner else {+ if pruned {+ emit_snapshot(app);+ }+ return;+ };+ let result = search(&cfg, found, &owner);+ let now = Utc::now();+ let msg = pace(pacing, result.as_ref().err().map(SearchError::error), now);+ record(&state, found, &owner, result, msg, now);+ emit_snapshot(app);+}++/// Puts the result of the search for `owner` into the snapshot and saves+/// the cache. `msg` is the rate-limit message from [`pace`], if any.+fn record(+ state: &AppState,+ found: &mut Found,+ owner: &str,+ result: Result<OpenPrs, SearchError>,+ msg: Option<String>,+ now: DateTime<Utc>,+) {+ let mut snap = state.snapshot.lock().unwrap();+ // The config as Keep or Hide left it, if either wrote it during the+ // search. They reload it before they take this lock to prune.+ let cfg = state.config.lock().unwrap().clone();+ let done = match result {+ Ok(one) => {+ if one.graphql_remaining.is_some() {+ snap.rate_limit_remaining = one.graphql_remaining;+ }+ searched(found, one)+ }+ Err(SearchError::Owner(+ e @ (GhError::GraphQl(_) | GhError::NotFound | GhError::Http(422, _)),+ )) => {+ eprintln!("open pull request search for {owner}: {e}");+ failed(found)+ }+ Err(SearchError::Token(e) | SearchError::Owner(e)) => {+ eprintln!("open pull request search for {owner}: {e}");+ // The next round starts over, after the token problem `step`+ // reports or a wait like a failed repo's.+ found.round = None;+ let wait = match e {+ GhError::Unauthorized | GhError::NoToken => cfg.poll_secs(),+ _ => REPO_RETRY,+ };+ found.next_at = Some(now + chrono::Duration::seconds(wait));+ None+ }+ };+ if let Some(round) = done {+ apply_search(found, &cfg, &mut snap, &round.open, &round.incomplete);+ found.next_at = Some(now + chrono::Duration::seconds(cfg.poll_secs()));+ }+ // Keep a config error up until the file is fixed.+ let config_error = snap+ .error+ .as_deref()+ .is_some_and(|e| e.starts_with("Config"));+ if let (Some(msg), false) = (msg, config_error) {+ snap.error = Some(msg);+ }+ if let Some(cache) = &state.cache {+ if let Err(e) = cache.save(&snap) {+ eprintln!("could not save cache: {e}");+ }+ }+}++/// The repos of `owner` where the token's user has an open pull request.+/// The login is read once per token.+fn search(cfg: &Config, found: &mut Found, owner: &str) -> Result<OpenPrs, SearchError> {+ let token = resolve_token(&cfg.github_token).map_err(SearchError::Token)?;+ let gh = GitHub::new(token.value.clone()).map_err(SearchError::Token)?;+ let login = login(found, &token.value, || gh.token_login()).map_err(SearchError::Token)?;+ gh.open_pr_repos(&login, owner).map_err(SearchError::Owner)+}++/// The owner to search next, starting a round over the configured owners+/// when none is under way.+fn next_owner(found: &mut Found, cfg: &Config, now: DateTime<Utc>) -> Option<String> {+ if found.round.is_none() {+ found.ran_at = Some(now);+ found.round = Some(Round {+ owners: cfg.owners(),+ open: OpenPrs {+ complete: true,+ ..Default::default()+ },+ incomplete: Vec::new(),+ });+ }+ let owner = found.round.as_ref()?.owners.first().cloned();+ if owner.is_none() {+ found.round = None;+ found.next_at = Some(now + chrono::Duration::seconds(cfg.poll_secs()));+ }+ owner+}++/// Adds the next owner's search to the round, and the owner to the round's+/// incomplete owners when the search was not read to its end. Returns the+/// round once it has searched every owner.+fn searched(found: &mut Found, one: OpenPrs) -> Option<Round> {+ let round = found.round.as_mut()?;+ if !round.owners.is_empty() {+ let owner = round.owners.remove(0);+ if !one.complete {+ round.incomplete.push(owner);+ }+ }+ round.open.repos.extend(one.repos);+ round.open.graphql_remaining = one.graphql_remaining.or(round.open.graphql_remaining);+ round.open.complete &= one.complete;+ if round.owners.is_empty() {+ found.round.take()+ } else {+ None+ }+}++/// Ends the search for the round's next owner as incomplete, after an error+/// GitHub gives again on every try for that owner, so the round goes on to+/// the next owner and counts no misses for that owner's repos. Returns what+/// [`searched`] returns.+fn failed(found: &mut Found) -> Option<Round> {+ searched(found, OpenPrs::default())+}++/// The login of the user `token` belongs to: the one held for that token, or+/// else the one `read` returns, which is then held.+fn login(+ found: &mut Found,+ token: &str,+ read: impl FnOnce() -> Result<String, GhError>,+) -> Result<String, GhError> {+ if let Some((held, login)) = &found.login {+ if held == token {+ return Ok(login.clone());+ }+ }+ let login = read()?;+ found.login = Some((token.to_string(), login.clone()));+ Ok(login)+}++/// Drops the added rows that `cfg` no longer allows (switched off, hidden,+/// configured, or under an owner it no longer lists). True if it dropped any.+fn prune(cfg: &Config, snap: &mut Snapshot) -> bool {+ let added = snap.added();+ let allowed: Vec<String> = added.iter().filter(|r| cfg.may_add(r)).cloned().collect();+ if allowed.len() == added.len() {+ return false;+ }+ snap.set_added(&allowed);+ true+}++/// Whether the poll loop may search at `now`: the switch is on, pacing+/// allows a request, no row refreshed by hand is waiting, at least+/// [`OPTIONAL_FLOOR`] points are left, and a round is under way, due again,+/// or older than a manual refresh.+fn due(found: &Found, pacing: &Pacing, cfg: &Config, snap: &Snapshot, now: DateTime<Utc>) -> bool {+ let budget = snap.rate_limit_remaining;+ cfg.auto_repos+ && !cfg.repos.is_empty()+ && pacing.not_before.is_none_or(|t| now >= t)+ && snap.requested.is_empty()+ && budget.is_none_or(|r| r >= OPTIONAL_FLOOR)+ && (found.round.is_some()+ || found.next_at.is_none_or(|t| now >= t)+ || pacing+ .forced_since+ .is_some_and(|f| found.ran_at.is_none_or(|r| r < f)))+}++/// Sets `pacing` after a search that failed with `error`, if it did, as+/// `step` does after a fetch: the gap, or a backoff on a rate limit. Returns+/// the message for the window.+fn pace(pacing: &mut Pacing, error: Option<&GhError>, now: DateTime<Utc>) -> Option<String> {+ let Some(GhError::RateLimited(until)) = error else {+ pacing.strikes = 0;+ pacing.not_before = Some(now + chrono::Duration::seconds(REPO_GAP));+ return None;+ };+ pacing.strikes += 1;+ let backoff = (BACKOFF_MIN << (pacing.strikes - 1).min(8)).min(BACKOFF_MAX);+ let mut resume = now + chrono::Duration::seconds(backoff);+ if let Some(end) = rate_limit_end(until, now) {+ resume = resume.max(end);+ }+ pacing.not_before = Some(resume);+ Some(format!(+ "Rate limited by GitHub; pausing until {}",+ clock(resume)+ ))+}++/// Updates the added rows from a round that found `open`: a repo `cfg`+/// allows gets a row, and an added row is removed on its [`MISSES`]th round+/// in a row without an open pull request. A row under one of the `incomplete`+/// owners, whose search failed or was not read to its end, counts no miss.+fn apply_search(+ found: &mut Found,+ cfg: &Config,+ snap: &mut Snapshot,+ open: &OpenPrs,+ incomplete: &[String],+) {+ let allowed: BTreeSet<&String> = open.repos.iter().filter(|r| cfg.may_add(r)).collect();+ let mut keep = Vec::new();+ let mut misses = BTreeMap::new();+ for repo in snap.added().into_iter().filter(|r| cfg.may_add(r)) {+ if allowed.iter().any(|o| o.eq_ignore_ascii_case(&repo)) {+ keep.push(repo);+ continue;+ }+ let owner = repo.split('/').next().unwrap_or_default();+ let searched = !incomplete.iter().any(|o| o.eq_ignore_ascii_case(owner));+ let held = found.misses.get(&repo).copied().unwrap_or(0);+ let n = if searched { held + 1 } else { held };+ if n < MISSES {+ misses.insert(repo.clone(), n);+ keep.push(repo);+ }+ }+ found.misses = misses;+ keep.extend(allowed.into_iter().cloned());+ snap.set_added(&keep);+}++#[cfg(test)]+mod tests {+ use super::*;+ use chrono::Duration as CDuration;+ use std::sync::atomic::AtomicBool;+ use std::sync::Mutex;++ const CONFIG: &str = r#"repos = ["acme/one", "acme/two"]"#;++ fn config(extra: &str) -> Config {+ Config::parse(&format!("{CONFIG}\n{extra}")).unwrap()+ }++ fn snapshot(cfg: &Config) -> Snapshot {+ let mut s = Snapshot::default();+ s.sync_repos(&cfg.repos);+ s+ }++ fn open(repos: &[&str]) -> OpenPrs {+ OpenPrs {+ repos: repos.iter().map(|r| r.to_string()).collect(),+ graphql_remaining: Some(4000),+ complete: true,+ }+ }++ fn rows(s: &Snapshot) -> Vec<String> {+ let mark = |auto| if auto { " (added)" } else { "" };+ s.repos+ .iter()+ .map(|e| format!("{}{}", e.repo, mark(e.auto)))+ .collect()+ }++ #[test]+ fn a_search_adds_repos_under_a_configured_owner_only() {+ let cfg = config(r#"hidden_auto_repos = ["acme/hidden"]"#);+ let mut s = snapshot(&cfg);+ let mut found = Found::default();+ let search = open(&["acme/new", "stranger/fork", "ACME/one", "acme/hidden"]);+ apply_search(&mut found, &cfg, &mut s, &search, &[]);+ assert_eq!(rows(&s), ["acme/one", "acme/two", "acme/new (added)"]);+ }++ #[test]+ fn a_round_searches_each_owner_in_a_step_of_its_own() {+ let cfg = Config::parse(r#"repos = ["acme/one", "other-org/lib", "Acme/two"]"#).unwrap();+ let s = snapshot(&cfg);+ let now = Utc::now();+ let mut found = Found::default();+ let mut pacing = Pacing::default();+ let first = next_owner(&mut found, &cfg, now);+ assert_eq!((first.as_deref(), found.ran_at), (Some("acme"), Some(now)));+ let done = searched(&mut found, open(&["acme/new"]));+ assert_eq!(done, None, "a round goes on");+ pace(&mut pacing, None, now);+ let waits = !due(&found, &pacing, &cfg, &s, now);+ assert!(waits, "the gap after each search");+ found.next_at = Some(now + CDuration::seconds(cfg.poll_secs()));+ let later = now + CDuration::seconds(REPO_GAP);+ let goes_on = due(&found, &pacing, &cfg, &s, later);+ assert!(goes_on, "a round under way goes on");+ let second = next_owner(&mut found, &cfg, later);+ assert_eq!(second.as_deref(), Some("other-org"));+ assert_eq!(found.ran_at, Some(now), "the same round");+ let cut_short = OpenPrs {+ complete: false,+ ..open(&["other-org/tool"])+ };+ let all = searched(&mut found, cut_short).unwrap();+ let repos: Vec<&str> = all.open.repos.iter().map(String::as_str).collect();+ assert_eq!(repos, ["acme/new", "other-org/tool"]);+ assert!(!all.open.complete, "one search was cut short");+ assert_eq!(all.incomplete, ["other-org"]);+ assert!(found.round.is_none());+ }++ #[test]+ fn an_added_row_goes_after_two_rounds_without_its_pull_request() {+ let cfg = config("");+ let mut s = snapshot(&cfg);+ let mut found = Found::default();+ let both = open(&["acme/new", "acme/other"]);+ let other = open(&["acme/other"]);+ apply_search(&mut found, &cfg, &mut s, &both, &[]);+ s.repos[2].fetched_at = Some(Utc::now());+ apply_search(&mut found, &cfg, &mut s, &other, &[]);+ let all = [+ "acme/one",+ "acme/two",+ "acme/new (added)",+ "acme/other (added)",+ ];+ assert_eq!(rows(&s), all, "one miss keeps the row");+ assert!(s.repos[2].fetched_at.is_some(), "and its data");+ apply_search(&mut found, &cfg, &mut s, &both, &[]);+ apply_search(&mut found, &cfg, &mut s, &other, &[]);+ assert_eq!(rows(&s), all, "a found pull request starts the count again");+ let cut_short = OpenPrs {+ complete: false,+ ..open(&["acme/other"])+ };+ let acme = ["acme".to_string()];+ apply_search(&mut found, &cfg, &mut s, &cut_short, &acme);+ assert_eq!(rows(&s), all, "a round cut short is no miss");+ apply_search(&mut found, &cfg, &mut s, &other, &[]);+ let left = ["acme/one", "acme/two", "acme/other (added)"];+ assert_eq!(rows(&s), left, "two misses in a row");+ apply_search(&mut found, &cfg, &mut s, &open(&[]), &[]);+ apply_search(&mut found, &cfg, &mut s, &open(&[]), &[]);+ assert_eq!(rows(&s), ["acme/one", "acme/two"]);+ }++ #[test]+ fn with_auto_repos_off_nothing_is_added_or_searched() {+ let on = config("");+ let mut s = snapshot(&on);+ let mut found = Found::default();+ apply_search(&mut found, &on, &mut s, &open(&["acme/new"]), &[]);+ let off = config("auto_repos = false");+ s.sync_repos(&off.repos);+ assert!(prune(&off, &mut s), "the switch drops the rows it added");+ assert_eq!(rows(&s), ["acme/one", "acme/two"]);+ apply_search(&mut found, &off, &mut s, &open(&["acme/new"]), &[]);+ assert_eq!(rows(&s), ["acme/one", "acme/two"]);+ assert!(!due(&found, &Pacing::default(), &off, &s, Utc::now()));+ }++ #[test]+ fn kept_and_hidden_rows_follow_the_reloaded_config() {+ let cfg = config("");+ let mut s = snapshot(&cfg);+ let mut found = Found::default();+ let search = open(&["acme/kept", "acme/hidden"]);+ apply_search(&mut found, &cfg, &mut s, &search, &[]);+ let added = [+ "acme/one",+ "acme/two",+ "acme/hidden (added)",+ "acme/kept (added)",+ ];+ assert_eq!(rows(&s), added);+ s.repos[3].fetched_at = Some(Utc::now());++ // What Keep and Hide wrote, read back as the poll thread reads it.+ let written = Config::parse(+ r#"repos = ["acme/one", "acme/two", "acme/kept"]+ hidden_auto_repos = ["acme/hidden"]"#,+ )+ .unwrap();+ s.sync_repos(&written.repos);+ assert!(prune(&written, &mut s));+ assert_eq!(rows(&s), ["acme/one", "acme/two", "acme/kept"]);+ assert!(s.repos[2].fetched_at.is_some(), "Keep keeps the data");+ apply_search(&mut found, &written, &mut s, &search, &[]);+ let kept = ["acme/one", "acme/two", "acme/kept"];+ assert_eq!(rows(&s), kept, "the hidden repo stays out");+ }++ #[test]+ fn a_search_that_ends_after_a_hide_uses_the_config_hide_wrote() {+ let before = config("");+ let mut found = Found::default();+ let owner = next_owner(&mut found, &before, Utc::now()).unwrap();+ // Hide wrote the config and pruned while the search was in flight.+ let hidden = config(r#"hidden_auto_repos = ["acme/hidden"]"#);+ let state = AppState {+ config: Mutex::new(hidden),+ config_mtime: Mutex::new(None),+ snapshot: Mutex::new(snapshot(&before)),+ cache: None,+ refresh_tx: Mutex::new(None),+ window_state: Mutex::new(None),+ window_dirty: AtomicBool::new(false),+ };+ let result = Ok(open(&["acme/hidden", "acme/new"]));+ record(&state, &mut found, &owner, result, None, Utc::now());+ let s = state.snapshot.lock().unwrap();+ assert_eq!(rows(&s), ["acme/one", "acme/two", "acme/new (added)"]);+ assert_eq!(s.rate_limit_remaining, Some(4000));+ assert!(found.round.is_none() && found.next_at.is_some());+ }++ #[test]+ fn an_owner_whose_search_fails_does_not_stop_the_round() {+ let cfg = Config::parse(r#"repos = ["acme/one", "other-org/lib"]"#).unwrap();+ let mut s = snapshot(&cfg);+ let mut found = Found::default();+ apply_search(&mut found, &cfg, &mut s, &open(&["acme/old"]), &[]);+ let state = AppState {+ config: Mutex::new(cfg.clone()),+ config_mtime: Mutex::new(None),+ snapshot: Mutex::new(s),+ cache: None,+ refresh_tx: Mutex::new(None),+ window_state: Mutex::new(None),+ window_dirty: AtomicBool::new(false),+ };+ let now = Utc::now();+ let refused = GhError::GraphQl("the listed users cannot be searched".into());+ let invalid = GhError::Http(422, "Validation Failed".into());+ for error in [refused, GhError::NotFound, invalid] {+ let acme = next_owner(&mut found, &cfg, now).unwrap();+ let result = Err(SearchError::Owner(error));+ record(&state, &mut found, &acme, result, None, now);+ assert!(found.round.is_some(), "the round goes on");+ let other = next_owner(&mut found, &cfg, now).unwrap();+ assert_eq!(other, "other-org");+ let result = Ok(open(&["other-org/new"]));+ record(&state, &mut found, &other, result, None, now);+ assert!(found.round.is_none(), "the round ends");+ let poll = Some(now + CDuration::seconds(cfg.poll_secs()));+ assert_eq!(found.next_at, poll);+ }+ let all = [+ "acme/one",+ "other-org/lib",+ "acme/old (added)",+ "other-org/new (added)",+ ];+ let s = rows(&state.snapshot.lock().unwrap());+ assert_eq!(s, all, "a round with a failed search is no miss");++ let acme = next_owner(&mut found, &cfg, now).unwrap();+ let down = SearchError::Owner(GhError::Http(502, "bad gateway".into()));+ record(&state, &mut found, &acme, Err(down), None, now);+ assert!(found.round.is_none(), "any other error starts over");+ let retry = Some(now + CDuration::seconds(REPO_RETRY));+ assert_eq!(found.next_at, retry);+ }++ #[test]+ fn a_failed_login_read_starts_the_round_over() {+ let cfg = Config::parse(r#"repos = ["acme/one", "other-org/lib"]"#).unwrap();+ let mut s = snapshot(&cfg);+ let mut found = Found::default();+ apply_search(&mut found, &cfg, &mut s, &open(&["acme/old"]), &[]);+ let state = AppState {+ config: Mutex::new(cfg.clone()),+ config_mtime: Mutex::new(None),+ snapshot: Mutex::new(s),+ cache: None,+ refresh_tx: Mutex::new(None),+ window_state: Mutex::new(None),+ window_dirty: AtomicBool::new(false),+ };+ let now = Utc::now();+ let no_login = GhError::GraphQl("no viewer.login".into());+ let invalid = GhError::Http(422, "Validation Failed".into());+ for error in [no_login, GhError::NotFound, invalid] {+ let acme = next_owner(&mut found, &cfg, now).unwrap();+ let result = Err(SearchError::Token(error));+ record(&state, &mut found, &acme, result, None, now);+ assert!(found.round.is_none(), "the round starts over");+ let retry = Some(now + CDuration::seconds(REPO_RETRY));+ assert_eq!(found.next_at, retry);+ }+ let s = rows(&state.snapshot.lock().unwrap());+ let all = ["acme/one", "other-org/lib", "acme/old (added)"];+ assert_eq!(s, all, "no round ended");+ }++ #[test]+ fn a_miss_counts_only_where_the_owners_search_was_complete() {+ let cfg = Config::parse(r#"repos = ["acme/one", "other-org/lib"]"#).unwrap();+ let now = Utc::now();+ for cut_short in [false, true] {+ let mut s = snapshot(&cfg);+ let mut found = Found::default();+ let both = open(&["acme/old", "other-org/old"]);+ apply_search(&mut found, &cfg, &mut s, &both, &[]);+ let state = AppState {+ config: Mutex::new(cfg.clone()),+ config_mtime: Mutex::new(None),+ snapshot: Mutex::new(s),+ cache: None,+ refresh_tx: Mutex::new(None),+ window_state: Mutex::new(None),+ window_dirty: AtomicBool::new(false),+ };+ for _ in 0..MISSES {+ let acme = next_owner(&mut found, &cfg, now).unwrap();+ let result = if cut_short {+ Ok(OpenPrs {+ complete: false,+ ..open(&[])+ })+ } else {+ let refused = GhError::GraphQl("cannot be searched".into());+ Err(SearchError::Owner(refused))+ };+ record(&state, &mut found, &acme, result, None, now);+ let other = next_owner(&mut found, &cfg, now).unwrap();+ record(&state, &mut found, &other, Ok(open(&[])), None, now);+ assert!(found.round.is_none(), "the round ends");+ }+ let s = rows(&state.snapshot.lock().unwrap());+ let left = ["acme/one", "other-org/lib", "acme/old (added)"];+ assert_eq!(s, left, "acme's search cut short: {cut_short}");+ }+ }++ #[test]+ fn a_search_waits_for_the_poll_loops_gap_pauses_and_budget() {+ let cfg = config("");+ let mut s = snapshot(&cfg);+ let now = Utc::now();+ let mut found = Found::default();+ let mut pacing = Pacing::default();+ let due_now =+ |found: &Found, pacing: &Pacing, s: &Snapshot| due(found, pacing, &cfg, s, now);+ assert!(due_now(&found, &pacing, &s), "the first search goes");++ pacing.not_before = Some(now + CDuration::seconds(5));+ assert!(!due_now(&found, &pacing, &s), "the gap or a pause");+ pacing.not_before = None;++ s.rate_limit_remaining = Some(OPTIONAL_FLOOR - 1);+ assert!(!due_now(&found, &pacing, &s), "below the budget floor");+ s.rate_limit_remaining = Some(OPTIONAL_FLOOR);+ assert!(due_now(&found, &pacing, &s));++ assert!(s.request("acme/one"));+ assert!(!due_now(&found, &pacing, &s), "a hand refresh goes first");+ s.take_requested();++ found.ran_at = Some(now - CDuration::seconds(1));+ found.next_at = Some(now + CDuration::seconds(cfg.poll_secs()));+ assert!(!due_now(&found, &pacing, &s), "searched this poll_minutes");+ pacing.forced_since = Some(now);+ assert!(due_now(&found, &pacing, &s), "a manual refresh");+ }++ #[test]+ fn the_login_is_read_once_per_token() {+ let mut found = Found::default();+ let mut reads = 0;+ let mut read = |login: &str| {+ reads += 1;+ Ok::<_, GhError>(login.to_string())+ };+ let ann = login(&mut found, "t1", || read("ann-example")).unwrap();+ assert_eq!(ann, "ann-example");+ let again = login(&mut found, "t1", || read("unused")).unwrap();+ assert_eq!(again, "ann-example", "held for the same token");+ let bo = login(&mut found, "t2", || read("bo-example")).unwrap();+ assert_eq!(bo, "bo-example", "read again for another token");+ let failed = login(&mut found, "t3", || Err(GhError::Unauthorized));+ assert!(matches!(failed, Err(GhError::Unauthorized)));+ let held = login(&mut found, "t2", || read("unused")).unwrap();+ assert_eq!(held, "bo-example", "a failed read keeps the held login");+ assert_eq!(reads, 2);+ }++ #[test]+ fn the_gap_follows_a_search_and_a_rate_limit_backs_off() {+ let now: DateTime<Utc> = "2026-10-18T04:20:00Z".parse().unwrap();+ let gap = Some(now + CDuration::seconds(REPO_GAP));+ let mut pacing = Pacing {+ strikes: 2,+ ..Default::default()+ };+ assert_eq!(pace(&mut pacing, None, now), None);+ assert_eq!((pacing.not_before, pacing.strikes), (gap, 0));++ let network = GhError::Http(502, "bad gateway".into());+ assert_eq!(pace(&mut pacing, Some(&network), now), None);+ assert_eq!(pacing.not_before, gap);++ let limited = GhError::RateLimited("2026-10-18T04:58:00Z".into());+ let msg = pace(&mut pacing, Some(&limited), now).unwrap();+ assert!(msg.starts_with("Rate limited by GitHub; pausing until "));+ assert_eq!(pacing.strikes, 1);+ let github = "2026-10-18T04:58:00Z".parse().ok();+ assert_eq!(pacing.not_before, github, "GitHub's time is later");+ let later = GhError::RateLimited("later".into());+ pace(&mut pacing, Some(&later), now);+ let doubled = Some(now + CDuration::seconds(2 * BACKOFF_MIN));+ assert_eq!(pacing.not_before, doubled, "the backoff doubles");+ }+}@@ -20,6 +20,8 @@ use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent} use tauri::{AppHandle, Emitter, LogicalSize, Manager, State, WebviewWindow, Wry}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; +mod added;+ const MAIN: &str = "main"; const TRAY: &str = "main"; const EV_SNAPSHOT: &str = "craft:snapshot";@@ -41,6 +43,8 @@ const BACKOFF_MAX: i64 = 60 * 60; /// Below this many GraphQL points left, pause for `LOW_BUDGET_PAUSE`. const BUDGET_FLOOR: u64 = 150; const LOW_BUDGET_PAUSE: i64 = 30 * 60;+/// Below this many GraphQL points left, skip requests nobody asked for.+const OPTIONAL_FLOOR: u64 = 1_500; /// Ignore manual refreshes this soon after the previous one. const MANUAL_DEBOUNCE: i64 = 15; @@ -472,8 +476,10 @@ fn spawn_poller(app: AppHandle, rx: mpsc::Receiver<Wake>) { .spawn(move || { let mut pacing = Pacing::default(); let mut last_manual: Option<DateTime<Utc>> = None;+ let mut found = added::Found::default(); loop { reload_config_if_changed(&app);+ added::discover(&app, &mut pacing, &mut found); step(&app, &mut pacing); // Staleness changes with the clock alone. update_tray(&app);@@ -793,6 +799,8 @@ pub fn run() { hide_window, log, quit,+ added::keep_repo,+ added::hide_repo, ]) .setup(move |app| { let cfg = app.state::<AppState>().config.lock().unwrap().clone();@@ -276,6 +276,31 @@ test('the window picker switches the activity columns and remembers', async () = assert.equal(stored(h, 'window'), '7d'); }); +test('a row the poller added is marked, and its Keep and Hide call the host', async () => {+ const h = await setup({ snap: snapshot([repo('alpha', { urgency: 100 }), repo('extra', { urgency: 50 }, { auto: true })]) });+ assert.equal(h.row('alpha').querySelector('.added'), null, 'a configured row has no marker');+ const mark = h.row('extra').querySelector<HTMLElement>('.added-mark')!;+ assert.equal(mark.title, 'Added because you have an open pull request here');+ const actions = () => [...h.row('extra').querySelectorAll<HTMLButtonElement>('.added-action')];+ assert.deepEqual(actions().map((b) => b.textContent), ['Keep', 'Hide']);++ const [keep, hide] = actions();+ pointer(h, keep!, 'pointerdown', 25);+ pointer(h, h.win as unknown as EventTarget, 'pointermove', 60);+ pointer(h, h.win as unknown as EventTarget, 'pointerup', 60);+ keep!.click();+ assert.deepEqual(h.rows(), ['alpha', 'extra'], 'not a drag');+ assert.equal(h.view(), 'repos', 'not a click on the row');+ assert.ok(keep!.disabled, 'once');+ hide!.click();+ const sent = h.calls.filter((c) => c.cmd.endsWith('_repo'));+ assert.deepEqual(sent, [{ cmd: 'keep_repo', args: { repo: 'o/extra' } }, { cmd: 'hide_repo', args: { repo: 'o/extra' } }]);+ assert.equal(stored(h, 'order'), null);++ h.emit('craft:snapshot', snapshot([repo('alpha', { urgency: 100 }), repo('extra', { urgency: 50 })]));+ assert.equal(h.row('extra').querySelector('.added'), null, 'kept: now a configured row');+});+ test('an empty config says so', async () => { const h = await setup({ snap: snapshot([]) }); assert.match(h.doc.getElementById('tbody')!.textContent ?? '', /No repositories configured/);@@ -100,6 +100,25 @@ export function start(api: Api): () => void { const link = (text: string, url: string, cls = ''): HTMLElement => el('span', { class: `link ${cls}`, onclick: (e: Event) => openUrl(url, e) }, text); + /** The marker of a row the poller added, with Keep and Hide, which write the config file. */+ function addedControls(entry: RepoEntry): HTMLElement {+ const action = (label: string, cmd: string, title: string) => el('button', {+ class: 'added-action',+ title,+ // Stop the press from starting a row drag or opening the urgent view.+ onpointerdown: (e: Event) => e.stopPropagation(),+ onclick: (e: Event) => {+ e.stopPropagation();+ (e.currentTarget as HTMLButtonElement).disabled = true;+ api.invoke(cmd, { repo: entry.repo }).catch((err: unknown) => console.warn(err));+ },+ }, label);+ return el('span', { class: 'added' },+ el('span', { class: 'added-mark', title: 'Added because you have an open pull request here' }, 'added'),+ action('Keep', 'keep_repo', `Keep ${shortName(entry.repo)}: add it to repos in the config file`),+ action('Hide', 'hide_repo', `Hide ${shortName(entry.repo)}: add it to hidden_auto_repos in the config file, so it isn’t added again`));+ }+ /** The repo's app icon; repos without one (libraries) get a lettered tile, unfetched ones a blank. */ function appIcon(entry: Pick<RepoEntry, 'repo'> & Partial<RepoEntry>): HTMLElement { if (entry.icon) return el('img', { class: 'app-icon', src: entry.icon, alt: '' });@@ -116,7 +135,8 @@ export function start(api: Api): () => void { ? `Last attempt failed: ${entry.error}` : entry.inaccessible ? 'Not accessible (private, or not found)' : entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';- return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), appIcon(entry), link(String(v), gh(entry.repo)));+ return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), appIcon(entry), link(String(v), gh(entry.repo)),+ entry.auto ? addedControls(entry) : null); } if (col.key === 'fetched') { const age = v ? el('span', { title: `Fetched ${localTime(String(v))}` }, ago(String(v))) : el('span', { class: 'muted', title: 'Not fetched yet' }, '—');@@ -62,6 +62,8 @@ export interface RepoEntry { fetched_at: Time | null; last_attempt_at?: Time | null; error?: string | null;+ /** The poller added the row: the token's user has an open pull request there (absent from older builds). */+ auto?: boolean; inaccessible?: boolean; icon?: string | null; icon_oid?: string | null;@@ -112,6 +112,11 @@ tr.stale td:not(:first-child) { opacity: .55; } .row-refresh:hover, .row-refresh.spinning { opacity: 1; color: var(--accent); } .row-refresh.spinning svg { animation: spin 1s linear infinite; } @media (prefers-reduced-motion: reduce) { .row-refresh.spinning svg { animation: none; } }+.added { display: inline-flex; align-items: center; gap: 3px; font-weight: 400; }+.added-mark { font-size: 9px; font-weight: 600; color: var(--accent); padding: 0 5px; border-radius: 7px; background: color-mix(in srgb, var(--accent) 14%, transparent); }+.added-action { border: 0; background: none; padding: 0 2px; font-size: 10px; color: var(--muted); opacity: .7; }+.added-action:hover:not(:disabled) { opacity: 1; color: var(--accent); text-decoration: underline; }+.added-action:disabled { opacity: .35; cursor: default; } .zero { color: var(--muted); opacity: .6; } .active { color: var(--accent); font-weight: 600; } .link:hover { text-decoration: underline; color: var(--accent); }