diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 17cba61a..35bf7ea1 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -2887,10 +2887,11 @@ impl PointerShape { } } -/// What `gh pr list` last said about one project's open pull requests, and -/// the timer deciding when to ask again. Held per project rather than -/// refetched per repaint because every answer is a `gh` process and a -/// GitHub API call, and the list changes on the order of minutes. +/// What `pull_request::list` last said about one project's open pull +/// requests, and the timer deciding when to ask again. Held per project +/// rather than refetched per repaint because every answer is a `gh` +/// process and a GitHub API call, and the list changes on the order of +/// minutes. #[derive(Debug, Clone)] pub struct OpenPrs { /// Open pull requests: newest first, with the drafts sunk below every @@ -3547,7 +3548,7 @@ pub struct App { /// entry, so arriving somewhere always asks again promptly; so does its /// pull request leaving the project's open list. pub pr_recheck: HashMap, - /// What `gh pr list` last said about each project's open pull requests + /// What `pull_request::list` last said about each project's open pull requests /// — the group at the bottom of the Worktrees panel. A missing key /// means "never asked"; only the selected project is ever asked, so a /// machine with thirty projects still costs one call per refresh. @@ -3555,6 +3556,12 @@ pub struct App { /// Projects with a list lookup in flight, so a repaint can't stack a /// second `gh` on the first. pub open_prs_inflight: std::collections::HashSet, + /// Projects whose last list lookup came back with no answer — `gh` + /// failed, timed out, or the checkout is gone. The list kept on screen + /// is then the last one that worked, so the PULL REQUESTS MODAL says + /// `couldn't refresh` rather than pass it off as current; the next + /// answer that lands clears it. + pub open_prs_failed: std::collections::HashSet, /// Bodies and conversations of the pull requests the cursor has rested /// on, keyed by URL. A second API call on top of the list, so it is /// fetched only for the row actually being read and kept for the whole @@ -3839,6 +3846,7 @@ impl App { pr_recheck: HashMap::new(), open_prs: HashMap::new(), open_prs_inflight: std::collections::HashSet::new(), + open_prs_failed: std::collections::HashSet::new(), pr_detail: HashMap::new(), pr_detail_inflight: std::collections::HashSet::new(), pr_detail_failed: std::collections::HashSet::new(), @@ -4872,7 +4880,7 @@ impl App { .collect() } - /// The selected project's open pull requests, every one `gh pr list` + /// The selected project's open pull requests, every one the list query /// answered with — drafts included whatever `hide_draft_prs` says, and /// whether or not the group under the checkouts is showing them. The /// list the fetch cap (`pull_request::LIST_LIMIT`) is measured @@ -5225,7 +5233,7 @@ impl App { } } - /// Whether `gh pr list` should be run for this project now: not while + /// Whether the open list should be asked for this project now: not while /// an answer is in flight, and not before the timer the last answer /// armed. A project nebula has never asked about is always due. pub fn open_prs_lookup_due(&self, project: &ProjectId) -> bool { diff --git a/crates/nebula-tui/src/config.rs b/crates/nebula-tui/src/config.rs index 40083dff..410f3709 100644 --- a/crates/nebula-tui/src/config.rs +++ b/crates/nebula-tui/src/config.rs @@ -1058,7 +1058,7 @@ pub struct Config { /// Leave draft pull requests out of the PROJECT OPEN PRS GROUP and the /// `/` PALETTE's pull-request rows, so browsing what's open shows only /// the rows asking for a reviewer. A view filter, not a fetch filter: - /// `gh pr list` still returns the drafts and the cache still holds + /// the open list's query still returns the drafts and the cache holds /// them, so switching this off shows them again at once, and a draft /// marked ready on GitHub joins the rows on the next refresh. Never /// touches a checkout, its sessions, or the checkout's own PR ROW in diff --git a/crates/nebula-tui/src/event_loop.rs b/crates/nebula-tui/src/event_loop.rs index 841addb9..8dcf9a5d 100644 --- a/crates/nebula-tui/src/event_loop.rs +++ b/crates/nebula-tui/src/event_loop.rs @@ -166,7 +166,7 @@ const PR_SWEEP_REFRESH: Duration = Duration::from_secs(5 * 60); /// How often the selected *project's* open-pull-request list is re-asked /// once a repo has proved it has any, and how a repo that answers empty (or -/// can't answer at all) backs off. One `gh pr list` is one GraphQL call — +/// can't answer at all) backs off. One list lookup is one GraphQL call — /// one point, however many pull requests come back. Every other project's /// list is on the slower `OPEN_PRS_SWEEP_REFRESH`. /// @@ -197,7 +197,7 @@ pub(crate) const OPEN_PRS_RECHECK_MAX: Duration = Duration::from_secs(10 * 60); /// How often the open list of a project the cursor is *not* on is re-asked /// — the background pass that keeps every project's group warm, so /// switching to one shows a list minutes old at worst (and the cache the -/// next launch hydrates from is as fresh as that). One `gh pr list` per +/// next launch hydrates from is as fresh as that). One list lookup per /// project per beat, one project per tick (`sweep_open_prs`): twelve /// calls an hour per project against the budget above, and a project that /// answers empty keeps its own backoff on top. Same reasoning and cadence @@ -1216,7 +1216,9 @@ fn open_prs_sweep_target(app: &App) -> Option<(ProjectId, std::path::PathBuf)> { /// attempt a backoff step further out, so a repo with no PRs (or a machine /// with no `gh`) settles at `OPEN_PRS_RECHECK_MAX` instead of asking all /// day. A failed call keeps whatever list was already on screen: one flaky -/// network round trip is no reason to blank the group. +/// network round trip is no reason to blank the group. It is noted, +/// though (`App::open_prs_failed`), so a call that keeps failing shows as +/// a list that `couldn't refresh` rather than a current one. fn note_open_prs_answer( app: &mut App, project: nebula_core::ProjectId, @@ -1232,6 +1234,15 @@ fn note_open_prs_answer( // the cursor goes with it — a checkout is never lost to a re-list. let checkout = app.selected_worktree().map(|w| w.id.clone()); app.open_prs_inflight.remove(&project); + let failed = list.is_none(); + if failed != app.open_prs_failed.contains(&project) { + app.dirty = true; + if failed { + app.open_prs_failed.insert(project.clone()); + } else { + app.open_prs_failed.remove(&project); + } + } let previous = app.open_prs.get(&project); let found = list.as_ref().is_some_and(|l| !l.is_empty()); let step = if found { @@ -1415,7 +1426,7 @@ fn adopt_pr_state(app: &mut App, detail: &crate::pull_request::PrDetail) { } /// Retire one pull request from every project's list ahead of the next -/// `gh pr list`, because GitHub has just told us — in the detail fetched +/// list lookup, because GitHub has just told us — in the detail fetched /// for the row the cursor is resting on — that it is merged or closed. /// The list refresh would catch it within the minute anyway; this is for /// the case where the user is looking straight at it. @@ -1450,6 +1461,7 @@ fn prune_pull_requests_to_tree(app: &mut App) { app.pull_requests.retain(|w, _| worktrees.contains(w)); app.pr_recheck.retain(|w, _| worktrees.contains(w)); app.open_prs.retain(|p, _| projects.contains(p)); + app.open_prs_failed.retain(|p| projects.contains(p)); app.pr_cache_dirty |= before != (app.pull_requests.len(), app.open_prs.len()); forget_retired_prs(app); } @@ -4584,7 +4596,7 @@ fn toggle_issues(app: &mut App, out: &mut Vec) { } /// Re-seat the Worktrees cursor on checkout `id` after the rows regrouped -/// under it — a fold, a draft toggle, a fresh `gh pr list` answer — each +/// under it — a fold, a draft toggle, a fresh open-list answer — each /// of which can move a checkout under its pull request's row or back out /// among the plain ones (`App::worktree_rows`). The pane needs nothing: /// the worktree under the cursor is the one it was showing. A cursor @@ -10550,6 +10562,7 @@ fn apply_removal(app: &mut App, id: &nebula_core::EntityId) { app.pull_requests.retain(|w, _| !wt_ids.contains(w)); app.pr_recheck.retain(|w, _| !wt_ids.contains(w)); app.open_prs.remove(id); + app.open_prs_failed.remove(id); app.pr_cache_dirty = true; app.tree.worktrees.retain(|w| &w.project_id != id); app.tree.projects.retain(|p| &p.id != id); @@ -12691,12 +12704,23 @@ mod tests { ); assert_eq!(app.visible_open_prs().len(), 1); + assert!(!app.open_prs_failed.contains(&pid)); note_open_prs_answer(&mut app, pid.clone(), None, &mut Vec::new()); assert_eq!( app.open_prs[&pid].list, found, "a failed call keeps the last good list" ); assert!(app.open_prs[&pid].step > OPEN_PRS_REFRESH, "but backs off"); + assert!( + app.open_prs_failed.contains(&pid), + "and marks it as one that couldn't be refreshed (#106)" + ); + + note_open_prs_answer(&mut app, pid.clone(), Some(vec![]), &mut Vec::new()); + assert!( + !app.open_prs_failed.contains(&pid), + "the next real answer clears the mark" + ); } /// Arriving at a project asks again promptly — but never more often than @@ -25740,12 +25764,12 @@ diff --git a/src/c.rs b/src/c.rs "the ROOT WORKTREE is on our main: {:?}", app.tree.worktrees ); - let list = crate::pull_request::parse_list( + let list = crate::pull_request::parse_list(&crate::pull_request::list_answer( r#"[{"number":129,"title":"Prefer PowerShell 7", "url":"https://github.com/o/r/pull/129","isDraft":false, "headRefName":"main","isCrossRepository":true, "headRepositoryOwner":{"login":"givemeurhats"}}]"#, - ) + )) .expect("parsed"); let project = app.selected_project().expect("a project").id.clone(); let now = std::time::Instant::now(); diff --git a/crates/nebula-tui/src/pr_modal.rs b/crates/nebula-tui/src/pr_modal.rs index 2bef99e1..a2458a76 100644 --- a/crates/nebula-tui/src/pr_modal.rs +++ b/crates/nebula-tui/src/pr_modal.rs @@ -801,6 +801,9 @@ pub(crate) fn draw( let rows: Vec = rows(app, &view.project).to_vec(); let inflight = app.open_prs_inflight.contains(&view.project); let asked = app.open_prs.contains_key(&view.project); + // The last ask came back with nothing — these rows are the last + // answer that worked, however old — and no second ask is running yet. + let stale = app.open_prs_failed.contains(&view.project) && !inflight; // The rows the filter leaves, and where the cursor sits among them. let visible = visible_rows(&view.query, &rows); let cursor = cursor_index(view, &rows); @@ -836,14 +839,28 @@ pub(crate) fn draw( let line = search_line(&view.query, "type to filter…", query_area, th); f.render_widget(Paragraph::new(line), query_area); } - let rows_area = crate::ui::below_first_row(list_inner); - if rows.is_empty() { - let text = if inflight || !asked { - "asking GitHub…" + let mut rows_area = crate::ui::below_first_row(list_inner); + // A list GitHub could not be asked for says so on a row of its own + // under the filter, never only in a title a narrow list would cut: + // rows that stopped refreshing look exactly like current ones (#106). + if stale { + let note = if rows.is_empty() { + "couldn't ask GitHub (^r retries)" } else { - "no open pull requests" + "couldn't refresh (^r retries)" }; - empty_list_row(f, rows_area, text, th); + if let Some(note_area) = row_rect(rows_area, 0) { + let note = Span::styled(note, Style::default().fg(th.warn)); + f.render_widget(Paragraph::new(note), note_area); + } + rows_area = crate::ui::below_first_row(rows_area); + } + if rows.is_empty() { + if inflight || !asked { + empty_list_row(f, rows_area, "asking GitHub…", th); + } else if !stale { + empty_list_row(f, rows_area, "no open pull requests", th); + } } else if visible.is_empty() { empty_list_row(f, rows_area, "no pull requests match", th); } @@ -1438,6 +1455,47 @@ mod tests { assert!(screen(&mut app, 100, 20).contains("asking GitHub…")); } + /// A list GitHub could not be asked for says so, on a row of its own + /// under the filter where a narrow modal cannot cut it off — rows that + /// stopped refreshing must not pass for current ones (#106). The rows + /// stay, and stay clickable under the note; a retry in flight says + /// `refreshing…` instead, and an answer clears it. + #[test] + fn a_list_that_could_not_be_refreshed_says_so() { + let (mut app, project) = app_with(vec![pr(42, "Fix login", false)], true); + open(&mut app); + let fine = screen(&mut app, 100, 20); + assert!(!fine.contains("couldn't refresh"), "{fine}"); + let first_row = view(&app).list_area.y; + + app.open_prs_failed.insert(project.clone()); + let stale = screen(&mut app, 100, 20); + assert!(stale.contains("couldn't refresh (^r retries)"), "{stale}"); + assert!(stale.contains("#42 Fix login"), "{stale}"); + assert_eq!( + view(&app).list_area.y, + first_row + 1, + "the rows' hit area starts under the note" + ); + + app.open_prs_inflight.insert(project.clone()); + // Wide enough for the title to say it in full. + let retrying = screen(&mut app, 160, 20); + assert!(!retrying.contains("couldn't refresh"), "{retrying}"); + assert!(retrying.contains("refreshing…"), "{retrying}"); + app.open_prs_inflight.remove(&project); + + app.open_prs.get_mut(&project).unwrap().list = vec![]; + let never = screen(&mut app, 100, 20); + assert!(never.contains("couldn't ask GitHub"), "{never}"); + assert!(!never.contains("no open pull requests"), "{never}"); + + app.open_prs_failed.remove(&project); + let answered = screen(&mut app, 100, 20); + assert!(!answered.contains("couldn't"), "{answered}"); + assert!(answered.contains("no open pull requests"), "{answered}"); + } + /// `Ctrl+o` and a click on the reading pane's `↗ open in browser` button run /// one open: the footer names where the browser went either way (INPUT /// PARITY), and the modal stays up. The button is drawn pinned right on diff --git a/crates/nebula-tui/src/pull_request.rs b/crates/nebula-tui/src/pull_request.rs index cbc04c2d..7507f506 100644 --- a/crates/nebula-tui/src/pull_request.rs +++ b/crates/nebula-tui/src/pull_request.rs @@ -434,13 +434,34 @@ fn activity(v: &serde_json::Value, viewer: Option<&str>) -> Vec { stamps } -/// The pull request's [`Health`] as a `gh pr view` / `gh pr list` payload -/// carries it: `mergeable` and `statusCheckRollup`, either missing when the -/// caller did not ask for it, which reads as healthy. +/// The pull request's [`Health`] as a payload carries it: `mergeable`, and +/// the checks in one of two shapes. `gh pr view` hands back every check on +/// the head commit (`statusCheckRollup`, folded by [`checks`]); a [`list`] +/// node carries only GitHub's own verdict on them, the rollup's `state` +/// on its last commit ([`rollup_state`]). Either missing — the caller did +/// not ask — reads as healthy. fn health(v: &serde_json::Value) -> Health { + let checks = match v.pointer("/commits/nodes/0/commit/statusCheckRollup") { + Some(rollup) => rollup_state(rollup), + None => checks(arr_at(v, "statusCheckRollup")), + }; Health { conflicts: str_at(v, "mergeable") == "CONFLICTING", - checks: checks(arr_at(v, "statusCheckRollup")), + checks, + } +} + +/// GitHub's one word for every check on a commit (`StatusCheckRollup`'s +/// `state`): the verdict the pull request page's ✓ / ✗ shows, worked out +/// server side, so asking for it costs nothing per check. `EXPECTED` is a +/// required status that has not reported yet — still pending. A commit +/// with no checks at all has no rollup (`null`): nothing to say. +fn rollup_state(rollup: &serde_json::Value) -> Checks { + match str_at(rollup, "state").as_str() { + "SUCCESS" => Checks::Passing, + "FAILURE" | "ERROR" => Checks::Failing, + "PENDING" | "EXPECTED" => Checks::Pending, + _ => Checks::Absent, } } @@ -477,17 +498,31 @@ fn checks(rollup: &[serde_json::Value]) -> Checks { /// Every open pull request on a project's repo, and what it costs to ask. /// /// A worktree's own PR ([`lookup`]) is one `gh pr view` per checkout; this -/// is one `gh pr list` per *project*, answering "what's still open here?" +/// is one GraphQL query per *project*, answering "what's still open here?" /// for the group at the bottom of the worktrees panel. It deliberately /// carries no conversation: reading comment counts for a hundred rows would /// be a request each, so the unread badge stays a per-worktree affair. /// One page, one call, however many PRs the repo has. /// -/// `gh` pages past its own 30-row default, so the cap is ours to set: a -/// repo with hundreds of open pull requests would spend several API calls -/// per refresh filling rows nobody scrolls to. +/// The cap is ours to set, and a page is all GraphQL gives in one request: +/// a repo with hundreds of open pull requests would spend several API +/// calls per refresh filling rows nobody scrolls to. pub const LIST_LIMIT: usize = 100; +/// The one GraphQL query [`list`] runs — `gh pr list`'s own fields, in its +/// own newest-first order, save the checks. `gh pr list --json +/// statusCheckRollup` asks for every check context on every pull +/// request's head commit, and on a busy repo (80 open pull requests, 30 +/// to 60 checks each) GitHub gives up on that with a 504 every time, so +/// the list never refreshed again (#106). All the row needs is one word +/// per pull request, and GitHub computes it: the rollup's `state`. +const LIST_QUERY: &str = "query($owner: String!, $repo: String!, $limit: Int!) { \ + repository(owner: $owner, name: $repo) { \ + pullRequests(states: OPEN, first: $limit, orderBy: {field: CREATED_AT, direction: DESC}) { \ + nodes { number url title isDraft headRefName isCrossRepository \ + headRepositoryOwner { login } mergeable \ + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } } } } }"; + /// One row of a project's open-pull-request list. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OpenPr { @@ -591,20 +626,27 @@ impl PrLaunch { /// simply stops coming back, so re-asking on a beat *is* the periodic /// "should this row still be here?" check. Nothing has to track closures /// separately. +/// +/// Asked as [`LIST_QUERY`] through `gh api graphql` rather than `gh pr +/// list`, whose checks field times out on a busy repo; `gh` still fills +/// in `{owner}` and `{repo}` from the checkout, the way `gh pr list` +/// resolves its repo. pub async fn list(dir: &Path) -> Option> { - let limit = LIST_LIMIT.to_string(); + let limit = format!("limit={LIST_LIMIT}"); + let query = format!("query={LIST_QUERY}"); let out = gh( Some(dir), &[ - "pr", - "list", - "--state", - "open", - "--limit", + "api", + "graphql", + "-F", + "owner={owner}", + "-F", + "repo={repo}", + "-F", &limit, - "--json", - "number,url,title,isDraft,headRefName,isCrossRepository,headRepositoryOwner,\ - mergeable,statusCheckRollup", + "-f", + &query, ], TIMEOUT, ) @@ -642,13 +684,16 @@ fn checkout_branch(v: &serde_json::Value) -> String { } } -/// Parse `gh pr list --json …` output — a bare array. Kept separate from -/// the process call so the shape it expects is testable without a GitHub -/// account. A row whose url could never be opened is dropped rather than -/// failing the whole list; a payload that isn't an array at all is a miss. +/// Parse [`LIST_QUERY`]'s answer — the rows under +/// `data.repository.pullRequests.nodes`. Kept separate from the process +/// call so the shape it expects is testable without a GitHub account. A +/// row whose url could never be opened is dropped rather than failing the +/// whole list; a payload with no list of rows at all is a miss. pub(crate) fn parse_list(json: &str) -> Option> { - let rows = serde_json::from_str::(json).ok()?; - let rows = rows.as_array()?; + let answer = serde_json::from_str::(json).ok()?; + let rows = answer + .pointer("/data/repository/pullRequests/nodes")? + .as_array()?; Some( rows.iter() .filter_map(|v| { @@ -666,6 +711,13 @@ pub(crate) fn parse_list(json: &str) -> Option> { ) } +/// A [`LIST_QUERY`] answer as `gh api graphql` prints it, around `nodes` +/// — a JSON array of rows. +#[cfg(test)] +pub(crate) fn list_answer(nodes: &str) -> String { + format!(r#"{{"data":{{"repository":{{"pullRequests":{{"nodes":{nodes}}}}}}}}}"#) +} + /// Sink the drafts below everything else, keeping `gh`'s newest-first /// order within each half. A draft is open, but it is not asking anyone for /// anything yet; the rows that want a reviewer come first, and a draft is @@ -1007,7 +1059,8 @@ mod tests { } /// GitHub's word on whether the branch still merges (`mergeable`) and - /// how its checks stand (`statusCheckRollup`) rides every payload — + /// how its checks stand (`statusCheckRollup`, the list's as its rollup + /// `state`) rides every payload — /// branch row, list row and detail alike — into the same `Health`, so /// the three surfaces go red together. `UNKNOWN` mergeability, what /// GitHub says while it is still computing, is not a conflict. @@ -1019,8 +1072,10 @@ mod tests { assert_eq!(pr.health.checks, Checks::Passing); assert_eq!(pr.trouble(), Some(Trouble::Conflicts)); - let list = r#"[{"number":8,"url":"https://github.com/o/r/pull/8","title":"t","isDraft":false,"headRefName":"h","mergeable":"UNKNOWN","statusCheckRollup":[{"__typename":"CheckRun","status":"COMPLETED","conclusion":"FAILURE"}]}]"#; - let rows = parse_list(list).expect("parsed"); + let list = list_answer( + r#"[{"number":8,"url":"https://github.com/o/r/pull/8","title":"t","isDraft":false,"headRefName":"h","mergeable":"UNKNOWN","commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"FAILURE"}}}]}}]"#, + ); + let rows = parse_list(&list).expect("parsed"); assert!(!rows[0].health.conflicts, "UNKNOWN is not a conflict"); assert_eq!(rows[0].health.checks, Checks::Failing); assert_eq!(rows[0].trouble(), Some(Trouble::FailingChecks)); @@ -1081,6 +1136,38 @@ mod tests { assert_eq!(checks(&[ctx("ERROR")]), Checks::Failing); } + /// A list row's checks are GitHub's own rollup `state` on its last + /// commit, read into the same four words the fold gives; a commit + /// with no checks has a `null` rollup, which says nothing. + #[test] + fn a_list_rows_checks_are_the_rollup_state() { + let row = |rollup: &str| { + let nodes = format!( + r#"[{{"number":1,"url":"https://github.com/o/r/pull/1","commits":{{"nodes":[{{"commit":{{"statusCheckRollup":{rollup}}}}}]}}}}]"# + ); + parse_list(&list_answer(&nodes)).expect("parsed")[0] + .health + .checks + }; + assert_eq!(row(r#"{"state":"SUCCESS"}"#), Checks::Passing); + assert_eq!(row(r#"{"state":"FAILURE"}"#), Checks::Failing); + assert_eq!(row(r#"{"state":"ERROR"}"#), Checks::Failing); + assert_eq!(row(r#"{"state":"PENDING"}"#), Checks::Pending); + assert_eq!(row(r#"{"state":"EXPECTED"}"#), Checks::Pending); + assert_eq!(row("null"), Checks::Absent); + } + + /// The list asks for GitHub's one word per pull request, never every + /// check on it: asking for the contexts is what timed out on a busy + /// repo and froze the list on its last good answer (#106). + #[test] + fn the_list_query_asks_for_the_rollup_state_alone() { + assert!(LIST_QUERY.contains("statusCheckRollup { state }")); + assert!(!LIST_QUERY.contains("contexts")); + assert!(LIST_QUERY.contains("states: OPEN")); + assert!(LIST_QUERY.contains("orderBy: {field: CREATED_AT, direction: DESC}")); + } + /// Trouble is an open pull request's: a merged or closed one is past /// needing its branch resolved, whatever the cached answer says; a /// draft's conflict still needs a person. Conflicts outrank failing @@ -1260,13 +1347,13 @@ mod tests { } #[test] - fn parses_a_gh_pr_list_payload() { - let prs = parse_list( + fn parses_the_open_list_answer() { + let prs = parse_list(&list_answer( r#"[ {"number":42,"title":"Attach links","url":"https://github.com/o/r/pull/42","isDraft":false,"headRefName":"attach-links"}, {"number":7,"title":"WIP","url":"https://github.com/o/r/pull/7","isDraft":true} ]"#, - ) + )) .expect("parsed"); assert_eq!(prs.len(), 2); assert_eq!(prs[0].label(), "#42 Attach links"); @@ -1289,7 +1376,7 @@ mod tests { /// fork since deleted is named for the pull request. #[test] fn a_forks_checkout_branch_carries_its_owner() { - let prs = parse_list( + let prs = parse_list(&list_answer( r#"[ {"number":129,"title":"Prefer PowerShell 7","url":"https://github.com/o/r/pull/129","isDraft":false, "headRefName":"main","isCrossRepository":true,"headRepositoryOwner":{"login":"givemeurhats"}}, @@ -1300,7 +1387,7 @@ mod tests { {"number":140,"title":"Orphan","url":"https://github.com/o/r/pull/140","isDraft":false, "headRefName":"main","isCrossRepository":true,"headRepositoryOwner":null} ]"#, - ) + )) .expect("parsed"); let heads: Vec<&str> = prs.iter().map(|pr| pr.head.as_str()).collect(); assert_eq!( @@ -1320,20 +1407,20 @@ mod tests { /// pretending it never asked. #[test] fn an_empty_list_is_an_answer_not_a_miss() { - assert_eq!(parse_list("[]"), Some(vec![])); + assert_eq!(parse_list(&list_answer("[]")), Some(vec![])); } /// One unusable row must not cost the whole list; a payload that isn't /// a list at all is a miss. #[test] fn list_rows_that_could_never_be_opened_drop_out() { - let prs = parse_list( + let prs = parse_list(&list_answer( r#"[ {"number":1,"url":"file:///etc/passwd"}, {"url":"https://github.com/o/r/pull/2"}, {"number":3,"url":"https://github.com/o/r/pull/3"} ]"#, - ) + )) .expect("parsed"); assert_eq!( prs.len(), @@ -1343,6 +1430,10 @@ mod tests { assert_eq!(prs[0].label(), "#3", "a missing title still names the PR"); assert!(parse_list("").is_none()); assert!(parse_list("{}").is_none()); + assert!(parse_list("[]").is_none(), "a bare array is not the answer"); + assert!(parse_list(&list_answer("{}")).is_none()); + // What `gh api graphql` prints for a repo it could not resolve. + assert!(parse_list(r#"{"data":{"repository":null}}"#).is_none()); } /// The preview payload: description, stats, and one merged oldest-first diff --git a/docs/configuration.md b/docs/configuration.md index b78b8229..44d5f2b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,7 +80,7 @@ behaviors that change how the tree is worked; every switch there is off by defau | `worktree_layout` | string | `"cards"` | Appearance | How the GRID lays out each worktree's band: `cards` (a row of cards under the band's rule) or `list` (a compact list — every session and terminal one line under the rule, stacked: its status dot and name, what it runs on, and its last prompt or the shell's last line, with how long since it moved at the right). A band in the list starts collapsed, showing only its 3 most recent sessions — plus the one the cursor is on, wherever it sits — and a `▾ 2 more · Tab: see all 5` line under them; `Tab` (or a click on that line) opens the band to every entry, and `Tab` or `Esc` folds it back. `j`/`k` walk the lines as one column across the bands. Anything off the list reads as `cards`. | | `hide_card_prompt` | bool | `false` | — (retired) | Through 0.40, **Card prompt** (Settings → Appearance, `shown` / `hidden`): `hidden` left the last prompt off every session card on the GRID. Every card shows it now, so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | | `card_issue_number` | bool | `true` | Appearance | Show the `#15` of the GitHub issue a session was started from (an issue session, launched out of the issues modal) at the right end of its card on the GRID (**Card issue number**, `on` / `off`). The number is a link: a click lands the cursor on the card and opens the issue in the browser, as `⇧I` does. Cards not started from an issue are unchanged. | -| `hide_draft_prs` | bool | `false` | Appearance | Leave draft pull requests out of the grid's PR & ISSUE COUNTS and the PALETTE's (`/`) pull-request rows, so browsing what's open shows only the rows asking for a reviewer; the PULL REQUESTS MODAL lists drafts either way. A view filter, not a fetch filter: `gh pr list` still fetches the drafts and the PR CACHE still holds them, so `shown` brings them back at once and a draft marked ready joins the rows on the refresh that says so. Sessions and the pull request under their cards are never hidden. | +| `hide_draft_prs` | bool | `false` | Appearance | Leave draft pull requests out of the grid's PR & ISSUE COUNTS and the PALETTE's (`/`) pull-request rows, so browsing what's open shows only the rows asking for a reviewer; the PULL REQUESTS MODAL lists drafts either way. A view filter, not a fetch filter: the open-list lookup still fetches the drafts and the PR CACHE still holds them, so `shown` brings them back at once and a draft marked ready joins the rows on the refresh that says so. Sessions and the pull request under their cards are never hidden. | | `card_line_changes` | bool | `false` | — (retired) | Through 0.37, **Card line counts** (Settings → Appearance): on, each GRID card followed its checkout's changed-file count with the lines behind it. Every card does now — `↳ feat +3 files +120 -45`, the added in the DIFF VIEWER's green and the removed in its red, counted as the DIFF VIEWER shows them (tracked files against HEAD, staged or not, and every line of an untracked file as added; a binary file, or an untracked one over 1 MiB, adds nothing) by one `git diff --numstat` beside each `git status` the file count already runs; on a narrow card the word `files` goes first, then the lines, before the branch gives up a letter — so this build never reads the key and no tab edits it; it is still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | | `projects` | object | `{}` | Project | PROJECT SETTINGS: one entry per project set up differently from the rest, keyed by the project's repo path exactly as the DAEMON stores it, holding that project's rows from the **Project** tab — `{"projects": {"/Users/me/src/app": {"run_command": "npm run dev", "open_command": "open http://localhost:3000"}}}`. Two rows: **Run command** (`run_command`, string, default `""`) is the RUN COMMAND **Run** (a card's or the project tab's right-click menu) starts in *that project's* worktrees — the same shell line a `.nebula.json` `run` would carry, and the way to set one without committing a file; while it is set, **Run** runs it and never opens the file, and empty (shown as `.nebula.json`) hands the decision back to the checkout's PROJECT FILE, so a project that has one needs nothing here. Typed, not cycled: `Enter` opens a prompt titled with the project, an empty answer puts `.nebula.json` back. The DAEMON reads it fresh at each **Run**. **Open command** (`open_command`, string, default `""`) is its twin for the OPEN COMMAND `Shift+Enter` / `Shift+O` fires on that project's worktrees — `open http://localhost:3000`, say — with the same precedence over the file's `open` and the same prompt; the TUI reads it fresh at each press, since it runs on the machine you are sitting at. The tab edits the selected project and names it on its first line; with no project in the tree its rows read `n/a`. A project with no entry reads as the defaults (an empty command in each row), and an entry that only repeats them is dropped on save, so the map names only the projects that differ; an empty `run_command` or `open_command` is left out of an entry rather than written; a key inside an entry this build doesn't know — the retired `hide_root_worktree` an older build wrote among them — is carried through a save. To the file's rules the map is one key: a value in it this build can't read costs the whole map, not one project. | | `hide_root_worktree` | bool | `false` | — (retired) | Through 0.27 one switch for every project (**Hide root worktree**, Settings → Experimental), then through 0.35 the fallback for a project whose `projects` entry had no **Hide root worktree** row of its own: on, that project's ROOT WORKTREE was left out of everything the grid launched into. The root is always listed now — a launch that must not land in the shared checkout cuts a fresh worktree instead (`^N` in the QUICK PROMPT, or `quick_prompt_new_worktree`) — so this build never reads the key, here or inside an entry, and no tab edits it; both are still loaded and written back as stored for an older nebula sharing the file ([Compatibility rules](#compatibility-rules)). | diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 4a2a6456..64cbb1ff 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -333,7 +333,7 @@ attachment is gone; links an earlier version saved stay in the database, so no d though the grid draws none of them. This is the one part of nebula the TUI asks for itself rather than the DAEMON: every `gh pr view`, -`gh pr list` and `gh pr diff` — and the ISSUES MODAL's `gh issue list`, `gh issue view` and `gh issue comment` — is spawned by the client, which is why the lookups stop the moment you +open-list query (`gh api graphql`) and `gh pr diff` — and the ISSUES MODAL's `gh issue list`, `gh issue view` and `gh issue comment` — is spawned by the client, which is why the lookups stop the moment you quit, and why a machine with no `gh` — or one that is unauthenticated, or pointed at a checkout with no remote — just shows no rows instead of an error. The selected project is asked about most: its selected worktree's pull request and its open list on every tick, one process each, and its other @@ -350,5 +350,11 @@ open — so a machine with thirty repos does not cost thirty API calls a beat. F terminal window pulls the next lookup forward, floored at a few seconds; `Shift+R` is the one gesture that asks straight away, every checkout of the project and its open issues included. +The open list is one GraphQL query per project rather than `gh pr list`, because a row's checks need +only GitHub's own verdict on them — the pass / fail / pending the pull request page shows — and `gh pr +list` asks for every check on every pull request instead, which on a busy repo times out every time. A +lookup that fails keeps the last list that worked on screen, and the PULL REQUESTS MODAL says +`couldn't refresh` under its filter until an answer lands. + Settings and hotkeys live in [Configuration](configuration.md). The process model, the IPC CODEC and the crate layout are covered in more depth in [ARCHITECTURE.md](../ARCHITECTURE.md). diff --git a/docs/keys.md b/docs/keys.md index b97f08e0..56d73133 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -23,7 +23,7 @@ The grid isn't the only view. With a session selected: | Context | Key | Action | |---|---|---| -| The grid | `/` | fuzzy jump across every live project, worktree and session on the machine (`Ctrl+n/p` move, `Ctrl+o` opens the hit, `Ctrl+f` just lands the selection on it). **Archived sessions are never rows here**, whatever the grid's `A` toggle shows and however exactly you type the name: an archived session's PTY is released, so there is nothing to jump to — `.` / `,` skip them for the same reason. The list is flat — one row per thing, nothing folded under anything: each reads `project/name`, the project it lives in dim, then its own name (a session's title, a worktree's branch, a pull request's `#42 title`), with a dim `23m ago` pinned right. Until you type it is a recent sessions list: the sessions and nothing else — no project or worktree rows in the way — in attention order, the ones waiting on you first, then the running ones, then the unread finishes, then the rest by last interaction, with the cursor on the top row, so `/` `Enter` is the fastest way back to what needs you or to what you last ran. `Enter` on a session that NEEDS FEEDBACK — the red row — attaches and focuses the TERMINAL PANE whatever `palette_enter_attaches` says, since the only thing to do with a jump to a question is answer it; `Ctrl+f` is still the way to only land on it. Typing reaches every row, projects and worktrees too, on lines of their own in that same order; the query runs over each row's full `project/branch/session` path, so a project's or branch's name narrows to what is under it, even the parts the row does not draw, and every visible row counts toward the `(hits/total)`. Every open pull request nebula has listed is a row too, `↗ project/#42 title`, searchable by number or title (`#` lists them all), and badged `draft` or `ready for review` after the title — the same states the PULL REQUESTS MODAL's rows wear, spelled out here where there is room (`ready for review` is an open pull request that is not a draft; it says nothing about approvals, checks or mergeability), or `merge conflicts` / `checks failing` in red, the row red with it, when GitHub says the pull request cannot merge as it stands — so a draft is told from a finished one, and a blocked one from either, before it is picked, by the word and not only by its color; the badge follows the next `gh pr list` answer, so a draft marked ready flips under an open palette. `Enter` on one selects its project and reads the pull request in the pane — no checkout or session of its own needed. `Ctrl+o` on it is the explicit way out to the browser, as is `Enter` on the row once landed | +| The grid | `/` | fuzzy jump across every live project, worktree and session on the machine (`Ctrl+n/p` move, `Ctrl+o` opens the hit, `Ctrl+f` just lands the selection on it). **Archived sessions are never rows here**, whatever the grid's `A` toggle shows and however exactly you type the name: an archived session's PTY is released, so there is nothing to jump to — `.` / `,` skip them for the same reason. The list is flat — one row per thing, nothing folded under anything: each reads `project/name`, the project it lives in dim, then its own name (a session's title, a worktree's branch, a pull request's `#42 title`), with a dim `23m ago` pinned right. Until you type it is a recent sessions list: the sessions and nothing else — no project or worktree rows in the way — in attention order, the ones waiting on you first, then the running ones, then the unread finishes, then the rest by last interaction, with the cursor on the top row, so `/` `Enter` is the fastest way back to what needs you or to what you last ran. `Enter` on a session that NEEDS FEEDBACK — the red row — attaches and focuses the TERMINAL PANE whatever `palette_enter_attaches` says, since the only thing to do with a jump to a question is answer it; `Ctrl+f` is still the way to only land on it. Typing reaches every row, projects and worktrees too, on lines of their own in that same order; the query runs over each row's full `project/branch/session` path, so a project's or branch's name narrows to what is under it, even the parts the row does not draw, and every visible row counts toward the `(hits/total)`. Every open pull request nebula has listed is a row too, `↗ project/#42 title`, searchable by number or title (`#` lists them all), and badged `draft` or `ready for review` after the title — the same states the PULL REQUESTS MODAL's rows wear, spelled out here where there is room (`ready for review` is an open pull request that is not a draft; it says nothing about approvals, checks or mergeability), or `merge conflicts` / `checks failing` in red, the row red with it, when GitHub says the pull request cannot merge as it stands — so a draft is told from a finished one, and a blocked one from either, before it is picked, by the word and not only by its color; the badge follows the next open-list answer, so a draft marked ready flips under an open palette. `Enter` on one selects its project and reads the pull request in the pane — no checkout or session of its own needed. `Ctrl+o` on it is the explicit way out to the browser, as is `Enter` on the row once landed | | The grid | `.` / `,` | next / previous session in that same attention order, with no modal: one press lands on the next session waiting on you (then the running ones, then the unread finishes, then the rest by last interaction), wrapping at both ends. It is `/` `Enter` without the typing — a session in another project selects that project, its worktree and its row, and `palette_enter_attaches` decides whether the pane is attached and focused or only selected and previewed — a session that NEEDS FEEDBACK is attached and focused either way, as it is under `/` `Enter`. Landing on an UNSEEN finish reads it, so the DONE BADGE counts down as you cycle. With nothing red or blue it still walks the running sessions, then everything else; with no sessions anywhere the footer says so | | The grid | `o` | open a project: a folder with a git repository in it, from any focus. The prompt starts on the folder nebula was started in — its parent listed, the folder itself highlighted — so `Enter` opens it; started anywhere else it starts at `~/`. A folder that already is a project (its root, a checkout of it, or a folder inside either) opens that project rather than adding it twice. A folder in no git repository is never added as it stands: a confirm asks to `git init` it first (`y` inits and opens it, `n` backs out), and a path that doesn't exist yet asks to create it, then inits it. On the first-run splash `n` opens the same prompt, and `Enter` opens the folder nebula was started in outright | | Open project | type + `Tab`, `↓↑` / `→` / `←` | browse for the repo: type to filter (bash-style Tab completion), arrows pick a directory, `→` steps in, `←` steps up, `Enter` opens the highlighted (or typed) path; `●` marks git repos | @@ -31,7 +31,7 @@ The grid isn't the only view. With a session selected: | The grid | `Shift+Enter` / `Shift+O` / `Alt+Enter` | open the selected checkout outside nebula, usually in your editor (`o`'s shift pair: `o` opens a folder as a project *in* nebula): fires its OPEN COMMAND once — **Open command** on the SETTINGS OVERLAY's Project tab (`s`) when it is set, else the `open` command from the checkout's `.nebula.json` — `open http://localhost:3000`, say — through `$SHELL -c` in that checkout, from the TUI, so it opens things on the machine you are sitting at; its output is discarded. Neither set, and the footer names both places. The worktree is the one whose band the cursor is on. `Shift+Enter` needs the KITTY PROTOCOL to arrive as a key of its own: Ghostty and kitty send it; Terminal.app sends a plain `Enter`, which steps into the pane instead, and tmux flattens a shifted Enter to a plain one on its way to the pane whatever `extended-keys` says. So two keys are bound beside it: `Shift+O`, which every terminal delivers, and `Alt+Enter` — the `ESC` `CR` that Claude Code's `/terminal-setup` makes VS Code's terminal send for Shift+Enter, and that Option+Enter sends on a Mac whose terminal treats Option as Meta — which tmux passes through untouched. Terminal.app can be taught the real key instead: Settings → Profiles → Keyboard, add `⇧↩` with the action *Send Text* and the text `\033[13;2u`. The project menu (a right-click on its tab) has **Open** too | | The grid | `Shift+R` | reload from GitHub now: the project's OPEN PRS list and its open issues, the selected worktree's own pull request, and the description and conversation of the pull request the pane is reading are all asked of `gh` again at once — past the 15-second beat and the few-second floor a focus event respects — with the reader's scroll kept. `reloading pull requests and issues from GitHub…` in the footer says it was heard; the rows repaint as the answers land. Its own key, not a meaning `r` takes on: it acts on the selected project and worktree, so it works from any card — `r` stays rename; the run command is the project menu's **Run** | | The grid | `i` | GitHub issues: the ISSUES MODAL for the selected project (above) — read an issue, type to filter the list by `#15 title`, `Enter` prompts an agent on it, `Shift+Tab` launches an AGENT PRESET on it, `Ctrl+c` leaves a comment on it, `Ctrl+e` edits its title and description, `Ctrl+o` or the pane's `↗ open in browser` button opens it in the browser, `Ctrl+r` refreshes | -| The grid | `v` | GitHub pull requests: the PULL REQUESTS MODAL for the selected project (above) — read a pull request, type to filter the list by `#42 title`, `Enter` prompts a PR SESSION on it, `Shift+Tab` launches an AGENT PRESET on it, `Tab` picks a harness, `Ctrl+c` comments, `Ctrl+g` opens its diff, `Ctrl+o`, the pane's `↗ open in browser` button or a double-click on the row opens it in the browser, `Ctrl+r` refreshes | +| The grid | `v` | GitHub pull requests: the PULL REQUESTS MODAL for the selected project (above) — read a pull request, type to filter the list by `#42 title`, `Enter` prompts a PR SESSION on it, `Shift+Tab` launches an AGENT PRESET on it, `Tab` picks a harness, `Ctrl+c` comments, `Ctrl+g` opens its diff, `Ctrl+o`, the pane's `↗ open in browser` button or a double-click on the row opens it in the browser, `Ctrl+r` refreshes; a list GitHub could not be asked for says `couldn't refresh (^r retries)` under the filter, over the last rows that worked | | New worktree | type a sentence, or `Enter` on the empty prompt | the branch name is slugified (`fix login redirect` → `fix-login-redirect`); empty takes a random `--`. The branch starts at the freshly fetched `origin/HEAD` (normally `origin/main`), untracked — never at the root checkout's HEAD; with no `origin`, or a fetch that fails, it falls back to HEAD. **Worktree base branch** (Settings → General; `worktree_base_branch` in CONFIG.JSON) names another start — `master`, `develop` — resolved the same way: origin's fetched copy first, the local branch of that name otherwise, and `origin/HEAD` again in a repo that has neither | | The grid | `n` | new session, harness first: the NEW SESSION PICKER lists the enabled harnesses (`→` drills into a harness's model and effort, `Enter` takes your defaults for whatever you did not drill into), and the pick opens the QUICK PROMPT set to it — the same box `p` opens (below), aimed at the same checkout, with your harness on its title instead of the Settings → Agents one. The cursor starts on that Settings → Agents harness, so `Enter` at once is `p`. Type the task and `Enter` launches; `Enter` on the box empty starts the session bare, and you type the first prompt in the CLI. `Esc` on the picker closes it and opens nothing; text parked by an earlier box comes back into the picked box, the pick kept. A shell terminal is `t` | | The grid (a card) | `Ctrl+d` / `Ctrl+u` | jump two rows of cards down / up, stopping at the first and last row, with the grid scrolling after the cursor so the card landed on is in view and the pane previewing it. Same rebindable actions, `half_page_down` / `half_page_up` | diff --git a/scripts/shot/bin/gh b/scripts/shot/bin/gh index 9fd1d521..3979a544 100755 --- a/scripts/shot/bin/gh +++ b/scripts/shot/bin/gh @@ -3,7 +3,11 @@ make from canned JSON under $NEBULA_GH_FIXTURES, so a screenshot never needs a network, a token or a real repository. Anything else is "couldn't ask" (exit 1) — exactly what nebula treats as no PR. - gh pr list --state open … --json … → fixtures/pr-list.json + gh api graphql … pullRequests … → fixtures/pr-list.json, rows in `gh pr list --json` shape, handed back as + the open list's GraphQL answer (a row's `statusCheckRollup` checks + become the rollup `state` on its last commit). With + fixtures/pr-list.ok-count holding N, only the first N calls answer and + every later one fails the way GitHub's 504 did in #106. gh pr view --json … → fixtures/pr-view-.json, else exit 1 (no PR on this branch) gh pr view --json … → fixtures/pr-.json, else exit 1 gh pr diff → fixtures/pr-.diff, else exit 1 @@ -11,6 +15,7 @@ real repository. Anything else is "couldn't ask" (exit 1) — exactly what nebul gh issue list --state open … --json … → fixtures/issue-list.json gh issue view --json … → fixtures/issue-.json, else exit 1 """ +import json import os import sys @@ -25,9 +30,47 @@ def emit(name): return 0 +PASSING = {"SUCCESS", "NEUTRAL", "SKIPPED"} +FAILING = {"FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"} + + +def rollup(checks): + """GitHub's one word for a commit's checks, from a `gh pr list` row's `statusCheckRollup`.""" + if not checks: + return None + words = [c["state"] if "state" in c else c.get("conclusion") if c.get("status") == "COMPLETED" else c.get("status") + for c in checks] + if any(w in FAILING for w in words): + return {"state": "FAILURE"} + if all(w in PASSING for w in words): + return {"state": "SUCCESS"} + return {"state": "PENDING"} + + +def open_list(): + ok = os.path.join(FIX, "pr-list.ok-count") + if os.path.isfile(ok): + calls = os.path.join(FIX, ".pr-list-calls") + n = int(open(calls).read() or 0) if os.path.isfile(calls) else 0 + open(calls, "w").write(str(n + 1)) + if n >= int(open(ok).read().strip() or 0): + sys.stderr.write("HTTP 504: We couldn't respond to your request in time. (https://api.github.com/graphql)\n") + return 1 + path = os.path.join(FIX, "pr-list.json") + if not os.path.isfile(path): + return 1 + nodes = [] + for row in json.load(open(path)): + checks = row.pop("statusCheckRollup", None) + row["commits"] = {"nodes": [{"commit": {"statusCheckRollup": rollup(checks)}}]} + nodes.append(row) + json.dump({"data": {"repository": {"pullRequests": {"nodes": nodes}}}}, sys.stdout) + return 0 + + def main(argv): - if argv[:2] == ["pr", "list"]: - return emit("pr-list.json") + if argv[:2] == ["api", "graphql"] and any("pullRequests" in a for a in argv): + return open_list() if argv[:2] == ["pr", "view"]: rest = [a for a in argv[2:] if not a.startswith("--")] number = next((a for a in rest if a.isdigit()), None) diff --git a/scripts/shot/scenes/pr-list-stale.keys b/scripts/shot/scenes/pr-list-stale.keys new file mode 100644 index 00000000..d9039f3b --- /dev/null +++ b/scripts/shot/scenes/pr-list-stale.keys @@ -0,0 +1,5 @@ +# Open the PULL REQUESTS MODAL on the list the boot answer landed, then Ctrl+r: the stand-in gh fails +# that ask, and the modal says `couldn't refresh (^r retries)` under its filter, over the rows it +# kept — #42 `conflicts` and #37 `failing` from the rollup state, the draft #39 last. +v +C-r diff --git a/scripts/shot/scenes/pr-list-stale.setup.sh b/scripts/shot/scenes/pr-list-stale.setup.sh new file mode 100644 index 00000000..1b0b7aa8 --- /dev/null +++ b/scripts/shot/scenes/pr-list-stale.setup.sh @@ -0,0 +1,6 @@ +# The PULL REQUESTS MODAL on a list GitHub stopped answering for (#106): the fixtures are +# pr-row-conflicts's — #42 conflicting, #37 with a failing check, #39 a draft — but the stand-in gh +# answers the open list once and fails every later ask the way GitHub's 504 did. The first answer +# lands at boot; the modal's Ctrl+r asks again and fails, so the rows are the last list that worked. +. "$HERE/scenes/pr-row-conflicts.setup.sh" +echo 1 > "$FIXTURES/pr-list.ok-count"