collective

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

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -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", ] diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -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"] }diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -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 diff --git a/craft_status_config.template.toml b/craft_status_config.template.toml--- a/craft_status_config.template.toml+++ b/craft_status_config.template.toml@@ -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.0diff --git a/crates/craft-core/Cargo.toml b/crates/craft-core/Cargo.toml--- a/crates/craft-core/Cargo.toml+++ b/crates/craft-core/Cargo.toml@@ -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 = truediff --git a/crates/craft-core/src/config.rs b/crates/craft-core/src/config.rs--- a/crates/craft-core/src/config.rs+++ b/crates/craft-core/src/config.rs@@ -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();+    } }diff --git a/crates/craft-core/src/model.rs b/crates/craft-core/src/model.rs--- a/crates/craft-core/src/model.rs+++ b/crates/craft-core/src/model.rs@@ -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();diff --git a/crates/craft-github/src/lib.rs b/crates/craft-github/src/lib.rs--- a/crates/craft-github/src/lib.rs+++ b/crates/craft-github/src/lib.rs@@ -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(diff --git a/crates/craft-status-app/src/added.rs b/crates/craft-status-app/src/added.rsnew file mode 100644--- /dev/null+++ b/crates/craft-status-app/src/added.rs@@ -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");+    }+}diff --git a/crates/craft-status-app/src/lib.rs b/crates/craft-status-app/src/lib.rs--- a/crates/craft-status-app/src/lib.rs+++ b/crates/craft-status-app/src/lib.rs@@ -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();diff --git a/ui/src/app.test.ts b/ui/src/app.test.ts--- a/ui/src/app.test.ts+++ b/ui/src/app.test.ts@@ -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/);diff --git a/ui/src/app.ts b/ui/src/app.ts--- a/ui/src/app.ts+++ b/ui/src/app.ts@@ -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' }, '—');diff --git a/ui/src/model.ts b/ui/src/model.ts--- a/ui/src/model.ts+++ b/ui/src/model.ts@@ -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;diff --git a/ui/style.css b/ui/style.css--- a/ui/style.css+++ b/ui/style.css@@ -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); }