From b070fbf61f0dd51f787788b8eb73775a87067c8e Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 21:04:53 -0700 Subject: [PATCH 01/12] fix(providers): classify gh failures by observed output Exit 2 from gh stack also means 'not a git repository', so only 'is not part of a stack' maps to LocalTrackingMissing. gh exit 4 (no token) now maps to Auth. gh installs a missing stack extension on first use and exits 0 without running the command, so the adapter retries once after seeing the install message. Classifier tests now use output observed from gh 2.101.0 and gh-stack v0.1.1. Refs: #23 --- .../src/providers/github_cli.rs | 15 +++++++++++- .../stack-stat-core/src/providers/graphql.rs | 8 +++++-- crates/stack-stat-core/tests/github_cli.rs | 24 ++++++++++++++++++- docs/architecture/05-providers-and-store.md | 2 ++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/stack-stat-core/src/providers/github_cli.rs b/crates/stack-stat-core/src/providers/github_cli.rs index 3b07d28..e9b682e 100644 --- a/crates/stack-stat-core/src/providers/github_cli.rs +++ b/crates/stack-stat-core/src/providers/github_cli.rs @@ -68,7 +68,13 @@ impl CliGitHubProvider { args: &[&str], deadline: Duration, ) -> Result<(String, String), GitHubError> { - let output = self.run_process(args, deadline).await?; + let mut output = self.run_process(args, deadline).await?; + if args.first() == Some(&"stack") && output.status.success() && installed_extension(&output) + { + // On first use gh installs the extension and exits 0 without running the + // subcommand, so the requested action has not happened yet. + output = self.run_process(args, deadline).await?; + } if !output.status.success() { return Err(classify_gh_failure( args, @@ -107,6 +113,13 @@ impl CliGitHubProvider { } } +/// Whether `gh` auto-installed the stack extension instead of running the command. +fn installed_extension(output: &std::process::Output) -> bool { + const MARKER: &str = "Successfully installed github/gh-stack"; + String::from_utf8_lossy(&output.stdout).contains(MARKER) + || String::from_utf8_lossy(&output.stderr).contains(MARKER) +} + #[async_trait] impl GitHubProvider for CliGitHubProvider { async fn stack_view(&self, current_branch: &str) -> Result { diff --git a/crates/stack-stat-core/src/providers/graphql.rs b/crates/stack-stat-core/src/providers/graphql.rs index cc869e0..28d1340 100644 --- a/crates/stack-stat-core/src/providers/graphql.rs +++ b/crates/stack-stat-core/src/providers/graphql.rs @@ -86,7 +86,10 @@ pub fn classify_gh_failure(args: &[&str], code: i32, stderr: &str) -> GitHubErro if lower.contains("unknown command \"stack\" for \"gh\"") { return GitHubError::MissingStackExtension; } - if code == 2 && matches!(args.get(1), Some(&"push" | &"sync")) { + if code == 2 + && matches!(args.get(1), Some(&"push" | &"sync")) + && lower.contains("is not part of a stack") + { return GitHubError::LocalTrackingMissing; } if matches!(code, 3 | 7) { @@ -103,7 +106,8 @@ pub fn classify_gh_failure(args: &[&str], code: i32, stderr: &str) -> GitHubErro .map(str::to_owned); return GitHubError::RateLimited { reset_hint: hint }; } - if lower.contains("auth") + if code == 4 + || lower.contains("auth") || lower.contains("gh auth login") || lower.contains("bad credentials") { diff --git a/crates/stack-stat-core/tests/github_cli.rs b/crates/stack-stat-core/tests/github_cli.rs index 18a7940..6f6856e 100644 --- a/crates/stack-stat-core/tests/github_cli.rs +++ b/crates/stack-stat-core/tests/github_cli.rs @@ -142,9 +142,22 @@ fn parser_rejects_responses_without_data_and_preserves_bot_and_context() { fn classifier_distinguishes_local_tracking_from_remote_discovery() { for action in ["push", "sync"] { assert_eq!( - classify_gh_failure(&["stack", action], 2, "not in a stack"), + classify_gh_failure( + &["stack", action], + 2, + "✗ current branch \"feature\" is not part of a stack" + ), GitHubError::LocalTrackingMissing ); + // Observed on gh-stack v0.1.1: exit 2 is also used outside a repository. + assert!(matches!( + classify_gh_failure( + &["stack", action], + 2, + "✗ not a git repository: failed to run git: fatal: not a git repository" + ), + GitHubError::Command { code: 2, .. } + )); } assert!(matches!( classify_gh_failure(&["api", "graphql"], 2, "query failed"), @@ -166,6 +179,15 @@ fn classifier_distinguishes_local_tracking_from_remote_discovery() { classify_gh_failure(&["pr", "list"], 1, "gh auth login first"), GitHubError::Auth ); + // Observed on gh 2.101.0 with no token: exit 4 and no "auth login" hint. + assert_eq!( + classify_gh_failure( + &["api", "user"], + 4, + "gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable." + ), + GitHubError::Auth + ); assert!(matches!( classify_gh_failure(&["api"], 1, "connection refused"), GitHubError::Network(_) diff --git a/docs/architecture/05-providers-and-store.md b/docs/architecture/05-providers-and-store.md index 49e15bf..0d95e23 100644 --- a/docs/architecture/05-providers-and-store.md +++ b/docs/architecture/05-providers-and-store.md @@ -35,6 +35,8 @@ Stacked checkout, push, and sync use `gh stack`; only these actions check that t Single-mode push runs plain `git push` and reports ordinary Git errors, including a missing upstream, without trying to link a stack or set an upstream. Because Git prints the remote and hook output before a rejection, the error message leads with the first `[rejected]`, `fatal:`, or `error:` line, followed by the full stderr; the status bar shows that first line. Single-mode checkout and sync are disabled by the App with a status message, not sent to `gh stack`. PR browser and draft/ready actions need no extension and remain available whenever the selected entry has a PR; a closed PR cannot toggle draft state. +Exit codes are ambiguous, so classification pairs them with stderr text: exit 2 on push or sync means missing local tracking only when stderr says the branch `is not part of a stack` (exit 2 also reports "not a git repository"), and exit 4 means authentication. `gh` installs a missing extension on first use and exits 0 without running the subcommand, so the adapter retries a `gh stack` command once when it sees that install message. These behaviors are pinned against the real binaries by the [live suite](07-live-validation.md). + ## Local check results The file store lives at `/stack-stat/status//.json`. Branch names are encoded as one platform-safe directory segment; `feat/api` does not create nested directories. The file contains a map of named results with outcome, optional message, and UTC timestamp. Cooperative readers take a shared lock and reporters/cleanup take an exclusive lock on a separate `status.lock` file. Writing merges a named result with its existing file and atomically replaces the file from a temporary sibling. This prevents a Windows reader's open handle from blocking replacement, while preserving complete JSON for readers across processes and worktrees. If no store exists, reads return empty without creating a lock file. From a72abc0350ef55d41da7fb763d05af13bded527c Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 21:04:53 -0700 Subject: [PATCH 02/12] test(core): add opt-in live gh and gh stack suite Runs the real binaries against fixture PRs (stacked, merged, unstacked, fork with same head branch, conflict). Opt-in with STACK_STAT_LIVE=1 via make live-test; scripts/live-fixture.sh resets fixture branches to their start tags. Refs: #23 --- Makefile | 8 +- crates/stack-stat-core/tests/live_gh.rs | 259 ++++++++++++++++++++++++ scripts/live-fixture.sh | 60 ++++++ 3 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 crates/stack-stat-core/tests/live_gh.rs create mode 100755 scripts/live-fixture.sh diff --git a/Makefile b/Makefile index 85ffd3c..b389bfe 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Developer convenience targets for stack-stat. .DEFAULT_GOAL := help -.PHONY: help dev-setup setup-hooks format lint tests preflight rustdoc docs-serve demo demo-gif clean +.PHONY: help dev-setup setup-hooks format lint tests live-test live-fixture-reset preflight rustdoc docs-serve demo demo-gif clean help: ## List available targets @grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-24s\033[0m %s\n", $$1, $$2}' @@ -30,6 +30,12 @@ tests: ## Run the full suite preflight: lint tests ## Run the CI-equivalent checks +live-test: ## Run end-to-end tests against real gh and gh stack (needs auth and push access to the fixture repo) + STACK_STAT_LIVE=1 cargo test -p stack-stat-core --test live_gh --locked -- --test-threads=4 + +live-fixture-reset: ## Restore the live fixture branches to their start tags + bash scripts/live-fixture.sh reset + rustdoc: ## Build rustdoc and open it cargo doc --workspace --no-deps --locked @echo "Rustdoc built in target/doc; open stack_stat_core/index.html with your platform's browser." diff --git a/crates/stack-stat-core/tests/live_gh.rs b/crates/stack-stat-core/tests/live_gh.rs new file mode 100644 index 0000000..d136324 --- /dev/null +++ b/crates/stack-stat-core/tests/live_gh.rs @@ -0,0 +1,259 @@ +//! End-to-end checks against the real `gh` and `gh stack` binaries and the live +//! fixture described in `docs/architecture/07-live-validation.md`. +//! +//! Skipped unless `STACK_STAT_LIVE=1`. Run with `make live-test`. + +use stack_stat_core::providers::{ + CliGitHubProvider, GitHubError, GitHubProvider, graphql::classify_gh_failure, +}; +use std::{path::Path, process::Command}; +use tempfile::TempDir; +use tokio::sync::Mutex; + +/// Repository holding the fixture stacks. +fn fixture_repo() -> String { + std::env::var("STACK_STAT_FIXTURE_REPO").unwrap_or_else(|_| "cam1se/stack-stat".into()) +} + +/// Fork of the fixture repository that holds the fork PR. +fn fork_repo() -> String { + std::env::var("STACK_STAT_FIXTURE_FORK").unwrap_or_else(|_| "tcamise-gpsw/stack-stat".into()) +} + +/// Mutating tests share one remote fixture, so they take turns. +static MUTATION_LOCK: Mutex<()> = Mutex::const_new(()); + +macro_rules! live { + () => { + if std::env::var("STACK_STAT_LIVE").as_deref() != Ok("1") { + eprintln!("skipped: set STACK_STAT_LIVE=1 to run live gh tests"); + return; + } + }; +} + +fn script(arg: &str) { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let status = Command::new("bash") + .arg(root.join("scripts/live-fixture.sh")) + .arg(arg) + .status() + .unwrap(); + assert!(status.success(), "live-fixture.sh {arg} failed"); +} + +/// Clone `slug` with `gh` and check out `branch`. +fn clone(slug: &str, branch: &str) -> TempDir { + let dir = tempfile::tempdir().unwrap(); + let output = Command::new("gh") + .args(["repo", "clone", slug]) + .arg(dir.path()) + .args(["--", "--branch", branch]) + .output() + .unwrap(); + assert!( + output.status.success(), + "clone {slug}@{branch}: {}", + String::from_utf8_lossy(&output.stderr) + ); + dir +} + +fn provider(dir: &Path) -> CliGitHubProvider { + let (owner, repo) = fixture_repo() + .split_once('/') + .map(|(o, r)| (o.to_owned(), r.to_owned())) + .unwrap(); + CliGitHubProvider::new(dir.to_path_buf(), owner, repo) +} + +/// Run `gh` in an empty, isolated config and data directory with a dummy token. +fn isolated_gh(args: &[&str], token: &str) -> (i32, String, String) { + let home = tempfile::tempdir().unwrap(); + let output = Command::new("gh") + .args(args) + .current_dir(home.path()) + .env("GH_TOKEN", token) + .env("GH_CONFIG_DIR", home.path().join("config")) + .env("XDG_DATA_HOME", home.path().join("data")) + .env("XDG_CONFIG_HOME", home.path().join("xdg")) + .env("GH_PROMPT_DISABLED", "1") + .env("GH_NO_UPDATE_NOTIFIER", "1") + .env("NO_COLOR", "1") + .output() + .unwrap(); + ( + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +#[tokio::test] +async fn resolves_owner_and_name_with_gh_repo_view() { + live!(); + let dir = clone(&fixture_repo(), "fixture/s1-b"); + let resolved = CliGitHubProvider::resolve_repo(dir.path()).await.unwrap(); + let expected = fixture_repo(); + assert_eq!(format!("{}/{}", resolved.0, resolved.1), expected); +} + +#[tokio::test] +async fn discovers_a_three_pr_stack_and_loads_details() { + live!(); + let dir = clone(&fixture_repo(), "fixture/s1-b"); + let github = provider(dir.path()); + let stack = github.stack_view("fixture/s1-b").await.unwrap(); + assert_eq!(stack.trunk, "main"); + let names: Vec<_> = stack.branches.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["fixture/s1-a", "fixture/s1-b", "fixture/s1-c"]); + let numbers: Vec<_> = stack + .branches + .iter() + .map(|b| b.pr.as_ref().unwrap().number) + .collect(); + assert_eq!(numbers, [27, 28, 29]); + assert!(stack.branches.iter().all(|b| !b.is_merged)); + let details = github.pr_details(&stack).await.unwrap(); + assert_eq!(details.prs.len(), 3); + assert!(details.prs.iter().all(|pr| pr.is_draft)); + assert!(details.remote_rebase_branches.is_empty()); +} + +#[tokio::test] +async fn branch_without_a_pull_request_is_not_in_a_stack() { + live!(); + let dir = clone(&fixture_repo(), "main"); + let result = provider(dir.path()) + .stack_view("fixture/no-such-branch") + .await; + assert_eq!(result.unwrap_err(), GitHubError::NotInStack); +} + +#[tokio::test] +async fn unstacked_pull_request_is_not_in_a_stack() { + live!(); + let dir = clone(&fixture_repo(), "fixture/s4-same"); + let result = provider(dir.path()).stack_view("fixture/s4-same").await; + assert_eq!(result.unwrap_err(), GitHubError::NotInStack); +} + +#[tokio::test] +async fn merged_stack_keeps_deleted_branches() { + live!(); + let dir = clone(&fixture_repo(), "main"); + let github = provider(dir.path()); + let stack = github.stack_view("fixture/s3-b").await.unwrap(); + assert_eq!(stack.trunk, "fixture/trunk"); + let names: Vec<_> = stack.branches.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["fixture/s3-a", "fixture/s3-b"]); + assert!(stack.branches.iter().all(|b| b.is_merged)); + let numbers: Vec<_> = stack + .branches + .iter() + .map(|b| b.pr.as_ref().unwrap().number) + .collect(); + assert_eq!(numbers, [34, 35]); +} + +/// Two PRs use head branch `fixture/s1-c`: #29, the top of stack s1, and a fork +/// PR that is not stacked. The push remote decides which one is ours. +#[tokio::test] +async fn same_branch_name_resolves_by_head_repository_owner() { + live!(); + let upstream = clone(&fixture_repo(), "fixture/s1-c"); + let stack = provider(upstream.path()) + .stack_view("fixture/s1-c") + .await + .unwrap(); + assert_eq!(stack.branches.len(), 3); + let fork = clone(&fork_repo(), "fixture/s1-c"); + let from_fork = provider(fork.path()).stack_view("fixture/s1-c").await; + assert_eq!(from_fork.unwrap_err(), GitHubError::NotInStack); +} + +#[tokio::test] +async fn push_without_local_tracking_is_classified() { + live!(); + let dir = clone(&fixture_repo(), "fixture/s1-b"); + let error = provider(dir.path()).push_stack().await.unwrap_err(); + assert_eq!(error, GitHubError::LocalTrackingMissing); +} + +#[tokio::test] +async fn push_outside_a_repository_is_a_plain_command_error() { + live!(); + let dir = tempfile::tempdir().unwrap(); + let error = provider(dir.path()).push_stack().await.unwrap_err(); + assert!( + matches!(&error, GitHubError::Command { code: 2, stderr } if stderr.contains("not a git repository")), + "{error:?}" + ); +} + +#[test] +fn missing_auth_is_classified() { + live!(); + let (code, _, stderr) = isolated_gh(&["api", "user"], ""); + assert_eq!(code, 4, "{stderr}"); + assert_eq!( + classify_gh_failure(&["api", "user"], code, &stderr), + GitHubError::Auth + ); +} + +/// gh 2.101.0 installs a missing extension on first use and exits 0 without +/// running the subcommand; the provider retries once after seeing this marker. +#[test] +fn gh_installs_missing_extension_on_first_use() { + live!(); + let token = Command::new("gh").args(["auth", "token"]).output().unwrap(); + let token = String::from_utf8(token.stdout).unwrap(); + let (code, stdout, stderr) = isolated_gh(&["stack", "view"], token.trim()); + assert_eq!(code, 0, "{stderr}"); + assert!( + format!("{stdout}{stderr}").contains("Successfully installed github/gh-stack"), + "{stdout}{stderr}" + ); +} + +#[tokio::test] +async fn link_push_sync_and_draft_toggle_round_trip() { + live!(); + let _guard = MUTATION_LOCK.lock().await; + script("reset"); + let dir = clone(&fixture_repo(), "fixture/s1-b"); + let github = provider(dir.path()); + github.checkout_branch("fixture/s1-b").await.unwrap(); + github.push_stack().await.unwrap(); + github.sync_stack().await.unwrap(); + github.toggle_draft(27, true).await.unwrap(); + let details = github + .pr_details(&github.stack_view("fixture/s1-b").await.unwrap()) + .await + .unwrap(); + assert!( + !details + .prs + .iter() + .find(|p| p.number == 27) + .unwrap() + .is_draft + ); + github.toggle_draft(27, false).await.unwrap(); + script("reset"); +} + +#[tokio::test] +async fn sync_against_conflicting_trunk_reports_rebase_conflict() { + live!(); + let _guard = MUTATION_LOCK.lock().await; + script("reset"); + let dir = clone(&fixture_repo(), "fixture/s2-b"); + let github = provider(dir.path()); + github.checkout_branch("fixture/s2-b").await.unwrap(); + script("conflict"); + let result = github.sync_stack().await; + script("reset"); + assert_eq!(result.unwrap_err(), GitHubError::RebaseConflict); +} diff --git a/scripts/live-fixture.sh b/scripts/live-fixture.sh new file mode 100755 index 0000000..748d08d --- /dev/null +++ b/scripts/live-fixture.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Reset or perturb the live GitHub fixture used by the end-to-end suite (issue #23). +# +# Usage: scripts/live-fixture.sh +# reset Force-push every fixture branch back to its fixture-start/* tag and +# restore draft state. Requires push access to the fixture repository. +# conflict Move fixture/trunk to fixture-conflict/trunk so `gh stack sync` on +# stack s2 hits a rebase conflict. Undo with `reset`. +# status Print each fixture branch and whether it matches its start tag. +# +# Environment: STACK_STAT_FIXTURE_REPO (default cam1se/stack-stat). +set -euo pipefail + +repo="${STACK_STAT_FIXTURE_REPO:-cam1se/stack-stat}" +url="https://github.com/${repo}.git" +branches=(s1-a s1-b s1-c s2-a s2-b trunk) +draft_prs=(27 28 29 31 32) + +remote_sha() { git ls-remote "$url" "$1" | cut -f1; } + +cmd="${1:-status}" +case "$cmd" in + status) + for b in "${branches[@]}"; do + head="$(remote_sha "refs/heads/fixture/$b")" + tag="$(remote_sha "refs/tags/fixture-start/$b^{}")" + [ -n "$tag" ] || tag="$(remote_sha "refs/tags/fixture-start/$b")" + if [ "$head" = "$tag" ]; then state=clean; else state=DRIFTED; fi + printf '%-14s %s\n' "fixture/$b" "$state" + done + ;; + reset) + for b in "${branches[@]}"; do + tag="$(remote_sha "refs/tags/fixture-start/$b")" + [ -n "$tag" ] || { echo "missing tag fixture-start/$b" >&2; exit 1; } + tmp="$(mktemp -d)" + git -C "$tmp" init -q + git -C "$tmp" fetch -q --depth 1 "$url" "refs/tags/fixture-start/$b:refs/tags/t" + git -C "$tmp" push -q --force "$url" "refs/tags/t:refs/heads/fixture/$b" + rm -rf "$tmp" + done + for pr in "${draft_prs[@]}"; do + is_draft="$(gh pr view "$pr" --repo "$repo" --json isDraft --jq .isDraft)" + [ "$is_draft" = true ] || gh pr ready "$pr" --repo "$repo" --undo >/dev/null + done + echo "fixture reset" + ;; + conflict) + tmp="$(mktemp -d)" + git -C "$tmp" init -q + git -C "$tmp" fetch -q --depth 1 "$url" "refs/tags/fixture-conflict/trunk:refs/tags/t" + git -C "$tmp" push -q --force "$url" "refs/tags/t:refs/heads/fixture/trunk" + rm -rf "$tmp" + echo "fixture/trunk now conflicts with stack s2" + ;; + *) + echo "usage: $0 " >&2 + exit 2 + ;; +esac From a4d33ee1dcc015c90b28761bf1ab7af6b6ec53df Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 21:04:53 -0700 Subject: [PATCH 03/12] chore(ci): add scheduled live gh workflow and skip fixture PRs The live workflow runs daily and on dispatch with a serializing concurrency group. CI and commit-lint skip pull requests whose head branch starts with fixture/. Refs: #23 --- .github/workflows/ci.yml | 2 ++ .github/workflows/commit-lint.yml | 2 +- .github/workflows/live-gh.yml | 51 +++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/live-gh.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c3c424..a57aa08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: fmt-clippy: name: Rust — fmt / clippy runs-on: ubuntu-latest + if: ${{ !startsWith(github.head_ref, 'fixture/') }} timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -34,6 +35,7 @@ jobs: test: name: Rust — test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + if: ${{ !startsWith(github.head_ref, 'fixture/') }} timeout-minutes: 15 strategy: fail-fast: false diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index 6509869..6441ef0 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -11,7 +11,7 @@ permissions: jobs: lint: runs-on: ubuntu-latest - if: ${{ !startsWith(github.head_ref, 'release-please--branches--') }} + if: ${{ !startsWith(github.head_ref, 'release-please--branches--') && !startsWith(github.head_ref, 'fixture/') }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/live-gh.yml b/.github/workflows/live-gh.yml new file mode 100644 index 0000000..9428628 --- /dev/null +++ b/.github/workflows/live-gh.yml @@ -0,0 +1,51 @@ +name: Live gh + +# End-to-end checks against the real gh and gh stack binaries and the live +# fixture PRs. See docs/architecture/07-live-validation.md. Not run on pull requests: it mutates +# shared remote state and needs a token with write access. +on: + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +# Tests reset shared fixture branches, so only one run may execute at a time and +# a cancelled run must never be replaced mid-reset. +concurrency: + group: live-gh + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + # A PAT with push access is needed if the default token cannot force-push the + # fixture branches; otherwise the workflow token is used. + GH_TOKEN: ${{ secrets.STACK_STAT_LIVE_TOKEN || github.token }} + STACK_STAT_LIVE: "1" + +jobs: + live: + name: Live gh and gh stack + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Configure git for gh + run: | + gh auth setup-git + git config --global user.name "stack-stat live tests" + git config --global user.email "live-tests@users.noreply.github.com" + - name: Show tool versions + run: | + gh --version + gh extension install github/gh-stack + gh extension list + - name: Run live tests + run: cargo test -p stack-stat-core --test live_gh --locked -- --test-threads=4 + - name: Restore fixture + if: ${{ always() }} + run: bash scripts/live-fixture.sh reset From b7177f2287810dc9ccc37a422546e1cd0d77d94b Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 21:04:53 -0700 Subject: [PATCH 04/12] docs: add ADR-0004 and live validation guide Records the live fixture approach, keeps the PTY rule, and lists coverage gaps. Refs: #23 --- AGENTS.md | 1 + docs/README.md | 1 + docs/adr/0004-live-fixture-validation.md | 38 ++++++++++++++++ docs/adr/README.md | 1 + docs/architecture/07-live-validation.md | 57 ++++++++++++++++++++++++ 5 files changed, 98 insertions(+) create mode 100644 docs/adr/0004-live-fixture-validation.md create mode 100644 docs/architecture/07-live-validation.md diff --git a/AGENTS.md b/AGENTS.md index 4316e78..d828fee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,5 +88,6 @@ Do not change production invariants or expose broad internals solely for tests. | App state machine | `crates/stack-stat-tui/tests/` | Fake providers, state transitions, action effects | | Rendering | `crates/stack-stat-tui/tests/` | `ratatui::TestBackend` frame contracts | | CLI | `crates/stack-stat-tui/tests/` | Report and update behavior against isolated temp repos | +| Live gh | `crates/stack-stat-core/tests/live_gh.rs` | Real `gh`/`gh stack` against fixture PRs; opt-in with `make live-test` ([guide](docs/architecture/07-live-validation.md)) | Do not add PTY-based integration tests or visual snapshots: direct App behavior and `TestBackend` buffer checks are more precise. diff --git a/docs/README.md b/docs/README.md index fe47161..0abb091 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ Read in this order: | [04 Domain and config](architecture/04-domain-and-config.md) | Model vocabulary and configuration precedence | | [05 Providers and store](architecture/05-providers-and-store.md) | CLI adapters and local check retention | | [06 Terminal application](architecture/06-tui.md) | App modes, polling, effects, and rendering | +| [07 Live validation](architecture/07-live-validation.md) | End-to-end suite, fixture, pinned gh behavior, and gaps | The first two pages follow the [C4 model](https://c4model.com); later numbered pages cover subsystems. Put extending recipes beside their subsystem. Do not hand-model generated schemas or public Rust signatures. Link to rustdoc instead. diff --git a/docs/adr/0004-live-fixture-validation.md b/docs/adr/0004-live-fixture-validation.md new file mode 100644 index 0000000..248cc22 --- /dev/null +++ b/docs/adr/0004-live-fixture-validation.md @@ -0,0 +1,38 @@ +# ADR-0004: Validate gh and gh stack against a live fixture + +- **Status:** Proposed +- **Date:** 2026-10-13 +- **Deciders:** tcamise + +## Context + +Every test ran against fakes or fixtures, so assumptions about `gh` and `gh stack` output and exit codes went unchecked. Probing the real binaries while building this showed three of them wrong or imprecise: + +- `gh` 2.101.0 installs a missing `stack` extension on first use and exits 0 without running the command, so the "unknown command" classifier never matched and the requested action silently did nothing. +- `gh stack` exits 2 both for a branch that is not in a stack and for a directory that is not a Git repository, so exit 2 alone cannot mean "local tracking missing". +- `gh` exits 4 with no "auth login" hint when no token is set, so the text-only auth classifier missed it. + +## Decision + +Add a live suite that runs the real binaries against long-lived fixture pull requests in this repository, described in [live validation](../architecture/07-live-validation.md). + +- **Fixture over ephemeral stacks.** Fixture PRs, `fixture-start/*` tags, and a reset script give deterministic starting states without per-run PR creation. The suite creates nothing and needs no merge rights. +- **Tags reset state.** Branch drift is undone by force-pushing a branch to its tag. A merged stack cannot be reset, so it is read-only. +- **Rulesets protect the fixture.** Branch deletion and tag updates are blocked for everyone but admins. CI skips `fixture/*` pull requests. +- **Opt-in and off the PR path.** Tests run only with `STACK_STAT_LIVE=1`. CI runs them on a schedule and by manual dispatch with a serializing `concurrency` group, because they mutate shared remote state, depend on rate limits, and need a write token that fork PRs must not reach. +- **Classify by text and code together.** Exit 2 maps to `LocalTrackingMissing` only with `is not part of a stack`; exit 4 maps to `Auth`; a first-use auto-install is retried once. +- **No PTY harness.** The `AGENTS.md` rule against PTY tests and visual snapshots stands. Dashboard behavior stays covered by `TestBackend`; the live suite targets the adapter boundary where the real binaries matter. +- **Existing fake tests stay.** They run offline on every pull request and cover logic the live suite does not. None is deleted; the classifier tests were rewritten to the observed output. + +## Consequences + +- A change in `gh` or `gh stack` output, exit codes, or GraphQL shape that breaks the adapter fails the scheduled run within a day, and a maintainer can reproduce it with `make live-test`. +- Fixture PRs appear in this repository's pull request list permanently. +- Runs take minutes and can fail from GitHub outages, rate limits, or someone editing a fixture PR. +- Coverage gaps (pagination over 100 entries, fork push, version matrix, real TUI) are listed in the living page and are not claimed as covered. + +## Alternatives considered + +- **Ephemeral stacks per run.** Isolated and self-cleaning, but slower, requiring a write token with PR creation, and leaves orphans when a run is cancelled. +- **Recorded responses behind a fake `gh`.** Fast and offline, but cannot detect that GitHub or `gh` changed; kept as a possible base layer. +- **PTY or VHS driver for the dashboard.** Rejected for now; it would reverse the `AGENTS.md` rule and adds flakiness for little extra coverage beyond the adapter checks. diff --git a/docs/adr/README.md b/docs/adr/README.md index 36ca77a..071449e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,6 +11,7 @@ Number ADRs chronologically with zero-padded permanent IDs (`0001-short-slug.md` |[ADR-0001](0001-remote-rebase-status.md)|Derive rebase status from remote state|Proposed| |[ADR-0002](0002-read-stack-from-github.md)|Read stack structure from GitHub; use gh stack only for actions|Accepted; single-mode missing-data and action rules superseded by [ADR-0003](0003-single-pr-and-no-pr-mode.md)| |[ADR-0003](0003-single-pr-and-no-pr-mode.md)|Show a single PR or current branch without a GitHub stack|Accepted| +|[ADR-0004](0004-live-fixture-validation.md)|Validate gh and gh stack against a live fixture|Proposed| ## Template diff --git a/docs/architecture/07-live-validation.md b/docs/architecture/07-live-validation.md new file mode 100644 index 0000000..17cfc97 --- /dev/null +++ b/docs/architecture/07-live-validation.md @@ -0,0 +1,57 @@ +# 07 Live validation + +The fake-based suites prove stack-stat's logic; they cannot prove that `gh` and `gh stack` still print and exit the way the adapter assumes. The live suite in [`live_gh.rs`](../../crates/stack-stat-core/tests/live_gh.rs) runs the real binaries against fixture pull requests on GitHub. The choice and its trade-offs are recorded in [ADR-0004](../adr/0004-live-fixture-validation.md). + +## Run it + +```sh +make live-test # needs gh auth with push access to the fixture repository +make live-fixture-reset # restore fixture branches to their start tags +``` + +Tests skip silently unless `STACK_STAT_LIVE=1`; `make live-test` sets it. `STACK_STAT_FIXTURE_REPO` and `STACK_STAT_FIXTURE_FORK` override the fixture repository (default `cam1se/stack-stat`) and the fork that holds the fork PRs (default `tcamise-gpsw/stack-stat`). The suite needs `git`, `gh`, and network access; the `gh stack` extension is installed by `gh` itself on first use. + +## Fixture + +The fixture lives in this repository under the `fixture/` branch prefix. Branches are protected from deletion and the `fixture-start/*` and `fixture-conflict/*` tags from update or deletion by repository rulesets; admins bypass them. CI skips pull requests whose head branch starts with `fixture/`. + +|Stack|Pull requests|Trunk|Role| +|---|---|---|---| +|`s1`|three stacked drafts on `fixture/s1-a`, `-b`, `-c`|`main`|Reads, link, push, sync, draft toggle| +|`s2`|two stacked drafts on `fixture/s2-a`, `-b`|`fixture/trunk`|Rebase conflict: `scripts/live-fixture.sh conflict` moves trunk to a commit editing the same file| +|`s3`|two merged PRs, branches deleted|`fixture/trunk`|Merged stack reads; read-only, cannot be reset| +|`s4`|one unstacked upstream PR on `fixture/s4-same`|`main`|Unstacked PR| +|fork|fork PRs from `fixture/s1-c` and `fixture/s4-same`|`main`|Same head branch name as an upstream PR, different head owner| + +[`scripts/live-fixture.sh`](../../scripts/live-fixture.sh) implements `status`, `reset`, and `conflict`. `reset` force-pushes each branch to its `fixture-start/` tag and restores draft state. Mutating tests reset before and after themselves and serialize on one lock; the workflow adds a `concurrency` group so two runs never reset the fixture at once. + +Tag a new baseline with `git tag -f fixture-start/ ` only after temporarily lifting the tag ruleset. + +## Pinned behavior + +Observed with `gh` 2.101.0 and `gh-stack` v0.1.1: + +|Situation|Observed|Adapter result| +|---|---|---| +|`gh stack` with the extension absent|`gh` installs it, prints `Successfully installed github/gh-stack`, exits 0 without running the subcommand|Retry the command once| +|Push or sync in a repository where the branch is untracked|exit 2, `current branch "…" is not part of a stack`|`LocalTrackingMissing`| +|Any `gh stack` command outside a Git repository|exit 2, `not a git repository`|`Command { code: 2, .. }`| +|`gh` with no token|exit 4, `To use GitHub CLI in automation, set the GH_TOKEN`|`Auth`| +|Sync against a conflicting trunk|stack-stat treats exit codes 3 and 7 as a conflict; the live test confirms the result, not which code|`RebaseConflict`| + +## Coverage and gaps + +Covered live: owner/name resolution; stack discovery and PR details for a stacked, merged, unstacked, missing, and same-name fork PR; link (`gh stack checkout`), push, sync, and draft toggle; sync conflict; missing tracking, repository, extension, and authentication. + +Not covered: + +- **Discovery over 100 stack entries.** Needs a 100-PR stack; pagination remains fixture-tested. +- **Fork clone pushing to its own fork.** The fork PR is read with a fork checkout, but push and sync from a fork are not exercised. +- **Exact conflict exit code.** The test asserts the classified result, not whether `gh stack` returned 3 or 7. +- **Real dashboard rendering.** There is no headless stack printer; the TUI is still covered through `TestBackend` and fake providers. Revisit if a non-interactive resolved-stack command is added. +- **Version matrix.** CI runs the runner's preinstalled `gh` and the latest extension daily; older `gh` versions are not tested. +- **`s3` and fork PR drift.** If someone closes a fixture PR or deletes a fork branch, tests fail until the fixture is rebuilt by hand. + +## Credentials + +CI uses `GH_TOKEN` from the `STACK_STAT_LIVE_TOKEN` secret when set, otherwise the workflow token with `contents: write` and `pull-requests: write`. A token must force-push `fixture/*` branches and mark the `s1`/`s2` PRs ready or draft. It never needs to merge. The workflow runs on a schedule and on manual dispatch only, never on pull requests, so a fork PR cannot reach the token. From e963c91773cb801068a2fcecc88faec9c6fb1577 Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 21:23:35 -0700 Subject: [PATCH 05/12] chore(ci): run live gh against pinned and latest toolchains Matrix of known-good (gh 2.101.0, gh-stack v0.1.1) and latest releases so upstream changes that break stack-stat show up as a latest-only failure. Uses the existing GH_ACCESS_TOKEN secret, opens or updates an issue when a scheduled run fails, and carries a temporary push trigger to exercise the workflow from this branch. Refs: #23 --- .github/workflows/live-gh.yml | 82 ++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 11 deletions(-) diff --git a/.github/workflows/live-gh.yml b/.github/workflows/live-gh.yml index 9428628..a16bc64 100644 --- a/.github/workflows/live-gh.yml +++ b/.github/workflows/live-gh.yml @@ -1,16 +1,26 @@ name: Live gh -# End-to-end checks against the real gh and gh stack binaries and the live -# fixture PRs. See docs/architecture/07-live-validation.md. Not run on pull requests: it mutates -# shared remote state and needs a token with write access. +# Detects drift in gh and the gh-stack extension. Runs the live suite (see +# docs/architecture/07-live-validation.md) against the fixture PRs with a +# known-good pinned toolchain and with the latest releases. A failure in +# "latest" while "pinned" passes means an upstream change broke stack-stat. +# +# Not run on pull requests: it mutates shared remote state and needs a token with +# write access. on: schedule: - cron: "17 6 * * *" workflow_dispatch: + # TEMPORARY: lets this branch be exercised before the workflow reaches main. + # Remove before merging. + push: + branches: [tcamise-gpsw/end-to-end-validation-of-gh-and-gh-stack-behavio] + paths: [".github/workflows/live-gh.yml"] permissions: contents: write pull-requests: write + issues: write # Tests reset shared fixture branches, so only one run may execute at a time and # a cancelled run must never be replaced mid-reset. @@ -20,32 +30,82 @@ concurrency: env: CARGO_TERM_COLOR: always - # A PAT with push access is needed if the default token cannot force-push the - # fixture branches; otherwise the workflow token is used. - GH_TOKEN: ${{ secrets.STACK_STAT_LIVE_TOKEN || github.token }} + # Needs push access to the fixture repository; the default workflow token is + # the fallback. + GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN || github.token }} STACK_STAT_LIVE: "1" jobs: live: - name: Live gh and gh stack + name: Live gh (${{ matrix.name }}) runs-on: ubuntu-latest timeout-minutes: 30 + strategy: + fail-fast: false + # Both jobs reset the same fixture branches. + max-parallel: 1 + matrix: + include: + # Versions the suite was validated against; update deliberately. + - name: pinned + gh: "2.101.0" + extension: "v0.1.1" + # Whatever upstream shipped most recently. + - name: latest + gh: latest + extension: latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Install gh ${{ matrix.gh }} + run: | + version="${{ matrix.gh }}" + if [ "$version" = latest ]; then + version="$(gh api repos/cli/cli/releases/latest --jq .tag_name)" + version="${version#v}" + fi + curl -fsSL "https://github.com/cli/cli/releases/download/v${version}/gh_${version}_linux_amd64.tar.gz" \ + | tar -xz -C "$RUNNER_TEMP" + sudo install "$RUNNER_TEMP/gh_${version}_linux_amd64/bin/gh" /usr/local/bin/gh + hash -r + - name: Install gh-stack ${{ matrix.extension }} + run: | + if [ "${{ matrix.extension }}" = latest ]; then + gh extension install github/gh-stack + else + gh extension install github/gh-stack --pin "${{ matrix.extension }}" + fi - name: Configure git for gh run: | gh auth setup-git git config --global user.name "stack-stat live tests" git config --global user.email "live-tests@users.noreply.github.com" - - name: Show tool versions + - name: Record tool versions run: | - gh --version - gh extension install github/gh-stack - gh extension list + { + echo "### Tool versions (${{ matrix.name }})" + echo '```' + gh --version + gh extension list + echo '```' + } | tee -a "$GITHUB_STEP_SUMMARY" - name: Run live tests run: cargo test -p stack-stat-core --test live_gh --locked -- --test-threads=4 - name: Restore fixture if: ${{ always() }} run: bash scripts/live-fixture.sh reset + - name: Report failure + # Scheduled runs only: manual and temporary branch runs report in the UI. + if: ${{ failure() && github.event_name == 'schedule' }} + run: | + title="Live gh drift detected" + body="Live gh (${{ matrix.name }}: gh ${{ matrix.gh }}, gh-stack ${{ matrix.extension }}) failed: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + existing="$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number // empty')" + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "$body" + else + gh issue create --title "$title" --label bug --body "$body + + If only the latest job failed, gh or gh-stack changed in a way that breaks stack-stat. See docs/architecture/07-live-validation.md." + fi From 925382bc0ccaea0e631ada35e9ac08be6c0efc49 Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 21:27:45 -0700 Subject: [PATCH 06/12] docs: describe pinned and latest drift detection Documents the version matrix, GH_ACCESS_TOKEN usage and failure issue; removes the temporary branch push trigger from the live workflow. Refs: #23 --- .github/workflows/live-gh.yml | 5 ----- docs/adr/0004-live-fixture-validation.md | 1 + docs/architecture/07-live-validation.md | 8 ++++++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/live-gh.yml b/.github/workflows/live-gh.yml index a16bc64..33f553b 100644 --- a/.github/workflows/live-gh.yml +++ b/.github/workflows/live-gh.yml @@ -11,11 +11,6 @@ on: schedule: - cron: "17 6 * * *" workflow_dispatch: - # TEMPORARY: lets this branch be exercised before the workflow reaches main. - # Remove before merging. - push: - branches: [tcamise-gpsw/end-to-end-validation-of-gh-and-gh-stack-behavio] - paths: [".github/workflows/live-gh.yml"] permissions: contents: write diff --git a/docs/adr/0004-live-fixture-validation.md b/docs/adr/0004-live-fixture-validation.md index 248cc22..b5bc941 100644 --- a/docs/adr/0004-live-fixture-validation.md +++ b/docs/adr/0004-live-fixture-validation.md @@ -20,6 +20,7 @@ Add a live suite that runs the real binaries against long-lived fixture pull req - **Tags reset state.** Branch drift is undone by force-pushing a branch to its tag. A merged stack cannot be reset, so it is read-only. - **Rulesets protect the fixture.** Branch deletion and tag updates are blocked for everyone but admins. CI skips `fixture/*` pull requests. - **Opt-in and off the PR path.** Tests run only with `STACK_STAT_LIVE=1`. CI runs them on a schedule and by manual dispatch with a serializing `concurrency` group, because they mutate shared remote state, depend on rate limits, and need a write token that fork PRs must not reach. +- **Pinned and latest toolchains.** The nightly run tests a pinned known-good `gh` and `gh-stack` pair and the latest releases of both. A latest-only failure is upstream drift; the pinned pair moves only after the suite passes against it. A failed scheduled run opens or updates a tracking issue. - **Classify by text and code together.** Exit 2 maps to `LocalTrackingMissing` only with `is not part of a stack`; exit 4 maps to `Auth`; a first-use auto-install is retried once. - **No PTY harness.** The `AGENTS.md` rule against PTY tests and visual snapshots stands. Dashboard behavior stays covered by `TestBackend`; the live suite targets the adapter boundary where the real binaries matter. - **Existing fake tests stay.** They run offline on every pull request and cover logic the live suite does not. None is deleted; the classifier tests were rewritten to the observed output. diff --git a/docs/architecture/07-live-validation.md b/docs/architecture/07-live-validation.md index 17cfc97..feaa4a2 100644 --- a/docs/architecture/07-live-validation.md +++ b/docs/architecture/07-live-validation.md @@ -49,9 +49,13 @@ Not covered: - **Fork clone pushing to its own fork.** The fork PR is read with a fork checkout, but push and sync from a fork are not exercised. - **Exact conflict exit code.** The test asserts the classified result, not whether `gh stack` returned 3 or 7. - **Real dashboard rendering.** There is no headless stack printer; the TUI is still covered through `TestBackend` and fake providers. Revisit if a non-interactive resolved-stack command is added. -- **Version matrix.** CI runs the runner's preinstalled `gh` and the latest extension daily; older `gh` versions are not tested. +- **Older `gh` versions.** The workflow tests one pinned known-good pair and the latest releases; versions in between are not tested. - **`s3` and fork PR drift.** If someone closes a fixture PR or deletes a fork branch, tests fail until the fixture is rebuilt by hand. ## Credentials -CI uses `GH_TOKEN` from the `STACK_STAT_LIVE_TOKEN` secret when set, otherwise the workflow token with `contents: write` and `pull-requests: write`. A token must force-push `fixture/*` branches and mark the `s1`/`s2` PRs ready or draft. It never needs to merge. The workflow runs on a schedule and on manual dispatch only, never on pull requests, so a fork PR cannot reach the token. +The workflow uses the `GH_ACCESS_TOKEN` repository secret, falling back to the workflow token. The token must force-push `fixture/*` branches, toggle draft state on the `s1`/`s2` PRs, and open issues; it never needs to merge. The workflow runs on a schedule and on manual dispatch only, never on pull requests, so a fork PR cannot reach the token. + +## Detecting version drift + +The workflow runs a two-job matrix nightly (06:17 UTC) and on dispatch. `pinned` uses the versions the suite was validated against (`gh` 2.101.0, `gh-stack` v0.1.1); `latest` installs the newest `gh` release and the newest extension. A failure in `latest` with `pinned` passing means an upstream change breaks stack-stat; a failure in both points at the fixture or the adapter. Raise the pinned versions only after the suite passes against them. A failed scheduled run opens or updates the issue titled "Live gh drift detected". Each job records the exact versions in its step summary. From 6de46595f6f8e13b962f65be3f8f1fdf5b0bfdce Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 21:38:50 -0700 Subject: [PATCH 07/12] test(core): cover single-pr and no-pr mode on the live fixture Adds fixtures for a single PR on the default base (s4), a single PR on a non-default base (s5), a pushed branch without a PR (s6), a closed PR (s7) and a merged PR (s8), and replaces the NotInStack expectations with the one-row single-mode view. The reset script restores s5 and s6; s7 and s8 are read-only. Refs: #23 --- crates/stack-stat-core/tests/live_gh.rs | 102 ++++++++++++++++++++---- docs/architecture/07-live-validation.md | 8 +- scripts/live-fixture.sh | 4 +- 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/crates/stack-stat-core/tests/live_gh.rs b/crates/stack-stat-core/tests/live_gh.rs index d136324..c677f23 100644 --- a/crates/stack-stat-core/tests/live_gh.rs +++ b/crates/stack-stat-core/tests/live_gh.rs @@ -3,8 +3,12 @@ //! //! Skipped unless `STACK_STAT_LIVE=1`. Run with `make live-test`. -use stack_stat_core::providers::{ - CliGitHubProvider, GitHubError, GitHubProvider, graphql::classify_gh_failure, +use stack_stat_core::{ + model::StackMode, + providers::{ + CliGitHubProvider, GitHubError, GitHubProvider, StackBranch, StackViewResponse, + graphql::classify_gh_failure, + }, }; use std::{path::Path, process::Command}; use tempfile::TempDir; @@ -104,6 +108,7 @@ async fn discovers_a_three_pr_stack_and_loads_details() { let dir = clone(&fixture_repo(), "fixture/s1-b"); let github = provider(dir.path()); let stack = github.stack_view("fixture/s1-b").await.unwrap(); + assert_eq!(stack.mode, StackMode::Stacked); assert_eq!(stack.trunk, "main"); let names: Vec<_> = stack.branches.iter().map(|b| b.name.as_str()).collect(); assert_eq!(names, ["fixture/s1-a", "fixture/s1-b", "fixture/s1-c"]); @@ -120,22 +125,69 @@ async fn discovers_a_three_pr_stack_and_loads_details() { assert!(details.remote_rebase_branches.is_empty()); } +/// Assert a one-row single-mode view and return its row. +fn single_row(stack: &StackViewResponse) -> &StackBranch { + assert_eq!(stack.mode, StackMode::Single); + assert_eq!(stack.branches.len(), 1); + &stack.branches[0] +} + #[tokio::test] -async fn branch_without_a_pull_request_is_not_in_a_stack() { +async fn branch_without_a_pull_request_is_a_single_row_on_the_default_branch() { live!(); - let dir = clone(&fixture_repo(), "main"); - let result = provider(dir.path()) - .stack_view("fixture/no-such-branch") - .await; - assert_eq!(result.unwrap_err(), GitHubError::NotInStack); + let dir = clone(&fixture_repo(), "fixture/s6-nopr"); + let github = provider(dir.path()); + let stack = github.stack_view("fixture/s6-nopr").await.unwrap(); + assert_eq!(stack.trunk, "main"); + let row = single_row(&stack); + assert_eq!(row.name, "fixture/s6-nopr"); + assert!(row.pr.is_none()); + assert!(github.pr_details(&stack).await.unwrap().prs.is_empty()); + let unknown = github.stack_view("fixture/no-such-branch").await.unwrap(); + assert!(single_row(&unknown).pr.is_none()); } #[tokio::test] -async fn unstacked_pull_request_is_not_in_a_stack() { +async fn unstacked_pull_request_is_a_single_row_based_on_its_base_ref() { live!(); let dir = clone(&fixture_repo(), "fixture/s4-same"); - let result = provider(dir.path()).stack_view("fixture/s4-same").await; - assert_eq!(result.unwrap_err(), GitHubError::NotInStack); + let stack = provider(dir.path()) + .stack_view("fixture/s4-same") + .await + .unwrap(); + assert_eq!(stack.trunk, "main"); + let pr = single_row(&stack).pr.as_ref().unwrap(); + assert_eq!((pr.number, pr.state.as_str()), (37, "OPEN")); +} + +#[tokio::test] +async fn single_pull_request_uses_a_non_default_base_as_trunk() { + live!(); + let dir = clone(&fixture_repo(), "fixture/s5-single"); + let github = provider(dir.path()); + let stack = github.stack_view("fixture/s5-single").await.unwrap(); + assert_eq!(stack.trunk, "fixture/single-base"); + let pr = single_row(&stack).pr.as_ref().unwrap(); + assert_eq!((pr.number, pr.state.as_str()), (43, "OPEN")); + let details = github.pr_details(&stack).await.unwrap(); + assert_eq!(details.prs.len(), 1); + assert!(details.prs[0].is_draft); +} + +#[tokio::test] +async fn closed_and_merged_single_pull_requests_keep_their_lifecycle() { + live!(); + let dir = clone(&fixture_repo(), "main"); + let github = provider(dir.path()); + let closed = github.stack_view("fixture/s7-closed").await.unwrap(); + let row = single_row(&closed); + assert!(!row.is_merged); + assert_eq!(row.pr.as_ref().map(|pr| pr.state.as_str()), Some("CLOSED")); + let merged = github.stack_view("fixture/s8-merged").await.unwrap(); + let row = single_row(&merged); + assert!(row.is_merged); + assert_eq!(row.pr.as_ref().map(|pr| pr.state.as_str()), Some("MERGED")); + assert_eq!(merged.trunk, "fixture/single-base"); } #[tokio::test] @@ -144,6 +196,7 @@ async fn merged_stack_keeps_deleted_branches() { let dir = clone(&fixture_repo(), "main"); let github = provider(dir.path()); let stack = github.stack_view("fixture/s3-b").await.unwrap(); + assert_eq!(stack.mode, StackMode::Stacked); assert_eq!(stack.trunk, "fixture/trunk"); let names: Vec<_> = stack.branches.iter().map(|b| b.name.as_str()).collect(); assert_eq!(names, ["fixture/s3-a", "fixture/s3-b"]); @@ -156,8 +209,8 @@ async fn merged_stack_keeps_deleted_branches() { assert_eq!(numbers, [34, 35]); } -/// Two PRs use head branch `fixture/s1-c`: #29, the top of stack s1, and a fork -/// PR that is not stacked. The push remote decides which one is ours. +/// Two PRs use head branch `fixture/s1-c`: #29, the top of stack s1, and fork +/// PR #40, which is not stacked. The push remote decides which one is ours. #[tokio::test] async fn same_branch_name_resolves_by_head_repository_owner() { live!(); @@ -166,10 +219,15 @@ async fn same_branch_name_resolves_by_head_repository_owner() { .stack_view("fixture/s1-c") .await .unwrap(); + assert_eq!(stack.mode, StackMode::Stacked); assert_eq!(stack.branches.len(), 3); let fork = clone(&fork_repo(), "fixture/s1-c"); - let from_fork = provider(fork.path()).stack_view("fixture/s1-c").await; - assert_eq!(from_fork.unwrap_err(), GitHubError::NotInStack); + let from_fork = provider(fork.path()) + .stack_view("fixture/s1-c") + .await + .unwrap(); + let pr = single_row(&from_fork).pr.as_ref().unwrap(); + assert_eq!(pr.number, 40); } #[tokio::test] @@ -257,3 +315,17 @@ async fn sync_against_conflicting_trunk_reports_rebase_conflict() { script("reset"); assert_eq!(result.unwrap_err(), GitHubError::RebaseConflict); } + +#[tokio::test] +async fn single_pull_request_draft_toggle_round_trip() { + live!(); + let _guard = MUTATION_LOCK.lock().await; + script("reset"); + let dir = clone(&fixture_repo(), "fixture/s5-single"); + let github = provider(dir.path()); + github.toggle_draft(43, true).await.unwrap(); + let stack = github.stack_view("fixture/s5-single").await.unwrap(); + assert!(!github.pr_details(&stack).await.unwrap().prs[0].is_draft); + github.toggle_draft(43, false).await.unwrap(); + script("reset"); +} diff --git a/docs/architecture/07-live-validation.md b/docs/architecture/07-live-validation.md index feaa4a2..12e2ce8 100644 --- a/docs/architecture/07-live-validation.md +++ b/docs/architecture/07-live-validation.md @@ -20,7 +20,11 @@ The fixture lives in this repository under the `fixture/` branch prefix. Branche |`s1`|three stacked drafts on `fixture/s1-a`, `-b`, `-c`|`main`|Reads, link, push, sync, draft toggle| |`s2`|two stacked drafts on `fixture/s2-a`, `-b`|`fixture/trunk`|Rebase conflict: `scripts/live-fixture.sh conflict` moves trunk to a commit editing the same file| |`s3`|two merged PRs, branches deleted|`fixture/trunk`|Merged stack reads; read-only, cannot be reset| -|`s4`|one unstacked upstream PR on `fixture/s4-same`|`main`|Unstacked PR| +|`s4`|one unstacked upstream PR on `fixture/s4-same`|`main`|Single PR on the default branch| +|`s5`|one unstacked draft on `fixture/s5-single`|`fixture/single-base`|Single PR whose base is not the default branch; draft toggle| +|`s6`|branch `fixture/s6-nopr`, pushed, no PR|none|No-PR branch; trunk is the default branch| +|`s7`|one closed, unmerged PR|`fixture/single-base`|Single closed PR; read-only| +|`s8`|one merged PR|`fixture/single-base`|Single merged PR; read-only, cannot be reset| |fork|fork PRs from `fixture/s1-c` and `fixture/s4-same`|`main`|Same head branch name as an upstream PR, different head owner| [`scripts/live-fixture.sh`](../../scripts/live-fixture.sh) implements `status`, `reset`, and `conflict`. `reset` force-pushes each branch to its `fixture-start/` tag and restores draft state. Mutating tests reset before and after themselves and serialize on one lock; the workflow adds a `concurrency` group so two runs never reset the fixture at once. @@ -41,7 +45,7 @@ Observed with `gh` 2.101.0 and `gh-stack` v0.1.1: ## Coverage and gaps -Covered live: owner/name resolution; stack discovery and PR details for a stacked, merged, unstacked, missing, and same-name fork PR; link (`gh stack checkout`), push, sync, and draft toggle; sync conflict; missing tracking, repository, extension, and authentication. +Covered live: owner/name resolution; stacked, merged-stack and single-mode discovery (unstacked, non-default base, no PR, closed, merged) with PR details; same-name fork PR; link (`gh stack checkout`), push, sync, and draft toggle for stacked and single PRs; sync conflict; missing tracking, repository, extension, and authentication. Not covered: diff --git a/scripts/live-fixture.sh b/scripts/live-fixture.sh index 748d08d..8550ea1 100755 --- a/scripts/live-fixture.sh +++ b/scripts/live-fixture.sh @@ -13,8 +13,8 @@ set -euo pipefail repo="${STACK_STAT_FIXTURE_REPO:-cam1se/stack-stat}" url="https://github.com/${repo}.git" -branches=(s1-a s1-b s1-c s2-a s2-b trunk) -draft_prs=(27 28 29 31 32) +branches=(s1-a s1-b s1-c s2-a s2-b s5-single s6-nopr trunk) +draft_prs=(27 28 29 31 32 43) remote_sha() { git ls-remote "$url" "$1" | cut -f1; } From 60868f834b038aa296bc404d5daba4003a2c901f Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 21:49:05 -0700 Subject: [PATCH 08/12] test(core): lock the live fixture and reset it even on panic Read-only tests share a lock and mutating tests hold it exclusively, resetting the fixture before and, via Drop, after the test. Verified by forcing a failing assertion after the conflict step: the fixture was clean afterwards. Refs: #23 --- crates/stack-stat-core/tests/live_gh.rs | 70 +++++++++++++++++++------ docs/architecture/07-live-validation.md | 2 +- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/crates/stack-stat-core/tests/live_gh.rs b/crates/stack-stat-core/tests/live_gh.rs index c677f23..44b9002 100644 --- a/crates/stack-stat-core/tests/live_gh.rs +++ b/crates/stack-stat-core/tests/live_gh.rs @@ -12,7 +12,7 @@ use stack_stat_core::{ }; use std::{path::Path, process::Command}; use tempfile::TempDir; -use tokio::sync::Mutex; +use tokio::sync::{RwLock, RwLockWriteGuard}; /// Repository holding the fixture stacks. fn fixture_repo() -> String { @@ -24,8 +24,17 @@ fn fork_repo() -> String { std::env::var("STACK_STAT_FIXTURE_FORK").unwrap_or_else(|_| "tcamise-gpsw/stack-stat".into()) } -/// Mutating tests share one remote fixture, so they take turns. -static MUTATION_LOCK: Mutex<()> = Mutex::const_new(()); +/// Every test depends on the remote fixture. Read-only tests hold this lock +/// shared; mutating tests hold it exclusively, so nothing reads a half-reset +/// fixture. +static FIXTURE: RwLock<()> = RwLock::const_new(()); + +/// Take the fixture lock shared for the rest of the test. +macro_rules! shared { + () => { + let _shared = FIXTURE.read().await; + }; +} macro_rules! live { () => { @@ -36,14 +45,40 @@ macro_rules! live { }; } -fn script(arg: &str) { +fn run_script(arg: &str) -> bool { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let status = Command::new("bash") + Command::new("bash") .arg(root.join("scripts/live-fixture.sh")) .arg(arg) .status() - .unwrap(); - assert!(status.success(), "live-fixture.sh {arg} failed"); + .is_ok_and(|status| status.success()) +} + +fn script(arg: &str) { + assert!(run_script(arg), "live-fixture.sh {arg} failed"); +} + +/// Exclusive access to the fixture: resets it before the test and again when +/// dropped, including when the test panics. +struct Exclusive { + _guard: RwLockWriteGuard<'static, ()>, +} + +impl Exclusive { + async fn begin() -> Self { + let guard = FIXTURE.write().await; + script("reset"); + Self { _guard: guard } + } +} + +impl Drop for Exclusive { + fn drop(&mut self) { + // Never panic while possibly unwinding; the next run resets again. + if !run_script("reset") { + eprintln!("warning: fixture reset after test failed"); + } + } } /// Clone `slug` with `gh` and check out `branch`. @@ -96,6 +131,7 @@ fn isolated_gh(args: &[&str], token: &str) -> (i32, String, String) { #[tokio::test] async fn resolves_owner_and_name_with_gh_repo_view() { live!(); + shared!(); let dir = clone(&fixture_repo(), "fixture/s1-b"); let resolved = CliGitHubProvider::resolve_repo(dir.path()).await.unwrap(); let expected = fixture_repo(); @@ -105,6 +141,7 @@ async fn resolves_owner_and_name_with_gh_repo_view() { #[tokio::test] async fn discovers_a_three_pr_stack_and_loads_details() { live!(); + shared!(); let dir = clone(&fixture_repo(), "fixture/s1-b"); let github = provider(dir.path()); let stack = github.stack_view("fixture/s1-b").await.unwrap(); @@ -135,6 +172,7 @@ fn single_row(stack: &StackViewResponse) -> &StackBranch { #[tokio::test] async fn branch_without_a_pull_request_is_a_single_row_on_the_default_branch() { live!(); + shared!(); let dir = clone(&fixture_repo(), "fixture/s6-nopr"); let github = provider(dir.path()); let stack = github.stack_view("fixture/s6-nopr").await.unwrap(); @@ -150,6 +188,7 @@ async fn branch_without_a_pull_request_is_a_single_row_on_the_default_branch() { #[tokio::test] async fn unstacked_pull_request_is_a_single_row_based_on_its_base_ref() { live!(); + shared!(); let dir = clone(&fixture_repo(), "fixture/s4-same"); let stack = provider(dir.path()) .stack_view("fixture/s4-same") @@ -163,6 +202,7 @@ async fn unstacked_pull_request_is_a_single_row_based_on_its_base_ref() { #[tokio::test] async fn single_pull_request_uses_a_non_default_base_as_trunk() { live!(); + shared!(); let dir = clone(&fixture_repo(), "fixture/s5-single"); let github = provider(dir.path()); let stack = github.stack_view("fixture/s5-single").await.unwrap(); @@ -177,6 +217,7 @@ async fn single_pull_request_uses_a_non_default_base_as_trunk() { #[tokio::test] async fn closed_and_merged_single_pull_requests_keep_their_lifecycle() { live!(); + shared!(); let dir = clone(&fixture_repo(), "main"); let github = provider(dir.path()); let closed = github.stack_view("fixture/s7-closed").await.unwrap(); @@ -193,6 +234,7 @@ async fn closed_and_merged_single_pull_requests_keep_their_lifecycle() { #[tokio::test] async fn merged_stack_keeps_deleted_branches() { live!(); + shared!(); let dir = clone(&fixture_repo(), "main"); let github = provider(dir.path()); let stack = github.stack_view("fixture/s3-b").await.unwrap(); @@ -214,6 +256,7 @@ async fn merged_stack_keeps_deleted_branches() { #[tokio::test] async fn same_branch_name_resolves_by_head_repository_owner() { live!(); + shared!(); let upstream = clone(&fixture_repo(), "fixture/s1-c"); let stack = provider(upstream.path()) .stack_view("fixture/s1-c") @@ -233,6 +276,7 @@ async fn same_branch_name_resolves_by_head_repository_owner() { #[tokio::test] async fn push_without_local_tracking_is_classified() { live!(); + shared!(); let dir = clone(&fixture_repo(), "fixture/s1-b"); let error = provider(dir.path()).push_stack().await.unwrap_err(); assert_eq!(error, GitHubError::LocalTrackingMissing); @@ -278,8 +322,7 @@ fn gh_installs_missing_extension_on_first_use() { #[tokio::test] async fn link_push_sync_and_draft_toggle_round_trip() { live!(); - let _guard = MUTATION_LOCK.lock().await; - script("reset"); + let _fixture = Exclusive::begin().await; let dir = clone(&fixture_repo(), "fixture/s1-b"); let github = provider(dir.path()); github.checkout_branch("fixture/s1-b").await.unwrap(); @@ -299,33 +342,28 @@ async fn link_push_sync_and_draft_toggle_round_trip() { .is_draft ); github.toggle_draft(27, false).await.unwrap(); - script("reset"); } #[tokio::test] async fn sync_against_conflicting_trunk_reports_rebase_conflict() { live!(); - let _guard = MUTATION_LOCK.lock().await; - script("reset"); + let _fixture = Exclusive::begin().await; let dir = clone(&fixture_repo(), "fixture/s2-b"); let github = provider(dir.path()); github.checkout_branch("fixture/s2-b").await.unwrap(); script("conflict"); let result = github.sync_stack().await; - script("reset"); assert_eq!(result.unwrap_err(), GitHubError::RebaseConflict); } #[tokio::test] async fn single_pull_request_draft_toggle_round_trip() { live!(); - let _guard = MUTATION_LOCK.lock().await; - script("reset"); + let _fixture = Exclusive::begin().await; let dir = clone(&fixture_repo(), "fixture/s5-single"); let github = provider(dir.path()); github.toggle_draft(43, true).await.unwrap(); let stack = github.stack_view("fixture/s5-single").await.unwrap(); assert!(!github.pr_details(&stack).await.unwrap().prs[0].is_draft); github.toggle_draft(43, false).await.unwrap(); - script("reset"); } diff --git a/docs/architecture/07-live-validation.md b/docs/architecture/07-live-validation.md index 12e2ce8..6960d2d 100644 --- a/docs/architecture/07-live-validation.md +++ b/docs/architecture/07-live-validation.md @@ -27,7 +27,7 @@ The fixture lives in this repository under the `fixture/` branch prefix. Branche |`s8`|one merged PR|`fixture/single-base`|Single merged PR; read-only, cannot be reset| |fork|fork PRs from `fixture/s1-c` and `fixture/s4-same`|`main`|Same head branch name as an upstream PR, different head owner| -[`scripts/live-fixture.sh`](../../scripts/live-fixture.sh) implements `status`, `reset`, and `conflict`. `reset` force-pushes each branch to its `fixture-start/` tag and restores draft state. Mutating tests reset before and after themselves and serialize on one lock; the workflow adds a `concurrency` group so two runs never reset the fixture at once. +[`scripts/live-fixture.sh`](../../scripts/live-fixture.sh) implements `status`, `reset`, and `conflict`. `reset` force-pushes each branch to its `fixture-start/` tag and restores draft state. Read-only tests hold a shared fixture lock; a mutating test holds it exclusively, resets the fixture before it starts, and resets again when it ends, including when it panics, so no test reads a half-reset fixture. The workflow adds a final reset step and a `concurrency` group so two runs never touch the fixture at once. Tag a new baseline with `git tag -f fixture-start/ ` only after temporarily lifting the tag ruleset. From ce2c6986e332d48b526d63b898707c24161c2aa7 Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 22:00:25 -0700 Subject: [PATCH 09/12] test(core): verify and label the live fixture, keep its PRs open Fixture PRs stay open: closing and reopening them sends notification emails and the local TUI tests need them open. live-fixture.sh gains check (tags, branches, PR states, fixture and do not merge labels), up (reopen after an accidental close) and rebuild (recreate missing branches from tags and missing s1, s2 and s5 PRs). The live tests run check once per process. Verified by deleting fixture/s6-nopr and rebuilding it. Refs: #23 --- Makefile | 5 +- crates/stack-stat-core/tests/live_gh.rs | 14 +- scripts/live-fixture.sh | 164 ++++++++++++++++++++---- 3 files changed, 158 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index b389bfe..58bc9b7 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Developer convenience targets for stack-stat. .DEFAULT_GOAL := help -.PHONY: help dev-setup setup-hooks format lint tests live-test live-fixture-reset preflight rustdoc docs-serve demo demo-gif clean +.PHONY: help dev-setup setup-hooks format lint tests live-test live-fixture-check live-fixture-reset preflight rustdoc docs-serve demo demo-gif clean help: ## List available targets @grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-24s\033[0m %s\n", $$1, $$2}' @@ -33,6 +33,9 @@ preflight: lint tests ## Run the CI-equivalent checks live-test: ## Run end-to-end tests against real gh and gh stack (needs auth and push access to the fixture repo) STACK_STAT_LIVE=1 cargo test -p stack-stat-core --test live_gh --locked -- --test-threads=4 +live-fixture-check: ## Verify the live fixture's tags, branches, PR states and labels + bash scripts/live-fixture.sh check + live-fixture-reset: ## Restore the live fixture branches to their start tags bash scripts/live-fixture.sh reset diff --git a/crates/stack-stat-core/tests/live_gh.rs b/crates/stack-stat-core/tests/live_gh.rs index 44b9002..128a6c5 100644 --- a/crates/stack-stat-core/tests/live_gh.rs +++ b/crates/stack-stat-core/tests/live_gh.rs @@ -10,7 +10,7 @@ use stack_stat_core::{ graphql::classify_gh_failure, }, }; -use std::{path::Path, process::Command}; +use std::{path::Path, process::Command, sync::LazyLock}; use tempfile::TempDir; use tokio::sync::{RwLock, RwLockWriteGuard}; @@ -29,10 +29,21 @@ fn fork_repo() -> String { /// fixture. static FIXTURE: RwLock<()> = RwLock::const_new(()); +/// Result of `live-fixture.sh check`, run once per test process. +static FIXTURE_OK: LazyLock = LazyLock::new(|| run_script("check")); + +fn require_fixture() { + assert!( + *FIXTURE_OK, + "fixture check failed; see FIXTURE: lines above and docs/architecture/07-live-validation.md" + ); +} + /// Take the fixture lock shared for the rest of the test. macro_rules! shared { () => { let _shared = FIXTURE.read().await; + require_fixture(); }; } @@ -67,6 +78,7 @@ struct Exclusive { impl Exclusive { async fn begin() -> Self { let guard = FIXTURE.write().await; + require_fixture(); script("reset"); Self { _guard: guard } } diff --git a/scripts/live-fixture.sh b/scripts/live-fixture.sh index 8550ea1..e817e2e 100755 --- a/scripts/live-fixture.sh +++ b/scripts/live-fixture.sh @@ -1,50 +1,162 @@ #!/usr/bin/env bash -# Reset or perturb the live GitHub fixture used by the end-to-end suite (issue #23). +# Manage the live GitHub fixture used by the end-to-end suite (issue #23). # -# Usage: scripts/live-fixture.sh -# reset Force-push every fixture branch back to its fixture-start/* tag and -# restore draft state. Requires push access to the fixture repository. -# conflict Move fixture/trunk to fixture-conflict/trunk so `gh stack sync` on -# stack s2 hits a rebase conflict. Undo with `reset`. +# Fixture PRs stay open permanently so the suite and local agent-led TUI tests +# can use them at any time; closing and reopening them only generates +# notification emails. +# +# Usage: scripts/live-fixture.sh +# up Reset branches and reopen any closed fixture PRs (recovery only). +# reset Force-push every fixture branch to its fixture-start/* tag and +# restore draft state. Requires the PRs to be open. +# conflict Move fixture/trunk to fixture-conflict/trunk so `gh stack sync` +# on stack s2 hits a rebase conflict. Undo with `reset`. +# check Verify the fixture: tags, branches, PR states and labels. # status Print each fixture branch and whether it matches its start tag. +# rebuild Recreate missing branches from their tags and missing PRs for +# stacks s1/s2, s4 and s5. Merged and fork PRs cannot be rebuilt +# here; see docs/architecture/07-live-validation.md. # # Environment: STACK_STAT_FIXTURE_REPO (default cam1se/stack-stat). set -euo pipefail repo="${STACK_STAT_FIXTURE_REPO:-cam1se/stack-stat}" url="https://github.com/${repo}.git" +labels=("fixture" "do not merge") + +# Branches restored to fixture-start/ by `reset`. branches=(s1-a s1-b s1-c s2-a s2-b s5-single s6-nopr trunk) +# PRs that stay open; `reset` restores the drafts among them. +open_prs=(27 28 29 31 32 37 40 43) +# Of those, PRs that must be drafts while open. draft_prs=(27 28 29 31 32 43) +# PRs that are permanently merged or closed; tests only read them. +merged_prs=(34 35 45) +closed_prs=(38 44) remote_sha() { git ls-remote "$url" "$1" | cut -f1; } +pr_state() { gh pr view "$1" --repo "$repo" --json state --jq .state; } + +reset_branches() { + for b in "${branches[@]}"; do + tag="$(remote_sha "refs/tags/fixture-start/$b")" + [ -n "$tag" ] || { echo "missing tag fixture-start/$b" >&2; exit 1; } + tmp="$(mktemp -d)" + git -C "$tmp" init -q + git -C "$tmp" fetch -q --depth 1 "$url" "refs/tags/fixture-start/$b:refs/tags/t" + git -C "$tmp" push -q --force "$url" "refs/tags/t:refs/heads/fixture/$b" + rm -rf "$tmp" + done +} + +reset_drafts() { + for pr in "${draft_prs[@]}"; do + is_draft="$(gh pr view "$pr" --repo "$repo" --json isDraft --jq .isDraft)" + [ "$is_draft" = true ] || gh pr ready "$pr" --repo "$repo" --undo >/dev/null + done +} + +reopen_prs() { + for pr in "${open_prs[@]}"; do + [ "$(pr_state "$pr")" = OPEN ] || gh pr reopen "$pr" --repo "$repo" >/dev/null + done +} + +check() { + failures=0 + fail() { echo "FIXTURE: $*" >&2; failures=$((failures + 1)); } + for b in "${branches[@]}"; do + [ -n "$(remote_sha "refs/tags/fixture-start/$b")" ] || fail "missing tag fixture-start/$b" + [ -n "$(remote_sha "refs/heads/fixture/$b")" ] || fail "missing branch fixture/$b" + done + for pr in "${open_prs[@]}"; do + want=OPEN + got="$(pr_state "$pr")" || { fail "PR #$pr not found"; continue; } + [ "$got" = "$want" ] || fail "PR #$pr is $got, expected $want" + done + for pr in "${merged_prs[@]}"; do + [ "$(pr_state "$pr")" = MERGED ] || fail "PR #$pr is not MERGED (it cannot be recreated by script)" + done + for pr in "${closed_prs[@]}"; do + [ "$(pr_state "$pr")" = CLOSED ] || fail "PR #$pr is not CLOSED" + done + for pr in "${open_prs[@]}" "${merged_prs[@]}" "${closed_prs[@]}"; do + have="$(gh pr view "$pr" --repo "$repo" --json labels --jq '.labels[].name')" + for label in "${labels[@]}"; do + printf '%s\n' "$have" | grep -qx "$label" || fail "PR #$pr lacks the '$label' label" + done + done + if [ "$failures" -ne 0 ]; then + echo "fixture check failed with $failures problem(s); see docs/architecture/07-live-validation.md" >&2 + exit 1 + fi + echo "fixture ok" +} + +rebuild() { + tmp="$(mktemp -d)" + gh repo clone "$repo" "$tmp/w" -- -q + cd "$tmp/w" + for b in "${branches[@]}"; do + if [ -z "$(remote_sha "refs/heads/fixture/$b")" ]; then + echo "recreating branch fixture/$b from its tag" + git fetch -q origin "refs/tags/fixture-start/$b:refs/tags/t-$b" + git push -q origin "refs/tags/t-$b:refs/heads/fixture/$b" + fi + done + git fetch -q origin + has_pr() { [ -n "$(gh pr list --repo "$repo" --head "$1" --state all --json number --jq '.[0].number // empty')" ]; } + ensure_stack() { + if has_pr "fixture/$2"; then return; fi + echo "creating stack $1" + base="${3:-main}" + git checkout -q -f "fixture/$2" + shift 3 + gh stack init --base "$base" "${@/#/fixture/}" + gh stack submit --auto + } + ensure_stack s1 s1-a main s1-a s1-b s1-c + ensure_stack s2 s2-a fixture/trunk s2-a s2-b + if ! has_pr fixture/s5-single; then + echo "creating PR for fixture/s5-single" + gh pr create --repo "$repo" --draft --base fixture/single-base --head fixture/s5-single \ + --title "fixture: s5-single (single PR, non-default base)" --body "Live fixture for stack-stat e2e tests (#23). Do not merge." >/dev/null + fi + gh label create "fixture" --repo "$repo" --color BFD4F2 \ + --description "Live e2e test fixture" 2>/dev/null || true + gh label create "do not merge" --repo "$repo" --color B60205 \ + --description "Never merge this PR" 2>/dev/null || true + for pr in "${open_prs[@]}" "${merged_prs[@]}" "${closed_prs[@]}"; do + gh pr edit "$pr" --repo "$repo" --add-label "fixture" --add-label "do not merge" >/dev/null \ + || echo "warning: could not label PR #$pr" >&2 + done + cd / + rm -rf "$tmp" + echo "rebuild done; run '$0 check' to verify" +} + cmd="${1:-status}" case "$cmd" in status) for b in "${branches[@]}"; do head="$(remote_sha "refs/heads/fixture/$b")" - tag="$(remote_sha "refs/tags/fixture-start/$b^{}")" - [ -n "$tag" ] || tag="$(remote_sha "refs/tags/fixture-start/$b")" + tag="$(remote_sha "refs/tags/fixture-start/$b")" if [ "$head" = "$tag" ]; then state=clean; else state=DRIFTED; fi - printf '%-14s %s\n' "fixture/$b" "$state" + printf '%-18s %s\n' "fixture/$b" "$state" done ;; reset) - for b in "${branches[@]}"; do - tag="$(remote_sha "refs/tags/fixture-start/$b")" - [ -n "$tag" ] || { echo "missing tag fixture-start/$b" >&2; exit 1; } - tmp="$(mktemp -d)" - git -C "$tmp" init -q - git -C "$tmp" fetch -q --depth 1 "$url" "refs/tags/fixture-start/$b:refs/tags/t" - git -C "$tmp" push -q --force "$url" "refs/tags/t:refs/heads/fixture/$b" - rm -rf "$tmp" - done - for pr in "${draft_prs[@]}"; do - is_draft="$(gh pr view "$pr" --repo "$repo" --json isDraft --jq .isDraft)" - [ "$is_draft" = true ] || gh pr ready "$pr" --repo "$repo" --undo >/dev/null - done + reset_branches + reset_drafts echo "fixture reset" ;; + up) + reopen_prs + reset_branches + reset_drafts + echo "fixture up" + ;; conflict) tmp="$(mktemp -d)" git -C "$tmp" init -q @@ -53,8 +165,14 @@ case "$cmd" in rm -rf "$tmp" echo "fixture/trunk now conflicts with stack s2" ;; + check) + check + ;; + rebuild) + rebuild + ;; *) - echo "usage: $0 " >&2 + echo "usage: $0 " >&2 exit 2 ;; esac From f5a9093371610d7a417707371f047e64ba69cf01 Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 22:00:25 -0700 Subject: [PATCH 10/12] chore(ci): check the fixture before the live tests A failed fixture check opens a 'Live gh fixture broken' issue instead of a drift issue, so fixture damage is not mistaken for a gh change. Refs: #23 --- .github/workflows/live-gh.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/live-gh.yml b/.github/workflows/live-gh.yml index 33f553b..593a5a3 100644 --- a/.github/workflows/live-gh.yml +++ b/.github/workflows/live-gh.yml @@ -85,6 +85,9 @@ jobs: gh extension list echo '```' } | tee -a "$GITHUB_STEP_SUMMARY" + - name: Check fixture + id: fixture + run: bash scripts/live-fixture.sh check - name: Run live tests run: cargo test -p stack-stat-core --test live_gh --locked -- --test-threads=4 - name: Restore fixture @@ -93,8 +96,16 @@ jobs: - name: Report failure # Scheduled runs only: manual and temporary branch runs report in the UI. if: ${{ failure() && github.event_name == 'schedule' }} + env: + FIXTURE_FAILED: ${{ steps.fixture.outcome == 'failure' }} run: | - title="Live gh drift detected" + if [ "$FIXTURE_FAILED" = true ]; then + title="Live gh fixture broken" + hint="The fixture check failed, so this is not gh drift. Run scripts/live-fixture.sh check and see docs/architecture/07-live-validation.md." + else + title="Live gh drift detected" + hint="If only the latest job failed, gh or gh-stack changed in a way that breaks stack-stat. See docs/architecture/07-live-validation.md." + fi body="Live gh (${{ matrix.name }}: gh ${{ matrix.gh }}, gh-stack ${{ matrix.extension }}) failed: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" existing="$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number // empty')" if [ -n "$existing" ]; then @@ -102,5 +113,5 @@ jobs: else gh issue create --title "$title" --label bug --body "$body - If only the latest job failed, gh or gh-stack changed in a way that breaks stack-stat. See docs/architecture/07-live-validation.md." + $hint" fi From e55d6895d58d49092ea6d894d49df55689bfb8d3 Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Tue, 13 Oct 2026 22:00:25 -0700 Subject: [PATCH 11/12] docs: describe fixture lifecycle, labels and recovery Refs: #23 --- docs/architecture/07-live-validation.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/architecture/07-live-validation.md b/docs/architecture/07-live-validation.md index 6960d2d..1fae44a 100644 --- a/docs/architecture/07-live-validation.md +++ b/docs/architecture/07-live-validation.md @@ -6,6 +6,7 @@ The fake-based suites prove stack-stat's logic; they cannot prove that `gh` and ```sh make live-test # needs gh auth with push access to the fixture repository +make live-fixture-check # verify tags, branches, PR states, and labels make live-fixture-reset # restore fixture branches to their start tags ``` @@ -15,6 +16,12 @@ Tests skip silently unless `STACK_STAT_LIVE=1`; `make live-test` sets it. `STACK The fixture lives in this repository under the `fixture/` branch prefix. Branches are protected from deletion and the `fixture-start/*` and `fixture-conflict/*` tags from update or deletion by repository rulesets; admins bypass them. CI skips pull requests whose head branch starts with `fixture/`. +The fixture PRs stay open permanently. Closing and reopening them, like the draft toggles and branch resets, sends notification emails, and the local agent-led TUI tests ([#42](https://github.com/cam1se/stack-stat/issues/42)) need them open. Every fixture PR carries the `fixture` and `do not merge` labels. Four PRs are permanent end states that tests only read: #34 and #35 (merged stack), #45 (merged single PR), and #44 (closed single PR). + +Muting the threads cuts the email noise, but GitHub requires the `notifications` token scope for that, which the CI and agent tokens do not have; mute them from each account's notification settings instead. + +`scripts/live-fixture.sh check` verifies the tags, branches, PR states, and labels. The tests run it once per process and the workflow runs it before the tests, so a broken fixture fails with `FIXTURE:` lines and is reported as "Live gh fixture broken", not as drift. `scripts/live-fixture.sh up` reopens any fixture PR that was closed by accident. `scripts/live-fixture.sh rebuild` recreates missing branches from their tags and missing PRs for stacks `s1` and `s2` and PR `s5`, and relabels every fixture PR. Merged PRs, the closed PR, and fork PRs cannot be rebuilt by the script; recreate them by hand, as the fixture was first built. + |Stack|Pull requests|Trunk|Role| |---|---|---|---| |`s1`|three stacked drafts on `fixture/s1-a`, `-b`, `-c`|`main`|Reads, link, push, sync, draft toggle| @@ -25,7 +32,7 @@ The fixture lives in this repository under the `fixture/` branch prefix. Branche |`s6`|branch `fixture/s6-nopr`, pushed, no PR|none|No-PR branch; trunk is the default branch| |`s7`|one closed, unmerged PR|`fixture/single-base`|Single closed PR; read-only| |`s8`|one merged PR|`fixture/single-base`|Single merged PR; read-only, cannot be reset| -|fork|fork PRs from `fixture/s1-c` and `fixture/s4-same`|`main`|Same head branch name as an upstream PR, different head owner| +|fork|fork PR #40 from `fixture/s1-c` (open); #38 from `fixture/s4-same` (closed, unused)|`main`|Same head branch name as an upstream PR, different head owner| [`scripts/live-fixture.sh`](../../scripts/live-fixture.sh) implements `status`, `reset`, and `conflict`. `reset` force-pushes each branch to its `fixture-start/` tag and restores draft state. Read-only tests hold a shared fixture lock; a mutating test holds it exclusively, resets the fixture before it starts, and resets again when it ends, including when it panics, so no test reads a half-reset fixture. The workflow adds a final reset step and a `concurrency` group so two runs never touch the fixture at once. From 8b2256c735af11b8b7243c4c81a5a30e5c614f19 Mon Sep 17 00:00:00 2001 From: Tim Camise Date: Thu, 15 Oct 2026 14:00:13 -0700 Subject: [PATCH 12/12] test(core): cover single-mode git push on the live fixture Pushes a no-PR branch with plain git push without local stack tracking and checks a non-fast-forward push is reported as a Git rejection. Refs: #23 --- crates/stack-stat-core/tests/live_gh.rs | 38 +++++++++++++++++++++++++ docs/architecture/07-live-validation.md | 2 +- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/stack-stat-core/tests/live_gh.rs b/crates/stack-stat-core/tests/live_gh.rs index 128a6c5..359df79 100644 --- a/crates/stack-stat-core/tests/live_gh.rs +++ b/crates/stack-stat-core/tests/live_gh.rs @@ -379,3 +379,41 @@ async fn single_pull_request_draft_toggle_round_trip() { assert!(!github.pr_details(&stack).await.unwrap().prs[0].is_draft); github.toggle_draft(43, false).await.unwrap(); } + +fn commit_here(dir: &Path, message: &str) { + let status = Command::new("git") + .args([ + "-c", + "user.name=stack-stat live tests", + "-c", + "user.email=live-tests@users.noreply.github.com", + "commit", + "--allow-empty", + "-m", + message, + ]) + .current_dir(dir) + .status() + .unwrap(); + assert!(status.success()); +} + +/// Single mode pushes with plain `git push`, not `gh stack push`: it succeeds +/// without any local stack tracking and reports a rejection as a Git error. +#[tokio::test] +async fn single_mode_push_uses_plain_git_and_reports_rejection() { + live!(); + let _fixture = Exclusive::begin().await; + let dir = clone(&fixture_repo(), "fixture/s6-nopr"); + let other = clone(&fixture_repo(), "fixture/s6-nopr"); + let github = provider(dir.path()); + commit_here(dir.path(), "fixture: live push"); + github.push_branch().await.unwrap(); + // The second clone predates that push, so its commit cannot fast-forward. + commit_here(other.path(), "fixture: diverging"); + let error = provider(other.path()).push_branch().await.unwrap_err(); + let GitHubError::Git(message) = error else { + panic!("expected a Git error, got {error:?}"); + }; + assert!(message.contains("rejected"), "{message}"); +} diff --git a/docs/architecture/07-live-validation.md b/docs/architecture/07-live-validation.md index 1fae44a..030fdae 100644 --- a/docs/architecture/07-live-validation.md +++ b/docs/architecture/07-live-validation.md @@ -52,7 +52,7 @@ Observed with `gh` 2.101.0 and `gh-stack` v0.1.1: ## Coverage and gaps -Covered live: owner/name resolution; stacked, merged-stack and single-mode discovery (unstacked, non-default base, no PR, closed, merged) with PR details; same-name fork PR; link (`gh stack checkout`), push, sync, and draft toggle for stacked and single PRs; sync conflict; missing tracking, repository, extension, and authentication. +Covered live: owner/name resolution; single-mode push with plain `git push` and its rejection; stacked, merged-stack and single-mode discovery (unstacked, non-default base, no PR, closed, merged) with PR details; same-name fork PR; link (`gh stack checkout`), push, sync, and draft toggle for stacked and single PRs; sync conflict; missing tracking, repository, extension, and authentication. Not covered: