Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/commit-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
117 changes: 117 additions & 0 deletions .github/workflows/live-gh.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
name: Live gh

# 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:

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.
concurrency:
group: live-gh
cancel-in-progress: false

env:
CARGO_TERM_COLOR: always
# 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 (${{ 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: Record tool versions
run: |
{
echo "### Tool versions (${{ matrix.name }})"
echo '```'
gh --version
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
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' }}
env:
FIXTURE_FAILED: ${{ steps.fixture.outcome == 'failure' }}
run: |
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
gh issue comment "$existing" --body "$body"
else
gh issue create --title "$title" --label bug --body "$body

$hint"
fi
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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-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}'
Expand Down Expand Up @@ -30,6 +30,15 @@ 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-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

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."
Expand Down
15 changes: 14 additions & 1 deletion crates/stack-stat-core/src/providers/github_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<StackViewResponse, GitHubError> {
Expand Down
8 changes: 6 additions & 2 deletions crates/stack-stat-core/src/providers/graphql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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")
{
Expand Down
24 changes: 23 additions & 1 deletion crates/stack-stat-core/tests/github_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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(_)
Expand Down
Loading
Loading